From 30067b2a5022debd68e320498e0a0055abefe502 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 9 May 2025 11:38:43 +0200 Subject: [PATCH] Keep call notification ringing while a call is present in the room (#4634) --- .../CallNotificationEventResolver.kt | 42 ++++- .../model/NotifiableRingingCallEvent.kt | 13 +- ...efaultCallNotificationEventResolverTest.kt | 171 ++++++++++++++++++ .../DefaultNotifiableEventResolverTest.kt | 88 +-------- .../FakeCallNotificationEventResolver.kt | 2 +- 5 files changed, 217 insertions(+), 99 deletions(-) create mode 100644 libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultCallNotificationEventResolverTest.kt diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt index 1857ef2b9b..2e56c45395 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt @@ -9,16 +9,22 @@ package io.element.android.libraries.push.impl.notifications import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.notification.CallNotifyType import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.push.impl.R 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.AppForegroundStateService import io.element.android.services.toolbox.api.strings.StringProvider +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds /** * Helper to resolve a valid [NotifiableEvent] from a [NotificationData]. @@ -31,7 +37,7 @@ interface CallNotificationEventResolver { * @param forceNotify `true` to force the notification to be non-ringing, `false` to use the default behaviour. Default is `false`. * @return a [NotifiableEvent] if the notification data is a call notification, null otherwise */ - fun resolveEvent( + suspend fun resolveEvent( sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean = false, @@ -41,8 +47,10 @@ interface CallNotificationEventResolver { @ContributesBinding(AppScope::class) class DefaultCallNotificationEventResolver @Inject constructor( private val stringProvider: StringProvider, + private val appForegroundStateService: AppForegroundStateService, + private val clientProvider: MatrixClientProvider, ) : CallNotificationEventResolver { - override fun resolveEvent( + override suspend fun resolveEvent( sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean @@ -50,8 +58,32 @@ class DefaultCallNotificationEventResolver @Inject constructor( val content = notificationData.content as? NotificationContent.MessageLike.CallNotify ?: throw ResolvingException("content is not a call notify") + val previousRingingCallStatus = appForegroundStateService.hasRingingCall.value + // We need the sync service working to get the updated room info + val isRoomCallActive = runCatching { + if (content.type == CallNotifyType.RING) { + appForegroundStateService.updateHasRingingCall(true) + + val client = clientProvider.getOrRestore(sessionId).getOrNull() ?: throw ResolvingException("Session $sessionId not found") + val room = client.getRoom(notificationData.roomId) ?: throw ResolvingException("Room ${notificationData.roomId} not found") + // Give a few seconds for the room info flow to catch up with the sync, if needed - this is usually instant + val isActive = withTimeoutOrNull(3.seconds) { room.roomInfoFlow.firstOrNull { it.hasRoomCall } }?.hasRoomCall ?: false + + // We no longer need the sync service to be active because of a call notification. + appForegroundStateService.updateHasRingingCall(previousRingingCallStatus) + + isActive + } else { + // If the call notification is not of ringing type, we don't need to check if the call is active + false + } + }.onFailure { + // Make sure to reset the hasRingingCall state in case of failure + appForegroundStateService.updateHasRingingCall(previousRingingCallStatus) + }.getOrDefault(false) + notificationData.run { - if (NotifiableRingingCallEvent.shouldRing(content.type, timestamp) && !forceNotify) { + if (content.type == CallNotifyType.RING && isRoomCallActive && !forceNotify) { NotifiableRingingCallEvent( sessionId = sessionId, roomId = roomId, @@ -70,9 +102,7 @@ class DefaultCallNotificationEventResolver @Inject constructor( senderAvatarUrl = senderAvatarUrl, ) } else { - val now = System.currentTimeMillis() - val elapsed = now - timestamp - Timber.d("Event $eventId is call notify but should not ring: $timestamp vs $now ($elapsed ms elapsed), notify: ${content.type}") + Timber.d("Event $eventId is call notify but should not ring: $isRoomCallActive, notify: ${content.type}") // Create a simple message notification event buildNotifiableMessageEvent( sessionId = sessionId, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableRingingCallEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableRingingCallEvent.kt index bf2de5c32d..07e85ab2bf 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableRingingCallEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableRingingCallEvent.kt @@ -12,8 +12,6 @@ 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.CallNotifyType -import java.time.Instant -import kotlin.time.Duration.Companion.seconds data class NotifiableRingingCallEvent( override val sessionId: SessionId, @@ -31,13 +29,4 @@ data class NotifiableRingingCallEvent( val roomAvatarUrl: String? = null, val callNotifyType: CallNotifyType, val timestamp: Long, -) : NotifiableEvent { - companion object { - fun shouldRing(callNotifyType: CallNotifyType, timestamp: Long): Boolean { - val timeout = 10.seconds.inWholeMilliseconds - val elapsed = Instant.now().toEpochMilli() - timestamp - // Only ring if the type is RING and the elapsed time is less than the timeout - return callNotifyType == CallNotifyType.RING && elapsed < timeout - } - } -} +) : NotifiableEvent diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultCallNotificationEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultCallNotificationEventResolverTest.kt new file mode 100644 index 0000000000..c9ebbb72b3 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultCallNotificationEventResolverTest.kt @@ -0,0 +1,171 @@ +/* + * 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 com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.notification.CallNotifyType +import io.element.android.libraries.matrix.api.notification.NotificationContent +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_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_NAME_2 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.notification.aNotificationData +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultCallNotificationEventResolverTest { + @Test + fun `resolve CallNotify - RING when call is still ongoing`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + // The call is still ongoing + initialRoomInfo = aRoomInfo(hasRoomCall = true), + ) + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + + val resolver = createDefaultNotifiableEventResolver( + clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }), + ) + val expectedResult = NotifiableRingingCallEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + senderId = A_USER_ID_2, + roomName = A_ROOM_NAME, + editedEventId = null, + description = "📹 Incoming call", + timestamp = 567L, + canBeReplaced = true, + isRedacted = false, + isUpdated = false, + senderDisambiguatedDisplayName = A_USER_NAME_2, + senderAvatarUrl = null, + callNotifyType = CallNotifyType.RING, + ) + + val notificationData = aNotificationData( + content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.RING) + ) + val result = resolver.resolveEvent(A_SESSION_ID, notificationData) + assertThat(result.getOrNull()).isEqualTo(expectedResult) + } + + @Test + fun `resolve CallNotify - NOTIFY`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + // The call already ended + initialRoomInfo = aRoomInfo(hasRoomCall = true), + ) + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + + val resolver = createDefaultNotifiableEventResolver( + clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }), + ) + val expectedResult = NotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + senderId = A_USER_ID_2, + roomName = A_ROOM_NAME, + editedEventId = null, + body = "📹 Incoming call", + timestamp = 567L, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + senderDisambiguatedDisplayName = A_USER_NAME_2, + noisy = true, + imageUriString = null, + imageMimeType = null, + threadId = null, + type = "m.call.notify", + ) + + val notificationData = aNotificationData( + content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.NOTIFY) + ) + val result = resolver.resolveEvent(A_SESSION_ID, notificationData) + assertThat(result.getOrNull()).isEqualTo(expectedResult) + } + + @Test + fun `resolve CallNotify - RING but timed out displays the same as NOTIFY`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + // The call already ended + initialRoomInfo = aRoomInfo(hasRoomCall = false), + ) + ) + val client = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + + val resolver = createDefaultNotifiableEventResolver( + clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }), + ) + val expectedResult = NotifiableMessageEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + senderId = A_USER_ID_2, + roomName = A_ROOM_NAME, + editedEventId = null, + body = "📹 Incoming call", + timestamp = 567L, + canBeReplaced = false, + isRedacted = false, + isUpdated = false, + senderDisambiguatedDisplayName = A_USER_NAME_2, + noisy = true, + imageUriString = null, + imageMimeType = null, + threadId = null, + type = "m.call.notify", + ) + + val notificationData = aNotificationData( + content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.RING) + ) + val result = resolver.resolveEvent(A_SESSION_ID, notificationData) + assertThat(result.getOrNull()).isEqualTo(expectedResult) + } + + private fun createDefaultNotifiableEventResolver( + stringProvider: FakeStringProvider = FakeStringProvider(defaultResult = "\uD83D\uDCF9 Incoming call"), + appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(), + clientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + ) = DefaultCallNotificationEventResolver( + stringProvider = stringProvider, + appForegroundStateService = appForegroundStateService, + clientProvider = clientProvider, + ) +} 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 479075694e..cf9244166d 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 @@ -49,10 +49,9 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable 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.NotifiableMessageEvent -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.test.notifications.FakeCallNotificationEventResolver import io.element.android.services.toolbox.impl.strings.AndroidStringProvider -import io.element.android.services.toolbox.impl.systemclock.DefaultSystemClock import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import kotlinx.coroutines.test.runTest @@ -606,80 +605,8 @@ class DefaultNotifiableEventResolverTest { } @Test - fun `resolve CallNotify - ringing`() = runTest { - val timestamp = DefaultSystemClock().epochMillis() - val sut = createDefaultNotifiableEventResolver( - notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.CallNotify( - A_USER_ID_2, - CallNotifyType.RING - ), - timestamp = timestamp, - ) - ) - ) - val expectedResult = ResolvedPushEvent.Event( - NotifiableRingingCallEvent( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = AN_EVENT_ID, - senderId = A_USER_ID_2, - roomName = A_ROOM_NAME, - editedEventId = null, - description = "📹 Incoming call", - timestamp = timestamp, - canBeReplaced = true, - isRedacted = false, - isUpdated = false, - senderDisambiguatedDisplayName = A_USER_NAME_2, - senderAvatarUrl = null, - callNotifyType = CallNotifyType.RING, - ) - ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result.getOrNull()).isEqualTo(expectedResult) - } - - @Test - fun `resolve CallNotify - ring but timed out displays the same as notify`() = runTest { - val sut = createDefaultNotifiableEventResolver( - notificationResult = Result.success( - aNotificationData( - content = NotificationContent.MessageLike.CallNotify( - A_USER_ID_2, - CallNotifyType.RING - ), - timestamp = 0L, - ) - ) - ) - val expectedResult = ResolvedPushEvent.Event( - NotifiableMessageEvent( - sessionId = A_SESSION_ID, - eventId = AN_EVENT_ID, - editedEventId = null, - noisy = true, - timestamp = 0L, - senderDisambiguatedDisplayName = A_USER_NAME_2, - senderId = A_USER_ID_2, - body = "📹 Incoming call", - roomId = A_ROOM_ID, - threadId = null, - roomName = A_ROOM_NAME, - canBeReplaced = false, - isRedacted = false, - imageUriString = null, - imageMimeType = null, - type = EventType.CALL_NOTIFY, - ) - ) - val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) - assertThat(result.getOrNull()).isEqualTo(expectedResult) - } - - @Test - fun `resolve CallNotify - notify`() = runTest { + fun `resolve CallNotify - goes through CallNotificationEventResolver`() = runTest { + val callNotificationEventResolver = FakeCallNotificationEventResolver() val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( aNotificationData( @@ -688,7 +615,8 @@ class DefaultNotifiableEventResolverTest { CallNotifyType.NOTIFY ), ) - ) + ), + callNotificationEventResolver = callNotificationEventResolver, ) val expectedResult = ResolvedPushEvent.Event( NotifiableMessageEvent( @@ -710,6 +638,7 @@ class DefaultNotifiableEventResolverTest { type = EventType.CALL_NOTIFY, ) ) + callNotificationEventResolver.resolveEventLambda = { _, _, _ -> Result.success(expectedResult.notifiableEvent) } val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) assertThat(result.getOrNull()).isEqualTo(expectedResult) } @@ -804,6 +733,7 @@ class DefaultNotifiableEventResolverTest { notificationService: FakeNotificationService? = FakeNotificationService(), notificationResult: Result = Result.success(null), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(), ): DefaultNotifiableEventResolver { val context = RuntimeEnvironment.getApplication() as Context notificationService?.givenGetNotificationResult(notificationResult) @@ -824,9 +754,7 @@ class DefaultNotifiableEventResolverTest { notificationMediaRepoFactory = notificationMediaRepoFactory, context = context, permalinkParser = FakePermalinkParser(), - callNotificationEventResolver = DefaultCallNotificationEventResolver( - stringProvider = AndroidStringProvider(context.resources) - ), + callNotificationEventResolver = callNotificationEventResolver, appPreferencesStore = appPreferencesStore, ) } diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt index 4ec395f173..947d57ee7e 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt @@ -18,7 +18,7 @@ class FakeCallNotificationEventResolver( lambdaError() }, ) : CallNotificationEventResolver { - override fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): Result { + override suspend fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): Result { return resolveEventLambda(sessionId, notificationData, forceNotify) } }