diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index b74ace6..e49abf1 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -70,6 +70,7 @@ + diff --git a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt index e5bf4ed..e334fe2 100644 --- a/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt +++ b/android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt @@ -5,6 +5,8 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.media.AudioAttributes +import android.media.RingtoneManager import android.os.Build import android.os.Bundle import java.util.UUID @@ -21,9 +23,18 @@ object UnifiedPushNotifier { // 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 CALLS_CHANNEL_ID = "calls" private const val ACTION_TYPE_ID = "sable-message" + private const val CALL_ACTION_TYPE_ID = "sable-call" private const val GROUP_KEY = "matrix_messages" + private const val RTC_NOTIFICATION_TYPE = "m.rtc.notification" + private const val RTC_NOTIFICATION_TYPE_UNSTABLE = "org.matrix.msc4075.rtc.notification" + private const val ANSWER_ACTION = "sable-call-answer" + private const val DECLINE_ACTION = "sable-call-decline" + private const val DEFAULT_RING_LIFETIME_MS = 30_000L + private const val MAX_RING_LIFETIME_MS = 120_000L + fun showFromPushInBackground(context: Context, rawMessage: String) { val app = context.applicationContext Thread { @@ -45,8 +56,15 @@ object UnifiedPushNotifier { } val generation = UUID.randomUUID().toString() val encrypted = notification.optString("type") == "m.room.encrypted" - post(context, notification, if (encrypted) "Encrypted message" else null, generation) - if (!encrypted) return + if (!encrypted) { + if (isRing(notification)) { + postIncomingCall(context, notification, notification) + return + } + post(context, notification, null, generation) + return + } + post(context, notification, "Encrypted message", generation) val state = UnifiedPushStateStore(context) if (!state.showEncryptedContent) { @@ -54,15 +72,33 @@ object UnifiedPushNotifier { return } if (userId.isEmpty() || userId != state.pushUserId) return - val (body, outcome) = decryptedBody(context, notification) + val (clear, outcome) = decryptedEvent(context, notification) PushDiagnostics.record(context, outcome) - if (body == null || !state.showEncryptedContent) return + if (clear == null || !state.showEncryptedContent) return + if (isRing(clear)) { + manager.cancel(id) + postIncomingCall(context, notification, clear) + return + } + val body = clear.optJSONObject("content") + ?.optString("body") + ?.takeIf { it.isNotEmpty() } + if (body == null) { + PushDiagnostics.record(context, PushOutcome.EMPTY_BODY) + return + } val stillCurrent = manager.activeNotifications.any { it.id == id && it.tag == null && it.notification.extras.getString(GENERATION_KEY) == generation } if (stillCurrent) post(context, notification, body, generation, silent = true) } + internal fun isRing(event: JSONObject): Boolean { + val type = event.optString("type") + if (type != RTC_NOTIFICATION_TYPE && type != RTC_NOTIFICATION_TYPE_UNSTABLE) return false + return event.optJSONObject("content")?.optString("notification_type") == "ring" + } + private const val GENERATION_KEY = "sable.push.generation" private fun post( @@ -161,6 +197,67 @@ object UnifiedPushNotifier { NotificationManagerCompat.from(context).notify(notifId, builder.build()) } + private fun postIncomingCall(context: Context, notification: JSONObject, event: JSONObject) { + ensureChannels(context) + + val roomId = notification.optString("room_id") + val userId = notification.optString("user_id") + val eventId = event.optString("event_id").ifEmpty { notification.optString("event_id") } + val caller = notification.optString("sender_display_name") + .ifEmpty { notification.optString("room_name") } + .ifEmpty { notification.optString("sender") } + .ifEmpty { "Unknown caller" } + + val notifId = callNotificationId(userId, roomId) + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_CANCEL_CURRENT + } + fun pending(action: String) = PendingIntent.getActivity( + context, + notifId + action.hashCode(), + buildPushIntent(context, notifId, roomId, eventId, userId, action, CALL_ACTION_TYPE_ID), + flags, + ) + + val iconId = context.resources + .getIdentifier("notification_icon", "drawable", context.packageName) + .takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info + val fullScreen = pending(DEFAULT_PRESS_ACTION) + + val builder = NotificationCompat.Builder(context, CALLS_CHANNEL_ID) + .setSmallIcon(iconId) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setOngoing(true) + .setAutoCancel(false) + .setStyle( + NotificationCompat.CallStyle.forIncomingCall( + Person.Builder().setName(caller).setImportant(true).build(), + pending(DECLINE_ACTION), + pending(ANSWER_ACTION), + ) + ) + .setContentIntent(fullScreen) + .setFullScreenIntent(fullScreen, true) + .setTimeoutAfter(ringLifetimeMs(event)) + + NotificationManagerCompat.from(context).notify(notifId, builder.build()) + } + + /** Clamped: a broken sender clock must not ring forever. */ + private fun ringLifetimeMs(event: JSONObject): Long { + val lifetime = event.optJSONObject("content")?.optLong("lifetime", 0L) ?: 0L + if (lifetime <= 0L) return DEFAULT_RING_LIFETIME_MS + return lifetime.coerceAtMost(MAX_RING_LIFETIME_MS) + } + + /** Distinct from [roomNotificationId]: a call and a conversation coexist. */ + internal fun callNotificationId(userId: String, roomId: String): Int = + roomNotificationId(userId, roomId + '\u0000' + "call") + private fun addReplyAction( context: Context, builder: NotificationCompat.Builder, @@ -228,7 +325,8 @@ object UnifiedPushNotifier { roomId: String, eventId: String, userId: String, - action: String = DEFAULT_PRESS_ACTION + action: String = DEFAULT_PRESS_ACTION, + actionTypeId: String = ACTION_TYPE_ID ): Intent { val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: Intent(Intent.ACTION_MAIN).setPackage(context.packageName) @@ -248,7 +346,7 @@ object UnifiedPushNotifier { val sourceJson = JSONObject().apply { put("id", notifId) put("extra", extraJson) - put("actionTypeId", ACTION_TYPE_ID) + put("actionTypeId", actionTypeId) }.toString() intent.putExtra(NOTIFICATION_OBJ_INTENT_KEY, sourceJson) @@ -267,10 +365,10 @@ object UnifiedPushNotifier { return "Encrypted message" } - private fun decryptedBody( + private fun decryptedEvent( context: Context, notification: JSONObject - ): Pair { + ): Pair { val state = UnifiedPushStateStore(context) val userId = state.pushUserId ?: return null to PushOutcome.NO_ACCOUNT val deviceId = state.pushDeviceId ?: return null to PushOutcome.NO_ACCOUNT @@ -300,13 +398,17 @@ object UnifiedPushNotifier { is PushDecryptResult.Failure -> return null to result.outcome } - val body = try { - JSONObject(clear).optJSONObject("content")?.optString("body")?.takeIf { it.isNotEmpty() } + val clearEvent = try { + JSONObject(clear) } catch (_: Exception) { null } - return if (body == null) null to PushOutcome.EMPTY_BODY else body to PushOutcome.DECRYPTED + return if (clearEvent == null) { + null to PushOutcome.EMPTY_BODY + } else { + clearEvent to PushOutcome.DECRYPTED + } } private fun buildBody(sender: String, text: String): String { @@ -338,6 +440,23 @@ object UnifiedPushNotifier { "Room and space invitations", NotificationManager.IMPORTANCE_DEFAULT ) + ensureChannel( + manager, + CALLS_CHANNEL_ID, + "Calls", + "Incoming calls", + NotificationManager.IMPORTANCE_HIGH + ) { channel -> + channel.setSound( + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE), + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(), + ) + channel.enableVibration(true) + channel.lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC + } } private fun ensureChannel( @@ -345,11 +464,15 @@ object UnifiedPushNotifier { id: String, name: String, channelDescription: String, - importance: Int + importance: Int, + configure: (NotificationChannel) -> Unit = {} ) { if (manager.getNotificationChannel(id) != null) return manager.createNotificationChannel( - NotificationChannel(id, name, importance).apply { description = channelDescription } + NotificationChannel(id, name, importance).apply { + description = channelDescription + configure(this) + } ) } } diff --git a/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt b/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt index f50da2c..7314ade 100644 --- a/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt +++ b/android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt @@ -69,6 +69,30 @@ class UnifiedPushNotifierTest { .toString() } + private fun ringPayload( + roomId: String, + eventId: String, + notificationType: String = "ring", + userId: String? = "@alice:example.org" + ): String { + val notification = JSONObject() + .put("room_id", roomId) + .put("event_id", eventId) + .put("room_name", "Room 1") + .put("sender_display_name", "Alice") + .put("type", "org.matrix.msc4075.rtc.notification") + .put( + "content", + JSONObject() + .put("notification_type", notificationType) + .put("lifetime", 30000) + ) + return JSONObject() + .put("notification", notification) + .apply { if (userId != null) put("user_id", userId) } + .toString() + } + private fun canonicalId(roomId: String, userId: String = "@alice:example.org") = UnifiedPushNotifier.roomNotificationId(userId, roomId) @@ -338,6 +362,42 @@ class UnifiedPushNotifierTest { assertEquals(1, shadow.allNotifications.size) } + @Test + fun ringIsRecognisedOnBothEventTypesAndOnlyForTheRingKind() { + fun event(type: String, kind: String) = JSONObject() + .put("type", type) + .put("content", JSONObject().put("notification_type", kind)) + + assertTrue(UnifiedPushNotifier.isRing(event("m.rtc.notification", "ring"))) + assertTrue(UnifiedPushNotifier.isRing(event("org.matrix.msc4075.rtc.notification", "ring"))) + // An announcement of a call already running is not a ring. + assertFalse(UnifiedPushNotifier.isRing(event("m.rtc.notification", "notification"))) + assertFalse(UnifiedPushNotifier.isRing(event("m.room.message", "ring"))) + assertFalse(UnifiedPushNotifier.isRing(JSONObject().put("type", "m.rtc.notification"))) + } + + @Test + fun callNotificationIdNeverCollidesWithTheRoomsConversation() { + assertNotEquals( + UnifiedPushNotifier.roomNotificationId("@alice:example.org", "!r1:example.org"), + UnifiedPushNotifier.callNotificationId("@alice:example.org", "!r1:example.org") + ) + } + + @Test + fun showFromPush_callAnnouncementStaysAnOrdinaryMessage() { + UnifiedPushNotifier.showFromPush( + context, + ringPayload("!r1:example.org", "\$ann", notificationType = "notification") + ) + + val callId = UnifiedPushNotifier.callNotificationId("@alice:example.org", "!r1:example.org") + assertNull(shadowNotificationManager().getNotification(null, callId)) + assertNotNull( + shadowNotificationManager().getNotification(null, canonicalId("!r1:example.org")) + ) + } + @Test fun showFromPush_postsMessagesOnHighImportanceMessagesChannel() { UnifiedPushNotifier.showFromPush(context, pushPayload("!r1:example.org", "\$e1"))