Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions android/src/main/java/app/tauri/notification/Notification.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,24 @@ class JSObjectDeserializer : JsonDeserializer<JSObject>() {
}
}

/** 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
var title: String? = null
var body: String? = null
var largeBody: String? = null
var summary: String? = null
var messages: List<NotificationMessage>? = null
var isGroupConversation = false
var sound: String? = null
var icon: String? = null
var largeIcon: String? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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()
Expand All @@ -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) {
Expand Down Expand Up @@ -222,6 +229,28 @@ class TauriNotificationManager(
}
}

/** Renders a conversation as MessagingStyle, the style Android expects for chat. */
private fun buildMessagingStyle(
notification: Notification,
messages: List<NotificationMessage>
): 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,
Expand Down
101 changes: 80 additions & 21 deletions android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ 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
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"

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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())
}
Expand Down Expand Up @@ -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 }
)
}
}
37 changes: 37 additions & 0 deletions android/src/test/java/app/tauri/notification/NotificationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading