From b604b062b43c25c454a905f6a55d3d611637a77d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 09:57:50 +0100 Subject: [PATCH 1/8] Dismiss fallback notification when the room list is rendered. --- .../DefaultNotificationDrawerManager.kt | 16 +++++++++++++++- .../notifications/NotificationDataFactory.kt | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index 3dbf09e2273..575206de10e 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -71,7 +71,10 @@ class DefaultNotificationDrawerManager( private fun onAppNavigationStateChange(navigationState: NavigationState) { when (navigationState) { NavigationState.Root -> {} - is NavigationState.Session -> {} + is NavigationState.Session -> { + // Cleanup the fallback notification + clearFallbackForSession(navigationState.sessionId) + } is NavigationState.Room -> { // Cleanup notification for current room clearMessagesForRoom( @@ -121,6 +124,17 @@ class DefaultNotificationDrawerManager( .forEach { notificationDisplayer.cancelNotification(it.tag, it.id) } } + /** + * Remove the fallback notification for the session. + */ + fun clearFallbackForSession(sessionId: SessionId) { + notificationDisplayer.cancelNotification( + DefaultNotificationDataFactory.FALLBACK_NOTIFICATION_TAG, + NotificationIdProvider.getFallbackNotificationId(sessionId), + ) + clearSummaryNotificationIfNeeded(sessionId) + } + /** * Should be called when the application is currently opened and showing timeline for the given [roomId]. * Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room. diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt index 53315191105..957894e9943 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -149,7 +149,7 @@ class DefaultNotificationDataFactory( fallback, ) return OneShotNotification( - tag = "FALLBACK", + tag = FALLBACK_NOTIFICATION_TAG, notification = notification, isNoisy = false, timestamp = fallback.first().timestamp @@ -174,6 +174,10 @@ class DefaultNotificationDataFactory( ) } } + + companion object { + const val FALLBACK_NOTIFICATION_TAG = "FALLBACK" + } } data class RoomNotification( From 73c3ffac2d8c84541e830d38380644c87fec0aaf Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 10:53:51 +0100 Subject: [PATCH 2/8] Improve FakeAppNavigationStateService --- .../DefaultNotificationDrawerManagerTest.kt | 25 ++++++++----------- ...efaultAnalyticsRoomListStateWatcherTest.kt | 6 ++--- .../sentry/SentryAnalyticsProviderTest.kt | 9 +++---- .../test/FakeAppNavigationStateService.kt | 16 ++++++++---- 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index 83891fe4d05..ced45ca1920 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -35,7 +35,6 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService -import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.aNavigationState import io.element.android.tests.testutils.lambda.any @@ -44,7 +43,6 @@ import io.element.android.tests.testutils.lambda.value import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -92,26 +90,25 @@ class DefaultNotificationDrawerManagerTest { @Test fun `react to applicationStateChange`() = runTest { // For now just call all the API. Later, add more valuable tests. - val appNavigationStateFlow: MutableStateFlow = MutableStateFlow( - AppNavigationState( - navigationState = NavigationState.Root, - isInForeground = true, - ) - ) - val appNavigationStateService = FakeAppNavigationStateService(appNavigationState = appNavigationStateFlow) + val appNavigationStateService = FakeAppNavigationStateService() createDefaultNotificationDrawerManager( appNavigationStateService = appNavigationStateService ) - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) + appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(), isInForeground = true)) runCurrent() - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID), isInForeground = true)) + appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(A_SESSION_ID), isInForeground = true)) runCurrent() - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_ROOM_ID), isInForeground = true)) + appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(A_SESSION_ID, A_ROOM_ID), isInForeground = true)) runCurrent() - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID), isInForeground = true)) + appNavigationStateService.emitNavigationState( + AppNavigationState( + aNavigationState(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID), + isInForeground = true + ) + ) runCurrent() // Like a user sign out - appNavigationStateFlow.emit(AppNavigationState(aNavigationState(), isInForeground = true)) + appNavigationStateService.emitNavigationState(AppNavigationState(aNavigationState(), isInForeground = true)) runCurrent() } diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt index 8796d6a2eec..80efdff7ae1 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt @@ -63,9 +63,9 @@ class DefaultAnalyticsRoomListStateWatcherTest { @Test fun `Opening the app in a cold state does nothing`() = runTest { - val navigationStateService = FakeAppNavigationStateService().apply { - appNavigationState.emit(AppNavigationState(NavigationState.Root, false)) - } + val navigationStateService = FakeAppNavigationStateService( + initialAppNavigationState = AppNavigationState(NavigationState.Root, false) + ) val roomListService = FakeRoomListService().apply { postState(RoomListService.State.Idle) } diff --git a/services/analyticsproviders/sentry/src/test/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProviderTest.kt b/services/analyticsproviders/sentry/src/test/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProviderTest.kt index 24b938d9a53..a8dc9f30869 100644 --- a/services/analyticsproviders/sentry/src/test/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProviderTest.kt +++ b/services/analyticsproviders/sentry/src/test/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProviderTest.kt @@ -31,7 +31,6 @@ import io.sentry.Sentry import io.sentry.SentryTracer import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction -import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Test import org.junit.runner.RunWith @@ -149,7 +148,7 @@ class SentryAnalyticsProviderTest { ) }, appNavigationStateService = FakeAppNavigationStateService( - MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) + initialAppNavigationState = AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true) ) ).run { init() @@ -182,7 +181,7 @@ class SentryAnalyticsProviderTest { ) }, appNavigationStateService = FakeAppNavigationStateService( - MutableStateFlow(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + initialAppNavigationState = AppNavigationState(navigationState = NavigationState.Root, isInForeground = true) ) ).run { init() @@ -203,7 +202,7 @@ class SentryAnalyticsProviderTest { ) }, appNavigationStateService = FakeAppNavigationStateService( - MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) + initialAppNavigationState = AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true) ) ).run { init() @@ -221,7 +220,7 @@ class SentryAnalyticsProviderTest { buildMeta: BuildMeta = aBuildMeta(), getDatabaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, appNavigationStateService: FakeAppNavigationStateService = FakeAppNavigationStateService( - MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) + initialAppNavigationState = AppNavigationState(NavigationState.Session("owner", A_SESSION_ID), isInForeground = true) ) ) = SentryAnalyticsProvider( context = InstrumentationRegistry.getInstrumentation().targetContext, diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt index fd23b7c4225..1e9289f0b51 100644 --- a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/FakeAppNavigationStateService.kt @@ -15,15 +15,21 @@ import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow class FakeAppNavigationStateService( - override val appNavigationState: MutableStateFlow = MutableStateFlow( - AppNavigationState( - navigationState = NavigationState.Root, - isInForeground = true, - ) + initialAppNavigationState: AppNavigationState = AppNavigationState( + navigationState = NavigationState.Root, + isInForeground = true, ), ) : AppNavigationStateService { + private val _appNavigationState: MutableStateFlow = MutableStateFlow(initialAppNavigationState) + override val appNavigationState = _appNavigationState.asStateFlow() + + fun emitNavigationState(state: AppNavigationState) { + _appNavigationState.value = state + } + override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit override fun onLeavingSession(owner: String) = Unit From 50264a9ab03f97d5830c467cdf8557abf1cee6e9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 10:46:20 +0100 Subject: [PATCH 3/8] Ignore fallback notification when the room list is rendered. Add more tests. --- .../DefaultNotificationDrawerManager.kt | 44 ++- .../model/NotifiableMessageEvent.kt | 25 -- .../DefaultNotificationDrawerManagerTest.kt | 266 +++++++++++++++++- .../fixtures/NotifiableEventFixture.kt | 6 +- .../appnavstate/test/AppNavStateFixture.kt | 9 + 5 files changed, 316 insertions(+), 34 deletions(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index 575206de10e..a0e6193d991 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -22,12 +22,20 @@ import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver +import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.api.currentRoomId +import io.element.android.services.appnavstate.api.currentSessionId +import io.element.android.services.appnavstate.api.currentThreadId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -97,14 +105,11 @@ class DefaultNotificationDrawerManager( * Events might be grouped and there might not be one notification per event! */ suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { - if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) { - return - } - renderEvents(listOf(notifiableEvent)) + onNotifiableEventsReceived(listOf(notifiableEvent)) } suspend fun onNotifiableEventsReceived(notifiableEvents: List) { - val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value) } + val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreRegardingApplicationState(appNavigationStateService.appNavigationState.value) } renderEvents(eventsToNotify) } @@ -206,3 +211,30 @@ class DefaultNotificationDrawerManager( } } } + +/** + * Used to check if a notification should be ignored based on the current application navigation state. + */ +private fun NotifiableEvent.shouldIgnoreRegardingApplicationState(appNavigationState: AppNavigationState): Boolean { + if (!appNavigationState.isInForeground) return false + return appNavigationState.navigationState.currentSessionId() == sessionId && + when (this) { + is NotifiableRingingCallEvent -> { + // Never ignore ringing call notifications + // Note that NotifiableRingingCallEvent are not handled by DefaultNotificationDrawerManage + false + } + is FallbackNotifiableEvent -> { + // Ignore if the room list is currently displayed + appNavigationState.navigationState is NavigationState.Session + } + is InviteNotifiableEvent, + is SimpleNotifiableEvent -> { + roomId == appNavigationState.navigationState.currentRoomId() + } + is NotifiableMessageEvent -> { + roomId == appNavigationState.navigationState.currentRoomId() && + threadId == appNavigationState.navigationState.currentThreadId() + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt index 07f7f8b3c71..1b29527cac7 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt @@ -15,10 +15,6 @@ 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.timeline.item.event.EventType -import io.element.android.services.appnavstate.api.AppNavigationState -import io.element.android.services.appnavstate.api.currentRoomId -import io.element.android.services.appnavstate.api.currentSessionId -import io.element.android.services.appnavstate.api.currentThreadId data class NotifiableMessageEvent( override val sessionId: SessionId, @@ -56,24 +52,3 @@ data class NotifiableMessageEvent( val imageUri: Uri? get() = imageUriString?.toUri() } - -/** - * Used to check if a notification should be ignored based on the current app and navigation state. - */ -fun NotifiableEvent.shouldIgnoreEventInRoom(appNavigationState: AppNavigationState): Boolean { - val currentSessionId = appNavigationState.navigationState.currentSessionId() ?: return false - return when (val currentRoomId = appNavigationState.navigationState.currentRoomId()) { - null -> false - else -> { - // Never ignore ringing call notifications - if (this is NotifiableRingingCallEvent) { - false - } else { - appNavigationState.isInForeground && - sessionId == currentSessionId && - roomId == currentRoomId && - (this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId() - } - } - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index ced45ca1920..b080c77a2d1 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -15,8 +15,11 @@ import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService 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_ID_2 import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.ui.components.aMatrixUser @@ -28,7 +31,11 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeNotificatio import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.test.InMemorySessionStore @@ -37,6 +44,7 @@ import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.aNavigationState +import io.element.android.services.appnavstate.test.anAppNavigationState import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -231,6 +239,262 @@ class DefaultNotificationDrawerManagerTest { listOf(value(null), value(summaryId)), ) } + + @Test + fun `when the application is in background, all events trigger a notification`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID), + isInForeground = false, + ), + notifiableEvents = listOf( + aFallbackNotifiableEvent(sessionId = A_SESSION_ID), + aFallbackNotifiableEvent(sessionId = A_SESSION_ID_2), + anInviteNotifiableEvent(sessionId = A_SESSION_ID), + anInviteNotifiableEvent(sessionId = A_SESSION_ID_2), + aSimpleNotifiableEvent(sessionId = A_SESSION_ID), + aSimpleNotifiableEvent(sessionId = A_SESSION_ID_2), + aNotifiableMessageEvent(sessionId = A_SESSION_ID), + aNotifiableMessageEvent(sessionId = A_SESSION_ID_2), + aNotifiableMessageEvent(sessionId = A_SESSION_ID, threadId = A_THREAD_ID), + aNotifiableMessageEvent(sessionId = A_SESSION_ID_2, threadId = A_THREAD_ID_2), + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 2, + ) + + @Test + fun `fallback event is ignored when the room list is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID), + ), + notifiableEvents = listOf(aFallbackNotifiableEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = false, + ) + + @Test + fun `fallback event is not ignored when a room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID), + ), + notifiableEvents = listOf(aFallbackNotifiableEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = true, + ) + + @Test + fun `fallback event for other session is not ignored when the room list is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID_2), + ), + notifiableEvents = listOf(aFallbackNotifiableEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = true, + ) + + @Test + fun `invite notifiable event is emits a notification when the room list is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID), + ), + notifiableEvents = listOf(anInviteNotifiableEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `invite notifiable event does not emit a notification when the same room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID), + ), + notifiableEvents = listOf( + anInviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = false, + ) + + @Test + fun `invite notifiable event emits a notification when another room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2), + ), + notifiableEvents = listOf( + anInviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `simple notifiable event is emits a notification when the room list is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID), + ), + notifiableEvents = listOf(aSimpleNotifiableEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `simple notifiable event does not emit a notification when the same room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID), + ), + notifiableEvents = listOf( + aSimpleNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = false, + ) + + @Test + fun `simple notifiable event emits a notification when another room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2), + ), + notifiableEvents = listOf( + aSimpleNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `notifiable event is emits a notification when the room list is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID), + ), + notifiableEvents = listOf(aNotifiableMessageEvent(sessionId = A_SESSION_ID)), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `notifiable event does not emit a notification when the same room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = false, + ) + + @Test + fun `notifiable event for a thread emits a notification when the same room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `notifiable event for a thread does not emit a notification when the same thread is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + ) + ), + shouldEmitNotification = false, + ) + + @Test + fun `notifiable event for a thread emits a notification when another thread is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID_2), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `notifiable event for a thread emits a notification when a thread of another room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2, threadId = A_THREAD_ID_2), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + @Test + fun `notifiable event emits a notification when another room is displayed`() = testOnNotifiableEventReceived( + appNavigationState = anAppNavigationState( + navigationState = aNavigationState(sessionId = A_SESSION_ID, roomId = A_ROOM_ID_2), + ), + notifiableEvents = listOf( + aNotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + ) + ), + shouldEmitNotification = true, + extraInvocationsForNotificationSummary = 1, + ) + + private fun testOnNotifiableEventReceived( + appNavigationState: AppNavigationState, + notifiableEvents: List, + shouldEmitNotification: Boolean, + extraInvocationsForNotificationSummary: Int = 0, + ) = runTest { + val showNotificationResult = lambdaRecorder { _, _, _ -> + true + } + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + appNavigationStateService = FakeAppNavigationStateService( + initialAppNavigationState = appNavigationState, + ), + notificationDisplayer = FakeNotificationDisplayer( + showNotificationResult = showNotificationResult, + ) + ) + defaultNotificationDrawerManager.onNotifiableEventsReceived(notifiableEvents) + showNotificationResult.assertions().isCalledExactly( + if (shouldEmitNotification) { + notifiableEvents.size + extraInvocationsForNotificationSummary + } else { + 0 + } + ) + } } fun TestScope.createDefaultNotificationDrawerManager( @@ -248,7 +512,7 @@ fun TestScope.createDefaultNotificationDrawerManager( return DefaultNotificationDrawerManager( notificationDisplayer = notificationDisplayer, notificationRenderer = notificationRenderer ?: NotificationRenderer( - notificationDisplayer = FakeNotificationDisplayer(), + notificationDisplayer = notificationDisplayer, notificationDataFactory = DefaultNotificationDataFactory( notificationCreator = FakeNotificationCreator(), roomGroupMessageCreator = roomGroupMessageCreator, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt index 9b7929b6a02..0ab39d51807 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotifiableEventFixture.kt @@ -144,8 +144,10 @@ fun aNotifiableCallEvent( rtcNotificationType = rtcNotificationType, ) -fun aFallbackNotifiableEvent() = FallbackNotifiableEvent( - sessionId = A_SESSION_ID, +fun aFallbackNotifiableEvent( + sessionId: SessionId = A_SESSION_ID, +) = FallbackNotifiableEvent( + sessionId = sessionId, roomId = A_ROOM_ID, eventId = AN_EVENT_ID, editedEventId = null, diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt index 7860320eeea..e8338cb09c3 100644 --- a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt @@ -11,6 +11,7 @@ package io.element.android.services.appnavstate.test 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.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.NavigationState const val A_SESSION_OWNER = "aSessionOwner" @@ -35,3 +36,11 @@ fun aNavigationState( } return NavigationState.Thread(A_THREAD_OWNER, threadId, room) } + +fun anAppNavigationState( + navigationState: NavigationState = aNavigationState(), + isInForeground: Boolean = true, +) = AppNavigationState( + navigationState = navigationState, + isInForeground = isInForeground, +) From 0bb1a2f80104bdde6211859fc9e7111e4e5f5e4d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 14:28:28 +0100 Subject: [PATCH 4/8] Fix warning --- .../android/features/messages/impl/MessagesPresenterTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index d52179f3ef7..f6967de0e51 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -1238,7 +1238,7 @@ class MessagesPresenterTest { } @Test - fun `present - shows a "world_readable" icon if the room is encrypted and history is world_readable`() = runTest { + fun `present - shows a 'world_readable' icon if the room is encrypted and history is world_readable`() = runTest { val presenter = createMessagesPresenter( joinedRoom = FakeJoinedRoom( baseRoom = FakeBaseRoom( From 7f356f26032b0901618d9571f156793d5b6748be Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 14:42:58 +0100 Subject: [PATCH 5/8] Fix typo --- .../push/impl/notifications/DefaultNotificationDrawerManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index a0e6193d991..0f64037aeee 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -221,7 +221,7 @@ private fun NotifiableEvent.shouldIgnoreRegardingApplicationState(appNavigationS when (this) { is NotifiableRingingCallEvent -> { // Never ignore ringing call notifications - // Note that NotifiableRingingCallEvent are not handled by DefaultNotificationDrawerManage + // Note that NotifiableRingingCallEvent are not handled by DefaultNotificationDrawerManager false } is FallbackNotifiableEvent -> { From bed65b3950259d2b9a6c4900ebad03875f0f203e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 17:57:31 +0100 Subject: [PATCH 6/8] Swap receiver and parameter for a nicer code. --- .../DefaultNotificationDrawerManager.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index 0f64037aeee..e328c462092 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -109,7 +109,7 @@ class DefaultNotificationDrawerManager( } suspend fun onNotifiableEventsReceived(notifiableEvents: List) { - val eventsToNotify = notifiableEvents.filter { !it.shouldIgnoreRegardingApplicationState(appNavigationStateService.appNavigationState.value) } + val eventsToNotify = notifiableEvents.filter { !appNavigationStateService.appNavigationState.value.shouldIgnoreEvent(it) } renderEvents(eventsToNotify) } @@ -213,12 +213,12 @@ class DefaultNotificationDrawerManager( } /** - * Used to check if a notification should be ignored based on the current application navigation state. + * Used to check if a notifiableEvent should be ignored based on the current application navigation state. */ -private fun NotifiableEvent.shouldIgnoreRegardingApplicationState(appNavigationState: AppNavigationState): Boolean { - if (!appNavigationState.isInForeground) return false - return appNavigationState.navigationState.currentSessionId() == sessionId && - when (this) { +private fun AppNavigationState.shouldIgnoreEvent(event: NotifiableEvent): Boolean { + if (!isInForeground) return false + return navigationState.currentSessionId() == event.sessionId && + when (event) { is NotifiableRingingCallEvent -> { // Never ignore ringing call notifications // Note that NotifiableRingingCallEvent are not handled by DefaultNotificationDrawerManager @@ -226,15 +226,15 @@ private fun NotifiableEvent.shouldIgnoreRegardingApplicationState(appNavigationS } is FallbackNotifiableEvent -> { // Ignore if the room list is currently displayed - appNavigationState.navigationState is NavigationState.Session + navigationState is NavigationState.Session } is InviteNotifiableEvent, is SimpleNotifiableEvent -> { - roomId == appNavigationState.navigationState.currentRoomId() + event.roomId == navigationState.currentRoomId() } is NotifiableMessageEvent -> { - roomId == appNavigationState.navigationState.currentRoomId() && - threadId == appNavigationState.navigationState.currentThreadId() + event.roomId == navigationState.currentRoomId() && + event.threadId == navigationState.currentThreadId() } } } From ab1af452c637da9075dc5859fad5a28f2d462d4c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 17:57:52 +0100 Subject: [PATCH 7/8] Add name parameters --- .../push/impl/notifications/NotificationDataFactory.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt index 957894e9943..487b3df597d 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -144,9 +144,9 @@ class DefaultNotificationDataFactory( .getFallbackNotification(notificationAccountParams.user.userId) ?.notification val notification = notificationCreator.createFallbackNotification( - existingNotification, - notificationAccountParams, - fallback, + existingNotification = existingNotification, + notificationAccountParams = notificationAccountParams, + fallbackNotifiableEvents = fallback, ) return OneShotNotification( tag = FALLBACK_NOTIFICATION_TAG, From d1d5fb9cd68c5e2f987d1d0a1ac9cb52703d0347 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Feb 2026 18:00:33 +0100 Subject: [PATCH 8/8] Fix test compilation --- .../DefaultAnalyticsRoomListStateWatcherTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt index 80efdff7ae1..5e378f6a315 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt @@ -43,9 +43,9 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // Make sure it's warm by changing its internal state - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) runCurrent() - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) runCurrent() // The transaction should be present now @@ -110,9 +110,9 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // Make sure it's warm by changing its internal state - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) runCurrent() - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) runCurrent() // The transaction should be present now @@ -145,9 +145,9 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // Make sure it's warm by changing its internal state - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = false)) runCurrent() - navigationStateService.appNavigationState.emit(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + navigationStateService.emitNavigationState(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) runCurrent() // The transaction was never added