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.
This commit is contained in:
hayaksi1
2026-08-09 13:38:28 +03:00
parent 044737a3a7
commit 32f7a9c836
2 changed files with 132 additions and 1 deletions
@@ -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<UserId>,
): Result<ResolvedPushEvent> = 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)
@@ -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<Map<EventId, Result<NotificationData>>> = 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,
)
}
}