Skip to content

Commit c39b480

Browse files
authored
Keep call notification ringing while a call is present in the room (#4634)
1 parent 3391e7c commit c39b480

File tree

5 files changed

+217
-99
lines changed

5 files changed

+217
-99
lines changed

libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/CallNotificationEventResolver.kt

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,22 @@ package io.element.android.libraries.push.impl.notifications
99

1010
import com.squareup.anvil.annotations.ContributesBinding
1111
import io.element.android.libraries.di.AppScope
12+
import io.element.android.libraries.matrix.api.MatrixClientProvider
1213
import io.element.android.libraries.matrix.api.core.SessionId
14+
import io.element.android.libraries.matrix.api.notification.CallNotifyType
1315
import io.element.android.libraries.matrix.api.notification.NotificationContent
1416
import io.element.android.libraries.matrix.api.notification.NotificationData
1517
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
1618
import io.element.android.libraries.push.impl.R
1719
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
1820
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
21+
import io.element.android.services.appnavstate.api.AppForegroundStateService
1922
import io.element.android.services.toolbox.api.strings.StringProvider
23+
import kotlinx.coroutines.flow.firstOrNull
24+
import kotlinx.coroutines.withTimeoutOrNull
2025
import timber.log.Timber
2126
import javax.inject.Inject
27+
import kotlin.time.Duration.Companion.seconds
2228

2329
/**
2430
* Helper to resolve a valid [NotifiableEvent] from a [NotificationData].
@@ -31,7 +37,7 @@ interface CallNotificationEventResolver {
3137
* @param forceNotify `true` to force the notification to be non-ringing, `false` to use the default behaviour. Default is `false`.
3238
* @return a [NotifiableEvent] if the notification data is a call notification, null otherwise
3339
*/
34-
fun resolveEvent(
40+
suspend fun resolveEvent(
3541
sessionId: SessionId,
3642
notificationData: NotificationData,
3743
forceNotify: Boolean = false,
@@ -41,17 +47,43 @@ interface CallNotificationEventResolver {
4147
@ContributesBinding(AppScope::class)
4248
class DefaultCallNotificationEventResolver @Inject constructor(
4349
private val stringProvider: StringProvider,
50+
private val appForegroundStateService: AppForegroundStateService,
51+
private val clientProvider: MatrixClientProvider,
4452
) : CallNotificationEventResolver {
45-
override fun resolveEvent(
53+
override suspend fun resolveEvent(
4654
sessionId: SessionId,
4755
notificationData: NotificationData,
4856
forceNotify: Boolean
4957
): Result<NotifiableEvent> = runCatching {
5058
val content = notificationData.content as? NotificationContent.MessageLike.CallNotify
5159
?: throw ResolvingException("content is not a call notify")
5260

61+
val previousRingingCallStatus = appForegroundStateService.hasRingingCall.value
62+
// We need the sync service working to get the updated room info
63+
val isRoomCallActive = runCatching {
64+
if (content.type == CallNotifyType.RING) {
65+
appForegroundStateService.updateHasRingingCall(true)
66+
67+
val client = clientProvider.getOrRestore(sessionId).getOrNull() ?: throw ResolvingException("Session $sessionId not found")
68+
val room = client.getRoom(notificationData.roomId) ?: throw ResolvingException("Room ${notificationData.roomId} not found")
69+
// Give a few seconds for the room info flow to catch up with the sync, if needed - this is usually instant
70+
val isActive = withTimeoutOrNull(3.seconds) { room.roomInfoFlow.firstOrNull { it.hasRoomCall } }?.hasRoomCall ?: false
71+
72+
// We no longer need the sync service to be active because of a call notification.
73+
appForegroundStateService.updateHasRingingCall(previousRingingCallStatus)
74+
75+
isActive
76+
} else {
77+
// If the call notification is not of ringing type, we don't need to check if the call is active
78+
false
79+
}
80+
}.onFailure {
81+
// Make sure to reset the hasRingingCall state in case of failure
82+
appForegroundStateService.updateHasRingingCall(previousRingingCallStatus)
83+
}.getOrDefault(false)
84+
5385
notificationData.run {
54-
if (NotifiableRingingCallEvent.shouldRing(content.type, timestamp) && !forceNotify) {
86+
if (content.type == CallNotifyType.RING && isRoomCallActive && !forceNotify) {
5587
NotifiableRingingCallEvent(
5688
sessionId = sessionId,
5789
roomId = roomId,
@@ -70,9 +102,7 @@ class DefaultCallNotificationEventResolver @Inject constructor(
70102
senderAvatarUrl = senderAvatarUrl,
71103
)
72104
} else {
73-
val now = System.currentTimeMillis()
74-
val elapsed = now - timestamp
75-
Timber.d("Event $eventId is call notify but should not ring: $timestamp vs $now ($elapsed ms elapsed), notify: ${content.type}")
105+
Timber.d("Event $eventId is call notify but should not ring: $isRoomCallActive, notify: ${content.type}")
76106
// Create a simple message notification event
77107
buildNotifiableMessageEvent(
78108
sessionId = sessionId,

libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableRingingCallEvent.kt

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ import io.element.android.libraries.matrix.api.core.RoomId
1212
import io.element.android.libraries.matrix.api.core.SessionId
1313
import io.element.android.libraries.matrix.api.core.UserId
1414
import io.element.android.libraries.matrix.api.notification.CallNotifyType
15-
import java.time.Instant
16-
import kotlin.time.Duration.Companion.seconds
1715

1816
data class NotifiableRingingCallEvent(
1917
override val sessionId: SessionId,
@@ -31,13 +29,4 @@ data class NotifiableRingingCallEvent(
3129
val roomAvatarUrl: String? = null,
3230
val callNotifyType: CallNotifyType,
3331
val timestamp: Long,
34-
) : NotifiableEvent {
35-
companion object {
36-
fun shouldRing(callNotifyType: CallNotifyType, timestamp: Long): Boolean {
37-
val timeout = 10.seconds.inWholeMilliseconds
38-
val elapsed = Instant.now().toEpochMilli() - timestamp
39-
// Only ring if the type is RING and the elapsed time is less than the timeout
40-
return callNotifyType == CallNotifyType.RING && elapsed < timeout
41-
}
42-
}
43-
}
32+
) : NotifiableEvent
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.libraries.push.impl.notifications
9+
10+
import com.google.common.truth.Truth.assertThat
11+
import io.element.android.libraries.matrix.api.notification.CallNotifyType
12+
import io.element.android.libraries.matrix.api.notification.NotificationContent
13+
import io.element.android.libraries.matrix.test.AN_EVENT_ID
14+
import io.element.android.libraries.matrix.test.A_ROOM_ID
15+
import io.element.android.libraries.matrix.test.A_ROOM_NAME
16+
import io.element.android.libraries.matrix.test.A_SESSION_ID
17+
import io.element.android.libraries.matrix.test.A_USER_ID_2
18+
import io.element.android.libraries.matrix.test.A_USER_NAME_2
19+
import io.element.android.libraries.matrix.test.FakeMatrixClient
20+
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
21+
import io.element.android.libraries.matrix.test.notification.aNotificationData
22+
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
23+
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
24+
import io.element.android.libraries.matrix.test.room.aRoomInfo
25+
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
26+
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
27+
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
28+
import io.element.android.services.toolbox.test.strings.FakeStringProvider
29+
import kotlinx.coroutines.test.runTest
30+
import org.junit.Test
31+
32+
class DefaultCallNotificationEventResolverTest {
33+
@Test
34+
fun `resolve CallNotify - RING when call is still ongoing`() = runTest {
35+
val room = FakeJoinedRoom(
36+
baseRoom = FakeBaseRoom(
37+
sessionId = A_SESSION_ID,
38+
roomId = A_ROOM_ID,
39+
// The call is still ongoing
40+
initialRoomInfo = aRoomInfo(hasRoomCall = true),
41+
)
42+
)
43+
val client = FakeMatrixClient().apply {
44+
givenGetRoomResult(A_ROOM_ID, room)
45+
}
46+
47+
val resolver = createDefaultNotifiableEventResolver(
48+
clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
49+
)
50+
val expectedResult = NotifiableRingingCallEvent(
51+
sessionId = A_SESSION_ID,
52+
roomId = A_ROOM_ID,
53+
eventId = AN_EVENT_ID,
54+
senderId = A_USER_ID_2,
55+
roomName = A_ROOM_NAME,
56+
editedEventId = null,
57+
description = "📹 Incoming call",
58+
timestamp = 567L,
59+
canBeReplaced = true,
60+
isRedacted = false,
61+
isUpdated = false,
62+
senderDisambiguatedDisplayName = A_USER_NAME_2,
63+
senderAvatarUrl = null,
64+
callNotifyType = CallNotifyType.RING,
65+
)
66+
67+
val notificationData = aNotificationData(
68+
content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.RING)
69+
)
70+
val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
71+
assertThat(result.getOrNull()).isEqualTo(expectedResult)
72+
}
73+
74+
@Test
75+
fun `resolve CallNotify - NOTIFY`() = runTest {
76+
val room = FakeJoinedRoom(
77+
baseRoom = FakeBaseRoom(
78+
sessionId = A_SESSION_ID,
79+
roomId = A_ROOM_ID,
80+
// The call already ended
81+
initialRoomInfo = aRoomInfo(hasRoomCall = true),
82+
)
83+
)
84+
val client = FakeMatrixClient().apply {
85+
givenGetRoomResult(A_ROOM_ID, room)
86+
}
87+
88+
val resolver = createDefaultNotifiableEventResolver(
89+
clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
90+
)
91+
val expectedResult = NotifiableMessageEvent(
92+
sessionId = A_SESSION_ID,
93+
roomId = A_ROOM_ID,
94+
eventId = AN_EVENT_ID,
95+
senderId = A_USER_ID_2,
96+
roomName = A_ROOM_NAME,
97+
editedEventId = null,
98+
body = "📹 Incoming call",
99+
timestamp = 567L,
100+
canBeReplaced = false,
101+
isRedacted = false,
102+
isUpdated = false,
103+
senderDisambiguatedDisplayName = A_USER_NAME_2,
104+
noisy = true,
105+
imageUriString = null,
106+
imageMimeType = null,
107+
threadId = null,
108+
type = "m.call.notify",
109+
)
110+
111+
val notificationData = aNotificationData(
112+
content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.NOTIFY)
113+
)
114+
val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
115+
assertThat(result.getOrNull()).isEqualTo(expectedResult)
116+
}
117+
118+
@Test
119+
fun `resolve CallNotify - RING but timed out displays the same as NOTIFY`() = runTest {
120+
val room = FakeJoinedRoom(
121+
baseRoom = FakeBaseRoom(
122+
sessionId = A_SESSION_ID,
123+
roomId = A_ROOM_ID,
124+
// The call already ended
125+
initialRoomInfo = aRoomInfo(hasRoomCall = false),
126+
)
127+
)
128+
val client = FakeMatrixClient().apply {
129+
givenGetRoomResult(A_ROOM_ID, room)
130+
}
131+
132+
val resolver = createDefaultNotifiableEventResolver(
133+
clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
134+
)
135+
val expectedResult = NotifiableMessageEvent(
136+
sessionId = A_SESSION_ID,
137+
roomId = A_ROOM_ID,
138+
eventId = AN_EVENT_ID,
139+
senderId = A_USER_ID_2,
140+
roomName = A_ROOM_NAME,
141+
editedEventId = null,
142+
body = "📹 Incoming call",
143+
timestamp = 567L,
144+
canBeReplaced = false,
145+
isRedacted = false,
146+
isUpdated = false,
147+
senderDisambiguatedDisplayName = A_USER_NAME_2,
148+
noisy = true,
149+
imageUriString = null,
150+
imageMimeType = null,
151+
threadId = null,
152+
type = "m.call.notify",
153+
)
154+
155+
val notificationData = aNotificationData(
156+
content = NotificationContent.MessageLike.CallNotify(A_USER_ID_2, CallNotifyType.RING)
157+
)
158+
val result = resolver.resolveEvent(A_SESSION_ID, notificationData)
159+
assertThat(result.getOrNull()).isEqualTo(expectedResult)
160+
}
161+
162+
private fun createDefaultNotifiableEventResolver(
163+
stringProvider: FakeStringProvider = FakeStringProvider(defaultResult = "\uD83D\uDCF9 Incoming call"),
164+
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
165+
clientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
166+
) = DefaultCallNotificationEventResolver(
167+
stringProvider = stringProvider,
168+
appForegroundStateService = appForegroundStateService,
169+
clientProvider = clientProvider,
170+
)
171+
}

0 commit comments

Comments
 (0)