Keep call notification ringing while a call is present in the room (#4634)
This commit is contained in:
committed by
GitHub
parent
e8e75af506
commit
30067b2a50
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user