diff --git a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt index 9b1ecef..a7fcf43 100644 --- a/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt +++ b/android/app/src/main/java/com/paarrot/app/MatrixSyncService.kt @@ -10,6 +10,7 @@ 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 @@ -220,9 +221,10 @@ class MatrixSyncService : Service() { val inlineImage: Bitmap? = if ( (msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file") ) { - content.optString("url").takeIf { it.startsWith("mxc://") } - ?.let { mxcToDownloadUrl(it, homeserver) } - ?.let { downloadBitmap(it, token) } + content.optString("url").takeIf { it.startsWith("mxc://") }?.let { mxc -> + mxcToDownloadUrls(mxc, homeserver) + .firstNotNullOfOrNull { downloadBitmap(it, token) } + } } else null showMessageNotification( @@ -404,9 +406,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 +417,47 @@ class MatrixSyncService : Service() { } } - private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? { + private fun mxcToThumbnailUrls(mxcUrl: String, homeserver: String, size: Int): List { 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 { 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 +528,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 +539,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, + title = title, + body = text, + groupId = groupInfo.groupId, + groupName = groupInfo.groupName, + kind = groupInfo.kind, + largeIcon = largeIcon, + inlineImage = inlineImage, + ) } private fun buildStatusNotification(): Notification { @@ -620,28 +587,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 +632,129 @@ 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 + } + } + + /** + * Posts a room notification (+ space/DM group summary) for both background sync + * and JS-driven Capacitor notifications (with optional avatar bitmap). + */ + fun postMessageNotification( + context: Context, + roomId: String, + title: String, + body: String, + groupId: String, + groupName: String, + kind: String, + largeIcon: Bitmap? = null, + inlineImage: Bitmap? = null, + ) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ensureMessageChannels(context) + + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?: Intent(context, MainActivity::class.java) + val roomNotifId = notificationIdForRoom(roomId) + val pi = PendingIntent.getActivity( + context, roomNotifId, launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val channelId = channelIdForKindStatic(kind) + val builder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_stat_paarrot) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setContentIntent(pi) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroup(groupId) + .setOnlyAlertOnce(true) + .setSubText(groupName) + + builder.extras.putString(EXTRA_ROOM_ID, roomId) + builder.extras.putString(EXTRA_GROUP_ID, 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(body) + .setSummaryText(groupName) + ) + } + + nm.notify(roomNotifId, builder.build()) + + val summaryId = notificationIdForGroupSummary(groupId) + val summary = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_stat_paarrot) + .setContentTitle(groupName) + .setContentText("New messages") + .setAutoCancel(true) + .setContentIntent(pi) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroup(groupId) + .setGroupSummary(true) + .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 diff --git a/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt b/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt index 6e51a1e..bbb50fd 100644 --- a/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt +++ b/android/app/src/main/java/com/paarrot/app/SyncServicePlugin.kt @@ -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() { @@ -124,6 +125,34 @@ 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 title = call.getString("title") ?: return call.reject("title required") + val body = call.getString("body") ?: return call.reject("body required") + val roomId = call.getString("roomId") ?: return call.reject("roomId required") + val groupId = call.getString("groupId") ?: "paarrot_home" + val groupName = call.getString("groupName") ?: "Home" + val kind = call.getString("kind") ?: "home" + val largeIconBase64 = call.getString("largeIconBase64") + + MatrixSyncService.postMessageNotification( + context = context, + roomId = roomId, + title = title, + body = body, + groupId = groupId, + groupName = groupName, + kind = kind, + largeIcon = MatrixSyncService.decodeBase64Bitmap(largeIconBase64), + ) + call.resolve(JSObject().put("shown", true)) + } + companion object { const val PREFS = "sync_service_prefs" } diff --git a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx index 52f5d05..44d3612 100644 --- a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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,13 @@ 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, +} from '../../utils/matrix'; import { mDirectAtom } from '../../state/mDirectList'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; @@ -62,6 +69,92 @@ 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((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 { + try { + const blob = await downloadMedia(url, accessToken); + 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 (err) { + console.warn('[Notifications] Failed to fetch avatar for notification icon:', err); + return undefined; + } +} + /** * Applies the selected emoji style font to the document. * - System: Uses the native OS emoji font @@ -249,6 +342,7 @@ function MessageNotifications() { ({ roomName, roomAvatar, + iconBase64, username, messageBody, roomId, @@ -257,6 +351,7 @@ function MessageNotifications() { }: { roomName: string; roomAvatar?: string; + iconBase64?: string; username: string; messageBody?: string; roomId: string; @@ -346,6 +441,8 @@ function MessageNotifications() { path: roomPath, roomId, group, + icon: roomAvatar, + iconBase64, onClick: () => { if (!window.closed) navigate(roomPath); }, @@ -443,34 +540,35 @@ 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; + + let iconBase64: string | undefined; + if (roomAvatar && isCapacitorNative()) { + iconBase64 = await mediaUrlToBase64(roomAvatar, mx.getAccessToken()); + } + + const messageBody = notificationBodyFromEvent(mEvent); + + notify({ + roomName: room.name ?? 'Unknown', + roomAvatar, + iconBase64, + username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender, + messageBody, + roomId: room.roomId, + eventId, + isDm, + }); + })(); } if (notificationSound) { diff --git a/overlay/src/app/utils/backgroundSync.ts b/overlay/src/app/utils/backgroundSync.ts index a3bb2cf..aa08cb7 100644 --- a/overlay/src/app/utils/backgroundSync.ts +++ b/overlay/src/app/utils/backgroundSync.ts @@ -64,6 +64,16 @@ 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; + roomId: string; + groupId: string; + groupName: string; + kind: string; + largeIconBase64?: string; + }): Promise<{ shown: boolean }>; addListener( eventName: 'unifiedPushNewEndpoint', listenerFunc: (event: UnifiedPushEndpointEvent) => void @@ -564,3 +574,27 @@ 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; + roomId: string; + groupId: string; + groupName: string; + kind: string; + largeIconBase64?: string; +}): Promise => { + if (!isBackgroundSyncSupported()) return false; + + try { + await MatrixBackgroundSync.showNotification(options); + return true; + } catch (err) { + console.warn('[BackgroundSync] showNotification failed:', err); + return false; + } +}; diff --git a/overlay/src/app/utils/tauri.ts b/overlay/src/app/utils/tauri.ts index 3a5d95c..7e7ee65 100644 --- a/overlay/src/app/utils/tauri.ts +++ b/overlay/src/app/utils/tauri.ts @@ -632,12 +632,13 @@ export const sendNotification = async (options: { title: string; body: string; icon?: string; + iconBase64?: string; path?: string; roomId?: string; group?: NotificationGroupInfo; onClick?: () => void; }): Promise => { - const { title, body, icon, path, roomId, group, onClick } = options; + const { title, body, icon, iconBase64, path, roomId, group, onClick } = options; const extra = { ...(path ? { path } : {}), ...(roomId ? { roomId } : {}), @@ -693,6 +694,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 +718,26 @@ 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, + roomId, + groupId, + groupName, + kind: group.kind, + largeIconBase64: iconBase64, + }); + 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();