Skip to content

Commit 5d11540

Browse files
authored
Call: MSC4310 sending RTC decline event and listening for Decline from other sessions
MSC4310 RTC decline event support
2 parents 9f59db8 + 5bbe299 commit 5d11540

File tree

10 files changed

+214
-8
lines changed

10 files changed

+214
-8
lines changed

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,12 +180,22 @@ class DefaultActiveCallManager(
180180
}
181181

182182
override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
183-
if (activeCall.value?.callType != callType) {
183+
Timber.tag(tag).d("Hung up call: $callType")
184+
val currentActiveCall = activeCall.value ?: run {
185+
Timber.tag(tag).w("No active call, ignoring hang up")
186+
return
187+
}
188+
if (currentActiveCall.callType != callType) {
184189
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
185190
return
186191
}
187-
188-
Timber.tag(tag).d("Hung up call: $callType")
192+
if (currentActiveCall.callState is CallState.Ringing) {
193+
// Decline the call
194+
val notificationData = currentActiveCall.callState.notificationData
195+
matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull()
196+
?.getRoom(notificationData.roomId)
197+
?.declineCall(notificationData.eventId)
198+
}
189199

190200
cancelIncomingCallNotification()
191201
if (activeWakeLock?.isHeld == true) {
@@ -256,6 +266,43 @@ class DefaultActiveCallManager(
256266

257267
@OptIn(ExperimentalCoroutinesApi::class)
258268
private fun observeRingingCall() {
269+
activeCall
270+
.filterNotNull()
271+
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
272+
.flatMapLatest { activeCall ->
273+
val callType = activeCall.callType as CallType.RoomCall
274+
val ringingInfo = activeCall.callState as CallState.Ringing
275+
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
276+
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
277+
return@flatMapLatest flowOf()
278+
}
279+
val room = client.getRoom(callType.roomId) ?: run {
280+
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
281+
return@flatMapLatest flowOf()
282+
}
283+
284+
Timber.tag(tag).d("Found room for ringing call: ${room.roomId}")
285+
286+
// If we have declined from another phone we want to stop ringing.
287+
room.subscribeToCallDecline(ringingInfo.notificationData.eventId)
288+
.filter { decliner ->
289+
Timber.tag(tag).d("Call: $activeCall was declined by $decliner")
290+
// only want to listen if the call was declined from another of my sessions,
291+
// (we are ringing for an incoming call in a DM)
292+
decliner == client.sessionId
293+
}
294+
}
295+
.onEach { decliner ->
296+
Timber.tag(tag).d("Call: $activeCall was declined by user from another session")
297+
// Remove the active call and cancel the notification
298+
activeCall.value = null
299+
if (activeWakeLock?.isHeld == true) {
300+
Timber.tag(tag).d("Releasing partial wakelock after call declined from another session")
301+
activeWakeLock.release()
302+
}
303+
cancelIncomingCallNotification()
304+
}
305+
.launchIn(coroutineScope)
259306
// This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user
260307
// has joined the call from another session.
261308
activeCall

features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,16 @@ import io.element.android.features.call.test.aCallNotificationData
2222
import io.element.android.libraries.matrix.api.core.EventId
2323
import io.element.android.libraries.matrix.api.core.RoomId
2424
import io.element.android.libraries.matrix.api.core.SessionId
25+
import io.element.android.libraries.matrix.api.room.JoinedRoom
2526
import io.element.android.libraries.matrix.test.AN_EVENT_ID
27+
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
2628
import io.element.android.libraries.matrix.test.A_ROOM_ID
2729
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
2830
import io.element.android.libraries.matrix.test.A_SESSION_ID
2931
import io.element.android.libraries.matrix.test.FakeMatrixClient
3032
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
3133
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
34+
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
3235
import io.element.android.libraries.matrix.test.room.aRoomInfo
3336
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
3437
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
@@ -38,6 +41,8 @@ import io.element.android.libraries.push.test.notifications.push.FakeNotificatio
3841
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
3942
import io.element.android.tests.testutils.lambda.lambdaRecorder
4043
import io.element.android.tests.testutils.lambda.value
44+
import io.element.android.tests.testutils.plantTestTimber
45+
import io.mockk.coVerify
4146
import io.mockk.mockk
4247
import io.mockk.verify
4348
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -164,6 +169,102 @@ class DefaultActiveCallManagerTest {
164169
verify { notificationManagerCompat.cancel(notificationId) }
165170
}
166171

172+
@Test
173+
fun `Decline event - Hangup on a ringing call should send a decline event`() = runTest {
174+
setupShadowPowerManager()
175+
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
176+
177+
val room = mockk<JoinedRoom>(relaxed = true)
178+
179+
val matrixClient = FakeMatrixClient().apply {
180+
givenGetRoomResult(A_ROOM_ID, room)
181+
}
182+
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
183+
184+
val manager = createActiveCallManager(
185+
matrixClientProvider = clientProvider,
186+
notificationManagerCompat = notificationManagerCompat
187+
)
188+
189+
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
190+
manager.registerIncomingCall(notificationData)
191+
192+
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
193+
194+
coVerify {
195+
room.declineCall(notificationEventId = notificationData.eventId)
196+
}
197+
}
198+
199+
@OptIn(ExperimentalCoroutinesApi::class)
200+
@Test
201+
fun `Decline event - Declining from another session should stop ringing`() = runTest {
202+
setupShadowPowerManager()
203+
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
204+
205+
val room = FakeJoinedRoom()
206+
207+
val matrixClient = FakeMatrixClient().apply {
208+
givenGetRoomResult(A_ROOM_ID, room)
209+
}
210+
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
211+
212+
val manager = createActiveCallManager(
213+
matrixClientProvider = clientProvider,
214+
notificationManagerCompat = notificationManagerCompat
215+
)
216+
217+
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
218+
manager.registerIncomingCall(notificationData)
219+
220+
runCurrent()
221+
222+
// Simulate declined from other session
223+
room.baseRoom.givenDecliner(matrixClient.sessionId, notificationData.eventId)
224+
225+
runCurrent()
226+
227+
assertThat(manager.activeCall.value).isNull()
228+
assertThat(manager.activeWakeLock?.isHeld).isFalse()
229+
230+
verify { notificationManagerCompat.cancel(notificationId) }
231+
}
232+
233+
@OptIn(ExperimentalCoroutinesApi::class)
234+
@Test
235+
fun `Decline event - Should ignore decline for other notification events`() = runTest {
236+
plantTestTimber()
237+
setupShadowPowerManager()
238+
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
239+
240+
val room = FakeJoinedRoom()
241+
242+
val matrixClient = FakeMatrixClient().apply {
243+
givenGetRoomResult(A_ROOM_ID, room)
244+
}
245+
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
246+
247+
val manager = createActiveCallManager(
248+
matrixClientProvider = clientProvider,
249+
notificationManagerCompat = notificationManagerCompat
250+
)
251+
252+
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
253+
manager.registerIncomingCall(notificationData)
254+
255+
runCurrent()
256+
257+
// Simulate declined for another notification event
258+
room.baseRoom.givenDecliner(matrixClient.sessionId, AN_EVENT_ID_2)
259+
260+
runCurrent()
261+
262+
assertThat(manager.activeCall.value).isNotNull()
263+
assertThat(manager.activeWakeLock?.isHeld).isTrue()
264+
265+
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
266+
}
267+
167268
@Test
168269
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
169270
setupShadowPowerManager()

features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ private fun RoomMemberActionsBottomSheet(
242242
)
243243
}
244244
Text(
245-
text = user.userId.toString(),
245+
text = user.userId.value,
246246
style = ElementTheme.typography.fontBodyLgRegular,
247247
color = ElementTheme.colors.textSecondary,
248248
maxLines = 1,

libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultBaseRoomLastMessageFormatterTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ class DefaultBaseRoomLastMessageFormatterTest {
102102
val info = ImageInfo(null, null, null, null, null, null, null)
103103
val message = createRoomEvent(false, null, aStickerContent(body, info, aMediaSource(url = "url")))
104104
val result = formatter.format(message, false)
105-
val expectedBody = someoneElseId.toString() + ": Sticker (a sticker body)"
105+
val expectedBody = someoneElseId.value + ": Sticker (a sticker body)"
106106
assertThat(result.toString()).isEqualTo(expectedBody)
107107
}
108108

libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ object MatrixPatterns {
151151
val urlMatch = match.groupValues[1]
152152
when (val permalink = permalinkParser.parse(urlMatch)) {
153153
is PermalinkData.UserLink -> {
154-
add(MatrixPatternResult(MatrixPatternType.USER_ID, permalink.userId.toString(), match.range.first, match.range.last + 1))
154+
add(MatrixPatternResult(MatrixPatternType.USER_ID, permalink.userId.value, match.range.first, match.range.last + 1))
155155
}
156156
is PermalinkData.RoomLink -> {
157157
when (permalink.roomIdOrAlias) {

libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
1818
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
1919
import io.element.android.libraries.matrix.api.timeline.ReceiptType
2020
import kotlinx.coroutines.CoroutineScope
21+
import kotlinx.coroutines.flow.Flow
2122
import kotlinx.coroutines.flow.StateFlow
2223
import java.io.Closeable
2324

@@ -239,7 +240,11 @@ interface BaseRoom : Closeable {
239240
*/
240241
suspend fun reportRoom(reason: String?): Result<Unit>
241242

242-
/**
243+
suspend fun declineCall(notificationEventId: EventId): Result<Unit>
244+
245+
suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId>
246+
247+
/**
243248
* Destroy the room and release all resources associated to it.
244249
*/
245250
fun destroy()

libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@ import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
3838
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
3939
import kotlinx.coroutines.CoroutineScope
4040
import kotlinx.coroutines.cancel
41+
import kotlinx.coroutines.flow.Flow
4142
import kotlinx.coroutines.flow.SharingStarted
4243
import kotlinx.coroutines.flow.StateFlow
4344
import kotlinx.coroutines.flow.stateIn
4445
import kotlinx.coroutines.withContext
46+
import org.matrix.rustcomponents.sdk.CallDeclineListener
4547
import org.matrix.rustcomponents.sdk.RoomInfoListener
4648
import org.matrix.rustcomponents.sdk.use
4749
import timber.log.Timber
@@ -300,4 +302,20 @@ class RustBaseRoom(
300302
innerRoom.reportRoom(reason.orEmpty())
301303
}
302304
}
305+
306+
override suspend fun declineCall(notificationEventId: EventId): Result<Unit> = withContext(roomDispatcher) {
307+
runCatchingExceptions {
308+
innerRoom.declineCall(notificationEventId.value)
309+
}
310+
}
311+
312+
override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId> = withContext(roomDispatcher) {
313+
mxCallbackFlow {
314+
innerRoom.subscribeToCallDeclineEvents(notificationEventId.value, object : CallDeclineListener {
315+
override fun call(declinerUserId: String) {
316+
trySend(UserId(declinerUserId))
317+
}
318+
})
319+
}
320+
}
303321
}

libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/AllowRule.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ fun RustAllowRule.map(): AllowRule {
2020

2121
fun AllowRule.map(): RustAllowRule {
2222
return when (this) {
23-
is AllowRule.RoomMembership -> RustAllowRule.RoomMembership(roomId.toString())
23+
is AllowRule.RoomMembership -> RustAllowRule.RoomMembership(roomId.value)
2424
is AllowRule.Custom -> RustAllowRule.Custom(json)
2525
}
2626
}

libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
2929
import io.element.android.tests.testutils.lambda.lambdaError
3030
import io.element.android.tests.testutils.simulateLongTask
3131
import kotlinx.coroutines.CoroutineScope
32+
import kotlinx.coroutines.flow.Flow
33+
import kotlinx.coroutines.flow.MutableSharedFlow
3234
import kotlinx.coroutines.flow.MutableStateFlow
3335
import kotlinx.coroutines.flow.StateFlow
3436
import kotlinx.coroutines.test.TestScope
@@ -77,6 +79,12 @@ class FakeBaseRoom(
7779
_roomInfoFlow.tryEmit(roomInfo)
7880
}
7981

82+
private val declineCallFlowMap: MutableMap<EventId, MutableSharedFlow<UserId>> = mutableMapOf()
83+
84+
suspend fun givenDecliner(userId: UserId, forNotificationEventId: EventId) {
85+
declineCallFlowMap[forNotificationEventId]?.emit(userId)
86+
}
87+
8088
override val membersStateFlow: MutableStateFlow<RoomMembersState> = MutableStateFlow(RoomMembersState.Unknown)
8189

8290
override suspend fun updateMembers() = updateMembersResult()
@@ -222,6 +230,15 @@ class FakeBaseRoom(
222230

223231
override suspend fun reportRoom(reason: String?) = reportRoomResult(reason)
224232

233+
override suspend fun declineCall(notificationEventId: EventId): Result<Unit> {
234+
return Result.success(Unit)
235+
}
236+
237+
override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId> {
238+
val flow = declineCallFlowMap.getOrPut(notificationEventId, { MutableSharedFlow() })
239+
return flow
240+
}
241+
225242
override fun predecessorRoom(): PredecessorRoom? = predecessorRoomResult()
226243

227244
fun givenUpdateMembersResult(result: () -> Unit) {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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.tests.testutils
9+
10+
import timber.log.Timber
11+
12+
fun plantTestTimber() {
13+
Timber.plant(object : Timber.Tree() {
14+
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
15+
println("$tag: $message")
16+
}
17+
})
18+
}

0 commit comments

Comments
 (0)