Keep call notification ringing while a call is present in the room (#4634)

This commit is contained in:
Jorge Martin Espinosa
2025-05-09 11:38:43 +02:00
committed by GitHub
parent e8e75af506
commit 30067b2a50
5 changed files with 217 additions and 99 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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,
)
}

View File

@@ -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<NotificationData?> = 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,
)
}

View File

@@ -18,7 +18,7 @@ class FakeCallNotificationEventResolver(
lambdaError()
},
) : CallNotificationEventResolver {
override fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): Result<NotifiableEvent> {
override suspend fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): Result<NotifiableEvent> {
return resolveEventLambda(sessionId, notificationData, forceNotify)
}
}