Redact message on displayed notification

<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->

## Content

<!-- Describe shortly what has been changed -->
Ensure that any message currently displayed in a notification is removed when the Event is redacted.

Note: request SDK change in https://github.com/matrix-org/matrix-rust-sdk/pull/3868.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #3310

## Screenshots / GIFs

<!--
We have screenshot tests in the project, so attaching screenshots to a PR is not mandatory, as far as there
is a Composable Preview covering the changes. In this case, the change will appear in the file diff.
Note that all the UI composables should be covered by a Composable Preview.

Providing a video of the change is still very useful for the reviewer and for the history of the project.

You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|

|Before|After|
|-|-|
|||
 -->

![NotificationRedaction](https://github.com/user-attachments/assets/737fa394-f335-4103-8885-e0bceeab2349)

## Tests

<!-- Explain how you tested your development -->

- Alice send a message to Bob and Bob observes the notification
- Alice redacts the message
- Bob now see the the message is replaced by "Message removed" in the notification.

## Tested devices

- [ ] Physical
- [x] Emulator
- OS version(s):

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 23
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [ ] You've made a self review of your PR

Redacted messages continue to show in notification previews
### Steps to reproduce

1. Send an image from another client
2. Receive the notification on Element X which shows the image
3. Redact the image on the other client
4. Notice how the notification on Element X still shows the image

Message with image redacted on Element Web:

![Screenshot 2024-08-20 at 09 15 32](https://github.com/user-attachments/assets/3cd0f2f0-b098-4deb-a2fe-5ad6d33be4d6)

Element X still showing the image in the notification:

![01fab203-0550-4228-a8ed-388fa3bc37d37530435372318880093](https://github.com/user-attachments/assets/0ef9e3d2-f515-4e44-a413-328e7a04e5cf)

This might seem fairly benign, however, last night there was a CSAM assault on one of the public Foundation rooms. Even hours after the attacker had been banned and their messages redacted, Element X on my phone still continued to present me the pornographic pictures in the unread notification.

### Outcome

#### What did you expect?

Redacted images should not show in notifications.

#### What happened instead?

They continued to show post redaction.

### Your phone model

Google Pixel 6

### Operating system version

14

### Application version and app store

0.5.0

### Homeserver

matrix.org

### Will you send logs?

No

### Are you willing to provide a PR?

Yes

Waiting for https://github.com/matrix-org/matrix-rust-sdk/issues/3859 to be handled.
If this needs Rust support, it might be an issue on Element X iOS, too? I haven't tested this on iOS so far.

diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
index 74bd2d8e14b..b1dd0c683c4 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
@@ -79,8 +79,8 @@ sealed interface NotificationContent {
         ) : MessageLike

         data class RoomRedaction(
-            val redactedEventId: String?,
-            val reason: String?
+            val redactedEventId: EventId?,
+            val reason: String?,
         ) : MessageLike

         data object Sticker : MessageLike
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
index fa0b6365f3c..39dadffe59b 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
@@ -16,6 +16,7 @@

 package io.element.android.libraries.matrix.impl.notification

+import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.matrix.api.notification.CallNotifyType
 import io.element.android.libraries.matrix.api.notification.NotificationContent
@@ -94,7 +95,10 @@ private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationCon
             is MessageLikeEventContent.RoomMessage -> {
                 NotificationContent.MessageLike.RoomMessage(senderId, EventMessageMapper().mapMessageType(messageType))
             }
-            is MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction(redactedEventId = redactedEventId, reason = reason)
+            is MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction(
+                redactedEventId = redactedEventId?.let(::EventId),
+                reason = reason,
+            )
             MessageLikeEventContent.Sticker -> NotificationContent.MessageLike.Sticker
             is MessageLikeEventContent.Poll -> NotificationContent.MessageLike.Poll(senderId, question)
         }
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt
index 01b9837f3ba..c742c375d4e 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt
@@ -26,7 +26,6 @@ import io.element.android.libraries.push.api.notifications.NotificationIdProvide
 import javax.inject.Inject

 interface ActiveNotificationsProvider {
-    fun getAllNotifications(): List<StatusBarNotification>
     fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
     fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification>
     fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification>
@@ -39,10 +38,6 @@ interface ActiveNotificationsProvider {
 class DefaultActiveNotificationsProvider @Inject constructor(
     private val notificationManager: NotificationManagerCompat,
 ) : ActiveNotificationsProvider {
-    override fun getAllNotifications(): List<StatusBarNotification> {
-        return notificationManager.activeNotifications
-    }
-
     override fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification> {
         return notificationManager.activeNotifications.filter { it.notification.group == sessionId.value }
     }
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
index ef97dbf0020..7dd9ef9d9d2 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
@@ -50,8 +50,8 @@ import io.element.android.libraries.matrix.ui.messages.toPlainText
 import io.element.android.libraries.push.impl.R
 import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
 import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
-import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
 import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
+import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
 import io.element.android.libraries.ui.strings.CommonStrings
 import io.element.android.services.toolbox.api.strings.StringProvider
 import io.element.android.services.toolbox.api.systemclock.SystemClock
@@ -67,7 +67,7 @@ private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.No
  * this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk.
  */
 interface NotifiableEventResolver {
-    suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent?
+    suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent?
 }

 @ContributesBinding(AppScope::class)
@@ -80,7 +80,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
     private val permalinkParser: PermalinkParser,
     private val callNotificationEventResolver: CallNotificationEventResolver,
 ) : NotifiableEventResolver {
-    override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
+    override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent? {
         // Restore session
         val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null
         val notificationService = client.notificationService()
@@ -99,8 +99,9 @@ class DefaultNotifiableEventResolver @Inject constructor(
     private suspend fun NotificationData.asNotifiableEvent(
         client: MatrixClient,
         userId: SessionId,
-    ): NotifiableEvent? {
-        return when (val content = this.content) {
+    ): ResolvedPushEvent? {
+        val content = this.content
+        val notifiableEvent = when (content) {
             is NotificationContent.MessageLike.RoomMessage -> {
                 val senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId)
                 val messageBody = descriptionFromMessageContent(content, senderDisambiguatedDisplayName)
@@ -204,8 +205,9 @@ class DefaultNotifiableEventResolver @Inject constructor(
             NotificationContent.MessageLike.RoomEncrypted -> fallbackNotifiableEvent(userId, roomId, eventId).also {
                 Timber.tag(loggerTag.value).w("Notification with encrypted content -> fallback")
             }
-            is NotificationContent.MessageLike.RoomRedaction -> null.also {
-                Timber.tag(loggerTag.value).d("Ignoring notification for redaction")
+            is NotificationContent.MessageLike.RoomRedaction -> {
+                // Note: this case will be handled below
+                null
             }
             NotificationContent.MessageLike.Sticker -> null.also {
                 Timber.tag(loggerTag.value).d("Ignoring notification for sticker")
@@ -233,6 +235,25 @@ class DefaultNotifiableEventResolver @Inject constructor(
                 Timber.tag(loggerTag.value).d("Ignoring notification for state event ${content.javaClass.simpleName}")
             }
         }
+
+        return if (notifiableEvent != null) {
+            ResolvedPushEvent.Event(notifiableEvent)
+        } else if (content is NotificationContent.MessageLike.RoomRedaction) {
+            val redactedEventId = content.redactedEventId
+            if (redactedEventId == null) {
+                Timber.tag(loggerTag.value).d("redactedEventId is null.")
+                null
+            } else {
+                ResolvedPushEvent.Redaction(
+                    sessionId = userId,
+                    roomId = roomId,
+                    redactedEventId = redactedEventId,
+                    reason = content.reason,
+                )
+            }
+        } else {
+            null
+        }
     }

     private fun fallbackNotifiableEvent(
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt
index e36966cc9a3..181a1bd3647 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt
@@ -42,77 +42,75 @@ class NotificationRenderer @Inject constructor(
         imageLoader: ImageLoader,
     ) {
         val groupedEvents = eventsToProcess.groupByType()
-        with(notificationDataFactory) {
-            val roomNotifications = toNotifications(groupedEvents.roomEvents, currentUser, imageLoader)
-            val invitationNotifications = toNotifications(groupedEvents.invitationEvents)
-            val simpleNotifications = toNotifications(groupedEvents.simpleEvents)
-            val fallbackNotifications = toNotifications(groupedEvents.fallbackEvents)
-            val summaryNotification = createSummaryNotification(
-                currentUser = currentUser,
-                roomNotifications = roomNotifications,
-                invitationNotifications = invitationNotifications,
-                simpleNotifications = simpleNotifications,
-                fallbackNotifications = fallbackNotifications,
+        val roomNotifications = notificationDataFactory.toNotifications(groupedEvents.roomEvents, currentUser, imageLoader)
+        val invitationNotifications = notificationDataFactory.toNotifications(groupedEvents.invitationEvents)
+        val simpleNotifications = notificationDataFactory.toNotifications(groupedEvents.simpleEvents)
+        val fallbackNotifications = notificationDataFactory.toNotifications(groupedEvents.fallbackEvents)
+        val summaryNotification = notificationDataFactory.createSummaryNotification(
+            currentUser = currentUser,
+            roomNotifications = roomNotifications,
+            invitationNotifications = invitationNotifications,
+            simpleNotifications = simpleNotifications,
+            fallbackNotifications = fallbackNotifications,
+        )
+
+        // Remove summary first to avoid briefly displaying it after dismissing the last notification
+        if (summaryNotification == SummaryNotification.Removed) {
+            Timber.tag(loggerTag.value).d("Removing summary notification")
+            notificationDisplayer.cancelNotificationMessage(
+                tag = null,
+                id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId)
             )
+        }

-            // Remove summary first to avoid briefly displaying it after dismissing the last notification
-            if (summaryNotification == SummaryNotification.Removed) {
-                Timber.tag(loggerTag.value).d("Removing summary notification")
-                notificationDisplayer.cancelNotificationMessage(
-                    tag = null,
-                    id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId)
-                )
-            }
+        roomNotifications.forEach { notificationData ->
+            notificationDisplayer.showNotificationMessage(
+                tag = notificationData.roomId.value,
+                id = NotificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
+                notification = notificationData.notification
+            )
+        }

-            roomNotifications.forEach { notificationData ->
+        invitationNotifications.forEach { notificationData ->
+            if (useCompleteNotificationFormat) {
+                Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.key}")
                 notificationDisplayer.showNotificationMessage(
-                    tag = notificationData.roomId.value,
-                    id = NotificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
+                    tag = notificationData.key,
+                    id = NotificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
                     notification = notificationData.notification
                 )
             }
+        }

-            invitationNotifications.forEach { notificationData ->
-                if (useCompleteNotificationFormat) {
-                    Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.key}")
-                    notificationDisplayer.showNotificationMessage(
-                        tag = notificationData.key,
-                        id = NotificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
-                        notification = notificationData.notification
-                    )
-                }
-            }
-
-            simpleNotifications.forEach { notificationData ->
-                if (useCompleteNotificationFormat) {
-                    Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.key}")
-                    notificationDisplayer.showNotificationMessage(
-                        tag = notificationData.key,
-                        id = NotificationIdProvider.getRoomEventNotificationId(currentUser.userId),
-                        notification = notificationData.notification
-                    )
-                }
-            }
-
-            // Show only the first fallback notification
-            if (fallbackNotifications.isNotEmpty()) {
-                Timber.tag(loggerTag.value).d("Showing fallback notification")
+        simpleNotifications.forEach { notificationData ->
+            if (useCompleteNotificationFormat) {
+                Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.key}")
                 notificationDisplayer.showNotificationMessage(
-                    tag = "FALLBACK",
-                    id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId),
-                    notification = fallbackNotifications.first().notification
+                    tag = notificationData.key,
+                    id = NotificationIdProvider.getRoomEventNotificationId(currentUser.userId),
+                    notification = notificationData.notification
                 )
             }
+        }

-            // Update summary last to avoid briefly displaying it before other notifications
-            if (summaryNotification is SummaryNotification.Update) {
-                Timber.tag(loggerTag.value).d("Updating summary notification")
-                notificationDisplayer.showNotificationMessage(
-                    tag = null,
-                    id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId),
-                    notification = summaryNotification.notification
-                )
-            }
+        // Show only the first fallback notification
+        if (fallbackNotifications.isNotEmpty()) {
+            Timber.tag(loggerTag.value).d("Showing fallback notification")
+            notificationDisplayer.showNotificationMessage(
+                tag = "FALLBACK",
+                id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId),
+                notification = fallbackNotifications.first().notification
+            )
+        }
+
+        // Update summary last to avoid briefly displaying it before other notifications
+        if (summaryNotification is SummaryNotification.Update) {
+            Timber.tag(loggerTag.value).d("Updating summary notification")
+            notificationDisplayer.showNotificationMessage(
+                tag = null,
+                id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId),
+                notification = summaryNotification.notification
+            )
         }
     }
 }
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
index b1b4068efe3..096132ffaba 100755
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt
@@ -430,6 +430,7 @@ class DefaultNotificationCreator @Inject constructor(
                         event.imageUri?.let {
                             message.setData("image/", it)
                         }
+                        message.extras.putString(MESSAGE_EVENT_ID, event.eventId.value)
                     }
                     addMessage(message)
                 }
@@ -446,10 +447,10 @@ class DefaultNotificationCreator @Inject constructor(
     ): MessagingStyle {
         return MessagingStyle(
             Person.Builder()
-            .setName(user.displayName?.annotateForDebug(50))
-            .setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader))
-            .setKey(sessionId.value)
-            .build()
+                .setName(user.displayName?.annotateForDebug(50))
+                .setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader))
+                .setKey(sessionId.value)
+                .build()
         ).also {
             it.conversationTitle = roomName.takeIf { roomIsGroup }
             it.isGroupConversation = roomIsGroup
@@ -465,6 +466,10 @@ class DefaultNotificationCreator @Inject constructor(
         drawable.draw(canvas)
         return bitmap
     }
+
+    companion object {
+        const val MESSAGE_EVENT_ID = "message_event_id"
+    }
 }

 fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt
index ddfbbf8b076..282382eaee1 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableEvent.kt
@@ -19,12 +19,11 @@ package io.element.android.libraries.push.impl.notifications.model
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.RoomId
 import io.element.android.libraries.matrix.api.core.SessionId
-import java.io.Serializable

 /**
  * Parent interface for all events which can be displayed as a Notification.
  */
-sealed interface NotifiableEvent : Serializable {
+sealed interface NotifiableEvent {
     val sessionId: SessionId
     val roomId: RoomId
     val eventId: EventId
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/ResolvedPushEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/ResolvedPushEvent.kt
new file mode 100644
index 00000000000..9722d6c3f64
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/ResolvedPushEvent.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl.notifications.model
+
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+
+sealed interface ResolvedPushEvent {
+    data class Event(val notifiableEvent: NotifiableEvent) : ResolvedPushEvent
+
+    data class Redaction(
+        val sessionId: SessionId,
+        val roomId: RoomId,
+        val redactedEventId: EventId,
+        val reason: String?,
+    ) : ResolvedPushEvent
+}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt
index a553c149520..5f33e3134a2 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt
@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
 import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
 import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
 import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
+import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
 import io.element.android.libraries.push.impl.test.DefaultTestPush
 import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
 import io.element.android.libraries.pushproviders.api.PushData
@@ -41,6 +42,7 @@ private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag)
 @ContributesBinding(AppScope::class)
 class DefaultPushHandler @Inject constructor(
     private val onNotifiableEventReceived: OnNotifiableEventReceived,
+    private val onRedactedEventReceived: OnRedactedEventReceived,
     private val notifiableEventResolver: NotifiableEventResolver,
     private val incrementPushDataStore: IncrementPushDataStore,
     private val userPushStoreFactory: UserPushStoreFactory,
@@ -96,19 +98,26 @@ class DefaultPushHandler @Inject constructor(
                 Timber.w("Unable to get a session")
                 return
             }
-            val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId)
-            when (notifiableEvent) {
+            val resolvedPushEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId)
+            when (resolvedPushEvent) {
                 null -> Timber.tag(loggerTag.value).w("Unable to get a notification data")
-                is NotifiableRingingCallEvent -> handleRingingCallEvent(notifiableEvent)
-                else -> {
-                    val userPushStore = userPushStoreFactory.getOrCreate(userId)
-                    val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
-                    if (areNotificationsEnabled) {
-                        onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent)
-                    } else {
-                        Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.")
+                is ResolvedPushEvent.Event -> {
+                    when (resolvedPushEvent.notifiableEvent) {
+                        is NotifiableRingingCallEvent -> handleRingingCallEvent(resolvedPushEvent.notifiableEvent)
+                        else -> {
+                            val userPushStore = userPushStoreFactory.getOrCreate(userId)
+                            val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
+                            if (areNotificationsEnabled) {
+                                onNotifiableEventReceived.onNotifiableEventReceived(resolvedPushEvent.notifiableEvent)
+                            } else {
+                                Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.")
+                            }
+                        }
                     }
                 }
+                is ResolvedPushEvent.Redaction -> {
+                    onRedactedEventReceived.onRedactedEventReceived(resolvedPushEvent)
+                }
             }
         } catch (e: Exception) {
             Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt
new file mode 100644
index 00000000000..f632c2bbcc6
--- /dev/null
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnRedactedEventReceived.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl.push
+
+import android.content.Context
+import android.graphics.Typeface
+import android.text.style.StyleSpan
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationCompat.MessagingStyle
+import androidx.core.text.buildSpannedString
+import androidx.core.text.inSpans
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider
+import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
+import io.element.android.libraries.push.impl.notifications.factories.DefaultNotificationCreator
+import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.services.toolbox.api.strings.StringProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+
+interface OnRedactedEventReceived {
+    fun onRedactedEventReceived(redaction: ResolvedPushEvent.Redaction)
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultOnRedactedEventReceived @Inject constructor(
+    private val activeNotificationsProvider: ActiveNotificationsProvider,
+    private val notificationDisplayer: NotificationDisplayer,
+    private val coroutineScope: CoroutineScope,
+    @ApplicationContext private val context: Context,
+    private val stringProvider: StringProvider,
+) : OnRedactedEventReceived {
+    override fun onRedactedEventReceived(redaction: ResolvedPushEvent.Redaction) {
+        coroutineScope.launch {
+            val notifications = activeNotificationsProvider.getMessageNotificationsForRoom(
+                redaction.sessionId,
+                redaction.roomId,
+            )
+            if (notifications.isEmpty()) {
+                Timber.d("No notifications found for redacted event")
+            }
+            notifications.forEach { statusBarNotification ->
+                val notification = statusBarNotification.notification
+                val messagingStyle = MessagingStyle.extractMessagingStyleFromNotification(notification)
+                if (messagingStyle == null) {
+                    Timber.w("Unable to retrieve messaging style from notification")
+                    return@forEach
+                }
+                val messageToRedactIndex = messagingStyle.messages.indexOfFirst { message ->
+                    message.extras.getString(DefaultNotificationCreator.MESSAGE_EVENT_ID) == redaction.redactedEventId.value
+                }
+                if (messageToRedactIndex == -1) {
+                    Timber.d("Unable to find the message to remove from notification")
+                    return@forEach
+                }
+                val oldMessage = messagingStyle.messages[messageToRedactIndex]
+                val content = buildSpannedString {
+                    inSpans(StyleSpan(Typeface.ITALIC)) {
+                        append(stringProvider.getString(CommonStrings.common_message_removed))
+                    }
+                }
+                val newMessage = MessagingStyle.Message(
+                    content,
+                    oldMessage.timestamp,
+                    oldMessage.person
+                )
+                messagingStyle.messages[messageToRedactIndex] = newMessage
+                notificationDisplayer.showNotificationMessage(
+                    statusBarNotification.tag,
+                    statusBarNotification.id,
+                    NotificationCompat.Builder(context, notification)
+                        .setStyle(messagingStyle)
+                        .build()
+                )
+            }
+        }
+    }
+}

diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
index a84fe1edf63..3b395309784 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
@@ -63,6 +63,8 @@ const val A_MESSAGE = "Hello world!"
 const val A_REPLY = "OK, I'll be there!"
 const val ANOTHER_MESSAGE = "Hello universe!"

+const val A_REDACTION_REASON = "A redaction reason"
+
 const val A_HOMESERVER_URL = "matrix.org"
 const val A_HOMESERVER_URL_2 = "matrix-client.org"

diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt
index 70403e19861..04c2a38a340 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt
@@ -36,27 +36,6 @@ import org.robolectric.RobolectricTestRunner
 class DefaultActiveNotificationsProviderTest {
     private val notificationIdProvider = NotificationIdProvider

-    @Test
-    fun `getAllNotifications with no active notifications returns empty list`() {
-        val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = emptyList())
-
-        val emptyNotifications = activeNotificationsProvider.getAllNotifications()
-        assertThat(emptyNotifications).isEmpty()
-    }
-
-    @Test
-    fun `getAllNotifications with active notifications returns all`() {
-        val activeNotifications = listOf(
-            aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
-            aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
-            aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
-        )
-        val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
-
-        val result = activeNotificationsProvider.getAllNotifications()
-        assertThat(result).hasSize(3)
-    }
-
     @Test
     fun `getNotificationsForSession returns only notifications for that session id`() {
         val activeNotifications = listOf(
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
index 0c0dbcc495e..2623bc87416 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
@@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageT
 import io.element.android.libraries.matrix.test.AN_EVENT_ID
 import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
 import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.A_REDACTION_REASON
 import io.element.android.libraries.matrix.test.A_ROOM_ID
 import io.element.android.libraries.matrix.test.A_SESSION_ID
 import io.element.android.libraries.matrix.test.A_USER_ID_2
@@ -52,6 +53,7 @@ import io.element.android.libraries.push.impl.notifications.model.FallbackNotifi
 import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
 import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
 import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
+import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
 import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
 import io.element.android.services.toolbox.impl.systemclock.DefaultSystemClock
 import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
@@ -104,7 +106,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Hello world")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Hello world")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -123,7 +127,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Hello world", hasMentionOrReply = true)
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Hello world", hasMentionOrReply = true)
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -146,7 +152,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Hello world")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Hello world")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -169,7 +177,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Hello world")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Hello world")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -186,7 +196,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Audio")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Audio")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -203,7 +215,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Video")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Video")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -220,7 +234,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Voice message")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Voice message")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -237,7 +253,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Image")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Image")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -254,7 +272,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Sticker")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Sticker")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -271,7 +291,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "File")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "File")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -288,7 +310,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Location")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Location")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -305,7 +329,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Notice")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Notice")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -322,7 +348,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "* Bob is happy")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "* Bob is happy")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -339,7 +367,9 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = createNotifiableMessageEvent(body = "Poll: A question")
+        val expectedResult = ResolvedPushEvent.Event(
+            createNotifiableMessageEvent(body = "Poll: A question")
+        )
         assertThat(result).isEqualTo(expectedResult)
     }

@@ -357,21 +387,23 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = InviteNotifiableEvent(
-            sessionId = A_SESSION_ID,
-            roomId = A_ROOM_ID,
-            eventId = AN_EVENT_ID,
-            editedEventId = null,
-            canBeReplaced = true,
-            roomName = null,
-            noisy = false,
-            title = null,
-            description = "Invited you to join the room",
-            type = null,
-            timestamp = A_TIMESTAMP,
-            soundName = null,
-            isRedacted = false,
-            isUpdated = false,
+        val expectedResult = ResolvedPushEvent.Event(
+            InviteNotifiableEvent(
+                sessionId = A_SESSION_ID,
+                roomId = A_ROOM_ID,
+                eventId = AN_EVENT_ID,
+                editedEventId = null,
+                canBeReplaced = true,
+                roomName = null,
+                noisy = false,
+                title = null,
+                description = "Invited you to join the room",
+                type = null,
+                timestamp = A_TIMESTAMP,
+                soundName = null,
+                isRedacted = false,
+                isUpdated = false,
+            )
         )
         assertThat(result).isEqualTo(expectedResult)
     }
@@ -390,21 +422,23 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = InviteNotifiableEvent(
-            sessionId = A_SESSION_ID,
-            roomId = A_ROOM_ID,
-            eventId = AN_EVENT_ID,
-            editedEventId = null,
-            canBeReplaced = true,
-            roomName = null,
-            noisy = false,
-            title = null,
-            description = "Invited you to chat",
-            type = null,
-            timestamp = A_TIMESTAMP,
-            soundName = null,
-            isRedacted = false,
-            isUpdated = false,
+        val expectedResult = ResolvedPushEvent.Event(
+            InviteNotifiableEvent(
+                sessionId = A_SESSION_ID,
+                roomId = A_ROOM_ID,
+                eventId = AN_EVENT_ID,
+                editedEventId = null,
+                canBeReplaced = true,
+                roomName = null,
+                noisy = false,
+                title = null,
+                description = "Invited you to chat",
+                type = null,
+                timestamp = A_TIMESTAMP,
+                soundName = null,
+                isRedacted = false,
+                isUpdated = false,
+            )
         )
         assertThat(result).isEqualTo(expectedResult)
     }
@@ -435,16 +469,18 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = FallbackNotifiableEvent(
-            sessionId = A_SESSION_ID,
-            roomId = A_ROOM_ID,
-            eventId = AN_EVENT_ID,
-            editedEventId = null,
-            description = "Notification",
-            canBeReplaced = true,
-            isRedacted = false,
-            isUpdated = false,
-            timestamp = A_FAKE_TIMESTAMP,
+        val expectedResult = ResolvedPushEvent.Event(
+            FallbackNotifiableEvent(
+                sessionId = A_SESSION_ID,
+                roomId = A_ROOM_ID,
+                eventId = AN_EVENT_ID,
+                editedEventId = null,
+                description = "Notification",
+                canBeReplaced = true,
+                isRedacted = false,
+                isUpdated = false,
+                timestamp = A_FAKE_TIMESTAMP,
+            )
         )
         assertThat(result).isEqualTo(expectedResult)
     }
@@ -459,27 +495,29 @@ class DefaultNotifiableEventResolverTest {
             )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
-        val expectedResult = NotifiableMessageEvent(
-            sessionId = A_SESSION_ID,
-            roomId = A_ROOM_ID,
-            eventId = AN_EVENT_ID,
-            editedEventId = null,
-            canBeReplaced = false,
-            senderId = A_USER_ID_2,
-            noisy = false,
-            timestamp = A_TIMESTAMP,
-            senderDisambiguatedDisplayName = "Bob",
-            body = "Call in progress (unsupported)",
-            imageUriString = null,
-            threadId = null,
-            roomName = null,
-            roomAvatarPath = null,
-            senderAvatarPath = null,
-            soundName = null,
-            outGoingMessage = false,
-            outGoingMessageFailed = false,
-            isRedacted = false,
-            isUpdated = false
+        val expectedResult = ResolvedPushEvent.Event(
+            NotifiableMessageEvent(
+                sessionId = A_SESSION_ID,
+                roomId = A_ROOM_ID,
+                eventId = AN_EVENT_ID,
+                editedEventId = null,
+                canBeReplaced = false,
+                senderId = A_USER_ID_2,
+                noisy = false,
+                timestamp = A_TIMESTAMP,
+                senderDisambiguatedDisplayName = "Bob",
+                body = "Call in progress (unsupported)",
+                imageUriString = null,
+                threadId = null,
+                roomName = null,
+                roomAvatarPath = null,
+                senderAvatarPath = null,
+                soundName = null,
+                outGoingMessage = false,
+                outGoingMessageFailed = false,
+                isRedacted = false,
+                isUpdated = false
+            )
         )
         assertThat(result).isEqualTo(expectedResult)
     }
@@ -498,21 +536,23 @@ class DefaultNotifiableEventResolverTest {
                 )
             )
         )
-        val expectedResult = NotifiableRingingCallEvent(
-            sessionId = A_SESSION_ID,
-            roomId = A_ROOM_ID,
-            eventId = AN_EVENT_ID,
-            senderId = A_USER_ID_2,
-            roomName = null,
-            editedEventId = null,
-            description = "Incoming call",
-            timestamp = timestamp,
-            canBeReplaced = true,
-            isRedacted = false,
-            isUpdated = false,
-            senderDisambiguatedDisplayName = "Bob",
-            senderAvatarUrl = null,
-            callNotifyType = CallNotifyType.RING,
+        val expectedResult = ResolvedPushEvent.Event(
+            NotifiableRingingCallEvent(
+                sessionId = A_SESSION_ID,
+                roomId = A_ROOM_ID,
+                eventId = AN_EVENT_ID,
+                senderId = A_USER_ID_2,
+                roomName = null,
+                editedEventId = null,
+                description = "Incoming call",
+                timestamp = timestamp,
+                canBeReplaced = true,
+                isRedacted = false,
+                isUpdated = false,
+                senderDisambiguatedDisplayName = "Bob",
+                senderAvatarUrl = null,
+                callNotifyType = CallNotifyType.RING,
+            )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
         assertThat(result).isEqualTo(expectedResult)
@@ -531,22 +571,24 @@ class DefaultNotifiableEventResolverTest {
                 )
             )
         )
-        val expectedResult = NotifiableMessageEvent(
-            sessionId = A_SESSION_ID,
-            eventId = AN_EVENT_ID,
-            editedEventId = null,
-            noisy = true,
-            timestamp = 0L,
-            senderDisambiguatedDisplayName = "Bob",
-            senderId = UserId("@bob:server.org"),
-            body = "☎\uFE0F Incoming call",
-            roomId = A_ROOM_ID,
-            threadId = null,
-            roomName = null,
-            canBeReplaced = false,
-            isRedacted = false,
-            imageUriString = null,
-            type = EventType.CALL_NOTIFY,
+        val expectedResult = ResolvedPushEvent.Event(
+            NotifiableMessageEvent(
+                sessionId = A_SESSION_ID,
+                eventId = AN_EVENT_ID,
+                editedEventId = null,
+                noisy = true,
+                timestamp = 0L,
+                senderDisambiguatedDisplayName = "Bob",
+                senderId = UserId("@bob:server.org"),
+                body = "☎\uFE0F Incoming call",
+                roomId = A_ROOM_ID,
+                threadId = null,
+                roomName = null,
+                canBeReplaced = false,
+                isRedacted = false,
+                imageUriString = null,
+                type = EventType.CALL_NOTIFY,
+            )
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
         assertThat(result).isEqualTo(expectedResult)
@@ -564,27 +606,67 @@ class DefaultNotifiableEventResolverTest {
                 )
             )
         )
-        val expectedResult = NotifiableMessageEvent(
+        val expectedResult = ResolvedPushEvent.Event(
+            NotifiableMessageEvent(
+                sessionId = A_SESSION_ID,
+                eventId = AN_EVENT_ID,
+                editedEventId = null,
+                noisy = true,
+                timestamp = A_TIMESTAMP,
+                senderDisambiguatedDisplayName = "Bob",
+                senderId = UserId("@bob:server.org"),
+                body = "☎\uFE0F Incoming call",
+                roomId = A_ROOM_ID,
+                threadId = null,
+                roomName = null,
+                canBeReplaced = false,
+                isRedacted = false,
+                imageUriString = null,
+                type = EventType.CALL_NOTIFY,
+            )
+        )
+        val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
+        assertThat(result).isEqualTo(expectedResult)
+    }
+
+    @Test
+    fun `resolve RoomRedaction`() = runTest {
+        val sut = createDefaultNotifiableEventResolver(
+            notificationResult = Result.success(
+                createNotificationData(
+                    content = NotificationContent.MessageLike.RoomRedaction(
+                        AN_EVENT_ID_2,
+                        A_REDACTION_REASON,
+                    )
+                )
+            )
+        )
+        val expectedResult = ResolvedPushEvent.Redaction(
             sessionId = A_SESSION_ID,
-            eventId = AN_EVENT_ID,
-            editedEventId = null,
-            noisy = true,
-            timestamp = A_TIMESTAMP,
-            senderDisambiguatedDisplayName = "Bob",
-            senderId = UserId("@bob:server.org"),
-            body = "☎\uFE0F Incoming call",
             roomId = A_ROOM_ID,
-            threadId = null,
-            roomName = null,
-            canBeReplaced = false,
-            isRedacted = false,
-            imageUriString = null,
-            type = EventType.CALL_NOTIFY,
+            redactedEventId = AN_EVENT_ID_2,
+            reason = A_REDACTION_REASON,
         )
         val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
         assertThat(result).isEqualTo(expectedResult)
     }

+    @Test
+    fun `resolve RoomRedaction with null redactedEventId should return null`() = runTest {
+        val sut = createDefaultNotifiableEventResolver(
+            notificationResult = Result.success(
+                createNotificationData(
+                    content = NotificationContent.MessageLike.RoomRedaction(
+                        null,
+                        A_REDACTION_REASON,
+                    )
+                )
+            )
+        )
+        val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
+        assertThat(result).isNull()
+    }
+
     @Test
     fun `resolve null cases`() {
         testNull(NotificationContent.MessageLike.CallAnswer)
@@ -598,7 +680,6 @@ class DefaultNotifiableEventResolverTest {
         testNull(NotificationContent.MessageLike.KeyVerificationMac)
         testNull(NotificationContent.MessageLike.KeyVerificationDone)
         testNull(NotificationContent.MessageLike.ReactionContent(relatedEventId = AN_EVENT_ID_2.value))
-        testNull(NotificationContent.MessageLike.RoomRedaction(redactedEventId = AN_EVENT_ID_2.value, reason = null))
         testNull(NotificationContent.MessageLike.Sticker)
         testNull(NotificationContent.StateEvent.PolicyRuleRoom)
         testNull(NotificationContent.StateEvent.PolicyRuleServer)
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt
index bbe5e273b27..7d1e8069117 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt
@@ -72,7 +72,7 @@ class DefaultNotificationDrawerManagerTest {
         // For now just call all the API. Later, add more valuable tests.
         val matrixUser = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice", avatarUrl = "mxc://data")
         val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator(
-            createRoomMessageResult = lambdaRecorder { user, _, roomId, _, existingNotification, ->
+            createRoomMessageResult = lambdaRecorder { user, _, roomId, _, existingNotification ->
                 assertThat(user).isEqualTo(matrixUser)
                 assertThat(roomId).isEqualTo(A_ROOM_ID)
                 assertThat(existingNotification).isNull()
@@ -167,11 +167,12 @@ class DefaultNotificationDrawerManagerTest {
         }
         val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID)
         val activeNotificationsProvider = FakeActiveNotificationsProvider(
-            mutableListOf(
+            getSummaryNotificationResult = {
                 mockk {
                     every { id } returns summaryId
                 }
-            )
+            },
+            countResult = { 1 },
         )
         val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager(
             notificationManager = notificationManager,
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt
index a9ccabaa6d6..234f2f11f7e 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt
@@ -19,13 +19,13 @@ package io.element.android.libraries.push.impl.notifications
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.RoomId
 import io.element.android.libraries.matrix.api.core.SessionId
-import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
+import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
 import io.element.android.tests.testutils.lambda.lambdaError

 class FakeNotifiableEventResolver(
-    private val notifiableEventResult: (SessionId, RoomId, EventId) -> NotifiableEvent? = { _, _, _ -> lambdaError() }
+    private val notifiableEventResult: (SessionId, RoomId, EventId) -> ResolvedPushEvent? = { _, _, _ -> lambdaError() }
 ) : NotifiableEventResolver {
-    override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
+    override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): ResolvedPushEvent? {
         return notifiableEventResult(sessionId, roomId, eventId)
     }
 }
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt
index 680688d3dcd..d99a4264c01 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt
@@ -22,33 +22,34 @@ import io.element.android.libraries.matrix.api.core.SessionId
 import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider

 class FakeActiveNotificationsProvider(
-    var activeNotifications: MutableList<StatusBarNotification> = mutableListOf(),
+    private val getMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
+    private val getNotificationsForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() },
+    private val getMembershipNotificationForSessionResult: (SessionId) -> List<StatusBarNotification> = { emptyList() },
+    private val getMembershipNotificationForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
+    private val getSummaryNotificationResult: (SessionId) -> StatusBarNotification? = { null },
+    private val countResult: (SessionId) -> Int = { 0 },
 ) : ActiveNotificationsProvider {
-    override fun getAllNotifications(): List<StatusBarNotification> {
-        return activeNotifications
-    }
-
     override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
-        return activeNotifications
+        return getMessageNotificationsForRoomResult(sessionId, roomId)
     }

     override fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification> {
-        return activeNotifications
+        return getNotificationsForSessionResult(sessionId)
     }

     override fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification> {
-        return activeNotifications
+        return getMembershipNotificationForSessionResult(sessionId)
     }

     override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
-        return activeNotifications
+        return getMembershipNotificationForRoomResult(sessionId, roomId)
     }

     override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? {
-        return activeNotifications.firstOrNull()
+        return getSummaryNotificationResult(sessionId)
     }

     override fun count(sessionId: SessionId): Int {
-        return activeNotifications.size
+        return countResult(sessionId)
     }
 }
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt
new file mode 100644
index 00000000000..bb2f3184679
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultOnRedactedEventReceivedTest.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl.push
+
+import android.service.notification.StatusBarNotification
+import androidx.test.platform.app.InstrumentationRegistry
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
+import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
+import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
+import io.element.android.services.toolbox.test.strings.FakeStringProvider
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class DefaultOnRedactedEventReceivedTest {
+    @Test
+    fun `when no notifications are found, nothing happen`() = runTest {
+        val sut = createDefaultOnRedactedEventReceived(
+            getMessageNotificationsForRoomResult = { _, _ -> emptyList() }
+        )
+        sut.onRedactedEventReceived(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null))
+    }
+
+    @Test
+    fun `when a notification is found, try to retrieve the message`() = runTest {
+        val sut = createDefaultOnRedactedEventReceived(
+            getMessageNotificationsForRoomResult = { _, _ ->
+                listOf(
+                    mockk {
+                        every { notification } returns mockk {}
+                    }
+                )
+            }
+        )
+        sut.onRedactedEventReceived(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null))
+    }
+
+    private fun TestScope.createDefaultOnRedactedEventReceived(
+        getMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> lambdaError() },
+    ): DefaultOnRedactedEventReceived {
+        val context = InstrumentationRegistry.getInstrumentation().context
+        return DefaultOnRedactedEventReceived(
+            activeNotificationsProvider = FakeActiveNotificationsProvider(
+                getMessageNotificationsForRoomResult = getMessageNotificationsForRoomResult,
+                getNotificationsForSessionResult = { lambdaError() },
+                getMembershipNotificationForSessionResult = { lambdaError() },
+                getMembershipNotificationForRoomResult = { _, _ -> lambdaError() },
+                getSummaryNotificationResult = { lambdaError() },
+                countResult = { lambdaError() },
+            ),
+            notificationDisplayer = FakeNotificationDisplayer(),
+            coroutineScope = this,
+            context = context,
+            stringProvider = FakeStringProvider(),
+        )
+    }
+}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt
index 160f9f5135b..2c287ee2944 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt
@@ -30,8 +30,10 @@ import io.element.android.libraries.matrix.api.core.UserId
 import io.element.android.libraries.matrix.api.notification.CallNotifyType
 import io.element.android.libraries.matrix.api.timeline.item.event.EventType
 import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
 import io.element.android.libraries.matrix.test.A_ROOM_ID
 import io.element.android.libraries.matrix.test.A_SECRET
+import io.element.android.libraries.matrix.test.A_SESSION_ID
 import io.element.android.libraries.matrix.test.A_USER_ID
 import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
 import io.element.android.libraries.matrix.test.core.aBuildMeta
@@ -40,6 +42,7 @@ import io.element.android.libraries.push.impl.notifications.channels.FakeNotific
 import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
 import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
 import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
+import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
 import io.element.android.libraries.push.impl.test.DefaultTestPush
 import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
 import io.element.android.libraries.pushproviders.api.PushData
@@ -61,7 +64,9 @@ class DefaultPushHandlerTest {
     fun `when classical PushData is received, the notification drawer is informed`() = runTest {
         val aNotifiableMessageEvent = aNotifiableMessageEvent()
         val notifiableEventResult =
-            lambdaRecorder<SessionId, RoomId, EventId, NotifiableEvent> { _, _, _ -> aNotifiableMessageEvent }
+            lambdaRecorder<SessionId, RoomId, EventId, ResolvedPushEvent> { _, _, _ ->
+                ResolvedPushEvent.Event(aNotifiableMessageEvent)
+            }
         val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
         val incrementPushCounterResult = lambdaRecorder<Unit> {}
         val aPushData = PushData(
@@ -94,7 +99,9 @@ class DefaultPushHandlerTest {
         runTest {
             val aNotifiableMessageEvent = aNotifiableMessageEvent()
             val notifiableEventResult =
-                lambdaRecorder<SessionId, RoomId, EventId, NotifiableEvent> { _, _, _ -> aNotifiableMessageEvent }
+                lambdaRecorder<SessionId, RoomId, EventId, ResolvedPushEvent.Event> { _, _, _ ->
+                    ResolvedPushEvent.Event(aNotifiableMessageEvent)
+                }
             val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
             val incrementPushCounterResult = lambdaRecorder<Unit> {}
             val aPushData = PushData(
@@ -128,7 +135,9 @@ class DefaultPushHandlerTest {
         runTest {
             val aNotifiableMessageEvent = aNotifiableMessageEvent()
             val notifiableEventResult =
-                lambdaRecorder<SessionId, RoomId, EventId, NotifiableEvent> { _, _, _ -> aNotifiableMessageEvent }
+                lambdaRecorder<SessionId, RoomId, EventId, ResolvedPushEvent.Event> { _, _, _ ->
+                    ResolvedPushEvent.Event(aNotifiableMessageEvent)
+                }
             val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
             val incrementPushCounterResult = lambdaRecorder<Unit> {}
             val aPushData = PushData(
@@ -164,7 +173,9 @@ class DefaultPushHandlerTest {
         runTest {
             val aNotifiableMessageEvent = aNotifiableMessageEvent()
             val notifiableEventResult =
-                lambdaRecorder<SessionId, RoomId, EventId, NotifiableEvent> { _, _, _ -> aNotifiableMessageEvent }
+                lambdaRecorder<SessionId, RoomId, EventId, ResolvedPushEvent.Event> { _, _, _ ->
+                    ResolvedPushEvent.Event(aNotifiableMessageEvent)
+                }
             val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
             val incrementPushCounterResult = lambdaRecorder<Unit> {}
             val aPushData = PushData(
@@ -197,7 +208,7 @@ class DefaultPushHandlerTest {
     fun `when classical PushData is received, but not able to resolve the event, nothing happen`() =
         runTest {
             val notifiableEventResult =
-                lambdaRecorder<SessionId, RoomId, EventId, NotifiableEvent?> { _, _, _ -> null }
+                lambdaRecorder<SessionId, RoomId, EventId, ResolvedPushEvent.Event?> { _, _, _ -> null }
             val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
             val incrementPushCounterResult = lambdaRecorder<Unit> {}
             val aPushData = PushData(
@@ -240,7 +251,9 @@ class DefaultPushHandlerTest {
         val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
         val defaultPushHandler = createDefaultPushHandler(
             elementCallEntryPoint = elementCallEntryPoint,
-            notifiableEventResult = { _, _, _ -> aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli()) },
+            notifiableEventResult = { _, _, _ ->
+                ResolvedPushEvent.Event(aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli()))
+            },
             incrementPushCounterResult = {},
             pushClientSecret = FakePushClientSecret(
                 getUserIdFromSecretResult = { A_USER_ID }
@@ -265,7 +278,9 @@ class DefaultPushHandlerTest {
         val defaultPushHandler = createDefaultPushHandler(
             elementCallEntryPoint = elementCallEntryPoint,
             onNotifiableEventReceived = onNotifiableEventReceived,
-            notifiableEventResult = { _, _, _ -> aNotifiableMessageEvent(type = EventType.CALL_NOTIFY) },
+            notifiableEventResult = { _, _, _ ->
+                ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.CALL_NOTIFY))
+            },
             incrementPushCounterResult = {},
             pushClientSecret = FakePushClientSecret(
                 getUserIdFromSecretResult = { A_USER_ID }
@@ -291,7 +306,9 @@ class DefaultPushHandlerTest {
         val defaultPushHandler = createDefaultPushHandler(
             elementCallEntryPoint = elementCallEntryPoint,
             onNotifiableEventReceived = onNotifiableEventReceived,
-            notifiableEventResult = { _, _, _ -> aNotifiableCallEvent() },
+            notifiableEventResult = { _, _, _ ->
+                ResolvedPushEvent.Event(aNotifiableCallEvent())
+            },
             incrementPushCounterResult = {},
             userPushStore = FakeUserPushStore().apply {
                 setNotificationEnabledForDevice(false)
@@ -305,6 +322,37 @@ class DefaultPushHandlerTest {
         onNotifiableEventReceived.assertions().isNeverCalled()
     }

+    @Test
+    fun `when a redaction is received, the onRedactedEventReceived is informed`() = runTest {
+        val aPushData = PushData(
+            eventId = AN_EVENT_ID,
+            roomId = A_ROOM_ID,
+            unread = 0,
+            clientSecret = A_SECRET,
+        )
+        val aRedaction = ResolvedPushEvent.Redaction(
+            sessionId = A_SESSION_ID,
+            roomId = A_ROOM_ID,
+            redactedEventId = AN_EVENT_ID_2,
+            reason = null
+        )
+        val onRedactedEventReceived = lambdaRecorder<ResolvedPushEvent.Redaction, Unit> { }
+        val incrementPushCounterResult = lambdaRecorder<Unit> {}
+        val defaultPushHandler = createDefaultPushHandler(
+            onRedactedEventReceived = onRedactedEventReceived,
+            incrementPushCounterResult = incrementPushCounterResult,
+            notifiableEventResult = { _, _, _ -> aRedaction },
+            pushClientSecret = FakePushClientSecret(
+                getUserIdFromSecretResult = { A_USER_ID }
+            ),
+        )
+        defaultPushHandler.handle(aPushData)
+        incrementPushCounterResult.assertions()
+            .isCalledOnce()
+        onRedactedEventReceived.assertions().isCalledOnce()
+            .with(value(aRedaction))
+    }
+
     @Test
     fun `when diagnostic PushData is received, the diagnostic push handler is informed`() =
         runTest {
@@ -327,7 +375,8 @@ class DefaultPushHandlerTest {

     private fun createDefaultPushHandler(
         onNotifiableEventReceived: (NotifiableEvent) -> Unit = { lambdaError() },
-        notifiableEventResult: (SessionId, RoomId, EventId) -> NotifiableEvent? = { _, _, _ -> lambdaError() },
+        onRedactedEventReceived: (ResolvedPushEvent.Redaction) -> Unit = { lambdaError() },
+        notifiableEventResult: (SessionId, RoomId, EventId) -> ResolvedPushEvent? = { _, _, _ -> lambdaError() },
         incrementPushCounterResult: () -> Unit = { lambdaError() },
         userPushStore: UserPushStore = FakeUserPushStore(),
         pushClientSecret: PushClientSecret = FakePushClientSecret(),
@@ -339,6 +388,7 @@ class DefaultPushHandlerTest {
     ): DefaultPushHandler {
         return DefaultPushHandler(
             onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceived),
+            onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventReceived),
             notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventResult),
             incrementPushDataStore = object : IncrementPushDataStore {
                 override suspend fun incrementPushCounter() {
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnRedactedEventReceived.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnRedactedEventReceived.kt
new file mode 100644
index 00000000000..6a602436850
--- /dev/null
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnRedactedEventReceived.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.libraries.push.impl.push
+
+import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeOnRedactedEventReceived(
+    private val onRedactedEventReceivedResult: (ResolvedPushEvent.Redaction) -> Unit = { lambdaError() },
+) : OnRedactedEventReceived {
+    override fun onRedactedEventReceived(redaction: ResolvedPushEvent.Redaction) {
+        onRedactedEventReceivedResult(redaction)
+    }
+}


Big emoji-only messages

## Content

Render emoji-only messages in a larger text

Adapted from SpiritCroc's SchildNext implementation from https://github.com/SchildiChat/schildichat-android-next/commit/7eba87f02e884b9b921aa4e74dd0a737e4da31ad

Authored-by: SpiritCroc <dev@spiritcroc.de>
Submitted-by: Joe Groocock <me@frebib.net>

## Motivation and context

Parity with Element-X iOS, Element Web, and pretty much any other chat client ever.

Fixes #1438

## Screenshots / GIFs

|Before|After|
|-|-|
|![Screenshot_20240811-175500](https://github.com/user-attachments/assets/8fd1c3a8-eaa6-4a54-8d5e-d37b8bb3e487)|![Screenshot_20240811-175137](https://github.com/user-attachments/assets/072c281f-955e-425a-ac8b-82622f436c71)|

## Tests

<!-- Explain how you tested your development -->

- Step 1
- Step 2
- Step ...

## Tested devices

- [x] Physical
- [ ] Emulator
- OS version(s): Android 15

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 23
- [x] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [x] Pull request is based on the develop branch
- [x] Pull request title will be used in the release note, it clearly define what will change for the user
- [x] Pull request includes screenshots or videos if containing UI changes
- [x] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [x] You've made a self review of your PR

Implement large emoji

Implement large emoji
### Your use case

![image](https://github.com/vector-im/element-x-android/assets/4626865/6d58ce1e-2c31-44dc-9e63-661794003fa9)

Left: Element X iOS. Right: Element X Android.

We should match the implementation here (& Element Web) where we enlarge emoji when sent on their own.

### Have you considered any alternatives?

_No response_

### Additional context

_No response_

### Are you willing to provide a PR?

No

./gradlew :features:messages:impl:testDebugUnitTest --tests "io.element.android.features.messages.impl.utils.EmojiTest"

diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts
index c1d3067510e..7f451a54a50 100644
--- a/features/messages/impl/build.gradle.kts
+++ b/features/messages/impl/build.gradle.kts
@@ -72,6 +72,7 @@ dependencies {
     implementation(libs.androidx.constraintlayout.compose)
     implementation(libs.androidx.media3.exoplayer)
     implementation(libs.androidx.media3.ui)
+    implementation(libs.sigpwned.emoji4j)
     implementation(libs.vanniktech.blurhash)
     implementation(libs.telephoto.zoomableimage)
     implementation(libs.matrix.emojibase.bindings)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
index e691c1e5499..d6d34418bd0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
@@ -35,6 +35,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
 import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
+import io.element.android.features.messages.impl.utils.containsOnlyEmojis
 import io.element.android.libraries.designsystem.preview.ElementPreview
 import io.element.android.libraries.designsystem.preview.PreviewsDayNight
 import io.element.android.libraries.matrix.api.core.UserId
@@ -54,9 +55,15 @@ fun TimelineItemTextView(
     modifier: Modifier = Modifier,
     onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {},
 ) {
+    val emojiOnly = (content.formattedBody == null || content.formattedBody.toString() == content.body) &&
+        content.body.replace(" ", "").containsOnlyEmojis()
+    val textStyle = when {
+        emojiOnly -> ElementTheme.typography.fontHeadingXlRegular
+        else -> ElementTheme.typography.fontBodyLgRegular
+    }
     CompositionLocalProvider(
         LocalContentColor provides ElementTheme.colors.textPrimary,
-        LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
+        LocalTextStyle provides textStyle
     ) {
         val body = getTextWithResolvedMentions(content)
         Box(modifier.semantics { contentDescription = content.plainText }) {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt
index 087de1753ab..35baf24207d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/AggregatedReactionProvider.kt
@@ -38,10 +38,10 @@ fun anAggregatedReaction(
     count: Int = 1,
     isHighlighted: Boolean = false,
 ): AggregatedReaction {
+    val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT, java.util.Locale.US)
+    val date = Date(1_689_061_264L)
     val senders = buildList {
         repeat(count) { index ->
-            val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT)
-            val date = Date(1_689_061_264L)
             add(
                 AggregatedReactionSender(
                     senderId = if (isHighlighted && index == 0) userId else UserId("@user$index:server.org"),
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
index 29fa048e1f5..3cdfb735109 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
@@ -42,6 +42,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
         aTimelineItemTextContent(),
         aTimelineItemUnknownContent(),
         aTimelineItemTextContent().copy(isEdited = true),
+        aTimelineItemTextContent(body = "😁")
     )
 }

diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/Emoji.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/Emoji.kt
new file mode 100644
index 00000000000..5ab5d1e2452
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/Emoji.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.utils
+
+import com.sigpwned.emoji4j.core.Grapheme.Type.EMOJI
+import com.sigpwned.emoji4j.core.Grapheme.Type.PICTOGRAPHIC
+import com.sigpwned.emoji4j.core.GraphemeMatchResult
+import com.sigpwned.emoji4j.core.GraphemeMatcher
+
+/**
+ * Returns true if the string consists exclusively of "emoji or pictographic graphemes".
+ */
+fun String.containsOnlyEmojis(): Boolean {
+    if (isEmpty()) return false
+
+    val matcher = GraphemeMatcher(this)
+    var m: GraphemeMatchResult? = null
+    var contiguous = true
+    var previous = 0
+    while (contiguous && matcher.find()) {
+        m = matcher.toMatchResult()
+        // Many non-"emoji" characters are pictographics. We only want to identify this specific range
+        // https://en.wikipedia.org/wiki/Miscellaneous_Symbols_and_Pictographs
+        val isEmoji = m!!.grapheme().type == EMOJI || m.grapheme().type == PICTOGRAPHIC && m.group() in "🌍".."🗺"
+        contiguous = isEmoji and (m.start() == previous)
+        previous = m.end()
+    }
+
+    return contiguous and (m?.end() == length)
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7c5f5e440ce..e2d98454718 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -193,6 +193,7 @@ matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.23.1"

 # Emojibase
 matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3"
+sigpwned_emoji4j = "com.sigpwned:emoji4j-core:15.1.0"

 # Di
 inject = "javax.inject:javax.inject:1"

diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/EmojiTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/EmojiTest.kt
new file mode 100644
index 00000000000..d8b823704f7
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/EmojiTest.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.messages.impl.utils
+
+import org.junit.Assert
+import org.junit.Test
+
+class EmojiTest {
+    @Test
+    fun validEmojis() {
+        // Simple single/multiple single-codepoint emojis per string
+        Assert.assertTrue("👍".containsOnlyEmojis())
+        Assert.assertTrue("😀".containsOnlyEmojis())
+        Assert.assertTrue("🙂🙁".containsOnlyEmojis())
+        Assert.assertTrue("👁❤🍝".containsOnlyEmojis()) // 👁 is a pictographic
+        Assert.assertTrue("👨👩👦1⃣🚀👳🏾♂🪩".containsOnlyEmojis())
+        Assert.assertTrue("🌍🌎🌏".containsOnlyEmojis())
+
+        // Awkward multi-codepoint graphemes
+        Assert.assertTrue("🧑🧑🧒🧒".containsOnlyEmojis())
+        Assert.assertTrue("🏴☠".containsOnlyEmojis())
+        Assert.assertTrue("👩🏿🔧".containsOnlyEmojis())
+
+        Assert.assertFalse("".containsOnlyEmojis())
+        Assert.assertFalse(" ".containsOnlyEmojis())
+        Assert.assertFalse("🙂 🙁".containsOnlyEmojis())
+        Assert.assertFalse(" 🙂 🙁 ".containsOnlyEmojis())
+        Assert.assertFalse("Hello".containsOnlyEmojis())
+        Assert.assertFalse("Hello 👋".containsOnlyEmojis())
+    }
+}
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.reactionsummary_SheetContent_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.reactionsummary_SheetContent_Day_0_en.png
index 3fdfe25ad29..d49c11a6a20 100644
--- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.reactionsummary_SheetContent_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.reactionsummary_SheetContent_Day_0_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:9490990c863318390d0f088ff82ad01a688c81cada63d749872b22b633fa12fe
-size 23040
+oid sha256:65eb29b745fabe2587019c169a48a862b129a3ece30cf9bc82aff09c67878b35
+size 23180
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.reactionsummary_SheetContent_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.reactionsummary_SheetContent_Night_0_en.png
index 1a24a720d85..df75d7f9096 100644
--- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.reactionsummary_SheetContent_Night_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.reactionsummary_SheetContent_Night_0_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:730c2c5ab52209366611546dbdbbed62214f6f5e13328be66cce5f08d8f4f0e5
-size 23125
+oid sha256:c1cf0091d377d8018d14804433aaa67d1b0b510778b6f8a4e8a73faff2b4c782
+size 23280
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png
new file mode 100644
index 00000000000..8baeaa25a57
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f6378c00fdec9f1d7e98835e8c894c33b4d2d14508df4d36107c9c74d05e8dc3
+size 63866
diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png
new file mode 100644
index 00000000000..ca782d0072d
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1536adcfba0421171f830d49751af7d5d9b39672cdb43b9ad1421e949515cbe8
+size 63888