Split notifications for messages in threads (#5595)

* Separate thread notifications into their own notifications when the feature flag is enabled.

Otherwise, set the `threadId` to null so it'll behave as usual. It's done this way to avoid having to inject `FeatureFlagService` in several places.

* Add permalink navigation to threads from notifications, focusing on the latest event in the list of messages of the notification tapped

* Fix redactions in threads

* Clear notifications for a thread when visiting it

* Fix opening a thread happening twice, first because of the `openThreadId` value, then because of the `focusedEventId` one

* Make opening a room through a notification also focus on the latest event

* Add helper `NotificationCreator.messageTag` function

* Remove unused `ROOM_CALL_NOTIFICATION_ID`: `FOREGROUND_SERVICE_NOTIFICATION_ID`+ `ForegroundServiceType` is used instead

* Simplify `DefaultDeepLinkCreator`

* Make sure the main timeline focuses on the thread root id too when navigating to a thread

* Handle "Mark as read" action for thread notification, using `timeline.markAsRead`

* Log failures to mark rooms as read using the notification action

---------

Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
Jorge Martin Espinosa 2025-10-30 16:15:00 +01:00 committed by GitHub
parent e8b7db22cd
commit 7facc40771
55 changed files with 702 additions and 284 deletions

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.push.impl.intent
import android.content.Intent
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.matrix.api.core.ThreadId
@ -20,5 +21,6 @@ interface IntentProvider {
sessionId: SessionId,
roomId: RoomId?,
threadId: ThreadId?,
eventId: EventId?,
): Intent
}

View file

@ -14,11 +14,21 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import timber.log.Timber
interface ActiveNotificationsProvider {
fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
/**
* Gets the displayed notifications for the combination of [sessionId], [roomId] and [threadId].
*/
fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification>
/**
* Gets all displayed notifications associated to [sessionId] and [roomId]. These will include all thread notifications as well.
*/
fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification>
fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification>
@ -44,9 +54,15 @@ class DefaultActiveNotificationsProvider(
return getNotificationsForSession(sessionId).filter { it.id == notificationId }
}
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification> {
val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
val expectedTag = NotificationCreator.messageTag(roomId, threadId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == expectedTag }
}
override fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag.startsWith(roomId.value) }
}
override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {

View file

@ -17,6 +17,8 @@ import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.EventId
@ -85,6 +87,7 @@ class DefaultNotifiableEventResolver(
private val permalinkParser: PermalinkParser,
private val callNotificationEventResolver: CallNotificationEventResolver,
private val fallbackNotificationFactory: FallbackNotificationFactory,
private val featureFlagService: FeatureFlagService,
) : NotifiableEventResolver {
override suspend fun resolveEvents(
sessionId: SessionId,
@ -141,7 +144,7 @@ class DefaultNotifiableEventResolver(
senderId = content.senderId,
roomId = roomId,
eventId = eventId,
threadId = threadId,
threadId = threadId.takeIf { featureFlagService.isFeatureEnabled(FeatureFlags.Threads) },
noisy = isNoisy,
timestamp = this.timestamp,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService
@ -93,10 +94,10 @@ class DefaultNotificationDrawerManager(
)
}
is NavigationState.Thread -> {
onEnteringThread(
navigationState.parentRoom.parentSpace.parentSession.sessionId,
navigationState.parentRoom.roomId,
navigationState.threadId
clearMessagesForThread(
sessionId = navigationState.parentRoom.parentSpace.parentSession.sessionId,
roomId = navigationState.parentRoom.roomId,
threadId = navigationState.threadId,
)
}
}
@ -145,6 +146,16 @@ class DefaultNotificationDrawerManager(
clearSummaryNotificationIfNeeded(sessionId)
}
/**
* Should be called when the application is currently opened and showing timeline for the given threadId.
* Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
*/
override fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
val tag = NotificationCreator.messageTag(roomId, threadId)
notificationManager.cancel(tag, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
clearSummaryNotificationIfNeeded(sessionId)
}
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
activeNotificationsProvider.getMembershipNotificationForSession(sessionId)
.forEach { notificationManager.cancel(it.tag, it.id) }
@ -176,16 +187,6 @@ class DefaultNotificationDrawerManager(
}
}
/**
* Should be called when the application is currently opened and showing timeline for the given threadId.
* Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
*/
@Suppress("UNUSED_PARAMETER")
private fun onEnteringThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) {
// TODO maybe we'll have to embed more data in the tag to get a threadId
// Do nothing for now
}
private suspend fun renderEvents(eventsToRender: List<NotifiableEvent>) {
// Group by sessionId
val eventsForSessions = eventsToRender.groupBy {

View file

@ -16,9 +16,11 @@ 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.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.impl.R
@ -72,8 +74,12 @@ class NotificationBroadcastReceiverHandler(
notificationCleaner.clearEvent(sessionId, eventId)
}
actionIds.markRoomRead -> if (roomId != null) {
notificationCleaner.clearMessagesForRoom(sessionId, roomId)
handleMarkAsRead(sessionId, roomId)
if (threadId == null) {
notificationCleaner.clearMessagesForRoom(sessionId, roomId)
} else {
notificationCleaner.clearMessagesForThread(sessionId, roomId, threadId)
}
handleMarkAsRead(sessionId, roomId, threadId)
}
actionIds.join -> if (roomId != null) {
notificationCleaner.clearMembershipNotificationForRoom(sessionId, roomId)
@ -96,7 +102,8 @@ class NotificationBroadcastReceiverHandler(
client.getRoom(roomId)?.leave()
}
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
@Suppress("unused")
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?) = appCoroutineScope.launch {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
val isSendPublicReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isSendPublicReadReceiptsEnabled().first()
val receiptType = if (isSendPublicReadReceiptsEnabled) {
@ -104,7 +111,26 @@ class NotificationBroadcastReceiverHandler(
} else {
ReceiptType.READ_PRIVATE
}
client.getRoom(roomId)?.markAsRead(receiptType = receiptType)
val room = client.getJoinedRoom(roomId) ?: return@launch
val timeline = if (threadId != null) {
room.createTimeline(CreateTimelineParams.Threaded(threadId)).getOrNull()
} else {
room.liveTimeline
}
timeline?.markAsRead(receiptType)
?.onSuccess {
if (threadId != null) {
Timber.d("Marked thread $threadId in room $roomId as read with receipt type $receiptType")
} else {
Timber.d("Marked room $roomId as read with receipt type $receiptType")
}
}
?.onFailure {
Timber.e(it, "Fails to mark as read with receipt type $receiptType")
}
if (timeline?.mode != Timeline.Mode.Live) {
timeline?.close()
}
}
private fun handleSmartReply(

View file

@ -18,6 +18,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
@ -82,32 +83,38 @@ class DefaultNotificationDataFactory(
): List<RoomNotification> {
val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() }
.groupBy { it.roomId }
return messagesToDisplay.map { (roomId, events) ->
return messagesToDisplay.flatMap { (roomId, events) ->
val roomName = events.lastOrNull()?.roomName ?: roomId.value
val isDm = events.lastOrNull()?.roomIsDm ?: false
val notification = roomGroupMessageCreator.createRoomMessage(
currentUser = currentUser,
events = events,
roomId = roomId,
imageLoader = imageLoader,
existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId),
color = color,
)
RoomNotification(
notification = notification,
roomId = roomId,
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDm),
messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy }
)
val eventsByThreadId = events.groupBy { it.threadId }
eventsByThreadId.map { (threadId, events) ->
val notification = roomGroupMessageCreator.createRoomMessage(
currentUser = currentUser,
events = events,
roomId = roomId,
threadId = threadId,
imageLoader = imageLoader,
existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId, threadId),
color = color,
)
RoomNotification(
notification = notification,
roomId = roomId,
threadId = threadId,
summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDm),
messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy }
)
}
}
}
private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted
private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId): Notification? {
return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId).firstOrNull()?.notification
private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): Notification? {
return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId, threadId).firstOrNull()?.notification
}
@JvmName("toNotificationInvites")
@ -228,6 +235,7 @@ class DefaultNotificationDataFactory(
data class RoomNotification(
val notification: Notification,
val roomId: RoomId,
val threadId: ThreadId?,
val summaryLine: CharSequence,
val messageCount: Int,
val latestTimestamp: Long,
@ -236,6 +244,7 @@ data class RoomNotification(
fun isDataEqualTo(other: RoomNotification): Boolean {
return notification == other.notification &&
roomId == other.roomId &&
threadId == other.threadId &&
summaryLine.toString() == other.summaryLine.toString() &&
messageCount == other.messageCount &&
latestTimestamp == other.latestTimestamp &&

View file

@ -15,6 +15,7 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
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
@ -64,8 +65,12 @@ class NotificationRenderer(
}
roomNotifications.forEach { notificationData ->
val tag = NotificationCreator.messageTag(
roomId = notificationData.roomId,
threadId = notificationData.threadId
)
notificationDisplayer.showNotificationMessage(
tag = notificationData.roomId.value,
tag = tag,
id = NotificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
notification = notificationData.notification
)

View file

@ -14,6 +14,7 @@ import coil3.ImageLoader
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.R
@ -27,6 +28,7 @@ interface RoomGroupMessageCreator {
currentUser: MatrixUser,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
threadId: ThreadId?,
imageLoader: ImageLoader,
existingNotification: Notification?,
@ColorInt color: Int,
@ -43,6 +45,7 @@ class DefaultRoomGroupMessageCreator(
currentUser: MatrixUser,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
threadId: ThreadId?,
imageLoader: ImageLoader,
existingNotification: Notification?,
@ColorInt color: Int,
@ -73,7 +76,7 @@ class DefaultRoomGroupMessageCreator(
customSound = events.last().soundName,
isUpdated = events.last().isUpdated,
),
threadId = lastKnownRoomEvent.threadId,
threadId = threadId,
largeIcon = largeBitmap,
lastMessageTimestamp = lastMessageTimestamp,
tickerText = tickerText,

View file

@ -107,7 +107,7 @@ class DefaultNotificationConversationService(
val shortcutInfo = ShortcutInfoCompat.Builder(context, createShortcutId(sessionId, roomId))
.setShortLabel(roomName)
.setIcon(icon)
.setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null))
.setIntent(intentProvider.getViewRoomIntent(sessionId, roomId, threadId = null, eventId = null))
.setCategories(categories)
.setLongLived(true)
.let {

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.push.impl.notifications.factories
import android.app.Notification
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Icon
import androidx.annotation.ColorInt
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.MessagingStyle
@ -20,7 +21,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -38,6 +39,7 @@ import io.element.android.libraries.push.impl.notifications.model.InviteNotifiab
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
interface NotificationCreator {
@ -86,6 +88,17 @@ interface NotificationCreator {
fun createDiagnosticNotification(
@ColorInt color: Int,
): Notification
companion object {
/**
* Creates a tag for a message notification given its [roomId] and optional [threadId].
*/
fun messageTag(roomId: RoomId, threadId: ThreadId?): String = if (threadId != null) {
"$roomId|$threadId"
} else {
roomId.value
}
}
}
@ContributesBinding(AppScope::class)
@ -99,7 +112,7 @@ class DefaultNotificationCreator(
private val quickReplyActionFactory: QuickReplyActionFactory,
private val bitmapLoader: NotificationBitmapLoader,
private val acceptInvitationActionFactory: AcceptInvitationActionFactory,
private val rejectInvitationActionFactory: RejectInvitationActionFactory
private val rejectInvitationActionFactory: RejectInvitationActionFactory,
) : NotificationCreator {
/**
* Create a notification for a Room.
@ -117,9 +130,10 @@ class DefaultNotificationCreator(
@ColorInt color: Int,
): Notification {
// Build the pending intent for when the notification is clicked
val eventId = events.firstOrNull()?.eventId
val openIntent = when {
threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo, threadId)
else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)
threadId != null -> pendingIntentFactory.createOpenThreadPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId, threadId)
else -> pendingIntentFactory.createOpenRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId, eventId)
}
val smallIcon = CommonDrawables.ic_notification
val containsMissedCall = events.any { it.type == EventType.RTC_NOTIFICATION }
@ -140,19 +154,30 @@ class DefaultNotificationCreator(
// Must match those created in the ShortcutInfoCompat.Builder()
// for the notification to appear as a "Conversation":
// https://developer.android.com/develop/ui/views/notifications/conversations
.setShortcutId(createShortcutId(roomInfo.sessionId, roomInfo.roomId))
.apply {
if (threadId == null) {
setShortcutId(createShortcutId(roomInfo.sessionId, roomInfo.roomId))
}
}
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
.setGroup(roomInfo.sessionId.value)
.setGroupSummary(false)
// In order to avoid notification making sound twice (due to the summary notification)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
// Remove notification after opening it or using an action
.setAutoCancel(true)
}
val messagingStyle = existingNotification?.let {
MessagingStyle.extractMessagingStyleFromNotification(it)
} ?: messagingStyleFromCurrentUser(roomInfo.sessionId, currentUser, imageLoader, roomInfo.roomDisplayName, !roomInfo.isDm)
} ?: messagingStyleFromCurrentUser(
user = currentUser,
imageLoader = imageLoader,
roomName = roomInfo.roomDisplayName,
isThread = threadId != null,
roomIsGroup = !roomInfo.isDm,
)
messagingStyle.addMessagesFromEvents(events, imageLoader)
@ -162,19 +187,6 @@ class DefaultNotificationCreator(
.setWhen(lastMessageTimestamp)
// MESSAGING_STYLE sets title and content for API 16 and above devices.
.setStyle(messagingStyle)
// Not needed anymore?
// Title for API < 16 devices.
.setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1))
// Content for API < 16 devices.
.setContentText(stringProvider.getString(R.string.notification_new_messages).annotateForDebug(2))
// Number of new notifications for API <24 (M and below) devices.
.setSubText(
stringProvider.getQuantityString(
R.plurals.notification_new_messages_for_room,
messagingStyle.messages.size,
messagingStyle.messages.size
).annotateForDebug(3)
)
.setSmallIcon(smallIcon)
// Set primary color (important for Wear 2.0 Notifications).
.setColor(color)
@ -197,8 +209,8 @@ class DefaultNotificationCreator(
// Clear existing actions since we might be updating an existing notification
clearActions()
// Add actions and notification intents
// Mark room as read
addAction(markAsReadActionFactory.create(roomInfo))
// Mark room/thread as read
addAction(markAsReadActionFactory.create(roomInfo, threadId))
// Quick reply
if (!roomInfo.hasSmartReplyError) {
val latestEventId = events.lastOrNull()?.eventId
@ -208,7 +220,7 @@ class DefaultNotificationCreator(
setContentIntent(openIntent)
}
if (largeIcon != null) {
setLargeIcon(largeIcon)
setLargeIcon(Icon.createWithBitmap(largeIcon))
}
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
@ -239,7 +251,7 @@ class DefaultNotificationCreator(
addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent))
addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent))
// Build the pending intent for when the notification is clicked
setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId))
setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId, null))
if (inviteNotifiableEvent.noisy) {
// Compat
@ -279,7 +291,7 @@ class DefaultNotificationCreator(
.setSmallIcon(smallIcon)
.setColor(color)
.setAutoCancel(true)
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId))
.setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(simpleNotifiableEvent.sessionId, simpleNotifiableEvent.roomId, null))
.apply {
if (simpleNotifiableEvent.noisy) {
// Compat
@ -447,21 +459,26 @@ class DefaultNotificationCreator(
}
private suspend fun messagingStyleFromCurrentUser(
sessionId: SessionId,
user: MatrixUser,
imageLoader: ImageLoader,
roomName: String,
isThread: Boolean,
roomIsGroup: Boolean
): MessagingStyle {
return MessagingStyle(
Person.Builder()
.setName(user.displayName?.annotateForDebug(50))
.setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader))
.setKey(sessionId.value)
.setKey(user.userId.value)
.build()
).also {
it.conversationTitle = roomName.takeIf { roomIsGroup }
it.isGroupConversation = roomIsGroup
it.conversationTitle = if (isThread) {
stringProvider.getString(CommonStrings.notification_thread_in_room, roomName)
} else {
roomName
}
// So the avatar is displayed even if they're part of a conversation
it.isGroupConversation = roomIsGroup || isThread
}
}

View file

@ -20,7 +20,6 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.intent.IntentProvider
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.TestNotificationReceiver
import io.element.android.services.toolbox.api.systemclock.SystemClock
@ -32,19 +31,19 @@ class PendingIntentFactory(
private val actionIds: NotificationActionIds,
) {
fun createOpenSessionPendingIntent(sessionId: SessionId): PendingIntent? {
return createRoomPendingIntent(sessionId = sessionId, roomId = null, threadId = null)
return createRoomPendingIntent(sessionId = sessionId, roomId = null, eventId = null, threadId = null)
}
fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? {
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, threadId = null)
fun createOpenRoomPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?): PendingIntent? {
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = null)
}
fun createOpenThreadPendingIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? {
return createRoomPendingIntent(sessionId = roomInfo.sessionId, roomId = roomInfo.roomId, threadId = threadId)
fun createOpenThreadPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId?, threadId: ThreadId): PendingIntent? {
return createRoomPendingIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId)
}
private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): PendingIntent? {
val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, threadId = threadId)
private fun createRoomPendingIntent(sessionId: SessionId, roomId: RoomId?, eventId: EventId?, threadId: ThreadId?): PendingIntent? {
val intent = intentProvider.getViewRoomIntent(sessionId = sessionId, roomId = roomId, eventId = eventId, threadId = threadId)
return PendingIntent.getActivity(
context,
clock.epochMillis().toInt(),

View file

@ -16,6 +16,7 @@ import io.element.android.appconfig.NotificationConfig
import io.element.android.libraries.androidutils.uri.createIgnoredUri
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationBroadcastReceiver
@ -30,15 +31,16 @@ class MarkAsReadActionFactory(
private val stringProvider: StringProvider,
private val clock: SystemClock,
) {
fun create(roomInfo: RoomEventGroupInfo): NotificationCompat.Action? {
fun create(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): NotificationCompat.Action? {
if (!NotificationConfig.SHOW_MARK_AS_READ_ACTION) return null
val sessionId = roomInfo.sessionId.value
val roomId = roomInfo.roomId.value
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.markRoomRead
intent.data = createIgnoredUri("markRead/$sessionId/$roomId")
intent.data = createIgnoredUri("markRead/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty())
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId)
threadId?.let { intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, threadId.value) }
val pendingIntent = PendingIntent.getBroadcast(
context,
clock.epochMillis().toInt(),

View file

@ -183,9 +183,9 @@ class DefaultPushHandler(
}
}
// Process redactions of messages
// Process redactions of messages in background to not block operations with higher priority
if (redactions.isNotEmpty()) {
onRedactedEventReceived.onRedactedEventsReceived(redactions)
appCoroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) }
}
// Find and process ringing call notifications separately

View file

@ -16,7 +16,6 @@ import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
@ -24,71 +23,63 @@ import io.element.android.libraries.push.impl.notifications.factories.DefaultNot
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
interface OnRedactedEventReceived {
fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>)
suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>)
}
@ContributesBinding(AppScope::class)
class DefaultOnRedactedEventReceived(
private val activeNotificationsProvider: ActiveNotificationsProvider,
private val notificationDisplayer: NotificationDisplayer,
@AppCoroutineScope
private val coroutineScope: CoroutineScope,
@ApplicationContext private val context: Context,
private val stringProvider: StringProvider,
) : OnRedactedEventReceived {
override fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
coroutineScope.launch {
val redactionsBySessionIdAndRoom = redactions.groupBy { redaction ->
redaction.sessionId to redaction.roomId
override suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
val redactionsBySessionIdAndRoom = redactions.groupBy { redaction ->
redaction.sessionId to redaction.roomId
}
for ((keys, roomRedactions) in redactionsBySessionIdAndRoom) {
val (sessionId, roomId) = keys
// Get all notifications for the room, including those for threads
val notifications = activeNotificationsProvider.getAllMessageNotificationsForRoom(sessionId, roomId)
if (notifications.isEmpty()) {
Timber.d("No notifications found for redacted event")
}
for ((keys, roomRedactions) in redactionsBySessionIdAndRoom) {
val (sessionId, roomId) = keys
val notifications = activeNotificationsProvider.getMessageNotificationsForRoom(
sessionId,
roomId,
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 ->
roomRedactions.any { it.redactedEventId.value == message.extras.getString(DefaultNotificationCreator.MESSAGE_EVENT_ID) }
}
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()
)
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 ->
roomRedactions.any { it.redactedEventId.value == message.extras.getString(DefaultNotificationCreator.MESSAGE_EVENT_ID) }
}
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()
)
}
}
}
}

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.mockk.every
import io.mockk.mockk
@ -80,8 +81,35 @@ class DefaultActiveNotificationsProviderTest {
)
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID)).hasSize(1)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2)).isEmpty()
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID, null)).hasSize(1)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2, null)).isEmpty()
}
@Test
fun `getMessageNotificationsForRoom with thread id returns only message notifications for a thread using those session and room ids`() {
val activeNotifications = listOf(
aStatusBarNotification(
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID),
groupId = A_SESSION_ID.value,
tag = "$A_ROOM_ID|$A_THREAD_ID",
),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value),
aStatusBarNotification(
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID_2),
groupId = A_SESSION_ID_2.value,
tag = "$A_ROOM_ID|$A_THREAD_ID",
),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value),
aStatusBarNotification(
id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2),
groupId = A_SESSION_ID_2.value,
tag = "$A_ROOM_ID|$A_THREAD_ID",
),
)
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)).hasSize(1)
assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2, A_THREAD_ID)).isEmpty()
}
@Test

View file

@ -53,6 +53,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(1)
@ -76,6 +77,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
@Suppress("DEPRECATION")
@ -141,6 +143,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(1)
@ -160,6 +163,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(2)
@ -189,6 +193,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
val actionTitles = result.actions?.map { it.title }
@ -214,6 +219,7 @@ class DefaultBaseRoomGroupMessageCreatorTest {
roomId = A_ROOM_ID,
imageLoader = fakeImageLoader.getImageLoader(),
existingNotification = null,
threadId = null,
color = A_COLOR_INT,
)
assertThat(result.number).isEqualTo(1)

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.push.impl.notifications
import android.content.Context
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.media.MediaSource
@ -854,7 +855,8 @@ class DefaultNotifiableEventResolverTest {
fallbackNotificationFactory = FallbackNotificationFactory(
clock = FakeSystemClock(),
stringProvider = FakeStringProvider(defaultResult = "You have new messages.")
)
),
featureFlagService = FakeFeatureFlagService(),
)
}
}

View file

@ -64,7 +64,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()
@ -144,9 +144,16 @@ class DefaultNotificationDrawerManagerTest {
messageCreator.createRoomMessageResult.assertions()
.isCalledExactly(3)
.withSequence(
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), any(), any(), any(), any()),
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value)), any(), any(), any(), any()),
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)), any(), any(), any(), any()),
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), any(), any(), any(), any(), any()),
listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value)), any(), any(), any(), any(), any()),
listOf(
value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)),
any(),
any(),
any(),
any(),
any()
),
)
defaultNotificationDrawerManager.destroy()

View file

@ -43,6 +43,7 @@ class DefaultSummaryGroupMessageCreatorTest {
messageCount = 1,
latestTimestamp = A_FAKE_TIMESTAMP + 10,
shouldBing = true,
threadId = null,
)
),
invitationNotifications = emptyList(),

View file

@ -222,10 +222,11 @@ class NotificationBroadcastReceiverHandlerTest {
)
val clearMessagesForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ -> }
val markAsReadResult = lambdaRecorder<ReceiptType, Result<Unit>> { Result.success(Unit) }
val timeline = FakeTimeline(markAsReadResult = markAsReadResult)
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
markAsReadResult = markAsReadResult,
),
baseRoom = FakeBaseRoom(),
liveTimeline = timeline,
createTimelineResult = { Result.success(timeline) },
)
val fakeNotificationCleaner = FakeNotificationCleaner(
clearMessagesForRoomLambda = clearMessagesForRoomLambda,

View file

@ -97,6 +97,7 @@ class NotificationDataFactoryTest {
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
events = events,
roomId = A_ROOM_ID,
threadId = null,
imageLoader = FakeImageLoader().getImageLoader(),
existingNotification = null,
color = A_COLOR_INT,
@ -105,7 +106,8 @@ class NotificationDataFactoryTest {
summaryLine = "A room name: Bob Hello world!",
messageCount = events.size,
latestTimestamp = events.maxOf { it.timestamp },
shouldBing = events.any { it.noisy }
shouldBing = events.any { it.noisy },
threadId = null,
)
val roomWithMessage = listOf(A_MESSAGE_EVENT)
@ -152,6 +154,7 @@ class NotificationDataFactoryTest {
currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
events = withRedactedRemoved,
roomId = A_ROOM_ID,
threadId = null,
imageLoader = FakeImageLoader().getImageLoader(),
existingNotification = null,
color = A_COLOR_INT,
@ -160,7 +163,8 @@ class NotificationDataFactoryTest {
summaryLine = "A room name: Bob Hello world!",
messageCount = withRedactedRemoved.size,
latestTimestamp = withRedactedRemoved.maxOf { it.timestamp },
shouldBing = withRedactedRemoved.any { it.noisy }
shouldBing = withRedactedRemoved.any { it.noisy },
threadId = null,
)
val fakeImageLoader = FakeImageLoader()

View file

@ -71,7 +71,7 @@ class NotificationRendererTest {
@Test
fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest {
roomGroupMessageCreator.createRoomMessageResult = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }
roomGroupMessageCreator.createRoomMessageResult = lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION }
renderEventsAsNotifications(listOf(aNotifiableMessageEvent()))

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.push.impl.notifications.factories.action.Acc
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
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.SimpleNotifiableEvent
@ -249,7 +250,7 @@ class DefaultNotificationCreatorTest {
currentUser = aMatrixUser(),
existingNotification = null,
imageLoader = FakeImageLoader().getImageLoader(),
events = emptyList(),
events = listOf(aNotifiableMessageEvent()),
color = A_COLOR_INT,
)
result.commonAssertions()
@ -276,7 +277,7 @@ class DefaultNotificationCreatorTest {
currentUser = aMatrixUser(),
existingNotification = null,
imageLoader = FakeImageLoader().getImageLoader(),
events = emptyList(),
events = listOf(aNotifiableMessageEvent()),
color = A_COLOR_INT,
)
result.commonAssertions()

View file

@ -8,11 +8,12 @@
package io.element.android.libraries.push.impl.notifications.factories
import android.content.Intent
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.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.intent.IntentProvider
class FakeIntentProvider : IntentProvider {
override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?) = Intent(Intent.ACTION_VIEW)
override fun getViewRoomIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?) = Intent(Intent.ACTION_VIEW)
}

View file

@ -10,18 +10,24 @@ package io.element.android.libraries.push.impl.notifications.fake
import android.service.notification.StatusBarNotification
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider
class FakeActiveNotificationsProvider(
private val getMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> emptyList() },
private val getMessageNotificationsForRoomResult: (SessionId, RoomId, ThreadId?) -> List<StatusBarNotification> = { _, _, _ -> emptyList() },
private val getAllMessageNotificationsForRoomResult: (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 getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
return getMessageNotificationsForRoomResult(sessionId, roomId)
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId, threadId: ThreadId?): List<StatusBarNotification> {
return getMessageNotificationsForRoomResult(sessionId, roomId, threadId)
}
override fun getAllMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
return getAllMessageNotificationsForRoomResult(sessionId, roomId)
}
override fun getNotificationsForSession(sessionId: SessionId): List<StatusBarNotification> {

View file

@ -11,25 +11,29 @@ import android.app.Notification
import androidx.annotation.ColorInt
import coil3.ImageLoader
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder
import io.element.android.tests.testutils.lambda.LambdaSixParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaRecorder
// We just can't make the param types fit
@Suppress("MaxLineLength", "ktlint:standard:max-line-length", "ktlint:standard:parameter-wrapping")
class FakeRoomGroupMessageCreator(
var createRoomMessageResult: LambdaFiveParamsRecorder<MatrixUser, List<NotifiableMessageEvent>, RoomId, ImageLoader, Notification?, Notification> =
lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }
var createRoomMessageResult: LambdaSixParamsRecorder<MatrixUser, List<NotifiableMessageEvent>, RoomId, ThreadId?, ImageLoader, Notification?, Notification> =
lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION }
) : RoomGroupMessageCreator {
override suspend fun createRoomMessage(
currentUser: MatrixUser,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
threadId: ThreadId?,
imageLoader: ImageLoader,
existingNotification: Notification?,
@ColorInt color: Int,
): Notification {
return createRoomMessageResult(currentUser, events, roomId, imageLoader, existingNotification)
return createRoomMessageResult(currentUser, events, roomId, threadId, imageLoader, existingNotification)
}
}

View file

@ -7,21 +7,30 @@
package io.element.android.libraries.push.impl.push
import android.app.Notification
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
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.AN_EVENT_ID_2
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_THREAD_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.push.impl.notifications.factories.DefaultNotificationCreator
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.element.android.tests.testutils.lambda.lambdaRecorder
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
@ -29,43 +38,113 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultOnRedactedEventReceivedTest {
private val fakePerson = Person.Builder().setName(A_USER_NAME).setKey(A_USER_ID.value).build()
private val fakeMessage = NotificationCompat.MessagingStyle.Message("A message", 0L, fakePerson).also {
it.extras.putString(DefaultNotificationCreator.MESSAGE_EVENT_ID, AN_EVENT_ID.value)
}
private val fakeNotification = NotificationCompat.Builder(InstrumentationRegistry.getInstrumentation().targetContext, "aChannel")
.setStyle(
NotificationCompat.MessagingStyle(fakePerson)
.addMessage(fakeMessage)
)
.setGroup(A_SESSION_ID.value)
.build()
private val fakeIncorrectMessage = NotificationCompat.MessagingStyle.Message("The wrong message", 0L, fakePerson).also {
it.extras.putString(DefaultNotificationCreator.MESSAGE_EVENT_ID, AN_EVENT_ID_2.value)
}
private val fakeIncorrectNotification = NotificationCompat.Builder(InstrumentationRegistry.getInstrumentation().targetContext, "aChannel")
.setGroup(A_SESSION_ID.value)
.setStyle(
NotificationCompat.MessagingStyle(fakePerson)
.addMessage(fakeIncorrectMessage)
)
.build()
@Test
fun `when no notifications are found, nothing happen`() = runTest {
val showNotificationLambda = lambdaRecorder<String?, Int, Notification, Boolean> { _, _, _ -> true }
val sut = createDefaultOnRedactedEventReceived(
getMessageNotificationsForRoomResult = { _, _ -> emptyList() }
getAllMessageNotificationsForRoomResult = { _, _ -> emptyList() },
displayer = FakeNotificationDisplayer(showNotificationLambda),
)
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
showNotificationLambda.assertions().isNeverCalled()
}
@Test
fun `when a notification is found, try to retrieve the message`() = runTest {
val showNotificationLambda = lambdaRecorder<String?, Int, Notification, Boolean> { tag, id, _ ->
assertThat(tag).isEqualTo(A_ROOM_ID.value)
assertThat(id).isEqualTo(1)
true
}
val sut = createDefaultOnRedactedEventReceived(
getMessageNotificationsForRoomResult = { _, _ ->
getAllMessageNotificationsForRoomResult = { _, _ ->
listOf(
mockk {
every { notification } returns mockk {}
every { id } returns 1
every { notification } returns fakeNotification
every { tag } returns A_ROOM_ID.value
},
mockk {
every { id } returns 2
every { notification } returns fakeIncorrectNotification
every { tag } returns A_ROOM_ID.value
}
)
}
},
displayer = FakeNotificationDisplayer(showNotificationLambda),
)
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
showNotificationLambda.assertions().isCalledOnce()
}
private fun TestScope.createDefaultOnRedactedEventReceived(
getMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> lambdaError() },
@Test
fun `when thread notifications are found, try to retrieve the message`() = runTest {
val showNotificationLambda = lambdaRecorder<String?, Int, Notification, Boolean> { tag, id, _ ->
assertThat(tag).isEqualTo("$A_ROOM_ID|$A_THREAD_ID")
assertThat(id).isEqualTo(1)
true
}
val sut = createDefaultOnRedactedEventReceived(
getAllMessageNotificationsForRoomResult = { _, _ ->
listOf(
mockk {
every { id } returns 1
every { notification } returns fakeNotification
every { tag } returns "$A_ROOM_ID|$A_THREAD_ID"
},
mockk {
every { id } returns 2
every { notification } returns fakeIncorrectNotification
every { tag } returns A_ROOM_ID.value
}
)
},
displayer = FakeNotificationDisplayer(showNotificationMessageResult = showNotificationLambda),
)
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
showNotificationLambda.assertions().isCalledOnce()
}
private fun createDefaultOnRedactedEventReceived(
getAllMessageNotificationsForRoomResult: (SessionId, RoomId) -> List<StatusBarNotification> = { _, _ -> lambdaError() },
displayer: FakeNotificationDisplayer = FakeNotificationDisplayer(),
): DefaultOnRedactedEventReceived {
val context = InstrumentationRegistry.getInstrumentation().context
return DefaultOnRedactedEventReceived(
activeNotificationsProvider = FakeActiveNotificationsProvider(
getMessageNotificationsForRoomResult = getMessageNotificationsForRoomResult,
getMessageNotificationsForRoomResult = { _, _, _ -> lambdaError() },
getAllMessageNotificationsForRoomResult = getAllMessageNotificationsForRoomResult,
getNotificationsForSessionResult = { lambdaError() },
getMembershipNotificationForSessionResult = { lambdaError() },
getMembershipNotificationForRoomResult = { _, _ -> lambdaError() },
getSummaryNotificationResult = { lambdaError() },
countResult = { lambdaError() },
),
notificationDisplayer = FakeNotificationDisplayer(),
coroutineScope = this,
notificationDisplayer = displayer,
context = context,
stringProvider = FakeStringProvider(),
)

View file

@ -13,7 +13,7 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakeOnRedactedEventReceived(
private val onRedactedEventsReceivedResult: (List<ResolvedPushEvent.Redaction>) -> Unit = { lambdaError() },
) : OnRedactedEventReceived {
override fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
override suspend fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
onRedactedEventsReceivedResult(redactions)
}
}