Compare commits

..

6 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
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
8 changed files with 873 additions and 159 deletions

View File

@@ -15,6 +15,19 @@ public class MainActivity extends BridgeActivity {
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. */
private void setupImageKeyboardSupport() {
if (bridge == null) return;

View File

@@ -10,10 +10,18 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Base64
import android.util.Log
import android.graphics.Bitmap
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.Person
import androidx.core.graphics.drawable.IconCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -68,7 +76,7 @@ class MatrixSyncService : Service() {
serviceScope.launch {
try {
runSingleSyncFetch(homeserver, token, userId)
runSingleSyncFetch(homeserver, token, userId, triggerReason)
Log.d(TAG, "One-shot sync completed (reason=$triggerReason)")
} finally {
stopForegroundCompat()
@@ -84,7 +92,12 @@ class MatrixSyncService : Service() {
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 since = prefs.getString(KEY_SINCE, null)
val isFirstSync = since == null
@@ -101,8 +114,17 @@ class MatrixSyncService : Service() {
if (nextBatch != null) {
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)
} else {
Log.d(
TAG,
"Skipping notifications (firstSync=$isFirstSync, foreground=$appInForeground, reason=$triggerReason)",
)
}
}
}
@@ -126,7 +148,8 @@ class MatrixSyncService : Service() {
}
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 sinceParam = if (since != null) "&since=${URLEncoder.encode(since, "UTF-8")}" else ""
return "$base/_matrix/client/v3/sync?timeout=12000&filter=$encodedFilter$sinceParam"
@@ -184,45 +207,76 @@ class MatrixSyncService : Service() {
// 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
var notifiedForRoom = false
for (i in 0 until events.length()) {
val event = events.optJSONObject(i) ?: continue
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 (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
val content = event.optJSONObject("content") ?: continue
if (mentionsOnly && !isSpecialMessage(content, notifyCtx)) continue
val content = event.optJSONObject("content") ?: JSONObject()
val msgtype = content.optString("msgtype")
val rawBody = content.optString("body")
val body = when (msgtype) {
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo"
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
else -> rawBody.takeIf { it.isNotBlank() } ?: continue
// Plaintext mention/keyword filter; encrypted events already passed highlight_count.
if (
mentionsOnly &&
eventType != "m.room.encrypted" &&
!isSpecialMessage(content, notifyCtx)
) {
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 profile = resolveProfile(sender, homeserver, token)
// For unencrypted image/sticker messages, try to download a preview bitmap.
// Encrypted messages have a `file` object instead of a top-level `url`.
val msgtype = content.optString("msgtype")
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 { mxcToDownloadUrl(it, homeserver) }
?.let { downloadBitmap(it, token) }
content.optString("url").takeIf { it.startsWith("mxc://") }?.let { mxc ->
mxcToDownloadUrls(mxc, homeserver)
.firstNotNullOfOrNull { downloadBitmap(it, token) }
}
} else null
showMessageNotification(
@@ -234,6 +288,21 @@ class MatrixSyncService : Service() {
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,
)
}
}
}
@@ -404,9 +473,7 @@ class MatrixSyncService : Service() {
conn.disconnect()
}
displayNameCache[mxid] = displayName
val avatar = avatarMxc
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
?.let { downloadBitmap(it, token) }
val avatar = avatarMxc?.let { downloadAvatarBitmap(it, homeserver, token, size = 96) }
avatarCache[mxid] = avatar
UserProfile(displayName, avatar)
} catch (e: Exception) {
@@ -417,24 +484,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 slash = withoutScheme.indexOf('/')
if (slash < 0) return null
if (slash < 0) return emptyList()
val serverName = withoutScheme.substring(0, slash)
val mediaId = withoutScheme.substring(slash + 1)
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
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 slash = withoutScheme.indexOf('/')
if (slash < 0) return null
if (slash < 0) return emptyList()
val serverName = withoutScheme.substring(0, slash)
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? {
return try {
val conn = URL(urlString).openConnection() as HttpURLConnection
@@ -505,11 +595,7 @@ class MatrixSyncService : Service() {
}
}
private fun channelIdForKind(kind: String): String = when (kind) {
"direct" -> CHANNEL_DIRECTS
"space" -> CHANNEL_SPACES
else -> CHANNEL_HOME
}
private fun channelIdForKind(kind: String): String = channelIdForKindStatic(kind)
private fun showMessageNotification(
nm: NotificationManager,
@@ -520,72 +606,20 @@ class MatrixSyncService : Service() {
inlineImage: Bitmap? = null,
groupInfo: NotificationGroupInfo,
) {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?: Intent(this, MainActivity::class.java)
val roomNotifId = notificationIdForRoom(roomId)
val pi = PendingIntent.getActivity(
this, roomNotifId, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val isDm = groupInfo.kind == "direct"
val title = if (isDm) sender else groupInfo.roomName.ifBlank { sender }
val text = if (isDm) body else "$sender: $body"
val channelId = channelIdForKind(groupInfo.kind)
val builder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(title)
.setContentText(text)
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(groupInfo.groupId)
.setOnlyAlertOnce(true)
.setSubText(groupInfo.groupName)
builder.extras.putString(EXTRA_ROOM_ID, roomId)
builder.extras.putString(EXTRA_GROUP_ID, groupInfo.groupId)
if (largeIcon != null) builder.setLargeIcon(largeIcon)
if (inlineImage != null) {
builder.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage)
.bigLargeIcon(null as Bitmap?)
)
} else {
builder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(text)
.setSummaryText(groupInfo.groupName)
)
}
// Stable per-room id replaces the previous tray entry instead of stacking.
nm.notify(roomNotifId, builder.build())
// Nest under Direct messages / Space name / Home in the notification shade.
val summaryId = notificationIdForGroupSummary(groupInfo.groupId)
val summary = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(groupInfo.groupName)
.setContentText("New messages")
.setAutoCancel(true)
.setContentIntent(pi)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(groupInfo.groupId)
.setGroupSummary(true)
.setStyle(
NotificationCompat.InboxStyle()
.setBigContentTitle(groupInfo.groupName)
.setSummaryText(groupInfo.groupName)
)
summary.extras.putString(EXTRA_GROUP_ID, groupInfo.groupId)
nm.notify(summaryId, summary.build())
postMessageNotification(
this,
roomId = roomId,
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,
)
}
private fun buildStatusNotification(): Notification {
@@ -620,28 +654,7 @@ class MatrixSyncService : Service() {
}
private fun ensureMessageChannels(nm: NotificationManager) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val soundUri = Uri.parse("android.resource://$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)
}
ensureMessageChannels(this)
}
companion object {
@@ -686,6 +699,218 @@ class MatrixSyncService : Service() {
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")
/**
* Posts a room notification (+ space/DM group summary) for both background sync
* 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(
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(resolvedSender)
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 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(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_GROUP_ID, groupId)
if (!path.isNullOrBlank()) {
builder.extras.putString(NotificationNavStore.EXTRA_NAV_PATH, path)
}
// Circular avatar on the left (collapsed + MessagingStyle Person).
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 {
val messaging = NotificationCompat.MessagingStyle(senderPerson)
.addMessage(
NotificationCompat.MessagingStyle.Message(
resolvedMessage,
System.currentTimeMillis(),
senderPerson,
)
)
if (!resolvedConversation.isNullOrBlank() && !isDm) {
messaging.conversationTitle = resolvedConversation
messaging.isGroupConversation = true
}
builder.setStyle(messaging)
}
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
@@ -723,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
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

@@ -19,6 +19,7 @@ import com.getcapacitor.annotation.CapacitorPlugin
* - `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")
class SyncServicePlugin : Plugin() {
@@ -30,13 +31,44 @@ class SyncServicePlugin : Plugin() {
override fun load() {
super.load()
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() {
UnifiedPushManager.clearPlugin(this)
if (NotificationNavStore.plugin === this) {
NotificationNavStore.plugin = null
}
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. */
@PluginMethod
fun start(call: PluginCall) {
@@ -124,6 +156,44 @@ class SyncServicePlugin : Plugin() {
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 {
const val PREFS = "sync_service_prefs"
}

View File

@@ -1,7 +1,7 @@
import { useAtomValue, useStore } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { RoomEvent, RoomEventHandlerMap, MatrixClient, MatrixEvent, MatrixEventEvent } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
import LogoSVG from '../../../../public/res/svg/paarrot.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 {
getMemberDisplayName,
getMemberAvatarMxc,
getNotificationType,
getUnreadInfo,
isNotificationEvent,
@@ -22,7 +23,15 @@ import {
guessPerfectParent,
} from '../../utils/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 { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
@@ -62,6 +71,162 @@ import {
materializeSharedFile,
} 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.
* - System: Uses the native OS emoji font
@@ -226,10 +391,37 @@ function MessageNotifications() {
// Set up notification tap listener for mobile
useEffect(() => {
setupNotificationTapListener((path) => {
navigate(path);
});
}, [navigate]);
const openFromNotification = (target: string) => {
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());
@@ -249,6 +441,8 @@ function MessageNotifications() {
({
roomName,
roomAvatar,
iconBase64,
bigPictureBase64,
username,
messageBody,
roomId,
@@ -257,6 +451,8 @@ function MessageNotifications() {
}: {
roomName: string;
roomAvatar?: string;
iconBase64?: string;
bigPictureBase64?: string;
username: string;
messageBody?: string;
roomId: string;
@@ -299,6 +495,8 @@ function MessageNotifications() {
: messageBody
? `${username}: ${messageBody}`
: `${username} sent a message`;
const messageText = messageBody || 'New message';
const conversationTitle = isDm ? undefined : roomName || undefined;
/** Replicates TitleBar click navigation logic */
const navigateToRoom = () => {
@@ -343,9 +541,15 @@ function MessageNotifications() {
sendNotification({
title: notificationTitle,
body: notificationBody,
senderName: username,
messageText,
conversationTitle,
path: roomPath,
roomId,
group,
icon: roomAvatar,
iconBase64,
bigPictureBase64,
onClick: () => {
if (!window.closed) navigate(roomPath);
},
@@ -443,34 +647,42 @@ function MessageNotifications() {
showNotifications &&
((isTauri() && !isElectron()) || isCapacitorNative() || notificationPermission('granted'))
) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
const content = mEvent.getContent();
let messageBody: string | undefined;
if (mEvent.getType() === 'm.reaction') {
// For reactions, show "reacted with {emoji}"
const reactionKey = content['m.relates_to']?.key;
if (reactionKey) {
messageBody = `reacted with ${reactionKey}`;
} else {
messageBody = 'reacted to a message';
}
} else {
messageBody = typeof content.body === 'string' ? content.body : undefined;
}
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
void (async () => {
await waitForEventDecryption(mx, mEvent);
const avatarMxc =
getMemberAvatarMxc(room, sender) ??
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ??
room.getMxcAvatarUrl();
const roomAvatar = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
messageBody,
roomId: room.roomId,
eventId,
isDm,
});
: undefined;
const username = resolveSenderDisplayName(mx, room, sender);
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);
notify({
roomName: room.name ?? 'Unknown',
roomAvatar,
iconBase64,
bigPictureBase64,
username,
messageBody,
roomId: room.roomId,
eventId,
isDm,
});
})();
}
if (notificationSound) {

View File

@@ -64,6 +64,27 @@ interface MatrixBackgroundSyncPlugin {
{ 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(
eventName: 'unifiedPushNewEndpoint',
listenerFunc: (event: UnifiedPushEndpointEvent) => void
@@ -564,3 +585,59 @@ export const syncNotificationGroupMap = async (
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

@@ -207,6 +207,36 @@ export const setupNotificationTapListener = async (onTap: (path: string) => void
} catch (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);
}
}
};
@@ -632,12 +662,30 @@ export const sendNotification = async (options: {
title: string;
body: string;
icon?: string;
iconBase64?: string;
bigPictureBase64?: string;
senderName?: string;
messageText?: string;
conversationTitle?: string;
path?: string;
roomId?: string;
group?: NotificationGroupInfo;
onClick?: () => void;
}): Promise<void> => {
const { title, body, icon, path, roomId, group, onClick } = options;
const {
title,
body,
icon,
iconBase64,
bigPictureBase64,
senderName,
messageText,
conversationTitle,
path,
roomId,
group,
onClick,
} = options;
const extra = {
...(path ? { path } : {}),
...(roomId ? { roomId } : {}),
@@ -693,6 +741,7 @@ export const sendNotification = async (options: {
// Use the channel on Android
channelId: isAndroid() ? channelId : undefined,
group: groupId,
icon,
// Store path/roomId in extra data for tap handling + clear-on-read
extra: hasExtra ? extra : undefined,
});
@@ -716,6 +765,31 @@ export const sendNotification = async (options: {
}
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 {
const { LocalNotifications } = await import('@capacitor/local-notifications');
let perm = await LocalNotifications.checkPermissions();

View File

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