Notification events resolving and rendering in batches (#4722)

- Use `NotiticationService.getNotifications()` function so we resolve the events in bulk.
- Added `NotifierResolverQueue` to group the notifications to resolve based on a debounce strategy.
- Batch rendering of these events as notifications.
This commit is contained in:
Jorge Martin Espinosa 2025-05-26 17:10:20 +02:00 committed by GitHub
parent f0c9f8294a
commit f455085e08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 882 additions and 523 deletions

View file

@ -9,12 +9,14 @@ package io.element.android.libraries.matrix.api.notification
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.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
data class NotificationData(
val sessionId: SessionId,
val eventId: EventId,
val threadId: ThreadId?,
val roomId: RoomId,

View file

@ -11,5 +11,5 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
interface NotificationService {
suspend fun getNotification(roomId: RoomId, eventId: EventId): Result<NotificationData?>
suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): Result<Map<EventId, NotificationData>>
}

View file

@ -152,7 +152,7 @@ class RustMatrixClient(
)
private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(innerSyncService)
private val innerNotificationClient = runBlocking { innerClient.notificationClient(notificationProcessSetup) }
private val notificationService = RustNotificationService(innerNotificationClient, dispatchers, clock)
private val notificationService = RustNotificationService(sessionId, innerNotificationClient, dispatchers, clock)
private val notificationSettingsService = RustNotificationSettingsService(innerClient, sessionCoroutineScope, dispatchers)
private val encryptionService = RustEncryptionService(
client = innerClient,

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.core.bool.orFalse
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.UserId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
@ -25,6 +26,7 @@ class NotificationMapper(
private val notificationContentMapper = NotificationContentMapper()
fun map(
sessionId: SessionId,
eventId: EventId,
roomId: RoomId,
notificationItem: NotificationItem
@ -35,6 +37,7 @@ class NotificationMapper(
activeMembersCount = item.roomInfo.joinedMembersCount.toInt(),
)
NotificationData(
sessionId = sessionId,
eventId = eventId,
// FIXME once the `NotificationItem` in the SDK returns the thread id
threadId = null,

View file

@ -10,28 +10,45 @@ package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.NotificationItemsRequest
import timber.log.Timber
class RustNotificationService(
private val sessionId: SessionId,
private val notificationClient: NotificationClient,
private val dispatchers: CoroutineDispatchers,
clock: SystemClock,
) : NotificationService {
private val notificationMapper: NotificationMapper = NotificationMapper(clock)
override suspend fun getNotification(
roomId: RoomId,
eventId: EventId,
): Result<NotificationData?> = withContext(dispatchers.io) {
override suspend fun getNotifications(
ids: Map<RoomId, List<EventId>>
): Result<Map<EventId, NotificationData>> = withContext(dispatchers.io) {
runCatching {
val item = notificationClient.getNotification(roomId.value, eventId.value)
item?.use {
notificationMapper.map(eventId, roomId, it)
val requests = ids.map { (roomId, eventIds) ->
NotificationItemsRequest(
roomId = roomId.value,
eventIds = eventIds.map { it.value }
)
}
val items = notificationClient.getNotifications(requests)
buildMap {
val eventIds = requests.flatMap { it.eventIds }
for (eventId in eventIds) {
val item = items[eventId]
if (item != null) {
val roomId = RoomId(requests.find { it.eventIds.contains(eventId) }?.roomId!!)
put(EventId(eventId), notificationMapper.map(sessionId, EventId(eventId), roomId, item))
} else {
Timber.e("Could not retrieve event for notification with $eventId")
}
}
}
}
}

View file

@ -7,15 +7,15 @@
package io.element.android.libraries.matrix.impl.fixtures.fakes
import io.element.android.tests.testutils.simulateLongTask
import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationItem
import org.matrix.rustcomponents.sdk.NotificationItemsRequest
class FakeRustNotificationClient(
var notificationItemResult: NotificationItem? = null
var notificationItemResult: Map<String, NotificationItem> = emptyMap(),
) : NotificationClient(NoPointer) {
override suspend fun getNotification(roomId: String, eventId: String): NotificationItem? = simulateLongTask {
notificationItemResult
override suspend fun getNotifications(requests: List<NotificationItemsRequest>): Map<String, NotificationItem> {
return notificationItemResult
}
}

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustNotificat
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
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
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
@ -28,12 +29,12 @@ class RustNotificationServiceTest {
@Test
fun test() = runTest {
val notificationClient = FakeRustNotificationClient(
notificationItemResult = aRustNotificationItem(),
notificationItemResult = mapOf(AN_EVENT_ID.value to aRustNotificationItem()),
)
val sut = createRustNotificationService(
notificationClient = notificationClient,
)
val result = sut.getNotification(A_ROOM_ID, AN_EVENT_ID).getOrThrow()!!
val result = sut.getNotifications(mapOf(A_ROOM_ID to listOf(AN_EVENT_ID))).getOrThrow()[AN_EVENT_ID]!!
assertThat(result.isEncrypted).isTrue()
assertThat(result.content).isEqualTo(
NotificationContent.MessageLike.RoomMessage(
@ -51,6 +52,7 @@ class RustNotificationServiceTest {
clock: SystemClock = FakeSystemClock(),
) =
RustNotificationService(
sessionId = A_SESSION_ID,
notificationClient = notificationClient,
dispatchers = testCoroutineDispatchers(),
clock = clock,

View file

@ -13,16 +13,13 @@ import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationService
class FakeNotificationService : NotificationService {
private var getNotificationResult: Result<NotificationData?> = Result.success(null)
private var getNotificationsResult: Result<Map<EventId, NotificationData>> = Result.success(emptyMap())
fun givenGetNotificationResult(result: Result<NotificationData?>) {
getNotificationResult = result
fun givenGetNotificationsResult(result: Result<Map<EventId, NotificationData>>) {
getNotificationsResult = result
}
override suspend fun getNotification(
roomId: RoomId,
eventId: EventId,
): Result<NotificationData?> {
return getNotificationResult
override suspend fun getNotifications(ids: Map<RoomId, List<EventId>>): Result<Map<EventId, NotificationData>> {
return getNotificationsResult
}
}

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.notification.NotificationData
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_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_NAME_2
@ -27,6 +28,7 @@ fun aNotificationData(
roomDisplayName: String? = A_ROOM_NAME
): NotificationData {
return NotificationData(
sessionId = A_SESSION_ID,
eventId = AN_EVENT_ID,
threadId = threadId,
roomId = A_ROOM_ID,

View file

@ -11,10 +11,10 @@ import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
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
@ -61,10 +61,14 @@ 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): Result<ResolvedPushEvent>
suspend fun resolveEvents(
sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest>
): Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>
}
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultNotifiableEventResolver @Inject constructor(
private val stringProvider: StringProvider,
private val clock: SystemClock,
@ -75,29 +79,34 @@ class DefaultNotifiableEventResolver @Inject constructor(
private val callNotificationEventResolver: CallNotificationEventResolver,
private val appPreferencesStore: AppPreferencesStore,
) : NotifiableEventResolver {
override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result<ResolvedPushEvent> {
// Restore session
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return Result.failure(
ResolvingException("Unable to restore session for $sessionId")
)
val notificationService = client.notificationService()
val notificationData = notificationService.getNotification(
roomId = roomId,
eventId = eventId,
).onFailure {
Timber.tag(loggerTag.value).e(it, "Unable to resolve event: $eventId.")
override suspend fun resolveEvents(
sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest>
): Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> {
Timber.d("Queueing notifications: $notificationEventRequests")
val client = matrixClientProvider.getOrRestore(sessionId).getOrElse {
return Result.failure(IllegalStateException("Couldn't get or restore client for session $sessionId"))
}
val ids = notificationEventRequests.groupBy { it.roomId }.mapValues { (_, value) -> value.map { it.eventId } }
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
return notificationData.flatMap {
if (it == null) {
Timber.tag(loggerTag.value).d("No notification data found for event $eventId")
return@flatMap Result.failure(ResolvingException("Unable to resolve event $eventId"))
} else {
Timber.tag(loggerTag.value).d("Found notification item for $eventId")
it.asNotifiableEvent(client, sessionId)
val notifications = client.notificationService().getNotifications(ids).mapCatching { map ->
map.mapValues { (_, notificationData) ->
notificationData.asNotifiableEvent(client, sessionId)
}
}
return Result.success(
notificationEventRequests.associate {
val notificationData = notifications.getOrNull()?.get(it.eventId)
if (notificationData != null) {
it to notificationData
} else {
// TODO once the SDK can actually return what went wrong, we should return it here instead of this generic error
it to Result.failure(ResolvingException("No notification data for ${it.roomId} - ${it.eventId}"))
}
}
)
}
private suspend fun NotificationData.asNotifiableEvent(

View file

@ -113,6 +113,11 @@ class DefaultNotificationDrawerManager @Inject constructor(
renderEvents(listOf(notifiableEvent))
}
suspend fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>) {
val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value) }
renderEvents(eventsToNotify)
}
/**
* Clear all known message events for a [sessionId].
*/

View file

@ -30,8 +30,9 @@ class DefaultOnMissedCallNotificationHandler @Inject constructor(
// Resolve the event and add a notification for it, at this point it should no longer be a ringing one
val notificationData = matrixClientProvider.getOrRestore(sessionId).getOrNull()
?.notificationService()
?.getNotification(roomId, eventId)
?.getNotifications(mapOf(roomId to listOf(eventId)))
?.getOrNull()
?.get(eventId)
?: return
val notifiableEvent = callNotificationEventResolver.resolveEvent(

View file

@ -163,7 +163,7 @@ class NotificationBroadcastReceiverHandler @Inject constructor(
roomIsDm = room.isDm(),
outGoingMessage = true,
)
onNotifiableEventReceived.onNotifiableEventReceived(notifiableMessageEvent)
onNotifiableEventReceived.onNotifiableEventsReceived(listOf(notifiableMessageEvent))
if (threadId != null && replyToEventId != null) {
room.liveTimeline.replyMessage(
@ -181,9 +181,11 @@ class NotificationBroadcastReceiverHandler @Inject constructor(
)
}.onFailure {
Timber.e(it, "Failed to send smart reply message")
onNotifiableEventReceived.onNotifiableEventReceived(
notifiableMessageEvent.copy(
outGoingMessageFailed = true
onNotifiableEventReceived.onNotifiableEventsReceived(
listOf(
notifiableMessageEvent.copy(
outGoingMessageFailed = true
)
)
)
}

View file

@ -37,6 +37,7 @@ class DefaultNotificationDisplayer @Inject constructor(
return false
}
notificationManager.notify(tag, id, notification)
Timber.d("Notifying with tag: $tag, id: $id")
return true
}

View file

@ -0,0 +1,101 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
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.ResolvedPushEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
/**
* This class is responsible for periodically batching notification requests and resolving them in a single call,
* so that we can avoid having to resolve each notification individually in the SDK.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@SingleIn(AppScope::class)
class NotificationResolverQueue @Inject constructor(
private val notifiableEventResolver: NotifiableEventResolver,
private val appCoroutineScope: CoroutineScope,
) {
companion object {
private const val BATCH_WINDOW_MS = 250L
}
private val requestQueue = Channel<NotificationEventRequest>(capacity = 100)
private var currentProcessingJob: Job? = null
/**
* A flow that emits pairs of a list of notification event requests and a map of the resolved events.
* The map contains the original request as the key and the resolved event as the value.
*/
val results: SharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> = MutableSharedFlow()
/**
* Enqueues a notification event request to be resolved.
* The request will be processed in batches, so it may not be resolved immediately.
*
* @param request The notification event request to enqueue.
*/
suspend fun enqueue(request: NotificationEventRequest) {
// Cancel previous processing job if it exists, acting as a debounce operation
Timber.d("Cancelling job: $currentProcessingJob")
currentProcessingJob?.cancel()
// Enqueue the request and start a delayed processing job
requestQueue.send(request)
currentProcessingJob = processQueue()
Timber.d("Starting processing job for request: $request")
}
private fun processQueue() = appCoroutineScope.launch(SupervisorJob()) {
delay(BATCH_WINDOW_MS.milliseconds)
// If this job is still active (so this is the latest job), we launch a separate one that won't be cancelled when enqueueing new items
// to process the existing queued items.
appCoroutineScope.launch {
val groupedRequestsById = buildList {
while (!requestQueue.isEmpty) {
requestQueue.receiveCatching().getOrNull()?.let(this::add)
}
}.groupBy { it.sessionId }
val sessionIds = groupedRequestsById.keys
for (sessionId in sessionIds) {
val requests = groupedRequestsById[sessionId].orEmpty()
Timber.d("Fetching notifications for $sessionId: $requests. Pending requests: ${!requestQueue.isEmpty}")
// Resolving the events in parallel should improve performance since each session id will query a different Client
launch {
// No need for a Mutex since the SDK already has one internally
val notifications = notifiableEventResolver.resolveEvents(sessionId, requests).getOrNull().orEmpty()
(results as MutableSharedFlow).emit(requests to notifications)
}
}
}
}
}
data class NotificationEventRequest(
val sessionId: SessionId,
val roomId: RoomId,
val eventId: EventId,
val providerInfo: String,
)

View file

@ -12,12 +12,22 @@ 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
val sessionId: SessionId
val roomId: RoomId
val eventId: EventId
data class Event(val notifiableEvent: NotifiableEvent) : ResolvedPushEvent {
override val sessionId: SessionId = notifiableEvent.sessionId
override val roomId: RoomId = notifiableEvent.roomId
override val eventId: EventId = notifiableEvent.eventId
}
data class Redaction(
val sessionId: SessionId,
val roomId: RoomId,
override val sessionId: SessionId,
override val roomId: RoomId,
val redactedEventId: EventId,
val reason: String?,
) : ResolvedPushEvent
) : ResolvedPushEvent {
override val eventId: EventId = redactedEventId
}
}

View file

@ -13,6 +13,7 @@ import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.history.onDiagnosticPush
@ -20,8 +21,10 @@ import io.element.android.libraries.push.impl.history.onInvalidPushReceived
import io.element.android.libraries.push.impl.history.onSuccess
import io.element.android.libraries.push.impl.history.onUnableToResolveEvent
import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.NotificationEventRequest
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
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
@ -30,17 +33,21 @@ import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.libraries.pushproviders.api.PushHandler
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag)
@SingleIn(AppScope::class)
@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,
private val pushClientSecret: PushClientSecret,
@ -50,7 +57,85 @@ class DefaultPushHandler @Inject constructor(
private val elementCallEntryPoint: ElementCallEntryPoint,
private val notificationChannels: NotificationChannels,
private val pushHistoryService: PushHistoryService,
private val resolverQueue: NotificationResolverQueue,
private val appCoroutineScope: CoroutineScope,
) : PushHandler {
init {
processPushEventResults()
}
/**
* Process the push notification event results emitted by the [resolverQueue].
*/
private fun processPushEventResults() {
resolverQueue.results
.map { (requests, resolvedEvents) ->
for (request in requests) {
// Log the result of the push notification event
val result = resolvedEvents[request]
if (result?.isSuccess == true) {
pushHistoryService.onSuccess(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
comment = "Push handled successfully",
)
} else {
pushHistoryService.onUnableToResolveEvent(
providerInfo = request.providerInfo,
eventId = request.eventId,
roomId = request.roomId,
sessionId = request.sessionId,
reason = "Push not handled",
)
}
}
val events = mutableListOf<NotifiableEvent>()
val redactions = mutableListOf<ResolvedPushEvent.Redaction>()
@Suppress("LoopWithTooManyJumpStatements")
for (result in resolvedEvents.values) {
val event = result.getOrNull() ?: continue
val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId)
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
// If notifications are disabled for this session and device, we don't want to show the notification
// But if it's a ringing call, we want to show it anyway
val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent
if (!areNotificationsEnabled && !isRingingCall) continue
// We categorise each result into either a NotifiableEvent or a Redaction
when (event) {
is ResolvedPushEvent.Event -> {
events.add(event.notifiableEvent)
}
is ResolvedPushEvent.Redaction -> {
redactions.add(event)
}
}
}
// Process redactions of messages
if (redactions.isNotEmpty()) {
onRedactedEventReceived.onRedactedEventsReceived(redactions)
}
// Find and process ringing call notifications separately
val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent }
for (ringingCallEvent in ringingCallEvents) {
Timber.tag(loggerTag.value).d("Ringing call event: $ringingCallEvent")
handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent)
}
// Finally, process other notifications (messages, invites, generic notifications, etc.)
if (nonRingingCallEvents.isNotEmpty()) {
onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents)
}
}
.launchIn(appCoroutineScope)
}
/**
* Called when message is received.
*
@ -119,52 +204,17 @@ class DefaultPushHandler @Inject constructor(
)
return
}
notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId).fold(
onSuccess = { resolvedPushEvent ->
pushHistoryService.onSuccess(
providerInfo = providerInfo,
eventId = pushData.eventId,
roomId = pushData.roomId,
sessionId = userId,
comment = resolvedPushEvent.javaClass.simpleName,
)
when (resolvedPushEvent) {
is ResolvedPushEvent.Event -> {
when (val notifiableEvent = resolvedPushEvent.notifiableEvent) {
is NotifiableRingingCallEvent -> {
Timber.tag(loggerTag.value).d("Notifiable event ${pushData.eventId} is ringing call: $notifiableEvent")
onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent)
handleRingingCallEvent(notifiableEvent)
}
else -> {
Timber.tag(loggerTag.value).d("Notifiable event ${pushData.eventId} is normal event: $notifiableEvent")
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.Redaction -> {
onRedactedEventReceived.onRedactedEventReceived(resolvedPushEvent)
}
}
},
onFailure = { failure ->
Timber.tag(loggerTag.value).w(failure, "Unable to get a notification data")
pushHistoryService.onUnableToResolveEvent(
providerInfo = providerInfo,
eventId = pushData.eventId,
roomId = pushData.roomId,
sessionId = userId,
reason = failure.message ?: failure.javaClass.simpleName,
)
}
)
appCoroutineScope.launch {
val notificationEventRequest = NotificationEventRequest(
sessionId = userId,
roomId = pushData.roomId,
eventId = pushData.eventId,
providerInfo = providerInfo,
)
Timber.d("Queueing notification: $notificationEventRequest")
resolverQueue.enqueue(notificationEventRequest)
}
} catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
}

View file

@ -17,7 +17,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
interface OnNotifiableEventReceived {
fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent)
fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>)
}
@ContributesBinding(AppScope::class)
@ -26,12 +26,10 @@ class DefaultOnNotifiableEventReceived @Inject constructor(
private val coroutineScope: CoroutineScope,
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
) : OnNotifiableEventReceived {
override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
override fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>) {
coroutineScope.launch {
launch { syncOnNotifiableEvent(notifiableEvent) }
if (notifiableEvent !is NotifiableRingingCallEvent) {
defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
}
launch { syncOnNotifiableEvent(notifiableEvents) }
defaultNotificationDrawerManager.onNotifiableEventsReceived(notifiableEvents.filter { it !is NotifiableRingingCallEvent })
}
}
}

View file

@ -29,7 +29,7 @@ import timber.log.Timber
import javax.inject.Inject
interface OnRedactedEventReceived {
fun onRedactedEventReceived(redaction: ResolvedPushEvent.Redaction)
fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>)
}
@ContributesBinding(AppScope::class)
@ -40,48 +40,54 @@ class DefaultOnRedactedEventReceived @Inject constructor(
@ApplicationContext private val context: Context,
private val stringProvider: StringProvider,
) : OnRedactedEventReceived {
override fun onRedactedEventReceived(redaction: ResolvedPushEvent.Redaction) {
override fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
coroutineScope.launch {
val notifications = activeNotificationsProvider.getMessageNotificationsForRoom(
redaction.sessionId,
redaction.roomId,
)
if (notifications.isEmpty()) {
Timber.d("No notifications found for redacted event")
val redactionsBySessionIdAndRoom = redactions.groupBy { redaction ->
redaction.sessionId to redaction.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
for ((keys, roomRedactions) in redactionsBySessionIdAndRoom) {
val (sessionId, roomId) = keys
val notifications = activeNotificationsProvider.getMessageNotificationsForRoom(
sessionId,
roomId,
)
if (notifications.isEmpty()) {
Timber.d("No notifications found for redacted event")
}
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))
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()
)
}
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

@ -8,15 +8,14 @@
package io.element.android.libraries.push.impl.push
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.flow.first
@ -33,55 +32,42 @@ class SyncOnNotifiableEvent @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val activeRoomsHolder: ActiveRoomsHolder,
) {
suspend operator fun invoke(notifiableEvent: NotifiableEvent) = withContext(dispatchers.io) {
val isRingingCallEvent = notifiableEvent is NotifiableRingingCallEvent
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush) && !isRingingCallEvent) {
suspend operator fun invoke(notifiableEvents: List<NotifiableEvent>) = withContext(dispatchers.io) {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) {
return@withContext
}
val activeRoom = activeRoomsHolder.getActiveRoomMatching(notifiableEvent.sessionId, notifiableEvent.roomId)
try {
val eventsBySession = notifiableEvents.groupBy { it.sessionId }
if (activeRoom != null) {
// If the room is already active, we can use it directly
activeRoom.subscribeToSyncAndWait(notifiableEvent, isRingingCallEvent)
} else {
// Otherwise, we need to get the room from the matrix client
val room = matrixClientProvider
.getOrRestore(notifiableEvent.sessionId)
.mapCatching { it.getJoinedRoom(notifiableEvent.roomId) }
.getOrNull()
appForegroundStateService.updateIsSyncingNotificationEvent(true)
room?.use { it.subscribeToSyncAndWait(notifiableEvent, isRingingCallEvent) }
}
}
for ((sessionId, events) in eventsBySession) {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: continue
val eventsByRoomId = events.groupBy { it.roomId }
private suspend fun JoinedRoom.subscribeToSyncAndWait(notifiableEvent: NotifiableEvent, isRingingCallEvent: Boolean) {
subscribeToSync()
client.roomListService.subscribeToVisibleRooms(eventsByRoomId.keys.toList())
// If the app is in foreground, sync is already running, so we just add the subscription above.
if (!appForegroundStateService.isInForeground.value) {
if (isRingingCallEvent) {
waitsUntilUserIsInTheCall(timeout = 60.seconds)
} else {
try {
appForegroundStateService.updateIsSyncingNotificationEvent(true)
waitsUntilEventIsKnown(eventId = notifiableEvent.eventId, timeout = 10.seconds)
} finally {
appForegroundStateService.updateIsSyncingNotificationEvent(false)
if (!appForegroundStateService.isInForeground.value) {
for ((roomId, eventsInRoom) in eventsByRoomId) {
val activeRoom = activeRoomsHolder.getActiveRoomMatching(sessionId, roomId)
val room = activeRoom ?: client.getJoinedRoom(roomId)
if (room != null) {
eventsInRoom.parallelMap { event ->
room.waitsUntilEventIsKnown(event.eventId, timeout = 10.seconds)
}
}
if (room != null && activeRoom == null) {
// Destroy the room we just instantiated to reset its live timeline
room.destroy()
}
}
}
}
}
}
/**
* User can be in the call if they answer using another session.
* If the user does not join the call, the timeout will be reached.
*/
private suspend fun BaseRoom.waitsUntilUserIsInTheCall(timeout: Duration) {
withTimeoutOrNull(timeout) {
roomInfoFlow.first {
sessionId in it.activeRoomCallParticipants
}
} finally {
appForegroundStateService.updateIsSyncingNotificationEvent(false)
}
}

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.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent
@ -67,7 +68,7 @@ class DefaultNotifiableEventResolverTest {
@Test
fun `resolve event no session`() = runTest {
val sut = createDefaultNotifiableEventResolver(notificationService = null)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val result = sut.resolveEvents(A_SESSION_ID, listOf(NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")))
assertThat(result.isFailure).isTrue()
}
@ -76,36 +77,31 @@ class DefaultNotifiableEventResolverTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.failure(AN_EXCEPTION)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result.isFailure).isTrue()
}
@Test
fun `resolve event null`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(null)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result.isFailure).isTrue()
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)?.isFailure).isTrue()
}
@Test
fun `resolve event message text`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(body = "Hello world", formatted = null)
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(body = "Hello world", formatted = null)
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Hello world")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
@ -113,292 +109,337 @@ class DefaultNotifiableEventResolverTest {
fun `resolve event message with mention`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(body = "Hello world", formatted = null)
),
hasMention = true,
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(body = "Hello world", formatted = null)
),
hasMention = true,
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Hello world", hasMentionOrReply = true)
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve HTML formatted event message text takes plain text version`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(
body = "Hello world!",
formatted = FormattedBody(
body = "<b>Hello world</b>",
format = MessageFormat.HTML,
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(
body = "Hello world!",
formatted = FormattedBody(
body = "<b>Hello world</b>",
format = MessageFormat.HTML,
)
)
)
),
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Hello world")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve incorrectly formatted event message text uses fallback`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(
body = "Hello world",
formatted = FormattedBody(
body = "???Hello world!???",
format = MessageFormat.UNKNOWN,
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = TextMessageType(
body = "Hello world",
formatted = FormattedBody(
body = "???Hello world!???",
format = MessageFormat.UNKNOWN,
)
)
)
),
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Hello world")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve event message audio`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = AudioMessageType("Audio", null, null, MediaSource("url"), null)
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = AudioMessageType("Audio", null, null, MediaSource("url"), null)
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Audio")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve event message video`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = VideoMessageType("Video", null, null, MediaSource("url"), null)
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = VideoMessageType("Video", null, null, MediaSource("url"), null)
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Video")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve event message voice`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = VoiceMessageType("Voice", null, null, MediaSource("url"), null, null)
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = VoiceMessageType("Voice", null, null, MediaSource("url"), null, null)
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Voice message")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve event message image`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = ImageMessageType("Image", null, null, MediaSource("url"), null),
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = ImageMessageType("Image", null, null, MediaSource("url"), null),
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Image")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve event message sticker`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = StickerMessageType("Sticker", null, null, MediaSource("url"), null),
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = StickerMessageType("Sticker", null, null, MediaSource("url"), null),
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Sticker")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve event message file`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = FileMessageType("File", null, null, MediaSource("url"), null),
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = FileMessageType("File", null, null, MediaSource("url"), null),
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "File")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve event message location`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = LocationMessageType("Location", "geo:1,2", null),
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = LocationMessageType("Location", "geo:1,2", null),
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Location")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve event message notice`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = NoticeMessageType("Notice", null),
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = NoticeMessageType("Notice", null),
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Notice")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve event message emote`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = EmoteMessageType("is happy", null),
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_USER_ID_2,
messageType = EmoteMessageType("is happy", null),
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "* Bob is happy")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve poll`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.Poll(
senderId = A_USER_ID_2,
question = "A question"
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.Poll(
senderId = A_USER_ID_2,
question = "A question"
),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Poll: A question")
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve RoomMemberContent invite room`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.StateEvent.RoomMemberContent(
userId = A_USER_ID_2,
membershipState = RoomMembershipState.INVITE
),
isDirect = false,
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.StateEvent.RoomMemberContent(
userId = A_USER_ID_2,
membershipState = RoomMembershipState.INVITE
),
isDirect = false,
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result.getOrNull()).isNull()
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)?.getOrNull()).isNull()
}
@Test
fun `resolve invite room`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
isDirect = false,
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
isDirect = false,
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
InviteNotifiableEvent(
sessionId = A_SESSION_ID,
@ -417,22 +458,25 @@ class DefaultNotifiableEventResolverTest {
isUpdated = false,
)
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve invite direct`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
isDirect = true,
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
isDirect = true,
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
InviteNotifiableEvent(
sessionId = A_SESSION_ID,
@ -451,23 +495,26 @@ class DefaultNotifiableEventResolverTest {
isUpdated = false,
)
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve invite direct, no display name`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
isDirect = true,
senderDisplayName = null,
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
isDirect = true,
senderDisplayName = null,
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
InviteNotifiableEvent(
sessionId = A_SESSION_ID,
@ -486,23 +533,26 @@ class DefaultNotifiableEventResolverTest {
isUpdated = false,
)
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve invite direct, ambiguous display name`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
isDirect = false,
senderIsNameAmbiguous = true,
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.Invite(
senderId = A_USER_ID_2,
),
isDirect = false,
senderIsNameAmbiguous = true,
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
InviteNotifiableEvent(
sessionId = A_SESSION_ID,
@ -521,35 +571,37 @@ class DefaultNotifiableEventResolverTest {
isUpdated = false,
)
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve RoomMemberContent other`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.StateEvent.RoomMemberContent(
userId = A_USER_ID_2,
membershipState = RoomMembershipState.JOIN
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.StateEvent.RoomMemberContent(
userId = A_USER_ID_2,
membershipState = RoomMembershipState.JOIN
)
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result.getOrNull()).isNull()
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)?.getOrNull()).isNull()
}
@Test
fun `resolve RoomEncrypted`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomEncrypted
)
mapOf(AN_EVENT_ID to aNotificationData(content = NotificationContent.MessageLike.RoomEncrypted))
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
FallbackNotifiableEvent(
sessionId = A_SESSION_ID,
@ -563,19 +615,22 @@ class DefaultNotifiableEventResolverTest {
timestamp = A_FAKE_TIMESTAMP,
)
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve CallInvite`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2),
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
NotifiableMessageEvent(
sessionId = A_SESSION_ID,
@ -601,7 +656,7 @@ class DefaultNotifiableEventResolverTest {
isUpdated = false
)
)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
@ -609,11 +664,13 @@ class DefaultNotifiableEventResolverTest {
val callNotificationEventResolver = FakeCallNotificationEventResolver()
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2,
CallNotifyType.NOTIFY
),
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2,
CallNotifyType.NOTIFY
),
)
)
),
callNotificationEventResolver = callNotificationEventResolver,
@ -639,18 +696,21 @@ class DefaultNotifiableEventResolverTest {
)
)
callNotificationEventResolver.resolveEventLambda = { _, _, _ -> Result.success(expectedResult.notifiableEvent) }
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve RoomRedaction`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomRedaction(
AN_EVENT_ID_2,
A_REDACTION_REASON,
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomRedaction(
AN_EVENT_ID_2,
A_REDACTION_REASON,
)
)
)
)
@ -661,82 +721,91 @@ class DefaultNotifiableEventResolverTest {
redactedEventId = AN_EVENT_ID_2,
reason = A_REDACTION_REASON,
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result.getOrNull()).isEqualTo(expectedResult)
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve RoomRedaction with null redactedEventId should return null`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomRedaction(
null,
A_REDACTION_REASON,
mapOf(
AN_EVENT_ID to aNotificationData(
content = NotificationContent.MessageLike.RoomRedaction(
null,
A_REDACTION_REASON,
)
)
)
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result.isFailure).isTrue()
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)?.getOrNull()).isNull()
}
@Test
fun `resolve null cases`() {
testFailure(NotificationContent.MessageLike.CallAnswer)
testFailure(NotificationContent.MessageLike.CallHangup)
testFailure(NotificationContent.MessageLike.CallCandidates)
testFailure(NotificationContent.MessageLike.KeyVerificationReady)
testFailure(NotificationContent.MessageLike.KeyVerificationStart)
testFailure(NotificationContent.MessageLike.KeyVerificationCancel)
testFailure(NotificationContent.MessageLike.KeyVerificationAccept)
testFailure(NotificationContent.MessageLike.KeyVerificationKey)
testFailure(NotificationContent.MessageLike.KeyVerificationMac)
testFailure(NotificationContent.MessageLike.KeyVerificationDone)
testFailure(NotificationContent.MessageLike.ReactionContent(relatedEventId = AN_EVENT_ID_2.value))
testFailure(NotificationContent.MessageLike.Sticker)
testFailure(NotificationContent.StateEvent.PolicyRuleRoom)
testFailure(NotificationContent.StateEvent.PolicyRuleServer)
testFailure(NotificationContent.StateEvent.PolicyRuleUser)
testFailure(NotificationContent.StateEvent.RoomAliases)
testFailure(NotificationContent.StateEvent.RoomAvatar)
testFailure(NotificationContent.StateEvent.RoomCanonicalAlias)
testFailure(NotificationContent.StateEvent.RoomCreate)
testFailure(NotificationContent.StateEvent.RoomEncryption)
testFailure(NotificationContent.StateEvent.RoomGuestAccess)
testFailure(NotificationContent.StateEvent.RoomHistoryVisibility)
testFailure(NotificationContent.StateEvent.RoomJoinRules)
testFailure(NotificationContent.StateEvent.RoomName)
testFailure(NotificationContent.StateEvent.RoomPinnedEvents)
testFailure(NotificationContent.StateEvent.RoomPowerLevels)
testFailure(NotificationContent.StateEvent.RoomServerAcl)
testFailure(NotificationContent.StateEvent.RoomThirdPartyInvite)
testFailure(NotificationContent.StateEvent.RoomTombstone)
testFailure(NotificationContent.StateEvent.RoomTopic(""))
testFailure(NotificationContent.StateEvent.SpaceChild)
testFailure(NotificationContent.StateEvent.SpaceParent)
testNoResults(NotificationContent.MessageLike.CallAnswer)
testNoResults(NotificationContent.MessageLike.CallHangup)
testNoResults(NotificationContent.MessageLike.CallCandidates)
testNoResults(NotificationContent.MessageLike.KeyVerificationReady)
testNoResults(NotificationContent.MessageLike.KeyVerificationStart)
testNoResults(NotificationContent.MessageLike.KeyVerificationCancel)
testNoResults(NotificationContent.MessageLike.KeyVerificationAccept)
testNoResults(NotificationContent.MessageLike.KeyVerificationKey)
testNoResults(NotificationContent.MessageLike.KeyVerificationMac)
testNoResults(NotificationContent.MessageLike.KeyVerificationDone)
testNoResults(NotificationContent.MessageLike.ReactionContent(relatedEventId = AN_EVENT_ID_2.value))
testNoResults(NotificationContent.MessageLike.Sticker)
testNoResults(NotificationContent.StateEvent.PolicyRuleRoom)
testNoResults(NotificationContent.StateEvent.PolicyRuleServer)
testNoResults(NotificationContent.StateEvent.PolicyRuleUser)
testNoResults(NotificationContent.StateEvent.RoomAliases)
testNoResults(NotificationContent.StateEvent.RoomAvatar)
testNoResults(NotificationContent.StateEvent.RoomCanonicalAlias)
testNoResults(NotificationContent.StateEvent.RoomCreate)
testNoResults(NotificationContent.StateEvent.RoomEncryption)
testNoResults(NotificationContent.StateEvent.RoomGuestAccess)
testNoResults(NotificationContent.StateEvent.RoomHistoryVisibility)
testNoResults(NotificationContent.StateEvent.RoomJoinRules)
testNoResults(NotificationContent.StateEvent.RoomName)
testNoResults(NotificationContent.StateEvent.RoomPinnedEvents)
testNoResults(NotificationContent.StateEvent.RoomPowerLevels)
testNoResults(NotificationContent.StateEvent.RoomServerAcl)
testNoResults(NotificationContent.StateEvent.RoomThirdPartyInvite)
testNoResults(NotificationContent.StateEvent.RoomTombstone)
testNoResults(NotificationContent.StateEvent.RoomTopic(""))
testNoResults(NotificationContent.StateEvent.SpaceChild)
testNoResults(NotificationContent.StateEvent.SpaceParent)
}
private fun testFailure(content: NotificationContent) = runTest {
private fun testNoResults(content: NotificationContent) = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
aNotificationData(
content = content
)
mapOf(AN_EVENT_ID to aNotificationData(content = content))
)
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result.isFailure).isTrue()
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)?.getOrNull()).isNull()
}
private fun Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>.getEvent(
request: NotificationEventRequest
): Result<ResolvedPushEvent>? {
return getOrNull()?.get(request)
}
private fun createDefaultNotifiableEventResolver(
notificationService: FakeNotificationService? = FakeNotificationService(),
notificationResult: Result<NotificationData?> = Result.success(null),
notificationResult: Result<Map<EventId, NotificationData>> = Result.success(emptyMap()),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(),
): DefaultNotifiableEventResolver {
val context = RuntimeEnvironment.getApplication() as Context
notificationService?.givenGetNotificationResult(notificationResult)
notificationService?.givenGetNotificationsResult(notificationResult)
val matrixClientProvider = FakeMatrixClientProvider(getClient = {
if (notificationService == null) {
Result.failure(IllegalStateException("Client not found"))

View file

@ -42,7 +42,9 @@ class DefaultOnMissedCallNotificationHandlerTest {
// Create a fake matrix client provider that returns a fake matrix client with a fake notification service that returns a valid notification data
val matrixClientProvider = FakeMatrixClientProvider(getClient = {
val notificationService = FakeNotificationService().apply {
givenGetNotificationResult(Result.success(aNotificationData(senderDisplayName = A_USER_NAME, senderIsNameAmbiguous = false)))
givenGetNotificationsResult(
Result.success(mapOf(AN_EVENT_ID to aNotificationData(senderDisplayName = A_USER_NAME, senderIsNameAmbiguous = false)))
)
}
Result.success(FakeMatrixClient(notificationService = notificationService))
})

View file

@ -7,16 +7,18 @@
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.ResolvedPushEvent
import io.element.android.tests.testutils.lambda.lambdaError
class FakeNotifiableEventResolver(
private val notifiableEventResult: (SessionId, RoomId, EventId) -> Result<ResolvedPushEvent> = { _, _, _ -> lambdaError() }
private val resolveEventsResult: (SessionId, List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> =
{ _, _ -> lambdaError() }
) : NotifiableEventResolver {
override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result<ResolvedPushEvent> {
return notifiableEventResult(sessionId, roomId, eventId)
override suspend fun resolveEvents(
sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest>
): Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> {
return resolveEventsResult(sessionId, notificationEventRequests)
}
}

View file

@ -354,8 +354,8 @@ class NotificationBroadcastReceiverHandlerTest {
)
)
}
val onNotifiableEventReceivedResult = lambdaRecorder<NotifiableEvent, Unit> { _ -> }
val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceivedResult = onNotifiableEventReceivedResult)
val onNotifiableEventsReceivedResult = lambdaRecorder<List<NotifiableEvent>, Unit> { _ -> }
val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceivedResult = onNotifiableEventsReceivedResult)
val sut = createNotificationBroadcastReceiverHandler(
joinedRoom = joinedRoom,
onNotifiableEventReceived = onNotifiableEventReceived,
@ -371,7 +371,7 @@ class NotificationBroadcastReceiverHandlerTest {
sendMessage.assertions()
.isCalledOnce()
.with(value(A_MESSAGE), value(null), value(emptyList<IntentionalMention>()))
onNotifiableEventReceivedResult.assertions()
onNotifiableEventsReceivedResult.assertions()
.isCalledOnce()
replyMessage.assertions()
.isNeverCalled()
@ -421,8 +421,8 @@ class NotificationBroadcastReceiverHandlerTest {
)
)
}
val onNotifiableEventReceivedResult = lambdaRecorder<NotifiableEvent, Unit> { _ -> }
val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceivedResult = onNotifiableEventReceivedResult)
val onNotifiableEventsReceivedResult = lambdaRecorder<List<NotifiableEvent>, Unit> { _ -> }
val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceivedResult = onNotifiableEventsReceivedResult)
val sut = createNotificationBroadcastReceiverHandler(
joinedRoom = joinedRoom,
onNotifiableEventReceived = onNotifiableEventReceived,
@ -439,7 +439,7 @@ class NotificationBroadcastReceiverHandlerTest {
runCurrent()
sendMessage.assertions()
.isNeverCalled()
onNotifiableEventReceivedResult.assertions()
onNotifiableEventsReceivedResult.assertions()
.isCalledOnce()
replyMessage.assertions()
.isCalledOnce()

View file

@ -34,7 +34,7 @@ class DefaultOnRedactedEventReceivedTest {
val sut = createDefaultOnRedactedEventReceived(
getMessageNotificationsForRoomResult = { _, _ -> emptyList() }
)
sut.onRedactedEventReceived(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null))
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
}
@Test
@ -48,7 +48,7 @@ class DefaultOnRedactedEventReceivedTest {
)
}
)
sut.onRedactedEventReceived(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null))
sut.onRedactedEventsReceived(listOf(ResolvedPushEvent.Redaction(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, null)))
}
private fun TestScope.createDefaultOnRedactedEventReceived(

View file

@ -31,6 +31,8 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.push.impl.history.FakePushHistoryService
import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.NotificationEventRequest
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
import io.element.android.libraries.push.impl.notifications.ResolvingException
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
@ -48,11 +50,15 @@ import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.Fa
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.matching
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.time.Instant
import kotlin.time.Duration.Companion.milliseconds
private const val A_PUSHER_INFO = "info"
@ -80,10 +86,11 @@ class DefaultPushHandlerTest {
fun `when classical PushData is received, the notification drawer is informed`() = runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, RoomId, EventId, Result<ResolvedPushEvent>> { _, _, _ ->
Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _, ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
}
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
@ -96,8 +103,8 @@ class DefaultPushHandlerTest {
clientSecret = A_SECRET,
)
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = notifiableEventResult,
onNotifiableEventsReceived = onNotifiableEventsReceived,
notifiableEventsResult = notifiableEventResult,
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
@ -105,14 +112,17 @@ class DefaultPushHandlerTest {
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
advanceTimeBy(300.milliseconds)
incrementPushCounterResult.assertions()
.isCalledOnce()
notifiableEventResult.assertions()
.isCalledOnce()
.with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID))
onNotifiableEventReceived.assertions()
.with(value(A_USER_ID), any())
onNotifiableEventsReceived.assertions()
.isCalledOnce()
.with(value(aNotifiableMessageEvent))
.with(value(listOf(aNotifiableMessageEvent)))
onPushReceivedResult.assertions()
.isCalledOnce()
}
@ -122,10 +132,11 @@ class DefaultPushHandlerTest {
runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, RoomId, EventId, Result<ResolvedPushEvent.Event>> { _, _, _ ->
Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
}
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val aPushData = PushData(
eventId = AN_EVENT_ID,
@ -138,8 +149,8 @@ class DefaultPushHandlerTest {
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = notifiableEventResult,
onNotifiableEventsReceived = onNotifiableEventsReceived,
notifiableEventsResult = notifiableEventResult,
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
@ -150,11 +161,14 @@ class DefaultPushHandlerTest {
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
advanceTimeBy(300.milliseconds)
incrementPushCounterResult.assertions()
.isCalledOnce()
notifiableEventResult.assertions()
.isCalledOnce()
onNotifiableEventReceived.assertions()
onNotifiableEventsReceived.assertions()
.isNeverCalled()
onPushReceivedResult.assertions()
.isCalledOnce()
@ -165,10 +179,11 @@ class DefaultPushHandlerTest {
runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, RoomId, EventId, Result<ResolvedPushEvent.Event>> { _, _, _ ->
Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
}
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val aPushData = PushData(
eventId = AN_EVENT_ID,
@ -181,8 +196,8 @@ class DefaultPushHandlerTest {
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = notifiableEventResult,
onNotifiableEventsReceived = onNotifiableEventsReceived,
notifiableEventsResult = notifiableEventResult,
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { null }
),
@ -193,14 +208,17 @@ class DefaultPushHandlerTest {
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
advanceTimeBy(300.milliseconds)
incrementPushCounterResult.assertions()
.isCalledOnce()
notifiableEventResult.assertions()
.isCalledOnce()
.with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID))
onNotifiableEventReceived.assertions()
.with(value(A_USER_ID), any())
onNotifiableEventsReceived.assertions()
.isCalledOnce()
.with(value(aNotifiableMessageEvent))
.with(value(listOf(aNotifiableMessageEvent)))
onPushReceivedResult.assertions()
.isCalledOnce()
}
@ -210,10 +228,11 @@ class DefaultPushHandlerTest {
runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, RoomId, EventId, Result<ResolvedPushEvent.Event>> { _, _, _ ->
Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
}
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val aPushData = PushData(
eventId = AN_EVENT_ID,
@ -226,8 +245,8 @@ class DefaultPushHandlerTest {
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = notifiableEventResult,
onNotifiableEventsReceived = onNotifiableEventsReceived,
notifiableEventsResult = notifiableEventResult,
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { null }
),
@ -242,7 +261,7 @@ class DefaultPushHandlerTest {
.isCalledOnce()
notifiableEventResult.assertions()
.isNeverCalled()
onNotifiableEventReceived.assertions()
onNotifiableEventsReceived.assertions()
.isNeverCalled()
onPushReceivedResult.assertions()
.isCalledOnce()
@ -252,10 +271,10 @@ class DefaultPushHandlerTest {
fun `when classical PushData is received, but not able to resolve the event, nothing happen`() =
runTest {
val notifiableEventResult =
lambdaRecorder<SessionId, RoomId, EventId, Result<ResolvedPushEvent.Event>> { _, _, _ ->
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
Result.failure(ResolvingException("Unable to resolve"))
}
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val aPushData = PushData(
eventId = AN_EVENT_ID,
@ -268,8 +287,8 @@ class DefaultPushHandlerTest {
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = notifiableEventResult,
onNotifiableEventsReceived = onNotifiableEventsReceived,
notifiableEventsResult = notifiableEventResult,
buildMeta = aBuildMeta(
// Also test `lowPrivacyLoggingEnabled = false` here
lowPrivacyLoggingEnabled = false
@ -281,12 +300,15 @@ class DefaultPushHandlerTest {
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
advanceTimeBy(300.milliseconds)
incrementPushCounterResult.assertions()
.isCalledOnce()
notifiableEventResult.assertions()
.isCalledOnce()
.with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID))
onNotifiableEventReceived.assertions()
.with(value(A_USER_ID), any())
onNotifiableEventsReceived.assertions()
.isNeverCalled()
onPushReceivedResult.assertions()
.isCalledOnce()
@ -313,28 +335,38 @@ class DefaultPushHandlerTest {
Unit,
> { _, _, _, _, _, _, _, _ -> }
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
elementCallEntryPoint = elementCallEntryPoint,
notifiableEventResult = { _, _, _ ->
notifiableEventsResult = { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(
ResolvedPushEvent.Event(aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli()))
mapOf(
request to Result.success(
ResolvedPushEvent.Event(
aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli())
)
)
)
)
},
incrementPushCounterResult = {},
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
onNotifiableEventReceived = onNotifiableEventReceived,
onNotifiableEventsReceived = onNotifiableEventsReceived,
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
advanceTimeBy(300.milliseconds)
handleIncomingCallLambda.assertions().isCalledOnce()
onNotifiableEventReceived.assertions().isCalledOnce()
onNotifiableEventsReceived.assertions().isNeverCalled()
onPushReceivedResult.assertions().isCalledOnce()
}
@ -346,7 +378,7 @@ class DefaultPushHandlerTest {
unread = 0,
clientSecret = A_SECRET,
)
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val handleIncomingCallLambda = lambdaRecorder<
CallType.RoomCall,
EventId,
@ -365,9 +397,10 @@ class DefaultPushHandlerTest {
)
val defaultPushHandler = createDefaultPushHandler(
elementCallEntryPoint = elementCallEntryPoint,
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = { _, _, _ ->
Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.CALL_NOTIFY)))
onNotifiableEventsReceived = onNotifiableEventsReceived,
notifiableEventsResult = { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.CALL_NOTIFY)))))
},
incrementPushCounterResult = {},
pushClientSecret = FakePushClientSecret(
@ -377,8 +410,10 @@ class DefaultPushHandlerTest {
)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
advanceTimeBy(300.milliseconds)
handleIncomingCallLambda.assertions().isNeverCalled()
onNotifiableEventReceived.assertions().isCalledOnce()
onNotifiableEventsReceived.assertions().isCalledOnce()
onPushReceivedResult.assertions().isCalledOnce()
}
@ -390,7 +425,7 @@ class DefaultPushHandlerTest {
unread = 0,
clientSecret = A_SECRET,
)
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val handleIncomingCallLambda = lambdaRecorder<
CallType.RoomCall,
EventId,
@ -409,9 +444,10 @@ class DefaultPushHandlerTest {
)
val defaultPushHandler = createDefaultPushHandler(
elementCallEntryPoint = elementCallEntryPoint,
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = { _, _, _ ->
Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent()))
onNotifiableEventsReceived = onNotifiableEventsReceived,
notifiableEventsResult = { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent()))))
},
incrementPushCounterResult = {},
userPushStore = FakeUserPushStore().apply {
@ -423,8 +459,11 @@ class DefaultPushHandlerTest {
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
advanceTimeBy(300.milliseconds)
handleIncomingCallLambda.assertions().isCalledOnce()
onNotifiableEventReceived.assertions().isCalledOnce()
onNotifiableEventsReceived.assertions().isNeverCalled()
onPushReceivedResult.assertions().isCalledOnce()
}
@ -442,26 +481,32 @@ class DefaultPushHandlerTest {
redactedEventId = AN_EVENT_ID_2,
reason = null
)
val onRedactedEventReceived = lambdaRecorder<ResolvedPushEvent.Redaction, Unit> { }
val onRedactedEventReceived = lambdaRecorder<List<ResolvedPushEvent.Redaction>, Unit> { }
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val defaultPushHandler = createDefaultPushHandler(
onRedactedEventReceived = onRedactedEventReceived,
onRedactedEventsReceived = onRedactedEventReceived,
incrementPushCounterResult = incrementPushCounterResult,
notifiableEventResult = { _, _, _ -> Result.success(aRedaction) },
notifiableEventsResult = { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(aRedaction)))
},
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
advanceTimeBy(300.milliseconds)
incrementPushCounterResult.assertions()
.isCalledOnce()
onRedactedEventReceived.assertions().isCalledOnce()
.with(value(aRedaction))
.with(value(listOf(aRedaction)))
onPushReceivedResult.assertions()
.isCalledOnce()
}
@ -493,10 +538,64 @@ class DefaultPushHandlerTest {
.isCalledOnce()
}
private fun createDefaultPushHandler(
onNotifiableEventReceived: (NotifiableEvent) -> Unit = { lambdaError() },
onRedactedEventReceived: (ResolvedPushEvent.Redaction) -> Unit = { lambdaError() },
notifiableEventResult: (SessionId, RoomId, EventId) -> Result<ResolvedPushEvent> = { _, _, _ -> lambdaError() },
@Test
fun `when receiving several push notifications at the same time, those are batched before being processed`() = runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _, ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
}
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val aPushData = PushData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
unread = 0,
clientSecret = A_SECRET,
)
val anotherPushData = PushData(
eventId = AN_EVENT_ID_2,
roomId = A_ROOM_ID,
unread = 0,
clientSecret = A_SECRET,
)
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventsReceived = onNotifiableEventsReceived,
notifiableEventsResult = notifiableEventResult,
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
incrementPushCounterResult = incrementPushCounterResult,
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
defaultPushHandler.handle(anotherPushData, A_PUSHER_INFO)
advanceTimeBy(300.milliseconds)
incrementPushCounterResult.assertions()
.isCalledExactly(2)
notifiableEventResult.assertions()
.isCalledOnce()
.with(value(A_USER_ID), matching<List<NotificationEventRequest>> { requests ->
requests.size == 2 && requests.first().eventId == AN_EVENT_ID && requests.last().eventId == AN_EVENT_ID_2
})
onNotifiableEventsReceived.assertions()
.isCalledOnce()
onPushReceivedResult.assertions()
.isCalledExactly(2)
}
private fun TestScope.createDefaultPushHandler(
onNotifiableEventsReceived: (List<NotifiableEvent>) -> Unit = { lambdaError() },
onRedactedEventsReceived: (List<ResolvedPushEvent.Redaction>) -> Unit = { lambdaError() },
notifiableEventsResult: (SessionId, List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> =
{ _, _, -> lambdaError() },
incrementPushCounterResult: () -> Unit = { lambdaError() },
userPushStore: UserPushStore = FakeUserPushStore(),
pushClientSecret: PushClientSecret = FakePushClientSecret(),
@ -508,9 +607,8 @@ class DefaultPushHandlerTest {
pushHistoryService: PushHistoryService = FakePushHistoryService(),
): DefaultPushHandler {
return DefaultPushHandler(
onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceived),
onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventReceived),
notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventResult),
onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived),
onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventsReceived),
incrementPushDataStore = object : IncrementPushDataStore {
override suspend fun incrementPushCounter() {
incrementPushCounterResult()
@ -524,6 +622,8 @@ class DefaultPushHandlerTest {
elementCallEntryPoint = elementCallEntryPoint,
notificationChannels = notificationChannels,
pushHistoryService = pushHistoryService,
resolverQueue = NotificationResolverQueue(notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventsResult), backgroundScope),
appCoroutineScope = backgroundScope,
)
}
}

View file

@ -11,9 +11,9 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven
import io.element.android.tests.testutils.lambda.lambdaError
class FakeOnNotifiableEventReceived(
private val onNotifiableEventReceivedResult: (NotifiableEvent) -> Unit = { lambdaError() },
private val onNotifiableEventsReceivedResult: (List<NotifiableEvent>) -> Unit = { lambdaError() },
) : OnNotifiableEventReceived {
override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
onNotifiableEventReceivedResult(notifiableEvent)
override fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>) {
onNotifiableEventsReceivedResult(notifiableEvents)
}
}

View file

@ -11,9 +11,9 @@ import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEv
import io.element.android.tests.testutils.lambda.lambdaError
class FakeOnRedactedEventReceived(
private val onRedactedEventReceivedResult: (ResolvedPushEvent.Redaction) -> Unit = { lambdaError() },
private val onRedactedEventsReceivedResult: (List<ResolvedPushEvent.Redaction>) -> Unit = { lambdaError() },
) : OnRedactedEventReceived {
override fun onRedactedEventReceived(redaction: ResolvedPushEvent.Redaction) {
onRedactedEventReceivedResult(redaction)
override fun onRedactedEventsReceived(redactions: List<ResolvedPushEvent.Redaction>) {
onRedactedEventsReceivedResult(redactions)
}
}

View file

@ -76,7 +76,7 @@ class SyncOnNotifiableEventTest {
fun `when feature flag is disabled, nothing happens`() = runTest {
val sut = createSyncOnNotifiableEvent(client = client, isSyncOnPushEnabled = false)
sut(notifiableEvent)
sut(listOf(notifiableEvent))
assert(startSyncLambda).isNeverCalled()
assert(stopSyncLambda).isNeverCalled()
@ -97,7 +97,7 @@ class SyncOnNotifiableEventTest {
unlocked.set(true)
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
}
sut(incomingCallNotifiableEvent)
sut(listOf(incomingCallNotifiableEvent))
// The process was completed before the timeout
assertThat(unlocked.get()).isTrue()
@ -117,30 +117,12 @@ class SyncOnNotifiableEventTest {
unlocked.set(true)
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
}
sut(incomingCallNotifiableEvent)
sut(listOf(incomingCallNotifiableEvent))
// Didn't unlock before the timeout
assertThat(unlocked.get()).isFalse()
}
@Test
fun `when feature flag is enabled and app is in foreground, sync is not started`() = runTest {
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = true,
)
val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true)
appForegroundStateService.isSyncingNotificationEvent.test {
sut(notifiableEvent)
sut(incomingCallNotifiableEvent)
// It's initially false
assertThat(awaitItem()).isFalse()
// It never becomes true
ensureAllEventsConsumed()
}
}
@Test
fun `when feature flag is enabled and app is in background, sync is started and stopped`() = runTest {
val appForegroundStateService = FakeAppForegroundStateService(
@ -154,7 +136,7 @@ class SyncOnNotifiableEventTest {
appForegroundStateService.isSyncingNotificationEvent.test {
syncService.emitSyncState(SyncState.Running)
sut(notifiableEvent)
sut(listOf(notifiableEvent))
// It's initially false
assertThat(awaitItem()).isFalse()
@ -175,8 +157,8 @@ class SyncOnNotifiableEventTest {
val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true)
appForegroundStateService.isSyncingNotificationEvent.test {
launch { sut(notifiableEvent) }
launch { sut(notifiableEvent) }
launch { sut(listOf(notifiableEvent)) }
launch { sut(listOf(notifiableEvent)) }
launch {
delay(1)
timelineItems.emit(

View file

@ -24,6 +24,18 @@ fun <T> value(expectedValue: T) = object : ParameterMatcher {
override fun toString(): String = "value($expectedValue)"
}
/**
* A matcher that matches a value based on a condition.
* Can be used to assert that a lambda has been called with a value that satisfies a specific condition.
*/
fun <T> matching(check: (T) -> Boolean) = object : ParameterMatcher {
override fun match(param: Any?): Boolean {
@Suppress("UNCHECKED_CAST")
return (param as? T)?.let { check(it) } ?: false
}
override fun toString(): String = "matching(condition)"
}
/**
* A matcher that matches any value.
* Can be used when we don't care about the value of a parameter.