From a98f23f2b11fe082b1a2bdd87b527133e775acbd Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Fri, 31 Jul 2026 17:32:09 +0200 Subject: [PATCH] feat(android): add messaging style notifications and per-category channels --- .../app/tauri/notification/Notification.kt | 11 ++ .../notification/TauriNotificationManager.kt | 59 +++++++--- .../tauri/notification/UnifiedPushNotifier.kt | 101 ++++++++++++++---- .../tauri/notification/NotificationTest.kt | 37 +++++++ .../notification/UnifiedPushNotifierTest.kt | 39 +++++++ guest-js/index.ts | 27 +++++ src/lib.rs | 17 +++ src/models.rs | 17 +++ 8 files changed, 272 insertions(+), 36 deletions(-) diff --git a/android/src/main/java/app/tauri/notification/Notification.kt b/android/src/main/java/app/tauri/notification/Notification.kt index d1fd9c49..1dbaa5d0 100644 --- a/android/src/main/java/app/tauri/notification/Notification.kt +++ b/android/src/main/java/app/tauri/notification/Notification.kt @@ -29,6 +29,15 @@ class JSObjectDeserializer : JsonDeserializer() { } } +/** One entry of a MessagingStyle conversation; a null [senderName] means the device owner. */ +@InvokeArg +class NotificationMessage { + lateinit var body: String + var timestamp: Long = 0 + var senderName: String? = null + var senderKey: String? = null +} + @InvokeArg class Notification { var id: Int = 0 @@ -36,6 +45,8 @@ class Notification { var body: String? = null var largeBody: String? = null var summary: String? = null + var messages: List? = null + var isGroupConversation = false var sound: String? = null var icon: String? = null var largeIcon: String? = null diff --git a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt index 48bf994d..bd4067e0 100644 --- a/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt +++ b/android/src/main/java/app/tauri/notification/TauriNotificationManager.kt @@ -18,6 +18,7 @@ import android.os.Build.VERSION.SDK_INT import android.os.UserManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person import androidx.core.app.RemoteInput import app.tauri.Logger import app.tauri.plugin.JSObject @@ -37,6 +38,7 @@ const val REMOTE_INPUT_KEY = "NotificationRemoteInput" const val DEFAULT_NOTIFICATION_CHANNEL_ID = "default" const val DEFAULT_PRESS_ACTION = "tap" const val TAG = "NotificationsPlugin" +const val SELF_PERSON_NAME = "You" class TauriNotificationManager( private val storage: NotificationStorage, @@ -140,8 +142,6 @@ class TauriNotificationManager( // TODO Progressbar support // TODO System categories (DO_NOT_DISTURB etc.) - // TODO use NotificationCompat.MessagingStyle for latest API - // TODO expandable notification NotificationCompat.MessagingStyle // TODO media style notification support NotificationCompat.MediaStyle @SuppressLint("MissingPermission") private fun buildNotification( @@ -158,7 +158,10 @@ class TauriNotificationManager( .setOngoing(notification.isOngoing) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setGroupSummary(notification.isGroupSummary) - if (notification.largeBody != null) { + val messages = notification.messages + if (!messages.isNullOrEmpty()) { + mBuilder.setStyle(buildMessagingStyle(notification, messages)) + } else if (notification.largeBody != null) { // support multiline text mBuilder.setStyle( NotificationCompat.BigTextStyle() @@ -174,19 +177,23 @@ class TauriNotificationManager( inboxStyle.setSummaryText(notification.summary) mBuilder.setStyle(inboxStyle) } - val sound = notification.getSound(context, getDefaultSound(context)) - if (sound != null) { - val soundUri = Uri.parse(sound) - // Grant permission to use sound - context.grantUriPermission( - "com.android.systemui", - soundUri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - mBuilder.setSound(soundUri) - mBuilder.setDefaults(android.app.Notification.DEFAULT_VIBRATE or android.app.Notification.DEFAULT_LIGHTS) + if (notification.silent == true) { + mBuilder.setSilent(true) } else { - mBuilder.setDefaults(android.app.Notification.DEFAULT_ALL) + val sound = notification.getSound(context, getDefaultSound(context)) + if (sound != null) { + val soundUri = Uri.parse(sound) + // Grant permission to use sound + context.grantUriPermission( + "com.android.systemui", + soundUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + mBuilder.setSound(soundUri) + mBuilder.setDefaults(android.app.Notification.DEFAULT_VIBRATE or android.app.Notification.DEFAULT_LIGHTS) + } else { + mBuilder.setDefaults(android.app.Notification.DEFAULT_ALL) + } } val group = notification.group if (group != null) { @@ -222,6 +229,28 @@ class TauriNotificationManager( } } + /** Renders a conversation as MessagingStyle, the style Android expects for chat. */ + private fun buildMessagingStyle( + notification: Notification, + messages: List + ): NotificationCompat.MessagingStyle { + val style = NotificationCompat.MessagingStyle( + Person.Builder().setName(SELF_PERSON_NAME).build() + ) + style.isGroupConversation = notification.isGroupConversation + // In a one-to-one chat the sender name is already the title Android shows. + if (notification.isGroupConversation) { + style.conversationTitle = notification.title + } + for (message in messages) { + val sender = message.senderName?.let { name -> + Person.Builder().setName(name).setKey(message.senderKey).build() + } + style.addMessage(message.body, message.timestamp, sender) + } + return style + } + // Create intents for open/dismiss actions private fun createActionIntents( notification: Notification, diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt index fbad220a..497017fb 100644 --- a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt +++ b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt @@ -8,6 +8,7 @@ import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person import androidx.core.app.RemoteInput import app.tauri.Logger import com.fasterxml.jackson.databind.ObjectMapper @@ -15,7 +16,9 @@ import org.json.JSONObject import kotlin.math.abs object UnifiedPushNotifier { - private const val CHANNEL_ID = "messages" + // Kept in sync with the channel ids the app creates in UnifiedPushNotifications.ts. + private const val MESSAGES_CHANNEL_ID = "messages.v2" + private const val INVITES_CHANNEL_ID = "invites" private const val ACTION_TYPE_ID = "sable-message" private const val GROUP_KEY = "matrix_messages" @@ -36,16 +39,22 @@ object UnifiedPushNotifier { val roomId = notification.optString("room_id") val eventId = notification.optString("event_id") val sender = notification.optString("sender_display_name") - val title = notification.optString("room_name").ifEmpty { - notification.optString("sender_display_name").ifEmpty { "New message" } + val isInvite = notification.optString("type") == "m.room.member" && + notification.optJSONObject("content")?.optString("membership") == "invite" + val roomName = notification.optString("room_name") + val title = if (isInvite) { + "New Invitation" + } else { + roomName.ifEmpty { sender.ifEmpty { "New message" } } } - val body = buildBody(notification, sender) + val body = if (isInvite) buildInviteBody(sender, roomName) else buildBody(notification, sender) + val channelId = if (isInvite) INVITES_CHANNEL_ID else MESSAGES_CHANNEL_ID val userId = rootJson.optString("user_id").ifEmpty { notification.optString("user_id") } - ensureChannel(context) + ensureChannels(context) val iconId = context.resources .getIdentifier("notification_icon", "drawable", context.packageName) @@ -75,11 +84,10 @@ object UnifiedPushNotifier { PendingIntent.FLAG_CANCEL_CURRENT } - val builder = NotificationCompat.Builder(context, CHANNEL_ID) + val builder = NotificationCompat.Builder(context, channelId) .setSmallIcon(iconId) .setContentTitle(title) .setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) .setAutoCancel(true) .setOnlyAlertOnce(true) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -88,7 +96,24 @@ object UnifiedPushNotifier { PendingIntent.getActivity(context, notifId, intent, flags) ) - addReplyAction(context, builder, notifId, roomId, eventId, userId, flags) + // Same style as the warm path, so JS enrichment updates it in place. + if (isInvite) { + builder.setStyle(NotificationCompat.BigTextStyle().bigText(body)) + } else { + builder.setStyle( + NotificationCompat.MessagingStyle( + Person.Builder().setName(SELF_PERSON_NAME).build() + ).addMessage( + messageText(notification), + System.currentTimeMillis(), + sender.takeIf { it.isNotEmpty() }?.let { Person.Builder().setName(it).build() } + ) + ) + } + + if (!isInvite) { + addReplyAction(context, builder, notifId, roomId, eventId, userId, flags) + } NotificationManagerCompat.from(context).notify(notifId, builder.build()) } @@ -187,24 +212,58 @@ object UnifiedPushNotifier { return intent } - private fun buildBody(notification: JSONObject, sender: String): String { - val prefix = if (sender.isNotEmpty()) "$sender: " else "" + /** The message text on its own, without a sender prefix. */ + private fun messageText(notification: JSONObject): String { if (notification.optString("type") == "m.room.encrypted") { - return "${prefix}Encrypted message" + return "Encrypted message" } - val text = notification.optJSONObject("content")?.optString("body")?.takeIf { it.isNotEmpty() } - return if (text != null) "$prefix$text" else "${prefix}New message" + return notification.optJSONObject("content") + ?.optString("body") + ?.takeIf { it.isNotEmpty() } + ?: "New message" + } + + private fun buildBody(notification: JSONObject, sender: String): String { + val prefix = if (sender.isNotEmpty()) "$sender: " else "" + return "$prefix${messageText(notification)}" + } + + private fun buildInviteBody(sender: String, roomName: String): String = when { + sender.isNotEmpty() && roomName.isNotEmpty() -> "$sender invites you to $roomName" + sender.isNotEmpty() -> "from $sender" + roomName.isNotEmpty() -> "to $roomName" + else -> "" } - private fun ensureChannel(context: Context) { + private fun ensureChannels(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = context.getSystemService(NotificationManager::class.java) ?: return - if (manager.getNotificationChannel(CHANNEL_ID) == null) { - manager.createNotificationChannel( - NotificationChannel(CHANNEL_ID, "Messages", NotificationManager.IMPORTANCE_HIGH).apply { - description = "Matrix message and invite notifications" - } - ) - } + ensureChannel( + manager, + MESSAGES_CHANNEL_ID, + "Messages", + "Matrix message notifications", + NotificationManager.IMPORTANCE_HIGH + ) + ensureChannel( + manager, + INVITES_CHANNEL_ID, + "Invitations", + "Room and space invitations", + NotificationManager.IMPORTANCE_DEFAULT + ) + } + + private fun ensureChannel( + manager: NotificationManager, + id: String, + name: String, + channelDescription: String, + importance: Int + ) { + if (manager.getNotificationChannel(id) != null) return + manager.createNotificationChannel( + NotificationChannel(id, name, importance).apply { description = channelDescription } + ) } } diff --git a/android/src/test/java/app/tauri/notification/NotificationTest.kt b/android/src/test/java/app/tauri/notification/NotificationTest.kt index 0fc59c8e..ed8b2c35 100644 --- a/android/src/test/java/app/tauri/notification/NotificationTest.kt +++ b/android/src/test/java/app/tauri/notification/NotificationTest.kt @@ -294,6 +294,43 @@ class NotificationTest { assertEquals(5, notification.number) } + /** + * The Rust layer re-serializes every notification before it reaches Kotlin, so a + * field only arrives if both sides agree on the wire name. Pins the camelCase keys + * Rust emits against the mapper configuration `Invoke.parseArgs` uses. + */ + @Test + fun testDeserializeMessagingStyleWireFormat() { + val objectMapper = ObjectMapper() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + + val json = """ + { + "id": 7, + "title": "Room", + "groupConversation": true, + "messages": [ + {"body": "hello", "timestamp": 1700000000000, "senderName": "Alice", "senderKey": "@alice:example.org"}, + {"body": "mine", "timestamp": 1700000000001} + ] + } + """.trimIndent() + + val notification = objectMapper.readValue(json, Notification::class.java) + + assertTrue(notification.isGroupConversation) + assertEquals(2, notification.messages?.size) + val first = notification.messages!![0] + assertEquals("hello", first.body) + assertEquals(1700000000000L, first.timestamp) + assertEquals("Alice", first.senderName) + assertEquals("@alice:example.org", first.senderKey) + // A message with no sender is the device owner's own. + assertNull(notification.messages!![1].senderName) + } + /** * Proves that Jackson's ObjectMapper cannot properly serialize a Notification * with a JSObject (extends org.json.JSONObject) in its `extra` field. diff --git a/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt b/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt index c27c3b02..95e8d3fb 100644 --- a/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt +++ b/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt @@ -150,6 +150,45 @@ class UnifiedPushNotifierTest { assertEquals(1, shadow.allNotifications.size) } + @Test + fun showFromPush_postsMessagesOnHighImportanceMessagesChannel() { + UnifiedPushNotifier.showFromPush(context, pushPayload("!r1:example.org", "\$e1")) + + val posted = shadowNotificationManager().getNotification(null, canonicalId("!r1:example.org"))!! + assertEquals("messages.v2", posted.channelId) + assertEquals( + NotificationManager.IMPORTANCE_HIGH, + notificationManager.getNotificationChannel("messages.v2").importance + ) + assertEquals( + "android.app.Notification\$MessagingStyle", + posted.extras.getString(Notification.EXTRA_TEMPLATE) + ) + } + + @Test + fun showFromPush_postsInvitesOnTheirOwnChannelWithoutReplyAction() { + val notification = JSONObject() + .put("room_id", "!r1:example.org") + .put("event_id", "\$invite") + .put("room_name", "Room 1") + .put("sender_display_name", "Alice") + .put("type", "m.room.member") + .put("content", JSONObject().put("membership", "invite")) + val payload = JSONObject() + .put("notification", notification) + .put("user_id", "@alice:example.org") + .toString() + + UnifiedPushNotifier.showFromPush(context, payload) + + val posted = shadowNotificationManager().getNotification(null, canonicalId("!r1:example.org"))!! + assertEquals("invites", posted.channelId) + assertEquals("New Invitation", posted.extras.getString(Notification.EXTRA_TITLE)) + assertEquals("Alice invites you to Room 1", posted.extras.getString(Notification.EXTRA_TEXT)) + assertTrue(posted.actions == null || posted.actions.isEmpty()) + } + @Test fun showFromPush_ignoresMalformedPayloads() { UnifiedPushNotifier.showFromPush(context, "not json at all") diff --git a/guest-js/index.ts b/guest-js/index.ts index 4f0113a7..477a0913 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -76,6 +76,17 @@ interface Options { * Only supports up to 5 lines. */ inboxLines?: string[]; + /** + * Conversation messages, newest last. + * Changes the notification style to messaging on Android. + * Takes precedence over `largeBody` and `inboxLines`. + */ + messages?: NotificationMessage[]; + /** + * Marks a `messages` conversation as having more than two participants, so + * Android shows the conversation title alongside each sender. + */ + groupConversation?: boolean; /** * Notification icon. * @@ -114,6 +125,7 @@ interface Options { autoCancel?: boolean; /** * Changes the notification presentation to be silent on iOS (no badge, no sound, not listed). + * On Android it suppresses sound and vibration for this post. */ silent?: boolean; /** @@ -263,6 +275,20 @@ class Schedule { } } +/** + * One message of a conversation notification. + */ +interface NotificationMessage { + /** The message text. */ + body: string; + /** When the message was sent, in milliseconds since the epoch. */ + timestamp: number; + /** Sender display name. Omit for messages sent by the device owner. */ + senderName?: string; + /** Stable sender identifier, so Android can recognise the same person again. */ + senderKey?: string; +} + /** * Attachment of a notification. */ @@ -952,6 +978,7 @@ async function onNotificationClicked( export type { Attachment, + NotificationMessage, Options, Action, ActionType, diff --git a/src/lib.rs b/src/lib.rs index d08e8dc6..7684e65b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -218,6 +218,23 @@ impl NotificationsBuilder { self } + /// Append a message to the conversation. + /// Changes the notification style to messaging on Android. + /// Takes precedence over `largeBody` and inbox lines. + #[must_use] + pub fn message(mut self, message: NotificationMessage) -> Self { + self.data.messages.push(message); + self + } + + /// Marks a `messages` conversation as having more than two participants, so + /// Android shows the conversation title alongside each sender. + #[must_use] + pub const fn group_conversation(mut self, group_conversation: bool) -> Self { + self.data.group_conversation = group_conversation; + self + } + /// Notification icon. /// /// On Android the icon must be placed in the app's `res/drawable` folder. diff --git a/src/models.rs b/src/models.rs index 629e4474..d826de23 100644 --- a/src/models.rs +++ b/src/models.rs @@ -188,6 +188,17 @@ mod iso8601 { } } +/// One message of a conversation notification, rendered by Android's `MessagingStyle`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationMessage { + pub(crate) body: String, + #[serde(default)] + pub(crate) timestamp: i64, + pub(crate) sender_name: Option, + pub(crate) sender_key: Option, +} + // Each bool is an independent flag in the JS wire format; grouping them would change the JSON shape. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Serialize, Deserialize)] @@ -208,6 +219,10 @@ pub struct NotificationData { pub(crate) sound: Option, #[serde(default)] pub(crate) inbox_lines: Vec, + #[serde(default)] + pub(crate) messages: Vec, + #[serde(default)] + pub(crate) group_conversation: bool, pub(crate) icon: Option, pub(crate) large_icon: Option, pub(crate) icon_color: Option, @@ -242,6 +257,8 @@ impl Default for NotificationData { group_summary: false, sound: None, inbox_lines: Vec::new(), + messages: Vec::new(), + group_conversation: false, icon: None, large_icon: None, icon_color: None,