From 32f7a9c836c8f036707bc06ea5a45c70f50189c0 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:38:28 +0300 Subject: [PATCH] Do not notify about an event sent by another account on the same device With several accounts logged in, sending a message from one of them raises a notification on every other account that shares the room. The device notifies its owner about something the owner has just done, and the more accounts share a room the more copies arrive. Resolve the set of logged-in user ids once per push batch and drop an event whose sender is one of the other accounts, using the filtered-out result the notification pipeline already understands, so it is recorded in push history rather than turned into a fallback notification. The receiving session is removed from that set, which keeps the change a strict no-op for a single account and protects the local echo that the reply action pushes back through the same path. Filtering happens at the one point that sees every kind of push, so messages, polls, call invites, ringing calls and room invites are all covered. Filtering is unconditional and has no setting; multiple accounts sit behind an experimental flag that is off by default, so nothing changes for a stock build. --- .../DefaultNotifiableEventResolver.kt | 22 +++- .../DefaultNotifiableEventResolverTest.kt | 111 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index abbda10a3c..4ba822aa25 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -56,6 +56,7 @@ import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.toolbox.api.strings.StringProvider import timber.log.Timber @@ -94,6 +95,7 @@ class DefaultNotifiableEventResolver( private val callNotificationEventResolver: CallNotificationEventResolver, private val fallbackNotificationFactory: FallbackNotificationFactory, private val featureFlagService: FeatureFlagService, + private val sessionStore: SessionStore, ) : NotifiableEventResolver { override suspend fun resolveEvents( sessionId: SessionId, @@ -117,10 +119,15 @@ class DefaultNotifiableEventResolver( return Result.failure(exception ?: NotificationResolverException.UnknownError("Unknown error while fetching notifications")) } + val otherSessionUserIds = sessionStore.getAllSessions() + .map { UserId(it.userId) } + .minus(sessionId) + .toSet() + // The null check is done above val notificationDataMap = notificationsResult.getOrNull()!!.mapValues { (_, notificationData) -> notificationData.flatMap { data -> - data.asNotifiableEvent(client, sessionId) + data.asNotifiableEvent(client, sessionId, otherSessionUserIds) } } @@ -136,10 +143,23 @@ class DefaultNotifiableEventResolver( ) } + private fun NotificationContent.senderIdOrNull(): UserId? = when (this) { + is NotificationContent.MessageLike.RoomMessage -> senderId + is NotificationContent.MessageLike.Poll -> senderId + is NotificationContent.MessageLike.CallInvite -> senderId + is NotificationContent.MessageLike.RtcNotification -> senderId + is NotificationContent.Invite -> senderId + else -> null + } + private suspend fun NotificationData.asNotifiableEvent( client: MatrixClient, userId: SessionId, + otherSessionUserIds: Set, ): Result = runCatchingExceptions { + if (content.senderIdOrNull() in otherSessionUserIds) { + throw NotificationResolverException.EventFilteredOut + } when (val content = this.content) { is NotificationContent.MessageLike.RoomMessage -> { val showMediaPreview = client.mediaPreviewService.getMediaPreviewValue().isPreviewEnabled(roomJoinRule) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index eedc107c67..6acce3405c 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.test.A_REDACTION_REASON import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME 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_SPACE_NAME import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_USER_ID_2 @@ -56,6 +57,9 @@ import io.element.android.libraries.push.impl.notifications.model.FallbackNotifi import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.services.toolbox.impl.strings.AndroidStringProvider import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import io.element.android.services.toolbox.test.systemclock.FakeSystemClock @@ -836,6 +840,111 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() { assertThat(result.getEvent(request)?.getOrNull()).isNull() } + @Test + fun `resolve RoomMessage sent by another session on the same device is filtered out`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success( + aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_SESSION_ID_2, + messageType = TextMessageType("Hello world", null), + ) + ) + ) + ) + ), + sessionStore = InMemorySessionStore( + listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ) + ), + ) + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.exceptionOrNull()).isEqualTo(NotificationResolverException.EventFilteredOut) + } + + @Test + fun `resolve RtcNotification sent by another session on the same device is filtered out`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success( + aNotificationData( + content = NotificationContent.MessageLike.RtcNotification( + senderId = A_SESSION_ID_2, + type = RtcNotificationType.RING, + callIntent = CallIntent.AUDIO, + expirationTimestampMillis = 0, + ) + ) + ) + ) + ), + sessionStore = InMemorySessionStore( + listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ) + ), + ) + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.exceptionOrNull()).isEqualTo(NotificationResolverException.EventFilteredOut) + } + + @Test + fun `resolve RoomMessage sent by a user who is not logged in is not filtered out`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success( + aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_SESSION_ID_2, + messageType = TextMessageType("Hello world", null), + ) + ) + ) + ) + ), + sessionStore = InMemorySessionStore(listOf(aSessionData(sessionId = A_SESSION_ID.value))), + ) + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.isSuccess).isTrue() + } + + @Test + fun `resolve RoomMessage sent by the receiving session itself is not filtered out`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success( + aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_SESSION_ID, + messageType = TextMessageType("Hello world", null), + ) + ) + ) + ) + ), + sessionStore = InMemorySessionStore( + listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ) + ), + ) + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.isSuccess).isTrue() + } + @Test fun `resolve null cases`() { testNoResults(NotificationContent.MessageLike.CallAnswer) @@ -893,6 +1002,7 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() { notificationService: FakeNotificationService? = FakeNotificationService(), notificationResult: Result>> = Result.success(emptyMap()), callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(), + sessionStore: SessionStore = InMemorySessionStore(), ): DefaultNotifiableEventResolver { val context = RuntimeEnvironment.getApplication() as Context notificationService?.givenGetNotificationsResult(notificationResult) @@ -917,6 +1027,7 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() { clock = FakeSystemClock(), ), featureFlagService = FakeFeatureFlagService(), + sessionStore = sessionStore, ) } }