Call: MSC4310 sending RTC decline event and listening for Decline from other sessions
MSC4310 RTC decline event support
This commit is contained in:
@@ -180,12 +180,22 @@ class DefaultActiveCallManager(
|
||||
}
|
||||
|
||||
override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
|
||||
if (activeCall.value?.callType != callType) {
|
||||
Timber.tag(tag).d("Hung up call: $callType")
|
||||
val currentActiveCall = activeCall.value ?: run {
|
||||
Timber.tag(tag).w("No active call, ignoring hang up")
|
||||
return
|
||||
}
|
||||
if (currentActiveCall.callType != callType) {
|
||||
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
Timber.tag(tag).d("Hung up call: $callType")
|
||||
if (currentActiveCall.callState is CallState.Ringing) {
|
||||
// Decline the call
|
||||
val notificationData = currentActiveCall.callState.notificationData
|
||||
matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull()
|
||||
?.getRoom(notificationData.roomId)
|
||||
?.declineCall(notificationData.eventId)
|
||||
}
|
||||
|
||||
cancelIncomingCallNotification()
|
||||
if (activeWakeLock?.isHeld == true) {
|
||||
@@ -256,6 +266,43 @@ class DefaultActiveCallManager(
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun observeRingingCall() {
|
||||
activeCall
|
||||
.filterNotNull()
|
||||
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
|
||||
.flatMapLatest { activeCall ->
|
||||
val callType = activeCall.callType as CallType.RoomCall
|
||||
val ringingInfo = activeCall.callState as CallState.Ringing
|
||||
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
|
||||
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
|
||||
return@flatMapLatest flowOf()
|
||||
}
|
||||
val room = client.getRoom(callType.roomId) ?: run {
|
||||
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
|
||||
return@flatMapLatest flowOf()
|
||||
}
|
||||
|
||||
Timber.tag(tag).d("Found room for ringing call: ${room.roomId}")
|
||||
|
||||
// If we have declined from another phone we want to stop ringing.
|
||||
room.subscribeToCallDecline(ringingInfo.notificationData.eventId)
|
||||
.filter { decliner ->
|
||||
Timber.tag(tag).d("Call: $activeCall was declined by $decliner")
|
||||
// only want to listen if the call was declined from another of my sessions,
|
||||
// (we are ringing for an incoming call in a DM)
|
||||
decliner == client.sessionId
|
||||
}
|
||||
}
|
||||
.onEach { decliner ->
|
||||
Timber.tag(tag).d("Call: $activeCall was declined by user from another session")
|
||||
// Remove the active call and cancel the notification
|
||||
activeCall.value = null
|
||||
if (activeWakeLock?.isHeld == true) {
|
||||
Timber.tag(tag).d("Releasing partial wakelock after call declined from another session")
|
||||
activeWakeLock.release()
|
||||
}
|
||||
cancelIncomingCallNotification()
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
// This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user
|
||||
// has joined the call from another session.
|
||||
activeCall
|
||||
|
||||
@@ -22,13 +22,16 @@ import io.element.android.features.call.test.aCallNotificationData
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
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.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||
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.api.notifications.ForegroundServiceType
|
||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||
@@ -38,6 +41,8 @@ import io.element.android.libraries.push.test.notifications.push.FakeNotificatio
|
||||
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.plantTestTimber
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -164,6 +169,102 @@ class DefaultActiveCallManagerTest {
|
||||
verify { notificationManagerCompat.cancel(notificationId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Decline event - Hangup on a ringing call should send a decline event`() = runTest {
|
||||
setupShadowPowerManager()
|
||||
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||
|
||||
val room = mockk<JoinedRoom>(relaxed = true)
|
||||
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
|
||||
|
||||
val manager = createActiveCallManager(
|
||||
matrixClientProvider = clientProvider,
|
||||
notificationManagerCompat = notificationManagerCompat
|
||||
)
|
||||
|
||||
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
|
||||
manager.registerIncomingCall(notificationData)
|
||||
|
||||
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
||||
|
||||
coVerify {
|
||||
room.declineCall(notificationEventId = notificationData.eventId)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `Decline event - Declining from another session should stop ringing`() = runTest {
|
||||
setupShadowPowerManager()
|
||||
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||
|
||||
val room = FakeJoinedRoom()
|
||||
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
|
||||
|
||||
val manager = createActiveCallManager(
|
||||
matrixClientProvider = clientProvider,
|
||||
notificationManagerCompat = notificationManagerCompat
|
||||
)
|
||||
|
||||
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
|
||||
manager.registerIncomingCall(notificationData)
|
||||
|
||||
runCurrent()
|
||||
|
||||
// Simulate declined from other session
|
||||
room.baseRoom.givenDecliner(matrixClient.sessionId, notificationData.eventId)
|
||||
|
||||
runCurrent()
|
||||
|
||||
assertThat(manager.activeCall.value).isNull()
|
||||
assertThat(manager.activeWakeLock?.isHeld).isFalse()
|
||||
|
||||
verify { notificationManagerCompat.cancel(notificationId) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `Decline event - Should ignore decline for other notification events`() = runTest {
|
||||
plantTestTimber()
|
||||
setupShadowPowerManager()
|
||||
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||
|
||||
val room = FakeJoinedRoom()
|
||||
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenGetRoomResult(A_ROOM_ID, room)
|
||||
}
|
||||
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
|
||||
|
||||
val manager = createActiveCallManager(
|
||||
matrixClientProvider = clientProvider,
|
||||
notificationManagerCompat = notificationManagerCompat
|
||||
)
|
||||
|
||||
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
|
||||
manager.registerIncomingCall(notificationData)
|
||||
|
||||
runCurrent()
|
||||
|
||||
// Simulate declined for another notification event
|
||||
room.baseRoom.givenDecliner(matrixClient.sessionId, AN_EVENT_ID_2)
|
||||
|
||||
runCurrent()
|
||||
|
||||
assertThat(manager.activeCall.value).isNotNull()
|
||||
assertThat(manager.activeWakeLock?.isHeld).isTrue()
|
||||
|
||||
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
|
||||
setupShadowPowerManager()
|
||||
|
||||
@@ -242,7 +242,7 @@ private fun RoomMemberActionsBottomSheet(
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = user.userId.toString(),
|
||||
text = user.userId.value,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -102,7 +102,7 @@ class DefaultBaseRoomLastMessageFormatterTest {
|
||||
val info = ImageInfo(null, null, null, null, null, null, null)
|
||||
val message = createRoomEvent(false, null, aStickerContent(body, info, aMediaSource(url = "url")))
|
||||
val result = formatter.format(message, false)
|
||||
val expectedBody = someoneElseId.toString() + ": Sticker (a sticker body)"
|
||||
val expectedBody = someoneElseId.value + ": Sticker (a sticker body)"
|
||||
assertThat(result.toString()).isEqualTo(expectedBody)
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ object MatrixPatterns {
|
||||
val urlMatch = match.groupValues[1]
|
||||
when (val permalink = permalinkParser.parse(urlMatch)) {
|
||||
is PermalinkData.UserLink -> {
|
||||
add(MatrixPatternResult(MatrixPatternType.USER_ID, permalink.userId.toString(), match.range.first, match.range.last + 1))
|
||||
add(MatrixPatternResult(MatrixPatternType.USER_ID, permalink.userId.value, match.range.first, match.range.last + 1))
|
||||
}
|
||||
is PermalinkData.RoomLink -> {
|
||||
when (permalink.roomIdOrAlias) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
|
||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.io.Closeable
|
||||
|
||||
@@ -239,7 +240,11 @@ interface BaseRoom : Closeable {
|
||||
*/
|
||||
suspend fun reportRoom(reason: String?): Result<Unit>
|
||||
|
||||
/**
|
||||
suspend fun declineCall(notificationEventId: EventId): Result<Unit>
|
||||
|
||||
suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId>
|
||||
|
||||
/**
|
||||
* Destroy the room and release all resources associated to it.
|
||||
*/
|
||||
fun destroy()
|
||||
|
||||
@@ -38,10 +38,12 @@ import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.CallDeclineListener
|
||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
@@ -300,4 +302,20 @@ class RustBaseRoom(
|
||||
innerRoom.reportRoom(reason.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun declineCall(notificationEventId: EventId): Result<Unit> = withContext(roomDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerRoom.declineCall(notificationEventId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId> = withContext(roomDispatcher) {
|
||||
mxCallbackFlow {
|
||||
innerRoom.subscribeToCallDeclineEvents(notificationEventId.value, object : CallDeclineListener {
|
||||
override fun call(declinerUserId: String) {
|
||||
trySend(UserId(declinerUserId))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ fun RustAllowRule.map(): AllowRule {
|
||||
|
||||
fun AllowRule.map(): RustAllowRule {
|
||||
return when (this) {
|
||||
is AllowRule.RoomMembership -> RustAllowRule.RoomMembership(roomId.toString())
|
||||
is AllowRule.RoomMembership -> RustAllowRule.RoomMembership(roomId.value)
|
||||
is AllowRule.Custom -> RustAllowRule.Custom(json)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
@@ -77,6 +79,12 @@ class FakeBaseRoom(
|
||||
_roomInfoFlow.tryEmit(roomInfo)
|
||||
}
|
||||
|
||||
private val declineCallFlowMap: MutableMap<EventId, MutableSharedFlow<UserId>> = mutableMapOf()
|
||||
|
||||
suspend fun givenDecliner(userId: UserId, forNotificationEventId: EventId) {
|
||||
declineCallFlowMap[forNotificationEventId]?.emit(userId)
|
||||
}
|
||||
|
||||
override val membersStateFlow: MutableStateFlow<RoomMembersState> = MutableStateFlow(RoomMembersState.Unknown)
|
||||
|
||||
override suspend fun updateMembers() = updateMembersResult()
|
||||
@@ -222,6 +230,15 @@ class FakeBaseRoom(
|
||||
|
||||
override suspend fun reportRoom(reason: String?) = reportRoomResult(reason)
|
||||
|
||||
override suspend fun declineCall(notificationEventId: EventId): Result<Unit> {
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId> {
|
||||
val flow = declineCallFlowMap.getOrPut(notificationEventId, { MutableSharedFlow() })
|
||||
return flow
|
||||
}
|
||||
|
||||
override fun predecessorRoom(): PredecessorRoom? = predecessorRoomResult()
|
||||
|
||||
fun givenUpdateMembersResult(result: () -> Unit) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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.tests.testutils
|
||||
|
||||
import timber.log.Timber
|
||||
|
||||
fun plantTestTimber() {
|
||||
Timber.plant(object : Timber.Tree() {
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
println("$tag: $message")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user