Merge pull request #3421 from vector-im/feature/fga/call_transfer

Feature/fga/call transfer
This commit is contained in:
Benoit Marty 2021-05-28 16:48:01 +02:00 committed by GitHub
commit 575ebdc3e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 267 additions and 112 deletions

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.call
import java.util.UUID
object CallIdGenerator {
fun generate() = UUID.randomUUID().toString()
}

View File

@ -26,8 +26,12 @@ interface MxCallDetail {
val callId: String val callId: String
val isOutgoing: Boolean val isOutgoing: Boolean
val roomId: String val roomId: String
val opponentUserId: String
val isVideoCall: Boolean val isVideoCall: Boolean
val ourPartyId: String
val opponentPartyId: Optional<String>?
val opponentVersion: Int
val opponentUserId: String
val capabilities: CallCapabilities?
} }
/** /**
@ -39,12 +43,6 @@ interface MxCall : MxCallDetail {
const val VOIP_PROTO_VERSION = 1 const val VOIP_PROTO_VERSION = 1
} }
val ourPartyId: String
var opponentPartyId: Optional<String>?
var opponentVersion: Int
var capabilities: CallCapabilities?
var state: CallState var state: CallState
/** /**
@ -91,8 +89,12 @@ interface MxCall : MxCallDetail {
/** /**
* Send a m.call.replaces event to initiate call transfer. * Send a m.call.replaces event to initiate call transfer.
* See [org.matrix.android.sdk.api.session.room.model.call.CallReplacesContent] for documentation about the parameters
*/ */
suspend fun transfer(targetUserId: String, targetRoomId: String?) suspend fun transfer(targetUserId: String,
targetRoomId: String?,
createCallId: String?,
awaitCallId: String?)
fun addListener(listener: StateListener) fun addListener(listener: StateListener)
fun removeListener(listener: StateListener) fun removeListener(listener: StateListener)

View File

@ -56,6 +56,9 @@ data class CallHangupContent(
@Json(name = "user_hangup") @Json(name = "user_hangup")
USER_HANGUP, USER_HANGUP,
@Json(name = "replaced")
REPLACED,
@Json(name = "user_media_failed") @Json(name = "user_media_failed")
USER_MEDIA_FAILED, USER_MEDIA_FAILED,

View File

@ -42,7 +42,7 @@ data class CallReplacesContent(
* (possibly waiting for user confirmation) and then continues the transfer in this room. * (possibly waiting for user confirmation) and then continues the transfer in this room.
* If absent, the transferee contacts the Matrix User ID given in the target_user field in a room of its choosing. * If absent, the transferee contacts the Matrix User ID given in the target_user field in a room of its choosing.
*/ */
@Json(name = "target_room") val targerRoomId: String? = null, @Json(name = "target_room") val targetRoomId: String? = null,
/** /**
* An object giving information about the transfer target * An object giving information about the transfer target
*/ */
@ -77,6 +77,5 @@ data class CallReplacesContent(
* Optional. The avatar URL of the transfer target. * Optional. The avatar URL of the transfer target.
*/ */
@Json(name = "avatar_url") val avatarUrl: String? @Json(name = "avatar_url") val avatarUrl: String?
) )
} }

View File

@ -24,18 +24,15 @@ import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent import org.matrix.android.sdk.api.session.room.model.call.CallAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent import org.matrix.android.sdk.api.session.room.model.call.CallCandidatesContent
import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities
import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent
import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent
import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent
import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.CallSignalingContent import org.matrix.android.sdk.api.session.room.model.call.CallSignalingContent
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.SessionScope import org.matrix.android.sdk.internal.session.SessionScope
import timber.log.Timber import timber.log.Timber
import java.math.BigDecimal
import javax.inject.Inject import javax.inject.Inject
@SessionScope @SessionScope
@ -192,6 +189,9 @@ internal class CallSignalingHandler @Inject constructor(private val activeCallHa
// Ignore remote echo // Ignore remote echo
return return
} }
if (event.roomId == null || event.senderId == null) {
return
}
if (event.senderId == userId) { if (event.senderId == userId) {
// discard current call, it's answered by another of my session // discard current call, it's answered by another of my session
activeCallHandler.removeCall(call.callId) activeCallHandler.removeCall(call.callId)
@ -201,11 +201,7 @@ internal class CallSignalingHandler @Inject constructor(private val activeCallHa
Timber.v("Ignoring answer from party ID ${content.partyId} we already have an answer from ${call.opponentPartyId}") Timber.v("Ignoring answer from party ID ${content.partyId} we already have an answer from ${call.opponentPartyId}")
return return
} }
call.apply { mxCallFactory.updateOutgoingCallWithOpponentData(call, event.senderId, content, content.capabilities)
opponentPartyId = Optional.from(content.partyId)
opponentVersion = content.version?.let { BigDecimal(it).intValueExact() } ?: MxCall.VOIP_PROTO_VERSION
capabilities = content.capabilities ?: CallCapabilities()
}
callListenersDispatcher.onCallAnswerReceived(content) callListenersDispatcher.onCallAnswerReceived(content)
} }
} }

View File

@ -17,18 +17,17 @@
package org.matrix.android.sdk.internal.session.call package org.matrix.android.sdk.internal.session.call
import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.session.call.CallIdGenerator
import org.matrix.android.sdk.api.session.call.MxCall import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities import org.matrix.android.sdk.api.session.room.model.call.CallCapabilities
import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.session.room.model.call.CallSignalingContent
import org.matrix.android.sdk.internal.di.DeviceId import org.matrix.android.sdk.internal.di.DeviceId
import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.call.model.MxCallImpl import org.matrix.android.sdk.internal.session.call.model.MxCallImpl
import org.matrix.android.sdk.internal.session.profile.GetProfileInfoTask import org.matrix.android.sdk.internal.session.profile.GetProfileInfoTask
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import java.math.BigDecimal
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
internal class MxCallFactory @Inject constructor( internal class MxCallFactory @Inject constructor(
@ -48,32 +47,38 @@ internal class MxCallFactory @Inject constructor(
roomId = roomId, roomId = roomId,
userId = userId, userId = userId,
ourPartyId = deviceId ?: "", ourPartyId = deviceId ?: "",
opponentUserId = opponentUserId,
isVideoCall = content.isVideo(), isVideoCall = content.isVideo(),
localEchoEventFactory = localEchoEventFactory, localEchoEventFactory = localEchoEventFactory,
eventSenderProcessor = eventSenderProcessor, eventSenderProcessor = eventSenderProcessor,
matrixConfiguration = matrixConfiguration, matrixConfiguration = matrixConfiguration,
getProfileInfoTask = getProfileInfoTask getProfileInfoTask = getProfileInfoTask
).apply { ).apply {
opponentPartyId = Optional.from(content.partyId) updateOpponentData(opponentUserId, content, content.capabilities)
opponentVersion = content.version?.let { BigDecimal(it).intValueExact() } ?: MxCall.VOIP_PROTO_VERSION
capabilities = content.capabilities ?: CallCapabilities()
} }
} }
fun createOutgoingCall(roomId: String, opponentUserId: String, isVideoCall: Boolean): MxCall { fun createOutgoingCall(roomId: String, opponentUserId: String, isVideoCall: Boolean): MxCall {
return MxCallImpl( return MxCallImpl(
callId = UUID.randomUUID().toString(), callId = CallIdGenerator.generate(),
isOutgoing = true, isOutgoing = true,
roomId = roomId, roomId = roomId,
userId = userId, userId = userId,
ourPartyId = deviceId ?: "", ourPartyId = deviceId ?: "",
opponentUserId = opponentUserId,
isVideoCall = isVideoCall, isVideoCall = isVideoCall,
localEchoEventFactory = localEchoEventFactory, localEchoEventFactory = localEchoEventFactory,
eventSenderProcessor = eventSenderProcessor, eventSenderProcessor = eventSenderProcessor,
matrixConfiguration = matrixConfiguration, matrixConfiguration = matrixConfiguration,
getProfileInfoTask = getProfileInfoTask getProfileInfoTask = getProfileInfoTask
) ).apply {
// Setup with this userId, might be updated when processing the Answer event
this.opponentUserId = opponentUserId
}
}
fun updateOutgoingCallWithOpponentData(call: MxCall,
userId: String,
content: CallSignalingContent,
callCapabilities: CallCapabilities?) {
(call as? MxCallImpl)?.updateOpponentData(userId, content, callCapabilities)
} }
} }

View File

@ -17,6 +17,7 @@
package org.matrix.android.sdk.internal.session.call.model package org.matrix.android.sdk.internal.session.call.model
import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.session.call.CallIdGenerator
import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Content
@ -36,6 +37,7 @@ import org.matrix.android.sdk.api.session.room.model.call.CallNegotiateContent
import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent
import org.matrix.android.sdk.api.session.room.model.call.CallReplacesContent import org.matrix.android.sdk.api.session.room.model.call.CallReplacesContent
import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent import org.matrix.android.sdk.api.session.room.model.call.CallSelectAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.CallSignalingContent
import org.matrix.android.sdk.api.session.room.model.call.SdpType import org.matrix.android.sdk.api.session.room.model.call.SdpType
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.session.call.DefaultCallSignalingService import org.matrix.android.sdk.internal.session.call.DefaultCallSignalingService
@ -43,14 +45,13 @@ import org.matrix.android.sdk.internal.session.profile.GetProfileInfoTask
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.math.BigDecimal
internal class MxCallImpl( internal class MxCallImpl(
override val callId: String, override val callId: String,
override val isOutgoing: Boolean, override val isOutgoing: Boolean,
override val roomId: String, override val roomId: String,
private val userId: String, private val userId: String,
override val opponentUserId: String,
override val isVideoCall: Boolean, override val isVideoCall: Boolean,
override val ourPartyId: String, override val ourPartyId: String,
private val localEchoEventFactory: LocalEchoEventFactory, private val localEchoEventFactory: LocalEchoEventFactory,
@ -61,8 +62,16 @@ internal class MxCallImpl(
override var opponentPartyId: Optional<String>? = null override var opponentPartyId: Optional<String>? = null
override var opponentVersion: Int = MxCall.VOIP_PROTO_VERSION override var opponentVersion: Int = MxCall.VOIP_PROTO_VERSION
override lateinit var opponentUserId: String
override var capabilities: CallCapabilities? = null override var capabilities: CallCapabilities? = null
fun updateOpponentData(userId: String, content: CallSignalingContent, callCapabilities: CallCapabilities?) {
opponentPartyId = Optional.from(content.partyId)
opponentVersion = content.version?.let { BigDecimal(it).intValueExact() } ?: MxCall.VOIP_PROTO_VERSION
opponentUserId = userId
capabilities = callCapabilities ?: CallCapabilities()
}
override var state: CallState = CallState.Idle override var state: CallState = CallState.Idle
set(value) { set(value) {
field = value field = value
@ -202,7 +211,10 @@ internal class MxCallImpl(
.also { eventSenderProcessor.postEvent(it) } .also { eventSenderProcessor.postEvent(it) }
} }
override suspend fun transfer(targetUserId: String, targetRoomId: String?) { override suspend fun transfer(targetUserId: String,
targetRoomId: String?,
createCallId: String?,
awaitCallId: String?) {
val profileInfoParams = GetProfileInfoTask.Params(targetUserId) val profileInfoParams = GetProfileInfoTask.Params(targetUserId)
val profileInfo = try { val profileInfo = try {
getProfileInfoTask.execute(profileInfoParams) getProfileInfoTask.execute(profileInfoParams)
@ -213,15 +225,16 @@ internal class MxCallImpl(
CallReplacesContent( CallReplacesContent(
callId = callId, callId = callId,
partyId = ourPartyId, partyId = ourPartyId,
replacementId = UUID.randomUUID().toString(), replacementId = CallIdGenerator.generate(),
version = MxCall.VOIP_PROTO_VERSION.toString(), version = MxCall.VOIP_PROTO_VERSION.toString(),
targetUser = CallReplacesContent.TargetUser( targetUser = CallReplacesContent.TargetUser(
id = targetUserId, id = targetUserId,
displayName = profileInfo?.get(ProfileService.DISPLAY_NAME_KEY) as? String, displayName = profileInfo?.get(ProfileService.DISPLAY_NAME_KEY) as? String,
avatarUrl = profileInfo?.get(ProfileService.AVATAR_URL_KEY) as? String avatarUrl = profileInfo?.get(ProfileService.AVATAR_URL_KEY) as? String
), ),
targerRoomId = targetRoomId, targetRoomId = targetRoomId,
createCall = UUID.randomUUID().toString() awaitCall = awaitCallId,
createCall = createCallId
) )
.let { createEventAndLocalEcho(type = EventType.CALL_REPLACES, roomId = roomId, content = it.toContent()) } .let { createEventAndLocalEcho(type = EventType.CALL_REPLACES, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) } .also { eventSenderProcessor.postEvent(it) }

View File

@ -0,0 +1 @@
VoIP: support attended transfer

View File

@ -198,7 +198,18 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
} }
is CallState.Connected -> { is CallState.Connected -> {
if (callState.iceConnectionState == MxPeerConnectionState.CONNECTED) { if (callState.iceConnectionState == MxPeerConnectionState.CONNECTED) {
if (state.isLocalOnHold || state.isRemoteOnHold) { if (state.transferee !is VectorCallViewState.TransfereeState.NoTransferee) {
val transfereeName = if (state.transferee is VectorCallViewState.TransfereeState.KnownTransferee) {
state.transferee.name
} else {
getString(R.string.call_transfer_unknown_person)
}
views.callActionText.text = getString(R.string.call_transfer_transfer_to_title, transfereeName)
views.callActionText.isVisible = true
views.callActionText.setOnClickListener { callViewModel.handle(VectorCallViewActions.TransferCall) }
views.callStatusText.text = state.formattedDuration
configureCallInfo(state)
} else if (state.isLocalOnHold || state.isRemoteOnHold) {
views.smallIsHeldIcon.isVisible = true views.smallIsHeldIcon.isVisible = true
views.callVideoGroup.isInvisible = true views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true views.callInfoGroup.isVisible = true
@ -247,7 +258,11 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
state.callInfo.otherUserItem?.let { state.callInfo.otherUserItem?.let {
val colorFilter = ContextCompat.getColor(this, R.color.bg_call_screen) val colorFilter = ContextCompat.getColor(this, R.color.bg_call_screen)
avatarRenderer.renderBlur(it, views.bgCallView, sampling = 20, rounded = false, colorFilter = colorFilter) avatarRenderer.renderBlur(it, views.bgCallView, sampling = 20, rounded = false, colorFilter = colorFilter)
if (state.transferee is VectorCallViewState.TransfereeState.NoTransferee) {
views.participantNameText.text = it.getBestName() views.participantNameText.text = it.getBestName()
} else {
views.participantNameText.text = getString(R.string.call_transfer_consulting_with, it.getBestName())
}
if (blurAvatar) { if (blurAvatar) {
avatarRenderer.renderBlur(it, views.otherMemberAvatar, sampling = 2, rounded = true, colorFilter = colorFilter) avatarRenderer.renderBlur(it, views.otherMemberAvatar, sampling = 2, rounded = true, colorFilter = colorFilter)
} else { } else {

View File

@ -34,4 +34,5 @@ sealed class VectorCallViewActions : VectorViewModelAction {
object ToggleCamera : VectorCallViewActions() object ToggleCamera : VectorCallViewActions()
object ToggleHDSD : VectorCallViewActions() object ToggleHDSD : VectorCallViewActions()
object InitiateCallTransfer : VectorCallViewActions() object InitiateCallTransfer : VectorCallViewActions()
object TransferCall: VectorCallViewActions()
} }

View File

@ -23,8 +23,8 @@ import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success import com.airbnb.mvrx.Success
import com.airbnb.mvrx.ViewModelContext import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.call.audio.CallAudioManager import im.vector.app.features.call.audio.CallAudioManager
@ -111,12 +111,21 @@ class VectorCallViewModel @AssistedInject constructor(
setState { setState {
copy( copy(
callState = Success(callState), callState = Success(callState),
canOpponentBeTransferred = call.capabilities.supportCallTransfer() canOpponentBeTransferred = call.capabilities.supportCallTransfer(),
transferee = computeTransfereeState(call)
) )
} }
} }
} }
private fun computeTransfereeState(call: MxCall): VectorCallViewState.TransfereeState {
val transfereeCall = callManager.getTransfereeForCallId(call.callId) ?: return VectorCallViewState.TransfereeState.NoTransferee
val transfereeRoom = session.getRoomSummary(transfereeCall.nativeRoomId)
return transfereeRoom?.displayName?.let {
VectorCallViewState.TransfereeState.KnownTransferee(it)
} ?: VectorCallViewState.TransfereeState.UnknownTransferee
}
private val currentCallListener = object : WebRtcCallManager.CurrentCallListener { private val currentCallListener = object : WebRtcCallManager.CurrentCallListener {
override fun onCurrentCallChange(call: WebRtcCall?) { override fun onCurrentCallChange(call: WebRtcCall?) {
@ -185,7 +194,8 @@ class VectorCallViewModel @AssistedInject constructor(
canSwitchCamera = webRtcCall.canSwitchCamera(), canSwitchCamera = webRtcCall.canSwitchCamera(),
formattedDuration = webRtcCall.formattedDuration(), formattedDuration = webRtcCall.formattedDuration(),
isHD = webRtcCall.mxCall.isVideoCall && webRtcCall.currentCaptureFormat() is CaptureFormat.HD, isHD = webRtcCall.mxCall.isVideoCall && webRtcCall.currentCaptureFormat() is CaptureFormat.HD,
canOpponentBeTransferred = webRtcCall.mxCall.capabilities.supportCallTransfer() canOpponentBeTransferred = webRtcCall.mxCall.capabilities.supportCallTransfer(),
transferee = computeTransfereeState(webRtcCall.mxCall)
) )
} }
updateOtherKnownCall(webRtcCall) updateOtherKnownCall(webRtcCall)
@ -272,9 +282,20 @@ class VectorCallViewModel @AssistedInject constructor(
VectorCallViewEvents.ShowCallTransferScreen VectorCallViewEvents.ShowCallTransferScreen
) )
} }
VectorCallViewActions.TransferCall -> {
handleCallTransfer()
}
}.exhaustive }.exhaustive
} }
private fun handleCallTransfer() {
viewModelScope.launch {
val currentCall = call ?: return@launch
val transfereeCall = callManager.getTransfereeForCallId(currentCall.callId) ?: return@launch
currentCall.transferToCall(transfereeCall)
}
}
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(initialState: VectorCallViewState): VectorCallViewModel fun create(initialState: VectorCallViewState): VectorCallViewModel

View File

@ -41,15 +41,22 @@ data class VectorCallViewState(
val otherKnownCallInfo: CallInfo? = null, val otherKnownCallInfo: CallInfo? = null,
val callInfo: CallInfo = CallInfo(callId), val callInfo: CallInfo = CallInfo(callId),
val formattedDuration: String = "", val formattedDuration: String = "",
val canOpponentBeTransferred: Boolean = false val canOpponentBeTransferred: Boolean = false,
val transferee: TransfereeState = TransfereeState.NoTransferee
) : MvRxState { ) : MvRxState {
sealed class TransfereeState {
object NoTransferee : TransfereeState()
data class KnownTransferee(val name: String) : TransfereeState()
object UnknownTransferee : TransfereeState()
}
data class CallInfo( data class CallInfo(
val callId: String, val callId: String,
val otherUserItem: MatrixItem? = null val otherUserItem: MatrixItem? = null
) )
constructor(callArgs: CallArgs): this( constructor(callArgs: CallArgs) : this(
callId = callArgs.callId, callId = callArgs.callId,
roomId = callArgs.signalingRoomId, roomId = callArgs.signalingRoomId,
isVideoCall = callArgs.isVideoCall isVideoCall = callArgs.isVideoCall

View File

@ -28,13 +28,16 @@ import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.call.dialpad.DialPadLookup import im.vector.app.features.call.dialpad.DialPadLookup
import im.vector.app.features.call.webrtc.WebRtcCall import im.vector.app.features.call.webrtc.WebRtcCall
import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.createdirect.DirectRoomHelper
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall import org.matrix.android.sdk.api.session.call.MxCall
class CallTransferViewModel @AssistedInject constructor(@Assisted initialState: CallTransferViewState, class CallTransferViewModel @AssistedInject constructor(@Assisted initialState: CallTransferViewState,
private val dialPadLookup: DialPadLookup, private val dialPadLookup: DialPadLookup,
callManager: WebRtcCallManager) private val directRoomHelper: DirectRoomHelper,
private val callManager: WebRtcCallManager)
: VectorViewModel<CallTransferViewState, CallTransferAction, CallTransferViewEvents>(initialState) { : VectorViewModel<CallTransferViewState, CallTransferAction, CallTransferViewEvents>(initialState) {
@AssistedFactory @AssistedFactory
@ -83,8 +86,17 @@ class CallTransferViewModel @AssistedInject constructor(@Assisted initialState:
private fun connectWithUserId(action: CallTransferAction.ConnectWithUserId) { private fun connectWithUserId(action: CallTransferAction.ConnectWithUserId) {
viewModelScope.launch { viewModelScope.launch {
try { try {
_viewEvents.post(CallTransferViewEvents.Loading) if (action.consultFirst) {
call?.mxCall?.transfer(action.selectedUserId, null) val dmRoomId = directRoomHelper.ensureDMExists(action.selectedUserId)
callManager.startOutgoingCall(
nativeRoomId = dmRoomId,
otherUserId = action.selectedUserId,
isVideoCall = call?.mxCall?.isVideoCall.orFalse(),
transferee = call
)
} else {
call?.transferToUser(action.selectedUserId, null)
}
_viewEvents.post(CallTransferViewEvents.Dismiss) _viewEvents.post(CallTransferViewEvents.Dismiss)
} catch (failure: Throwable) { } catch (failure: Throwable) {
_viewEvents.post(CallTransferViewEvents.FailToTransfer) _viewEvents.post(CallTransferViewEvents.FailToTransfer)
@ -97,7 +109,16 @@ class CallTransferViewModel @AssistedInject constructor(@Assisted initialState:
try { try {
_viewEvents.post(CallTransferViewEvents.Loading) _viewEvents.post(CallTransferViewEvents.Loading)
val result = dialPadLookup.lookupPhoneNumber(action.phoneNumber) val result = dialPadLookup.lookupPhoneNumber(action.phoneNumber)
call?.mxCall?.transfer(result.userId, result.roomId) if (action.consultFirst) {
callManager.startOutgoingCall(
nativeRoomId = result.roomId,
otherUserId = result.userId,
isVideoCall = call?.mxCall?.isVideoCall.orFalse(),
transferee = call
)
} else {
call?.transferToUser(result.userId, result.roomId)
}
_viewEvents.post(CallTransferViewEvents.Dismiss) _viewEvents.post(CallTransferViewEvents.Dismiss)
} catch (failure: Throwable) { } catch (failure: Throwable) {
_viewEvents.post(CallTransferViewEvents.FailToTransfer) _viewEvents.post(CallTransferViewEvents.FailToTransfer)

View File

@ -45,6 +45,7 @@ import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.call.CallIdGenerator
import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.MxCall import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.call.MxPeerConnectionState import org.matrix.android.sdk.api.session.call.MxPeerConnectionState
@ -85,8 +86,10 @@ private const val AUDIO_TRACK_ID = "ARDAMSa0"
private const val VIDEO_TRACK_ID = "ARDAMSv0" private const val VIDEO_TRACK_ID = "ARDAMSv0"
private val DEFAULT_AUDIO_CONSTRAINTS = MediaConstraints() private val DEFAULT_AUDIO_CONSTRAINTS = MediaConstraints()
class WebRtcCall(val mxCall: MxCall, class WebRtcCall(
// This is where the call is placed from an ui perspective. In case of virtual room, it can differs from the signalingRoomId. val mxCall: MxCall,
// This is where the call is placed from an ui perspective.
// In case of virtual room, it can differs from the signalingRoomId.
val nativeRoomId: String, val nativeRoomId: String,
private val rootEglBase: EglBase?, private val rootEglBase: EglBase?,
private val context: Context, private val context: Context,
@ -94,7 +97,8 @@ class WebRtcCall(val mxCall: MxCall,
private val sessionProvider: Provider<Session?>, private val sessionProvider: Provider<Session?>,
private val peerConnectionFactoryProvider: Provider<PeerConnectionFactory?>, private val peerConnectionFactoryProvider: Provider<PeerConnectionFactory?>,
private val onCallBecomeActive: (WebRtcCall) -> Unit, private val onCallBecomeActive: (WebRtcCall) -> Unit,
private val onCallEnded: (String) -> Unit) : MxCall.StateListener { private val onCallEnded: (String) -> Unit
) : MxCall.StateListener {
interface Listener : MxCall.StateListener { interface Listener : MxCall.StateListener {
fun onCaptureStateChanged() {} fun onCaptureStateChanged() {}
@ -118,6 +122,7 @@ class WebRtcCall(val mxCall: MxCall,
} }
val callId = mxCall.callId val callId = mxCall.callId
// room where call signaling is placed. In case of virtual room it can differs from the nativeRoomId. // room where call signaling is placed. In case of virtual room it can differs from the nativeRoomId.
val signalingRoomId = mxCall.roomId val signalingRoomId = mxCall.roomId
@ -289,6 +294,40 @@ class WebRtcCall(val mxCall: MxCall,
} }
} }
/**
* Without consultation
*/
suspend fun transferToUser(targetUserId: String, targetRoomId: String?) {
mxCall.transfer(
targetUserId = targetUserId,
targetRoomId = targetRoomId,
createCallId = CallIdGenerator.generate(),
awaitCallId = null
)
endCall(sendEndSignaling = false)
}
/**
* With consultation
*/
suspend fun transferToCall(transferTargetCall: WebRtcCall) {
val newCallId = CallIdGenerator.generate()
transferTargetCall.mxCall.transfer(
targetUserId = mxCall.opponentUserId,
targetRoomId = null,
createCallId = null,
awaitCallId = newCallId
)
mxCall.transfer(
targetUserId = transferTargetCall.mxCall.opponentUserId,
targetRoomId = null,
createCallId = newCallId,
awaitCallId = null
)
endCall(sendEndSignaling = false)
transferTargetCall.endCall(sendEndSignaling = false)
}
fun acceptIncomingCall() { fun acceptIncomingCall() {
sessionScope?.launch { sessionScope?.launch {
Timber.v("## VOIP acceptIncomingCall from state ${mxCall.state}") Timber.v("## VOIP acceptIncomingCall from state ${mxCall.state}")
@ -729,7 +768,7 @@ class WebRtcCall(val mxCall: MxCall,
} }
} }
fun endCall(originatedByMe: Boolean = true, reason: CallHangupContent.Reason? = null) { fun endCall(sendEndSignaling: Boolean = true, reason: CallHangupContent.Reason? = null) {
if (mxCall.state == CallState.Terminated) { if (mxCall.state == CallState.Terminated) {
return return
} }
@ -744,9 +783,9 @@ class WebRtcCall(val mxCall: MxCall,
mxCall.state = CallState.Terminated mxCall.state = CallState.Terminated
sessionScope?.launch(dispatcher) { sessionScope?.launch(dispatcher) {
release() release()
}
onCallEnded(callId) onCallEnded(callId)
if (originatedByMe) { }
if (sendEndSignaling) {
if (wasRinging) { if (wasRinging) {
mxCall.reject() mxCall.reject()
} else { } else {

View File

@ -147,6 +147,11 @@ class WebRtcCallManager @Inject constructor(
private val callsByCallId = ConcurrentHashMap<String, WebRtcCall>() private val callsByCallId = ConcurrentHashMap<String, WebRtcCall>()
private val callsByRoomId = ConcurrentHashMap<String, MutableList<WebRtcCall>>() private val callsByRoomId = ConcurrentHashMap<String, MutableList<WebRtcCall>>()
// Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one.
// callId (target) -> call (transferee)
private val transferees = ConcurrentHashMap<String, WebRtcCall>()
fun getCallById(callId: String): WebRtcCall? { fun getCallById(callId: String): WebRtcCall? {
return callsByCallId[callId] return callsByCallId[callId]
} }
@ -155,6 +160,10 @@ class WebRtcCallManager @Inject constructor(
return callsByRoomId[roomId] ?: emptyList() return callsByRoomId[roomId] ?: emptyList()
} }
fun getTransfereeForCallId(callId: String): WebRtcCall? {
return transferees[callId]
}
fun getCurrentCall(): WebRtcCall? { fun getCurrentCall(): WebRtcCall? {
return currentCall.get() return currentCall.get()
} }
@ -229,12 +238,11 @@ class WebRtcCallManager @Inject constructor(
CallService.onCallTerminated(context, callId) CallService.onCallTerminated(context, callId)
callsByRoomId[webRtcCall.signalingRoomId]?.remove(webRtcCall) callsByRoomId[webRtcCall.signalingRoomId]?.remove(webRtcCall)
callsByRoomId[webRtcCall.nativeRoomId]?.remove(webRtcCall) callsByRoomId[webRtcCall.nativeRoomId]?.remove(webRtcCall)
transferees.remove(callId)
if (getCurrentCall()?.callId == callId) { if (getCurrentCall()?.callId == callId) {
val otherCall = getCalls().lastOrNull() val otherCall = getCalls().lastOrNull()
currentCall.setAndNotify(otherCall) currentCall.setAndNotify(otherCall)
} }
// This must be done in this thread
executor.execute {
// There is no active calls // There is no active calls
if (getCurrentCall() == null) { if (getCurrentCall() == null) {
Timber.v("## VOIP Dispose peerConnectionFactory as there is no need to keep one") Timber.v("## VOIP Dispose peerConnectionFactory as there is no need to keep one")
@ -251,11 +259,9 @@ class WebRtcCallManager @Inject constructor(
} }
} }
} }
Timber.v("## VOIP WebRtcPeerConnectionManager close() executor done")
}
} }
suspend fun startOutgoingCall(nativeRoomId: String, otherUserId: String, isVideoCall: Boolean) { suspend fun startOutgoingCall(nativeRoomId: String, otherUserId: String, isVideoCall: Boolean, transferee: WebRtcCall? = null) {
val signalingRoomId = callUserMapper?.getOrCreateVirtualRoomForRoom(nativeRoomId, otherUserId) ?: nativeRoomId val signalingRoomId = callUserMapper?.getOrCreateVirtualRoomForRoom(nativeRoomId, otherUserId) ?: nativeRoomId
Timber.v("## VOIP startOutgoingCall in room $signalingRoomId to $otherUserId isVideo $isVideoCall") Timber.v("## VOIP startOutgoingCall in room $signalingRoomId to $otherUserId isVideo $isVideoCall")
if (getCallsByRoomId(nativeRoomId).isNotEmpty()) { if (getCallsByRoomId(nativeRoomId).isNotEmpty()) {
@ -274,7 +280,9 @@ class WebRtcCallManager @Inject constructor(
val mxCall = currentSession?.callSignalingService()?.createOutgoingCall(signalingRoomId, otherUserId, isVideoCall) ?: return val mxCall = currentSession?.callSignalingService()?.createOutgoingCall(signalingRoomId, otherUserId, isVideoCall) ?: return
val webRtcCall = createWebRtcCall(mxCall, nativeRoomId) val webRtcCall = createWebRtcCall(mxCall, nativeRoomId)
currentCall.setAndNotify(webRtcCall) currentCall.setAndNotify(webRtcCall)
if (transferee != null) {
transferees[webRtcCall.callId] = transferee
}
CallService.onOutgoingCallRinging( CallService.onOutgoingCallRinging(
context = context.applicationContext, context = context.applicationContext,
callId = mxCall.callId) callId = mxCall.callId)

View File

@ -52,7 +52,6 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_centerVertical="true" android:layout_centerVertical="true"
android:layout_alignParentStart="true" android:layout_alignParentStart="true"
android:enabled="false"
android:layout_height="wrap_content"/> android:layout_height="wrap_content"/>
<TextView <TextView

View File

@ -3237,7 +3237,9 @@
<string name="call_transfer_title">Transfer</string> <string name="call_transfer_title">Transfer</string>
<string name="call_transfer_failure">An error occurred while transferring call</string> <string name="call_transfer_failure">An error occurred while transferring call</string>
<string name="call_transfer_users_tab_title">Users</string> <string name="call_transfer_users_tab_title">Users</string>
<string name="call_transfer_consulting_with">Consulting with %1$s</string>
<string name="call_transfer_transfer_to_title">Transfer to %1$s</string>
<string name="call_transfer_unknown_person">Unknown person</string>
<string name="re_authentication_activity_title">Re-Authentication Needed</string> <string name="re_authentication_activity_title">Re-Authentication Needed</string>
<!-- Note to translators: the translation MUST contain the string "${app_name}", which will be replaced by the application name --> <!-- Note to translators: the translation MUST contain the string "${app_name}", which will be replaced by the application name -->