Skip to content

Notification events resolving and rendering in batches #4722

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
May 26, 2025

Conversation

jmartinesp
Copy link
Member

@jmartinesp jmartinesp commented May 14, 2025

Content

  • Added NotificationResolverQueue type that enqueues notification fetching requests received in a certain time window, and resolves them in batches, emitting the results when done.
  • DefaultPushHandler now subscribes to these results and also processes them in batches, categorising the items and redirecting them to their callbacks (onRedactedEventReceived, handleRingingCallEvent, etc.).
  • DefaultNotificationDrawerManager now has a onNotifiableEventsReceived to render several notifications at the same time.

Motivation and context

The latest SDK version allows clients to fetch several notifications as a group so we need to run a single sliding sync request for them. However, since the items were returned at the same time and immediately sent to the NotificationManager so they can be displayed, the OS thought we were spamming the notifications and 'muted' (or discarded) some of them. Instead, we had to rework how we process the resolved notifications.

Tests

  • Send several messages in a very small time window in a room. If done correctly, both messages should be added to the notification at the same time.
  • Redact some of those messages, it should display as 'Redacted message' or something similar in the notification drawer.
  • Start a call in a DM, you should receive the ringing call notification with no issues.

Tested devices

  • Physical
  • Emulator
  • OS version(s): 14

Checklist

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

@jmartinesp jmartinesp changed the title Notification batching WIP: Notification batching May 14, 2025
Copy link
Contributor

github-actions bot commented May 14, 2025

📱 Scan the QR code below to install the build (arm64 only) for this PR.
QR code
If you can't scan the QR code you can install the build via this link: https://i.diawi.com/TwdhKK

Copy link

codecov bot commented May 15, 2025

Codecov Report

Attention: Patch coverage is 76.96335% with 44 lines in your changes missing coverage. Please review.

Project coverage is 80.41%. Comparing base (9b9d75a) to head (a4f1595).
Report is 4 commits behind head on develop.

Files with missing lines Patch % Lines
...ibraries/push/impl/push/OnRedactedEventReceived.kt 37.14% 21 Missing and 1 partial ⚠️
.../libraries/push/impl/push/SyncOnNotifiableEvent.kt 72.22% 0 Missing and 5 partials ⚠️
...ifications/NotificationBroadcastReceiverHandler.kt 20.00% 4 Missing ⚠️
...atrix/impl/notification/RustNotificationService.kt 81.25% 1 Missing and 2 partials ⚠️
.../notifications/DefaultNotificationDrawerManager.kt 0.00% 2 Missing ⚠️
...ications/DefaultOnMissedCallNotificationHandler.kt 0.00% 0 Missing and 2 partials ⚠️
...sh/impl/notifications/NotificationResolverQueue.kt 93.10% 0 Missing and 2 partials ⚠️
...oid/libraries/push/impl/push/DefaultPushHandler.kt 96.22% 0 Missing and 2 partials ⚠️
...raries/push/impl/push/OnNotifiableEventReceived.kt 0.00% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #4722      +/-   ##
===========================================
+ Coverage    80.40%   80.41%   +0.01%     
===========================================
  Files         2143     2144       +1     
  Lines        56678    56751      +73     
  Branches      7096     7112      +16     
===========================================
+ Hits         45570    45635      +65     
- Misses        8681     8684       +3     
- Partials      2427     2432       +5     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Member

@bmarty bmarty left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for improving this part!
Some early remarks.


interface NotificationService {
suspend fun getNotification(roomId: RoomId, eventId: EventId): Result<NotificationData?>
suspend fun getNotification(sessionId: SessionId, roomId: RoomId, eventId: EventId): Result<NotificationData?>
suspend fun getNotifications(sessionId: SessionId, ids: Map<RoomId, List<EventId>>): Result<Map<EventId, NotificationData?>>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first sight, it's strange that the return type is not
Result<Map<RoomId, List<NotificationData>>>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was like that originally, but where it's immediately used later (here) having the notifications grouped by room id doesn't really help, we'd have to first search for the room, then use notifications.find { it.eventId == eventId }. Using having the id -> data mapping is more optimised.

suspend fun resolveEvents(
sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest>
): Result<Map<EventId, ResolvedPushEvent?>>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same type of remark, why don't we have a return type like:
Map<RoomId, List<ResolvedPushEvent>>?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here it might actually make sense to do that for the pushHistoryService.onSuccess and failure callbacks, searching might be a bit faster even if later we need to either iterate by room then event id or just flatten everything.

Maybe it would even make sense to use the NotificationEventRequest as the key itself? It has the session id, room and event ids to make fetching a single operation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmartinesp jmartinesp changed the title WIP: Notification batching Notification events resolving and rendering in batches May 16, 2025
@jmartinesp jmartinesp added the PR-Misc For other changes label May 16, 2025
@jmartinesp jmartinesp marked this pull request as ready for review May 16, 2025 08:23
@jmartinesp jmartinesp requested a review from a team as a code owner May 16, 2025 08:23
@jmartinesp jmartinesp requested review from bmarty and removed request for a team May 16, 2025 08:23
Copy link
Member

@bmarty bmarty left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More comments. I will test the code on a real device.

@@ -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(sessionId, mapOf(roomId to listOf(eventId)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks weird to have to provide a sessionId here, since we are using a method from a service of a matrix client.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but RustNotificationService doesn't have a reference to the matrix client. Maybe I should just add a SessionId as a constructor param for it. WDYT?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I should just add a SessionId as a constructor param for it. WDYT?

Yes please. We're doing it for RustRoomFactory already for instance.

Copy link
Member Author

@jmartinesp jmartinesp May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RustRoomFactory doesn't use injection though. Adding it here would mean creating a factory abstraction.

Never mind, I looked at the wrong component. I have a severe case of the Mondays, it seems.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coroutineScope.launch {
while (coroutineScope.isActive) {
// Wait for a batch of requests to be received in a specified time window
delay(BATCH_WINDOW_MS.milliseconds)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this code will behave as a first throttler?
If we get a Push just before the delay is ended, it will trigger the request without waiting for any other potential push. I have not checked in practice how much time we have between mutliple pending push received from Firebase though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't have to act as a throttle operation, but as a window one, grouping events that happen in an interval in batches. Those batches won't always be optimal, since as you say, if you receive one after one window closes it'll be processed on the next one.

If you think it's worth it implementing it more in a throttle/debounce way (without discarding items as those operators usually do) I can try to do that. There is the possibility that this means we chain lots of 'received item, wait for X ms' operations and end up delaying the processing for longer, but it's not really likely to happen in the real world except maybe for when you have several pushes in firebase that couldn't be delivered, the device reconnects and it starts receiving the pending pushes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in bd27b50, it seems to work quite well.

val notificationManager = NotificationManagerCompat.from(context)
val previousNotification = notificationManager.activeNotifications.find { it.tag == roomInfo.roomId.value }
val elapsed = System.currentTimeMillis() - (previousNotification?.postTime ?: 0L)
Timber.d("Creating noisy notification for room ${roomInfo.roomId.value} with elapsed time $elapsed")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this log into the if block (not sure)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooops, this should actually be gone. It was done for an experiment and I removed part of it, but apparently I kept this by mistake.

@jmartinesp jmartinesp requested a review from bmarty May 19, 2025 10:50
Copy link
Member

@bmarty bmarty left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few remarks, else I think we can merge this PR to make is live in the next nightlies for a few days


delay(BATCH_WINDOW_MS.milliseconds)

if (!isActive) return@launch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this line mandatory? I mean if the job is cancelled during the delay, I do not think this line and the following will be executed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, it's not needed. delay will check if the context is alive for the whole duration of the function being suspended.

/**
* 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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should cancel only a delay operation, just reading the code I am afraid that it can cancel a started treatment, but I may be wrong

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what I'm trying to do: the 'enqueue' job is saved and will be cancelled when a new one arrives. Internally, it waits for the time window and launches a new coroutine in the parent one, which should be independent AFAICT. Maybe we could use a SupervisorJob to ensure the 'enqueue' job being cancelled won't affect any other child coroutine of the injected scope.

@jmartinesp jmartinesp force-pushed the feat/process-notifications-in-batches branch from bd27b50 to a4f1595 Compare May 26, 2025 13:41
Copy link

@jmartinesp jmartinesp merged commit f455085 into develop May 26, 2025
30 of 31 checks passed
@jmartinesp jmartinesp deleted the feat/process-notifications-in-batches branch May 26, 2025 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
PR-Misc For other changes
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants