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

@ -38,23 +38,23 @@ data class CallReplacesContent(
*/ */
@Json(name = "replacement_id") val replacementId: String? = null, @Json(name = "replacement_id") val replacementId: String? = null,
/** /**
* Optional. If specified, the transferee client waits for an invite to this room and joins it * 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. * (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
*/ */
@Json(name = "target_user") val targetUser: TargetUser? = null, @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. * If specified, gives the call ID for the transferee's client to use when placing the replacement call.
* Mutually exclusive with await_call * Mutually exclusive with await_call
*/ */
@Json(name = "create_call") val createCall: String? = null, @Json(name = "create_call") val createCall: String? = null,
/** /**
* If specified, gives the call ID that the transferee's client should wait for. * If specified, gives the call ID that the transferee's client should wait for.
* Mutually exclusive with create_call. * Mutually exclusive with create_call.
*/ */
@Json(name = "await_call") val awaitCall: String? = null, @Json(name = "await_call") val awaitCall: String? = null,
/** /**
@ -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

@ -175,7 +175,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
when (callState) { when (callState) {
is CallState.Idle, is CallState.Idle,
is CallState.CreateOffer, is CallState.CreateOffer,
is CallState.Dialing -> { is CallState.Dialing -> {
views.callVideoGroup.isInvisible = true views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true views.callInfoGroup.isVisible = true
views.callStatusText.setText(R.string.call_ring) views.callStatusText.setText(R.string.call_ring)
@ -189,16 +189,27 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
configureCallInfo(state) configureCallInfo(state)
} }
is CallState.Answering -> { is CallState.Answering -> {
views.callVideoGroup.isInvisible = true views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true views.callInfoGroup.isVisible = true
views.callStatusText.setText(R.string.call_connecting) views.callStatusText.setText(R.string.call_connecting)
views.callConnectingProgress.isVisible = true views.callConnectingProgress.isVisible = true
configureCallInfo(state) configureCallInfo(state)
} }
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
@ -220,7 +231,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
if (callArgs.isVideoCall) { if (callArgs.isVideoCall) {
views.callVideoGroup.isVisible = true views.callVideoGroup.isVisible = true
views.callInfoGroup.isVisible = false views.callInfoGroup.isVisible = false
views.pipRenderer.isVisible = !state.isVideoCaptureInError && state.otherKnownCallInfo == null views.pipRenderer.isVisible = !state.isVideoCaptureInError && state.otherKnownCallInfo == null
} else { } else {
views.callVideoGroup.isInvisible = true views.callVideoGroup.isInvisible = true
views.callInfoGroup.isVisible = true views.callInfoGroup.isVisible = true
@ -235,10 +246,10 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
views.callConnectingProgress.isVisible = true views.callConnectingProgress.isVisible = true
} }
} }
is CallState.Terminated -> { is CallState.Terminated -> {
finish() finish()
} }
null -> { null -> {
} }
} }
} }
@ -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)
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) { 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 {
@ -322,13 +337,13 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
private fun handleViewEvents(event: VectorCallViewEvents?) { private fun handleViewEvents(event: VectorCallViewEvents?) {
Timber.v("## VOIP handleViewEvents $event") Timber.v("## VOIP handleViewEvents $event")
when (event) { when (event) {
VectorCallViewEvents.DismissNoCall -> { VectorCallViewEvents.DismissNoCall -> {
finish() finish()
} }
is VectorCallViewEvents.ConnectionTimeout -> { is VectorCallViewEvents.ConnectionTimeout -> {
onErrorTimoutConnect(event.turn) onErrorTimoutConnect(event.turn)
} }
is VectorCallViewEvents.ShowDialPad -> { is VectorCallViewEvents.ShowDialPad -> {
CallDialPadBottomSheet.newInstance(false).apply { CallDialPadBottomSheet.newInstance(false).apply {
callback = dialPadCallback callback = dialPadCallback
}.show(supportFragmentManager, FRAGMENT_DIAL_PAD_TAG) }.show(supportFragmentManager, FRAGMENT_DIAL_PAD_TAG)
@ -336,7 +351,7 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
is VectorCallViewEvents.ShowCallTransferScreen -> { is VectorCallViewEvents.ShowCallTransferScreen -> {
navigator.openCallTransfer(this, callArgs.callId) navigator.openCallTransfer(this, callArgs.callId)
} }
null -> { null -> {
} }
} }
} }

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?) {
@ -166,7 +175,7 @@ class VectorCallViewModel @AssistedInject constructor(
} else { } else {
call = webRtcCall call = webRtcCall
callManager.addCurrentCallListener(currentCallListener) callManager.addCurrentCallListener(currentCallListener)
val item = webRtcCall.getOpponentAsMatrixItem(session) val item = webRtcCall.getOpponentAsMatrixItem(session)
webRtcCall.addListener(callListener) webRtcCall.addListener(callListener)
val currentSoundDevice = callManager.audioManager.selectedDevice val currentSoundDevice = callManager.audioManager.selectedDevice
if (currentSoundDevice == CallAudioManager.Device.PHONE) { if (currentSoundDevice == CallAudioManager.Device.PHONE) {
@ -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)
@ -201,27 +211,27 @@ class VectorCallViewModel @AssistedInject constructor(
override fun handle(action: VectorCallViewActions) = withState { state -> override fun handle(action: VectorCallViewActions) = withState { state ->
when (action) { when (action) {
VectorCallViewActions.EndCall -> call?.endCall() VectorCallViewActions.EndCall -> call?.endCall()
VectorCallViewActions.AcceptCall -> { VectorCallViewActions.AcceptCall -> {
setState { setState {
copy(callState = Loading()) copy(callState = Loading())
} }
call?.acceptIncomingCall() call?.acceptIncomingCall()
} }
VectorCallViewActions.DeclineCall -> { VectorCallViewActions.DeclineCall -> {
setState { setState {
copy(callState = Loading()) copy(callState = Loading())
} }
call?.endCall() call?.endCall()
} }
VectorCallViewActions.ToggleMute -> { VectorCallViewActions.ToggleMute -> {
val muted = state.isAudioMuted val muted = state.isAudioMuted
call?.muteCall(!muted) call?.muteCall(!muted)
setState { setState {
copy(isAudioMuted = !muted) copy(isAudioMuted = !muted)
} }
} }
VectorCallViewActions.ToggleVideo -> { VectorCallViewActions.ToggleVideo -> {
if (state.isVideoCall) { if (state.isVideoCall) {
val videoEnabled = state.isVideoEnabled val videoEnabled = state.isVideoEnabled
call?.enableVideo(!videoEnabled) call?.enableVideo(!videoEnabled)
@ -231,14 +241,14 @@ class VectorCallViewModel @AssistedInject constructor(
} }
Unit Unit
} }
VectorCallViewActions.ToggleHoldResume -> { VectorCallViewActions.ToggleHoldResume -> {
val isRemoteOnHold = state.isRemoteOnHold val isRemoteOnHold = state.isRemoteOnHold
call?.updateRemoteOnHold(!isRemoteOnHold) call?.updateRemoteOnHold(!isRemoteOnHold)
} }
is VectorCallViewActions.ChangeAudioDevice -> { is VectorCallViewActions.ChangeAudioDevice -> {
callManager.audioManager.setAudioDevice(action.device) callManager.audioManager.setAudioDevice(action.device)
} }
VectorCallViewActions.SwitchSoundDevice -> { VectorCallViewActions.SwitchSoundDevice -> {
_viewEvents.post( _viewEvents.post(
VectorCallViewEvents.ShowSoundDeviceChooser(state.availableDevices, state.device) VectorCallViewEvents.ShowSoundDeviceChooser(state.availableDevices, state.device)
) )
@ -254,17 +264,17 @@ class VectorCallViewModel @AssistedInject constructor(
} }
Unit Unit
} }
VectorCallViewActions.ToggleCamera -> { VectorCallViewActions.ToggleCamera -> {
call?.switchCamera() call?.switchCamera()
} }
VectorCallViewActions.ToggleHDSD -> { VectorCallViewActions.ToggleHDSD -> {
if (!state.isVideoCall) return@withState if (!state.isVideoCall) return@withState
call?.setCaptureFormat(if (state.isHD) CaptureFormat.SD else CaptureFormat.HD) call?.setCaptureFormat(if (state.isHD) CaptureFormat.SD else CaptureFormat.HD)
} }
VectorCallViewActions.OpenDialPad -> { VectorCallViewActions.OpenDialPad -> {
_viewEvents.post(VectorCallViewEvents.ShowDialPad) _viewEvents.post(VectorCallViewEvents.ShowDialPad)
} }
is VectorCallViewActions.SendDtmfDigit -> { is VectorCallViewActions.SendDtmfDigit -> {
call?.sendDtmfDigit(action.digit) call?.sendDtmfDigit(action.digit)
} }
VectorCallViewActions.InitiateCallTransfer -> { VectorCallViewActions.InitiateCallTransfer -> {
@ -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
@ -75,7 +78,7 @@ class CallTransferViewModel @AssistedInject constructor(@Assisted initialState:
override fun handle(action: CallTransferAction) { override fun handle(action: CallTransferAction) {
when (action) { when (action) {
is CallTransferAction.ConnectWithUserId -> connectWithUserId(action) is CallTransferAction.ConnectWithUserId -> connectWithUserId(action)
is CallTransferAction.ConnectWithPhoneNumber -> connectWithPhoneNumber(action) is CallTransferAction.ConnectWithPhoneNumber -> connectWithPhoneNumber(action)
}.exhaustive }.exhaustive
} }
@ -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,16 +86,19 @@ 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,
val nativeRoomId: String, // This is where the call is placed from an ui perspective.
private val rootEglBase: EglBase?, // In case of virtual room, it can differs from the signalingRoomId.
private val context: Context, val nativeRoomId: String,
private val dispatcher: CoroutineContext, private val rootEglBase: EglBase?,
private val sessionProvider: Provider<Session?>, private val context: Context,
private val peerConnectionFactoryProvider: Provider<PeerConnectionFactory?>, private val dispatcher: CoroutineContext,
private val onCallBecomeActive: (WebRtcCall) -> Unit, private val sessionProvider: Provider<Session?>,
private val onCallEnded: (String) -> Unit) : MxCall.StateListener { private val peerConnectionFactoryProvider: Provider<PeerConnectionFactory?>,
private val onCallBecomeActive: (WebRtcCall) -> Unit,
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
@ -271,7 +276,7 @@ class WebRtcCall(val mxCall: MxCall,
sessionScope?.launch(dispatcher) { sessionScope?.launch(dispatcher) {
when (mode) { when (mode) {
VectorCallActivity.INCOMING_ACCEPT -> { VectorCallActivity.INCOMING_ACCEPT -> {
internalAcceptIncomingCall() internalAcceptIncomingCall()
} }
VectorCallActivity.INCOMING_RINGING -> { 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() { 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 (sendEndSignaling) {
if (originatedByMe) {
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,34 +238,31 @@ 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 // There is no active calls
executor.execute { if (getCurrentCall() == null) {
// There is no active calls Timber.v("## VOIP Dispose peerConnectionFactory as there is no need to keep one")
if (getCurrentCall() == null) { peerConnectionFactory?.dispose()
Timber.v("## VOIP Dispose peerConnectionFactory as there is no need to keep one") peerConnectionFactory = null
peerConnectionFactory?.dispose() audioManager.setMode(CallAudioManager.Mode.DEFAULT)
peerConnectionFactory = null // did we start background sync? so we should stop it
audioManager.setMode(CallAudioManager.Mode.DEFAULT) if (isInBackground) {
// did we start background sync? so we should stop it if (FcmHelper.isPushSupported()) {
if (isInBackground) { currentSession?.stopAnyBackgroundSync()
if (FcmHelper.isPushSupported()) { } else {
currentSession?.stopAnyBackgroundSync() // for fdroid we should not stop, it should continue syncing
} else { // maybe we should restore default timeout/delay though?
// 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) { 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()) {
Timber.w("## VOIP you already have a call in this room") 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 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 -->