Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4d56e095e | ||
| 86dc7f849a | |||
|
|
ca889f2eec | ||
| 157d707581 | |||
|
|
3940441d9b | ||
| cfabcc5fbf |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user