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 isOutgoing: Boolean
val roomId: String
val opponentUserId: String
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
}
val ourPartyId: String
var opponentPartyId: Optional<String>?
var opponentVersion: Int
var capabilities: CallCapabilities?
var state: CallState
/**
@ -91,8 +89,12 @@ interface MxCall : MxCallDetail {
/**
* 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 removeListener(listener: StateListener)

View file

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

View file

@ -38,23 +38,23 @@ data class CallReplacesContent(
*/
@Json(name = "replacement_id") val replacementId: String? = null,
/**
* Optional. If specified, the transferee client waits for an invite to this room and joins it
* (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.
* Optional. If specified, the transferee client waits for an invite to this room and joins it
* (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.
*/
@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
*/
@Json(name = "target_user") val targetUser: TargetUser? = null,
/**
* If specified, gives the call ID for the transferee's client to use when placing the replacement call.
* Mutually exclusive with await_call
* If specified, gives the call ID for the transferee's client to use when placing the replacement call.
* Mutually exclusive with await_call
*/
@Json(name = "create_call") val createCall: String? = null,
/**
* If specified, gives the call ID that the transferee's client should wait for.
* Mutually exclusive with create_call.
* If specified, gives the call ID that the transferee's client should wait for.
* Mutually exclusive with create_call.
*/
@Json(name = "await_call") val awaitCall: String? = null,
/**
@ -77,6 +77,5 @@ data class CallReplacesContent(
* Optional. The avatar URL of the transfer target.
*/
@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.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.CallCapabilities
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.CallNegotiateContent
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.CallSignalingContent
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.SessionScope
import timber.log.Timber
import java.math.BigDecimal
import javax.inject.Inject
@SessionScope
@ -192,6 +189,9 @@ internal class CallSignalingHandler @Inject constructor(private val activeCallHa
// Ignore remote echo
return
}
if (event.roomId == null || event.senderId == null) {
return
}
if (event.senderId == userId) {
// discard current call, it's answered by another of my session
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}")
return
}
call.apply {
opponentPartyId = Optional.from(content.partyId)
opponentVersion = content.version?.let { BigDecimal(it).intValueExact() } ?: MxCall.VOIP_PROTO_VERSION
capabilities = content.capabilities ?: CallCapabilities()
}
mxCallFactory.updateOutgoingCallWithOpponentData(call, event.senderId, content, content.capabilities)
callListenersDispatcher.onCallAnswerReceived(content)
}
}

View file

@ -17,18 +17,17 @@
package org.matrix.android.sdk.internal.session.call
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.room.model.call.CallCapabilities
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.UserId
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.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import java.math.BigDecimal
import java.util.UUID
import javax.inject.Inject
internal class MxCallFactory @Inject constructor(
@ -48,32 +47,38 @@ internal class MxCallFactory @Inject constructor(
roomId = roomId,
userId = userId,
ourPartyId = deviceId ?: "",
opponentUserId = opponentUserId,
isVideoCall = content.isVideo(),
localEchoEventFactory = localEchoEventFactory,
eventSenderProcessor = eventSenderProcessor,
matrixConfiguration = matrixConfiguration,
getProfileInfoTask = getProfileInfoTask
).apply {
opponentPartyId = Optional.from(content.partyId)
opponentVersion = content.version?.let { BigDecimal(it).intValueExact() } ?: MxCall.VOIP_PROTO_VERSION
capabilities = content.capabilities ?: CallCapabilities()
updateOpponentData(opponentUserId, content, content.capabilities)
}
}
fun createOutgoingCall(roomId: String, opponentUserId: String, isVideoCall: Boolean): MxCall {
return MxCallImpl(
callId = UUID.randomUUID().toString(),
callId = CallIdGenerator.generate(),
isOutgoing = true,
roomId = roomId,
userId = userId,
ourPartyId = deviceId ?: "",
opponentUserId = opponentUserId,
isVideoCall = isVideoCall,
localEchoEventFactory = localEchoEventFactory,
eventSenderProcessor = eventSenderProcessor,
matrixConfiguration = matrixConfiguration,
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
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.MxCall
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.CallReplacesContent
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.util.Optional
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.queue.EventSenderProcessor
import timber.log.Timber
import java.util.UUID
import java.math.BigDecimal
internal class MxCallImpl(
override val callId: String,
override val isOutgoing: Boolean,
override val roomId: String,
private val userId: String,
override val opponentUserId: String,
override val isVideoCall: Boolean,
override val ourPartyId: String,
private val localEchoEventFactory: LocalEchoEventFactory,
@ -61,8 +62,16 @@ internal class MxCallImpl(
override var opponentPartyId: Optional<String>? = null
override var opponentVersion: Int = MxCall.VOIP_PROTO_VERSION
override lateinit var opponentUserId: String
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
set(value) {
field = value
@ -202,7 +211,10 @@ internal class MxCallImpl(
.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 profileInfo = try {
getProfileInfoTask.execute(profileInfoParams)
@ -213,15 +225,16 @@ internal class MxCallImpl(
CallReplacesContent(
callId = callId,
partyId = ourPartyId,
replacementId = UUID.randomUUID().toString(),
replacementId = CallIdGenerator.generate(),
version = MxCall.VOIP_PROTO_VERSION.toString(),
targetUser = CallReplacesContent.TargetUser(
id = targetUserId,
displayName = profileInfo?.get(ProfileService.DISPLAY_NAME_KEY) as? String,
avatarUrl = profileInfo?.get(ProfileService.AVATAR_URL_KEY) as? String
),
targerRoomId = targetRoomId,
createCall = UUID.randomUUID().toString()
targetRoomId = targetRoomId,
awaitCall = awaitCallId,
createCall = createCallId
)
.let { createEventAndLocalEcho(type = EventType.CALL_REPLACES, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) }

View file

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

View file

@ -175,7 +175,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
when (callState) {
is CallState.Idle,
is CallState.CreateOffer,
is CallState.Dialing -> {
is CallState.Dialing -> {
views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true
views.callStatusText.setText(R.string.call_ring)
@ -189,16 +189,27 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
configureCallInfo(state)
}
is CallState.Answering -> {
is CallState.Answering -> {
views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true
views.callStatusText.setText(R.string.call_connecting)
views.callConnectingProgress.isVisible = true
configureCallInfo(state)
}
is CallState.Connected -> {
is CallState.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.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true
@ -220,7 +231,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
if (callArgs.isVideoCall) {
views.callVideoGroup.isVisible = true
views.callInfoGroup.isVisible = false
views.pipRenderer.isVisible = !state.isVideoCaptureInError && state.otherKnownCallInfo == null
views.pipRenderer.isVisible = !state.isVideoCaptureInError && state.otherKnownCallInfo == null
} else {
views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true
@ -235,10 +246,10 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
views.callConnectingProgress.isVisible = true
}
}
is CallState.Terminated -> {
is CallState.Terminated -> {
finish()
}
null -> {
null -> {
}
}
}
@ -247,7 +258,11 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
state.callInfo.otherUserItem?.let {
val colorFilter = ContextCompat.getColor(this, R.color.bg_call_screen)
avatarRenderer.renderBlur(it, views.bgCallView, sampling = 20, rounded = false, colorFilter = colorFilter)
views.participantNameText.text = it.getBestName()
if (state.transferee is VectorCallViewState.TransfereeState.NoTransferee) {
views.participantNameText.text = it.getBestName()
} else {
views.participantNameText.text = getString(R.string.call_transfer_consulting_with, it.getBestName())
}
if (blurAvatar) {
avatarRenderer.renderBlur(it, views.otherMemberAvatar, sampling = 2, rounded = true, colorFilter = colorFilter)
} else {
@ -322,13 +337,13 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
private fun handleViewEvents(event: VectorCallViewEvents?) {
Timber.v("## VOIP handleViewEvents $event")
when (event) {
VectorCallViewEvents.DismissNoCall -> {
VectorCallViewEvents.DismissNoCall -> {
finish()
}
is VectorCallViewEvents.ConnectionTimeout -> {
is VectorCallViewEvents.ConnectionTimeout -> {
onErrorTimoutConnect(event.turn)
}
is VectorCallViewEvents.ShowDialPad -> {
is VectorCallViewEvents.ShowDialPad -> {
CallDialPadBottomSheet.newInstance(false).apply {
callback = dialPadCallback
}.show(supportFragmentManager, FRAGMENT_DIAL_PAD_TAG)
@ -336,7 +351,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
is VectorCallViewEvents.ShowCallTransferScreen -> {
navigator.openCallTransfer(this, callArgs.callId)
}
null -> {
null -> {
}
}
}

View file

@ -34,4 +34,5 @@ sealed class VectorCallViewActions : VectorViewModelAction {
object ToggleCamera : VectorCallViewActions()
object ToggleHDSD : 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.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.call.audio.CallAudioManager
@ -111,12 +111,21 @@ class VectorCallViewModel @AssistedInject constructor(
setState {
copy(
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 {
override fun onCurrentCallChange(call: WebRtcCall?) {
@ -166,7 +175,7 @@ class VectorCallViewModel @AssistedInject constructor(
} else {
call = webRtcCall
callManager.addCurrentCallListener(currentCallListener)
val item = webRtcCall.getOpponentAsMatrixItem(session)
val item = webRtcCall.getOpponentAsMatrixItem(session)
webRtcCall.addListener(callListener)
val currentSoundDevice = callManager.audioManager.selectedDevice
if (currentSoundDevice == CallAudioManager.Device.PHONE) {
@ -185,7 +194,8 @@ class VectorCallViewModel @AssistedInject constructor(
canSwitchCamera = webRtcCall.canSwitchCamera(),
formattedDuration = webRtcCall.formattedDuration(),
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)
@ -201,27 +211,27 @@ class VectorCallViewModel @AssistedInject constructor(
override fun handle(action: VectorCallViewActions) = withState { state ->
when (action) {
VectorCallViewActions.EndCall -> call?.endCall()
VectorCallViewActions.AcceptCall -> {
VectorCallViewActions.EndCall -> call?.endCall()
VectorCallViewActions.AcceptCall -> {
setState {
copy(callState = Loading())
}
call?.acceptIncomingCall()
}
VectorCallViewActions.DeclineCall -> {
VectorCallViewActions.DeclineCall -> {
setState {
copy(callState = Loading())
}
call?.endCall()
}
VectorCallViewActions.ToggleMute -> {
VectorCallViewActions.ToggleMute -> {
val muted = state.isAudioMuted
call?.muteCall(!muted)
setState {
copy(isAudioMuted = !muted)
}
}
VectorCallViewActions.ToggleVideo -> {
VectorCallViewActions.ToggleVideo -> {
if (state.isVideoCall) {
val videoEnabled = state.isVideoEnabled
call?.enableVideo(!videoEnabled)
@ -231,14 +241,14 @@ class VectorCallViewModel @AssistedInject constructor(
}
Unit
}
VectorCallViewActions.ToggleHoldResume -> {
VectorCallViewActions.ToggleHoldResume -> {
val isRemoteOnHold = state.isRemoteOnHold
call?.updateRemoteOnHold(!isRemoteOnHold)
}
is VectorCallViewActions.ChangeAudioDevice -> {
callManager.audioManager.setAudioDevice(action.device)
}
VectorCallViewActions.SwitchSoundDevice -> {
VectorCallViewActions.SwitchSoundDevice -> {
_viewEvents.post(
VectorCallViewEvents.ShowSoundDeviceChooser(state.availableDevices, state.device)
)
@ -254,17 +264,17 @@ class VectorCallViewModel @AssistedInject constructor(
}
Unit
}
VectorCallViewActions.ToggleCamera -> {
VectorCallViewActions.ToggleCamera -> {
call?.switchCamera()
}
VectorCallViewActions.ToggleHDSD -> {
VectorCallViewActions.ToggleHDSD -> {
if (!state.isVideoCall) return@withState
call?.setCaptureFormat(if (state.isHD) CaptureFormat.SD else CaptureFormat.HD)
}
VectorCallViewActions.OpenDialPad -> {
VectorCallViewActions.OpenDialPad -> {
_viewEvents.post(VectorCallViewEvents.ShowDialPad)
}
is VectorCallViewActions.SendDtmfDigit -> {
is VectorCallViewActions.SendDtmfDigit -> {
call?.sendDtmfDigit(action.digit)
}
VectorCallViewActions.InitiateCallTransfer -> {
@ -272,9 +282,20 @@ class VectorCallViewModel @AssistedInject constructor(
VectorCallViewEvents.ShowCallTransferScreen
)
}
VectorCallViewActions.TransferCall -> {
handleCallTransfer()
}
}.exhaustive
}
private fun handleCallTransfer() {
viewModelScope.launch {
val currentCall = call ?: return@launch
val transfereeCall = callManager.getTransfereeForCallId(currentCall.callId) ?: return@launch
currentCall.transferToCall(transfereeCall)
}
}
@AssistedFactory
interface Factory {
fun create(initialState: VectorCallViewState): VectorCallViewModel

View file

@ -41,15 +41,22 @@ data class VectorCallViewState(
val otherKnownCallInfo: CallInfo? = null,
val callInfo: CallInfo = CallInfo(callId),
val formattedDuration: String = "",
val canOpponentBeTransferred: Boolean = false
val canOpponentBeTransferred: Boolean = false,
val transferee: TransfereeState = TransfereeState.NoTransferee
) : MvRxState {
sealed class TransfereeState {
object NoTransferee : TransfereeState()
data class KnownTransferee(val name: String) : TransfereeState()
object UnknownTransferee : TransfereeState()
}
data class CallInfo(
val callId: String,
val otherUserItem: MatrixItem? = null
)
constructor(callArgs: CallArgs): this(
constructor(callArgs: CallArgs) : this(
callId = callArgs.callId,
roomId = callArgs.signalingRoomId,
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.webrtc.WebRtcCall
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.createdirect.DirectRoomHelper
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.MxCall
class CallTransferViewModel @AssistedInject constructor(@Assisted initialState: CallTransferViewState,
private val dialPadLookup: DialPadLookup,
callManager: WebRtcCallManager)
private val directRoomHelper: DirectRoomHelper,
private val callManager: WebRtcCallManager)
: VectorViewModel<CallTransferViewState, CallTransferAction, CallTransferViewEvents>(initialState) {
@AssistedFactory
@ -75,7 +78,7 @@ class CallTransferViewModel @AssistedInject constructor(@Assisted initialState:
override fun handle(action: CallTransferAction) {
when (action) {
is CallTransferAction.ConnectWithUserId -> connectWithUserId(action)
is CallTransferAction.ConnectWithUserId -> connectWithUserId(action)
is CallTransferAction.ConnectWithPhoneNumber -> connectWithPhoneNumber(action)
}.exhaustive
}
@ -83,8 +86,17 @@ class CallTransferViewModel @AssistedInject constructor(@Assisted initialState:
private fun connectWithUserId(action: CallTransferAction.ConnectWithUserId) {
viewModelScope.launch {
try {
_viewEvents.post(CallTransferViewEvents.Loading)
call?.mxCall?.transfer(action.selectedUserId, null)
if (action.consultFirst) {
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)
} catch (failure: Throwable) {
_viewEvents.post(CallTransferViewEvents.FailToTransfer)
@ -97,7 +109,16 @@ class CallTransferViewModel @AssistedInject constructor(@Assisted initialState:
try {
_viewEvents.post(CallTransferViewEvents.Loading)
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)
} catch (failure: Throwable) {
_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.tryOrNull
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.MxCall
import org.matrix.android.sdk.api.session.call.MxPeerConnectionState
@ -85,16 +86,19 @@ private const val AUDIO_TRACK_ID = "ARDAMSa0"
private const val VIDEO_TRACK_ID = "ARDAMSv0"
private val DEFAULT_AUDIO_CONSTRAINTS = MediaConstraints()
class WebRtcCall(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,
private val rootEglBase: EglBase?,
private val context: Context,
private val dispatcher: CoroutineContext,
private val sessionProvider: Provider<Session?>,
private val peerConnectionFactoryProvider: Provider<PeerConnectionFactory?>,
private val onCallBecomeActive: (WebRtcCall) -> Unit,
private val onCallEnded: (String) -> Unit) : MxCall.StateListener {
class WebRtcCall(
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,
private val rootEglBase: EglBase?,
private val context: Context,
private val dispatcher: CoroutineContext,
private val sessionProvider: Provider<Session?>,
private val peerConnectionFactoryProvider: Provider<PeerConnectionFactory?>,
private val onCallBecomeActive: (WebRtcCall) -> Unit,
private val onCallEnded: (String) -> Unit
) : MxCall.StateListener {
interface Listener : MxCall.StateListener {
fun onCaptureStateChanged() {}
@ -118,6 +122,7 @@ class WebRtcCall(val mxCall: MxCall,
}
val callId = mxCall.callId
// room where call signaling is placed. In case of virtual room it can differs from the nativeRoomId.
val signalingRoomId = mxCall.roomId
@ -271,7 +276,7 @@ class WebRtcCall(val mxCall: MxCall,
sessionScope?.launch(dispatcher) {
when (mode) {
VectorCallActivity.INCOMING_ACCEPT -> {
VectorCallActivity.INCOMING_ACCEPT -> {
internalAcceptIncomingCall()
}
VectorCallActivity.INCOMING_RINGING -> {
@ -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() {
sessionScope?.launch {
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) {
return
}
@ -744,9 +783,9 @@ class WebRtcCall(val mxCall: MxCall,
mxCall.state = CallState.Terminated
sessionScope?.launch(dispatcher) {
release()
onCallEnded(callId)
}
onCallEnded(callId)
if (originatedByMe) {
if (sendEndSignaling) {
if (wasRinging) {
mxCall.reject()
} else {

View file

@ -147,6 +147,11 @@ class WebRtcCallManager @Inject constructor(
private val callsByCallId = ConcurrentHashMap<String, 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? {
return callsByCallId[callId]
}
@ -155,6 +160,10 @@ class WebRtcCallManager @Inject constructor(
return callsByRoomId[roomId] ?: emptyList()
}
fun getTransfereeForCallId(callId: String): WebRtcCall? {
return transferees[callId]
}
fun getCurrentCall(): WebRtcCall? {
return currentCall.get()
}
@ -229,34 +238,31 @@ class WebRtcCallManager @Inject constructor(
CallService.onCallTerminated(context, callId)
callsByRoomId[webRtcCall.signalingRoomId]?.remove(webRtcCall)
callsByRoomId[webRtcCall.nativeRoomId]?.remove(webRtcCall)
transferees.remove(callId)
if (getCurrentCall()?.callId == callId) {
val otherCall = getCalls().lastOrNull()
currentCall.setAndNotify(otherCall)
}
// This must be done in this thread
executor.execute {
// There is no active calls
if (getCurrentCall() == null) {
Timber.v("## VOIP Dispose peerConnectionFactory as there is no need to keep one")
peerConnectionFactory?.dispose()
peerConnectionFactory = null
audioManager.setMode(CallAudioManager.Mode.DEFAULT)
// did we start background sync? so we should stop it
if (isInBackground) {
if (FcmHelper.isPushSupported()) {
currentSession?.stopAnyBackgroundSync()
} else {
// for fdroid we should not stop, it should continue syncing
// maybe we should restore default timeout/delay though?
}
// There is no active calls
if (getCurrentCall() == null) {
Timber.v("## VOIP Dispose peerConnectionFactory as there is no need to keep one")
peerConnectionFactory?.dispose()
peerConnectionFactory = null
audioManager.setMode(CallAudioManager.Mode.DEFAULT)
// did we start background sync? so we should stop it
if (isInBackground) {
if (FcmHelper.isPushSupported()) {
currentSession?.stopAnyBackgroundSync()
} else {
// for fdroid we should not stop, it should continue syncing
// maybe we should restore default timeout/delay though?
}
}
Timber.v("## VOIP WebRtcPeerConnectionManager close() executor done")
}
}
suspend fun startOutgoingCall(nativeRoomId: String, otherUserId: String, isVideoCall: Boolean) {
val signalingRoomId = callUserMapper?.getOrCreateVirtualRoomForRoom(nativeRoomId, otherUserId) ?: nativeRoomId
suspend fun startOutgoingCall(nativeRoomId: String, otherUserId: String, isVideoCall: Boolean, transferee: WebRtcCall? = null) {
val signalingRoomId = callUserMapper?.getOrCreateVirtualRoomForRoom(nativeRoomId, otherUserId) ?: nativeRoomId
Timber.v("## VOIP startOutgoingCall in room $signalingRoomId to $otherUserId isVideo $isVideoCall")
if (getCallsByRoomId(nativeRoomId).isNotEmpty()) {
Timber.w("## VOIP you already have a call in this room")
@ -274,7 +280,9 @@ class WebRtcCallManager @Inject constructor(
val mxCall = currentSession?.callSignalingService()?.createOutgoingCall(signalingRoomId, otherUserId, isVideoCall) ?: return
val webRtcCall = createWebRtcCall(mxCall, nativeRoomId)
currentCall.setAndNotify(webRtcCall)
if (transferee != null) {
transferees[webRtcCall.callId] = transferee
}
CallService.onOutgoingCallRinging(
context = context.applicationContext,
callId = mxCall.callId)

View file

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

View file

@ -3237,7 +3237,9 @@
<string name="call_transfer_title">Transfer</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_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>
<!-- Note to translators: the translation MUST contain the string "${app_name}", which will be replaced by the application name -->