Skip to content

Commit e20b355

Browse files
committed
Call: RTC decline event support
1 parent 87c8c57 commit e20b355

File tree

5 files changed

+192
-1
lines changed

5 files changed

+192
-1
lines changed

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,15 @@ class DefaultActiveCallManager(
186186
}
187187

188188
Timber.tag(tag).d("Hung up call: $callType")
189+
if (activeCall.value?.callState is CallState.Ringing) {
190+
val ringing = activeCall.value!!.callState as CallState.Ringing
191+
// Decline the call
192+
matrixClientProvider.getOrRestore(ringing.notificationData.sessionId).getOrNull()?.let { client ->
193+
client.getRoom(ringing.notificationData.roomId)?.let { room ->
194+
room.declineCall(ringing.notificationData.eventId)
195+
}
196+
}
197+
}
189198

190199
cancelIncomingCallNotification()
191200
if (activeWakeLock?.isHeld == true) {
@@ -256,6 +265,42 @@ class DefaultActiveCallManager(
256265

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

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

Lines changed: 106 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,7 @@ 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.mockk.coVerify
4145
import io.mockk.mockk
4246
import io.mockk.verify
4347
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -49,6 +53,7 @@ import org.junit.Test
4953
import org.junit.runner.RunWith
5054
import org.robolectric.RobolectricTestRunner
5155
import org.robolectric.Shadows.shadowOf
56+
import timber.log.Timber
5257

5358
@RunWith(RobolectricTestRunner::class)
5459
class DefaultActiveCallManagerTest {
@@ -164,6 +169,107 @@ 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+
Timber.plant(object : Timber.Tree() {
237+
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
238+
println("$tag: $message")
239+
}
240+
})
241+
242+
setupShadowPowerManager()
243+
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
244+
245+
val room = FakeJoinedRoom()
246+
247+
val matrixClient = FakeMatrixClient().apply {
248+
givenGetRoomResult(A_ROOM_ID, room)
249+
}
250+
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
251+
252+
val manager = createActiveCallManager(
253+
matrixClientProvider = clientProvider,
254+
notificationManagerCompat = notificationManagerCompat
255+
)
256+
257+
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
258+
manager.registerIncomingCall(notificationData)
259+
260+
runCurrent()
261+
262+
// Simulate declined for another notification event
263+
room.baseRoom.givenDecliner(matrixClient.sessionId, AN_EVENT_ID_2)
264+
265+
runCurrent()
266+
267+
assertThat(manager.activeCall.value).isNotNull()
268+
assertThat(manager.activeWakeLock?.isHeld).isTrue()
269+
270+
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
271+
}
272+
167273
@Test
168274
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
169275
setupShadowPowerManager()

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.toString())
309+
}
310+
}
311+
312+
override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId> = withContext(roomDispatcher) {
313+
mxCallbackFlow {
314+
innerRoom.subscribeToCallDeclineEvents(notificationEventId.toString(), object : CallDeclineListener {
315+
override fun call(declinerUserId: String) {
316+
trySend(UserId(declinerUserId))
317+
}
318+
})
319+
}
320+
}
303321
}

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) {

0 commit comments

Comments
 (0)