VoIP: start handling negotiation flow (wip)

This commit is contained in:
ganfra 2020-11-17 17:06:49 +01:00
parent 10a5b35217
commit 48354721a2
10 changed files with 371 additions and 127 deletions

View File

@ -20,6 +20,7 @@ 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.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
@ -51,5 +52,10 @@ interface CallListener {
*/
fun onCallSelectAnswerReceived(callSelectAnswerContent: CallSelectAnswerContent)
/**
* Called when a negotiation is sent
*/
fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent)
fun onCallManagedByOtherSession(callId: String)
}

View File

@ -23,6 +23,11 @@ sealed class CallState {
/** Idle, setting up objects */
object Idle : CallState()
/**
* CreateOffer. Intermediate state between Idle and Dialing.
*/
object CreateOffer: CallState()
/** Dialing. Outgoing call is signaling the remote peer */
object Dialing : CallState()

View File

@ -25,11 +25,7 @@ interface MxCallDetail {
val isOutgoing: Boolean
val roomId: String
val opponentUserId: String
val ourPartyId: String
val isVideoCall: Boolean
var opponentPartyId: Optional<String>?
var opponentVersion: Int
}
/**
@ -41,7 +37,9 @@ interface MxCall : MxCallDetail {
const val VOIP_PROTO_VERSION = 0
}
val ourPartyId: String
var opponentPartyId: Optional<String>?
var opponentVersion: Int
var state: CallState
@ -51,6 +49,11 @@ interface MxCall : MxCallDetail {
*/
fun accept(sdp: SessionDescription)
/**
* SDP negotiation for media pause, hold/resume, ICE restarts and voice/video call up/downgrading
*/
fun negotiate(sdp: SessionDescription)
/**
* This has to be sent by the caller's client once it has chosen an answer.
*/

View File

@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room.model.call
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.webrtc.SessionDescription
@JsonClass(generateAdapter = false)
enum class SdpType {
@ -25,5 +26,21 @@ enum class SdpType {
OFFER,
@Json(name = "answer")
ANSWER
ANSWER;
}
fun SdpType.asWebRTC(): SessionDescription.Type {
return if (this == SdpType.OFFER) {
SessionDescription.Type.OFFER
} else {
SessionDescription.Type.ANSWER
}
}
fun SessionDescription.Type.toSdpType(): SdpType {
return if (this == SessionDescription.Type.OFFER) {
SdpType.OFFER
} else {
SdpType.ANSWER
}
}

View File

@ -23,6 +23,7 @@ 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.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
@ -59,6 +60,10 @@ class CallListenersDispatcher(private val listeners: Set<CallListener>) : CallLi
it.onCallSelectAnswerReceived(callSelectAnswerContent)
}
override fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent) = dispatch {
it.onCallNegotiateReceived(callNegotiateContent)
}
private fun dispatch(lambda: (CallListener) -> Unit) {
listeners.toList().forEach {
tryOrNull {

View File

@ -156,8 +156,21 @@ internal class DefaultCallSignalingService @Inject constructor(
EventType.CALL_SELECT_ANSWER -> {
handleCallSelectAnswerEvent(event)
}
EventType.CALL_NEGOTIATE -> {
handleCallNegotiateEvent(event)
}
}
}
private fun handleCallNegotiateEvent(event: Event) {
val content = event.getClearContent().toModel<CallSelectAnswerContent>() ?: return
val call = content.getCall() ?: return
if (call.ourPartyId == content.partyId) {
// Ignore remote echo
return
}
callListenersDispatcher.onCallSelectAnswerReceived(content)
}
private fun handleCallSelectAnswerEvent(event: Event) {
val content = event.getClearContent().toModel<CallSelectAnswerContent>() ?: return

View File

@ -28,8 +28,10 @@ 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.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.toSdpType
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.session.call.DefaultCallSignalingService
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
@ -164,6 +166,18 @@ internal class MxCallImpl(
.also { eventSenderProcessor.postEvent(it) }
}
override fun negotiate(sdp: SessionDescription) {
Timber.v("## VOIP negotiate $callId")
CallNegotiateContent(
callId = callId,
partyId = ourPartyId,
lifetime = DefaultCallSignalingService.CALL_TIMEOUT_MS,
description = CallNegotiateContent.Description(sdp = sdp.description, type = sdp.type.toSdpType())
)
.let { createEventAndLocalEcho(type = EventType.CALL_NEGOTIATE, roomId = roomId, content = it.toContent()) }
.also { eventSenderProcessor.postEvent(it) }
}
override fun selectAnswer() {
Timber.v("## VOIP select answer $callId")
if (isOutgoing) return

View File

@ -30,10 +30,10 @@ open class SdpObserverAdapter : SdpObserver {
}
override fun onCreateSuccess(p0: SessionDescription?) {
Timber.e("## SdpObserver: onSetFailure $p0")
Timber.v("## SdpObserver: onCreateSuccess $p0")
}
override fun onCreateFailure(p0: String?) {
Timber.e("## SdpObserver: onSetFailure $p0")
Timber.e("## SdpObserver: onCreateFailure $p0")
}
}

View File

@ -26,16 +26,26 @@ import im.vector.app.ActiveSessionDataSource
import im.vector.app.core.services.BluetoothHeadsetReceiver
import im.vector.app.core.services.CallService
import im.vector.app.core.services.WiredHeadsetStateReceiver
import im.vector.app.features.call.utils.awaitCreateAnswer
import im.vector.app.features.call.utils.awaitCreateOffer
import im.vector.app.features.call.utils.awaitSetLocalDescription
import im.vector.app.features.call.utils.awaitSetRemoteDescription
import im.vector.app.push.fcm.FcmHelper
import io.reactivex.disposables.Disposable
import io.reactivex.subjects.PublishSubject
import io.reactivex.subjects.ReplaySubject
import org.matrix.android.sdk.api.MatrixCallback
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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.CallState
import org.matrix.android.sdk.api.session.call.CallListener
import org.matrix.android.sdk.api.session.call.CallState
import org.matrix.android.sdk.api.session.call.EglUtils
import org.matrix.android.sdk.api.session.call.MxCall
import org.matrix.android.sdk.api.session.call.TurnServerResponse
@ -43,8 +53,12 @@ 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.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.SdpType
import org.matrix.android.sdk.api.session.room.model.call.asWebRTC
import org.matrix.android.sdk.internal.util.awaitCallback
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.Camera1Enumerator
@ -59,6 +73,7 @@ import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpReceiver
import org.webrtc.RtpTransceiver
import org.webrtc.SessionDescription
import org.webrtc.SurfaceTextureHelper
import org.webrtc.SurfaceViewRenderer
@ -120,7 +135,11 @@ class WebRtcPeerConnectionManager @Inject constructor(
var localVideoSource: VideoSource? = null,
var localVideoTrack: VideoTrack? = null,
var remoteVideoTrack: VideoTrack? = null
var remoteVideoTrack: VideoTrack? = null,
// Perfect negotiation state: https://www.w3.org/TR/webrtc/#perfect-negotiation-example
var makingOffer: Boolean = false,
var ignoreOffer: Boolean = false
) {
var offerSdp: CallInviteContent.Offer? = null
@ -165,6 +184,7 @@ class WebRtcPeerConnectionManager @Inject constructor(
// var localMediaStream: MediaStream? = null
private val executor = Executors.newSingleThreadExecutor()
private val dispatcher = executor.asCoroutineDispatcher()
private val rootEglBase by lazy { EglUtils.rootEglBase }
@ -291,39 +311,46 @@ class WebRtcPeerConnectionManager @Inject constructor(
callContext.peerConnection = peerConnectionFactory?.createPeerConnection(iceServers, StreamObserver(callContext))
}
private fun sendSdpOffer(callContext: CallContext) {
private fun CoroutineScope.sendSdpOffer(callContext: CallContext) = launch(dispatcher) {
val constraints = MediaConstraints()
// These are deprecated options
// constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
// constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", if (currentCall?.mxCall?.isVideoCall == true) "true" else "false"))
val call = callContext.mxCall
val peerConnection = callContext.peerConnection ?: return@launch
Timber.v("## VOIP creating offer...")
callContext.peerConnection?.createOffer(object : SdpObserverAdapter() {
override fun onCreateSuccess(p0: SessionDescription?) {
if (p0 == null) return
// localSdp = p0
callContext.peerConnection?.setLocalDescription(object : SdpObserverAdapter() {}, p0)
callContext.makingOffer = true
try {
val sessionDescription = peerConnection.awaitCreateOffer(constraints) ?: return@launch
peerConnection.awaitSetLocalDescription(sessionDescription)
if (peerConnection.iceGatheringState() == PeerConnection.IceGatheringState.GATHERING) {
// Allow a short time for initial candidates to be gathered
delay(200)
}
if (call.state == CallState.Terminated) {
return@launch
}
if (call.state == CallState.CreateOffer) {
// send offer to peer
currentCall?.mxCall?.offerSdp(p0)
if(currentCall?.mxCall?.opponentPartyId?.hasValue().orFalse()){
currentCall?.mxCall?.selectAnswer()
call.offerSdp(sessionDescription)
} else {
call.negotiate(sessionDescription)
}
} catch (failure: Throwable) {
// Need to handle error properly.
Timber.v("Failure while creating offer")
} finally {
callContext.makingOffer = false
}
}, constraints)
}
private fun getTurnServer(callback: ((TurnServerResponse?) -> Unit)) {
currentSession?.callSignalingService()
?.getTurnServer(object : MatrixCallback<TurnServerResponse?> {
override fun onSuccess(data: TurnServerResponse?) {
callback(data)
private suspend fun getTurnServer(): TurnServerResponse? {
return tryOrNull {
awaitCallback<TurnServerResponse?> {
currentSession?.callSignalingService()?.getTurnServer(it)
}
override fun onFailure(failure: Throwable) {
callback(null)
}
})
}
fun attachViewRenderers(localViewRenderer: SurfaceViewRenderer?, remoteViewRenderer: SurfaceViewRenderer, mode: String?) {
@ -349,8 +376,9 @@ class WebRtcPeerConnectionManager @Inject constructor(
callId = mxCall.callId)
}
getTurnServer { turnServer ->
val call = currentCall ?: return@getTurnServer
GlobalScope.launch(dispatcher) {
val turnServer = getTurnServer()
val call = currentCall ?: return@launch
when (mode) {
VectorCallActivity.INCOMING_ACCEPT -> {
internalAcceptIncomingCall(call, turnServer)
@ -360,7 +388,7 @@ class WebRtcPeerConnectionManager @Inject constructor(
// TODO eventually we could already display local stream in PIP?
}
VectorCallActivity.OUTGOING_CREATED -> {
executor.execute {
call.mxCall.state = CallState.CreateOffer
// 1. Create RTCPeerConnection
createPeerConnection(call, turnServer)
@ -371,9 +399,6 @@ class WebRtcPeerConnectionManager @Inject constructor(
call.localMediaStream?.let { call.peerConnection?.addStream(it) }
attachViewRenderersInternal()
// create an offer, set local description and send via signaling
sendSdpOffer(call)
Timber.v("## VOIP remoteCandidateSource ${call.remoteCandidateSource}")
call.remoteIceCandidateDisposable = call.remoteCandidateSource?.subscribe({
Timber.v("## VOIP adding remote ice candidate $it")
@ -381,7 +406,7 @@ class WebRtcPeerConnectionManager @Inject constructor(
}, {
Timber.v("## VOIP failed to add remote ice candidate $it")
})
}
// Now wait for negotiation callback
}
else -> {
// sink existing tracks (configuration change, e.g screen rotation)
@ -391,10 +416,10 @@ class WebRtcPeerConnectionManager @Inject constructor(
}
}
private fun internalAcceptIncomingCall(callContext: CallContext, turnServerResponse: TurnServerResponse?) {
private suspend fun internalAcceptIncomingCall(callContext: CallContext, turnServerResponse: TurnServerResponse?) {
val mxCall = callContext.mxCall
// Update service state
withContext(Dispatchers.Main) {
val name = currentSession?.getUser(mxCall.opponentUserId)?.getBestName()
?: mxCall.roomId
CallService.onPendingCall(
@ -405,7 +430,7 @@ class WebRtcPeerConnectionManager @Inject constructor(
matrixId = currentSession?.myUserId ?: "",
callId = mxCall.callId
)
executor.execute {
}
// 1) create peer connection
createPeerConnection(callContext, turnServerResponse)
@ -424,8 +449,9 @@ class WebRtcPeerConnectionManager @Inject constructor(
attachViewRenderersInternal()
// create a answer, set local description and send via signaling
createAnswer()
createAnswer()?.also {
callContext.mxCall.accept(it)
}
Timber.v("## VOIP remoteCandidateSource ${callContext.remoteCandidateSource}")
callContext.remoteIceCandidateDisposable = callContext.remoteCandidateSource?.subscribe({
Timber.v("## VOIP adding remote ice candidate $it")
@ -434,7 +460,6 @@ class WebRtcPeerConnectionManager @Inject constructor(
Timber.v("## VOIP failed to add remote ice candidate $it")
})
}
}
private fun createLocalStream(callContext: CallContext) {
if (callContext.localMediaStream != null) {
@ -544,10 +569,11 @@ class WebRtcPeerConnectionManager @Inject constructor(
}
fun acceptIncomingCall() {
GlobalScope.launch(dispatcher) {
Timber.v("## VOIP acceptIncomingCall from state ${currentCall?.mxCall?.state}")
val mxCall = currentCall?.mxCall
if (mxCall?.state == CallState.LocalRinging) {
getTurnServer { turnServer ->
val turnServer = getTurnServer()
internalAcceptIncomingCall(currentCall!!, turnServer)
}
}
@ -739,22 +765,21 @@ class WebRtcPeerConnectionManager @Inject constructor(
}
}
private fun createAnswer() {
private suspend fun createAnswer(): SessionDescription? {
Timber.w("## VOIP createAnswer")
val call = currentCall ?: return
val call = currentCall ?: return null
val peerConnection = call.peerConnection ?: return null
val constraints = MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", if (call.mxCall.isVideoCall) "true" else "false"))
}
executor.execute {
call.peerConnection?.createAnswer(object : SdpObserverAdapter() {
override fun onCreateSuccess(p0: SessionDescription?) {
if (p0 == null) return
call.peerConnection?.setLocalDescription(object : SdpObserverAdapter() {}, p0)
// Now need to send it
call.mxCall.accept(p0)
}
}, constraints)
return try {
val localDescription = peerConnection.awaitCreateAnswer(constraints) ?: return null
peerConnection.awaitSetLocalDescription(localDescription)
localDescription
} catch (failure: Throwable) {
Timber.v("Fail to create answer")
null
}
}
@ -862,11 +887,17 @@ class WebRtcPeerConnectionManager @Inject constructor(
matrixId = currentSession?.myUserId ?: "",
callId = mxCall.callId
)
executor.execute {
GlobalScope.launch(dispatcher) {
Timber.v("## VOIP onCallAnswerReceived ${callAnswerContent.callId}")
val sdp = SessionDescription(SessionDescription.Type.ANSWER, callAnswerContent.answer.sdp)
call.peerConnection?.setRemoteDescription(object : SdpObserverAdapter() {
}, sdp)
try {
call.peerConnection?.awaitSetRemoteDescription(sdp)
} catch (failure: Throwable) {
return@launch
}
if (call.mxCall.opponentPartyId?.hasValue().orFalse()) {
call.mxCall.selectAnswer()
}
}
}
@ -902,7 +933,50 @@ class WebRtcPeerConnectionManager @Inject constructor(
call.mxCall.state = CallState.Terminated
endCall(false)
}
}
override fun onCallNegotiateReceived(callNegotiateContent: CallNegotiateContent) {
val call = currentCall ?: return
if (call.mxCall.callId != callNegotiateContent.callId) return Unit.also {
Timber.w("onCallNegotiateReceived for non active call? ${callNegotiateContent.callId}")
}
val description = callNegotiateContent.description
val type = description?.type
val sdpText = description?.sdp
if (type == null || sdpText == null) {
Timber.i("Ignoring invalid m.call.negotiate event");
return;
}
val peerConnection = call.peerConnection ?: return
// Politeness always follows the direction of the call: in a glare situation,
// we pick either the inbound or outbound call, so one side will always be
// inbound and one outbound
val polite = !call.mxCall.isOutgoing
// Here we follow the perfect negotiation logic from
// https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation
val offerCollision = description.type == SdpType.OFFER
&& (call.makingOffer || peerConnection.signalingState() != PeerConnection.SignalingState.STABLE)
call.ignoreOffer = !polite && offerCollision
if (call.ignoreOffer) {
Timber.i("Ignoring colliding negotiate event because we're impolite")
return
}
GlobalScope.launch(dispatcher) {
try {
val sdp = SessionDescription(type.asWebRTC(), sdpText)
peerConnection.awaitSetRemoteDescription(sdp)
if (type == SdpType.OFFER) {
// create a answer, set local description and send via signaling
createAnswer()?.also {
call.mxCall.negotiate(it)
}
}
} catch (failure: Throwable) {
Timber.e(failure, "Failed to complete negotiation")
}
}
}
override fun onCallManagedByOtherSession(callId: String) {
@ -921,6 +995,27 @@ class WebRtcPeerConnectionManager @Inject constructor(
}
}
/**
* Indicates whether we are 'on hold' to the remote party (ie. if true,
* they cannot hear us). Note that this will return true when we put the
* remote on hold too due to the way hold is implemented (since we don't
* wish to play hold music when we put a call on hold, we use 'inactive'
* rather than 'sendonly')
* @returns true if the other party has put us on hold
*/
private fun isLocalOnHold(callContext: CallContext): Boolean {
if (callContext.mxCall.state !is CallState.Connected) return false
var callOnHold = true
// We consider a call to be on hold only if *all* the tracks are on hold
// (is this the right thing to do?)
for (transceiver in callContext.peerConnection?.transceivers ?: emptyList()) {
val trackOnHold = transceiver.currentDirection == RtpTransceiver.RtpTransceiverDirection.INACTIVE
|| transceiver.currentDirection == RtpTransceiver.RtpTransceiverDirection.RECV_ONLY
if (!trackOnHold) callOnHold = false;
}
return callOnHold;
}
private inner class StreamObserver(val callContext: CallContext) : PeerConnection.Observer {
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
@ -1090,8 +1185,12 @@ class WebRtcPeerConnectionManager @Inject constructor(
override fun onRenegotiationNeeded() {
Timber.v("## VOIP StreamObserver onRenegotiationNeeded")
// Should not do anything, for now we follow a pre-agreed-upon
// signaling/negotiation protocol.
val call = currentCall ?: return
if (call.mxCall.state != CallState.CreateOffer && call.mxCall.opponentVersion == 0) {
Timber.v("Opponent does not support renegotiation: ignoring onRenegotiationNeeded event")
return
}
GlobalScope.sendSdpOffer(callContext)
}
/**

View File

@ -0,0 +1,82 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* 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 im.vector.app.features.call.utils
import im.vector.app.features.call.SdpObserverAdapter
import org.webrtc.MediaConstraints
import org.webrtc.PeerConnection
import org.webrtc.SessionDescription
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
suspend fun PeerConnection.awaitCreateOffer(mediaConstraints: MediaConstraints): SessionDescription? = suspendCoroutine { cont ->
createOffer(object : SdpObserverAdapter() {
override fun onCreateSuccess(p0: SessionDescription?) {
super.onCreateSuccess(p0)
cont.resume(p0)
}
override fun onCreateFailure(p0: String?) {
super.onCreateFailure(p0)
cont.resumeWithException(IllegalStateException(p0))
}
}, mediaConstraints)
}
suspend fun PeerConnection.awaitCreateAnswer(mediaConstraints: MediaConstraints): SessionDescription? = suspendCoroutine { cont ->
createAnswer(object : SdpObserverAdapter() {
override fun onCreateSuccess(p0: SessionDescription?) {
super.onCreateSuccess(p0)
cont.resume(p0)
}
override fun onCreateFailure(p0: String?) {
super.onCreateFailure(p0)
cont.resumeWithException(IllegalStateException(p0))
}
}, mediaConstraints)
}
suspend fun PeerConnection.awaitSetLocalDescription(sessionDescription: SessionDescription): Unit = suspendCoroutine { cont ->
setLocalDescription(object : SdpObserverAdapter() {
override fun onSetFailure(p0: String?) {
super.onSetFailure(p0)
cont.resumeWithException(IllegalStateException(p0))
}
override fun onSetSuccess() {
super.onSetSuccess()
cont.resume(Unit)
}
}, sessionDescription)
}
suspend fun PeerConnection.awaitSetRemoteDescription(sessionDescription: SessionDescription): Unit = suspendCoroutine { cont ->
setRemoteDescription(object : SdpObserverAdapter() {
override fun onSetFailure(p0: String?) {
super.onSetFailure(p0)
cont.resumeWithException(IllegalStateException(p0))
}
override fun onSetSuccess() {
super.onSetSuccess()
cont.resume(Unit)
}
}, sessionDescription)
}