David Liu

protocol 3: subscriber as primary

1 package io.livekit.android.room 1 package io.livekit.android.room
2 2
  3 +import com.github.ajalt.timberkt.Timber
3 import dagger.assisted.Assisted 4 import dagger.assisted.Assisted
4 import dagger.assisted.AssistedFactory 5 import dagger.assisted.AssistedFactory
5 import dagger.assisted.AssistedInject 6 import dagger.assisted.AssistedInject
6 -import io.livekit.android.room.util.CoroutineSdpObserver 7 +import io.livekit.android.dagger.InjectionNames
  8 +import io.livekit.android.room.util.*
7 import io.livekit.android.util.Either 9 import io.livekit.android.util.Either
8 -import org.webrtc.IceCandidate  
9 -import org.webrtc.PeerConnection  
10 -import org.webrtc.PeerConnectionFactory  
11 -import org.webrtc.SessionDescription 10 +import io.livekit.android.util.debounce
  11 +import kotlinx.coroutines.CoroutineDispatcher
  12 +import kotlinx.coroutines.CoroutineScope
  13 +import kotlinx.coroutines.SupervisorJob
  14 +import org.webrtc.*
  15 +import javax.inject.Named
12 16
13 /** 17 /**
14 * @suppress 18 * @suppress
@@ -17,18 +21,28 @@ class PeerConnectionTransport @@ -17,18 +21,28 @@ class PeerConnectionTransport
17 @AssistedInject 21 @AssistedInject
18 constructor( 22 constructor(
19 @Assisted config: PeerConnection.RTCConfiguration, 23 @Assisted config: PeerConnection.RTCConfiguration,
20 - @Assisted listener: PeerConnection.Observer, 24 + @Assisted pcObserver: PeerConnection.Observer,
  25 + @Assisted private val listener: Listener?,
  26 + @Named(InjectionNames.DISPATCHER_IO)
  27 + private val ioDispatcher: CoroutineDispatcher,
21 connectionFactory: PeerConnectionFactory 28 connectionFactory: PeerConnectionFactory
22 ) { 29 ) {
  30 + private val coroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
23 val peerConnection: PeerConnection = connectionFactory.createPeerConnection( 31 val peerConnection: PeerConnection = connectionFactory.createPeerConnection(
24 config, 32 config,
25 - listener 33 + pcObserver
26 ) ?: throw IllegalStateException("peer connection creation failed?") 34 ) ?: throw IllegalStateException("peer connection creation failed?")
27 val pendingCandidates = mutableListOf<IceCandidate>() 35 val pendingCandidates = mutableListOf<IceCandidate>()
28 - var iceRestart: Boolean = false 36 + var restartingIce: Boolean = false
  37 +
  38 + var renegotiate = false
  39 +
  40 + interface Listener {
  41 + fun onOffer(sd: SessionDescription)
  42 + }
29 43
30 fun addIceCandidate(candidate: IceCandidate) { 44 fun addIceCandidate(candidate: IceCandidate) {
31 - if (peerConnection.remoteDescription != null && !iceRestart) { 45 + if (peerConnection.remoteDescription != null && !restartingIce) {
32 peerConnection.addIceCandidate(candidate) 46 peerConnection.addIceCandidate(candidate)
33 } else { 47 } else {
34 pendingCandidates.add(candidate) 48 pendingCandidates.add(candidate)
@@ -37,23 +51,62 @@ constructor( @@ -37,23 +51,62 @@ constructor(
37 51
38 suspend fun setRemoteDescription(sd: SessionDescription): Either<Unit, String?> { 52 suspend fun setRemoteDescription(sd: SessionDescription): Either<Unit, String?> {
39 53
40 - val observer = object : CoroutineSdpObserver() {  
41 - override fun onSetSuccess() {  
42 - pendingCandidates.forEach { pending ->  
43 - peerConnection.addIceCandidate(pending)  
44 - }  
45 - pendingCandidates.clear()  
46 - iceRestart = false  
47 - super.onSetSuccess() 54 + val result = peerConnection.setRemoteDescription(sd)
  55 + if (result is Either.Left) {
  56 + pendingCandidates.forEach { pending ->
  57 + peerConnection.addIceCandidate(pending)
48 } 58 }
  59 + pendingCandidates.clear()
  60 + restartingIce = false
  61 + }
  62 +
  63 + if (this.renegotiate) {
  64 + this.renegotiate = false
  65 + this.createAndSendOffer()
49 } 66 }
50 -  
51 - peerConnection.setRemoteDescription(observer, sd)  
52 - return observer.awaitSet() 67 +
  68 + return result
53 } 69 }
54 70
  71 + val negotiate = debounce<Unit, Unit>(100, coroutineScope) { createAndSendOffer() }
  72 + suspend fun createAndSendOffer(constraints: MediaConstraints = MediaConstraints()) {
  73 + if (listener == null) {
  74 + return
  75 + }
  76 +
  77 + val iceRestart =
  78 + constraints.findConstraint(MediaConstraintKeys.ICE_RESTART) == MediaConstraintKeys.TRUE
  79 + if (iceRestart) {
  80 + Timber.d { "restarting ice" }
  81 + restartingIce = true
  82 + }
  83 +
  84 + if (this.peerConnection.signalingState() == PeerConnection.SignalingState.HAVE_LOCAL_OFFER) {
  85 + // we're waiting for the peer to accept our offer, so we'll just wait
  86 + // the only exception to this is when ICE restart is needed
  87 + val curSd = peerConnection.remoteDescription
  88 + if (iceRestart && curSd != null) {
  89 + // TODO: handle when ICE restart is needed but we don't have a remote description
  90 + // the best thing to do is to recreate the peerconnection
  91 + peerConnection.setRemoteDescription(curSd)
  92 + } else {
  93 + renegotiate = true
  94 + return
  95 + }
  96 + }
  97 +
  98 + // actually negotiate
  99 + Timber.d { "starting to negotiate" }
  100 + val offer = peerConnection.createOffer(constraints)
  101 + if (offer is Either.Left) {
  102 + peerConnection.setLocalDescription(offer.value)
  103 + listener?.onOffer(offer.value)
  104 + }
  105 + }
  106 +
  107 +
55 fun prepareForIceRestart() { 108 fun prepareForIceRestart() {
56 - iceRestart = true 109 + restartingIce = true
57 } 110 }
58 111
59 fun close() { 112 fun close() {
@@ -64,7 +117,8 @@ constructor( @@ -64,7 +117,8 @@ constructor(
64 interface Factory { 117 interface Factory {
65 fun create( 118 fun create(
66 config: PeerConnection.RTCConfiguration, 119 config: PeerConnection.RTCConfiguration,
67 - listener: PeerConnection.Observer 120 + pcObserver: PeerConnection.Observer,
  121 + listener: Listener?
68 ): PeerConnectionTransport 122 ): PeerConnectionTransport
69 } 123 }
70 } 124 }
@@ -8,12 +8,18 @@ import org.webrtc.* @@ -8,12 +8,18 @@ import org.webrtc.*
8 * @suppress 8 * @suppress
9 */ 9 */
10 class PublisherTransportObserver( 10 class PublisherTransportObserver(
11 - private val engine: RTCEngine  
12 -) : PeerConnection.Observer { 11 + private val engine: RTCEngine,
  12 + private val client: SignalClient,
  13 +) : PeerConnection.Observer, PeerConnectionTransport.Listener {
  14 +
  15 + var dataChannelListener: ((DataChannel?) -> Unit)? = null
  16 + var iceConnectionChangeListener: ((newState: PeerConnection.IceConnectionState?) -> Unit)? =
  17 + null
13 18
14 override fun onIceCandidate(iceCandidate: IceCandidate?) { 19 override fun onIceCandidate(iceCandidate: IceCandidate?) {
15 val candidate = iceCandidate ?: return 20 val candidate = iceCandidate ?: return
16 - engine.client.sendCandidate(candidate, target = LivekitRtc.SignalTarget.PUBLISHER) 21 + Timber.v { "onIceCandidate: $candidate" }
  22 + client.sendCandidate(candidate, target = LivekitRtc.SignalTarget.PUBLISHER)
17 } 23 }
18 24
19 override fun onRenegotiationNeeded() { 25 override fun onRenegotiationNeeded() {
@@ -21,15 +27,12 @@ class PublisherTransportObserver( @@ -21,15 +27,12 @@ class PublisherTransportObserver(
21 } 27 }
22 28
23 override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) { 29 override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
24 - val state = newState ?: throw NullPointerException("unexpected null new state, what do?")  
25 Timber.v { "onIceConnection new state: $newState" } 30 Timber.v { "onIceConnection new state: $newState" }
26 - if (state == PeerConnection.IceConnectionState.CONNECTED) {  
27 - engine.iceState = IceState.CONNECTED  
28 - } else if (state == PeerConnection.IceConnectionState.FAILED) {  
29 - // when we publish tracks, some WebRTC versions will send out disconnected events periodically  
30 - engine.iceState = IceState.DISCONNECTED  
31 - engine.listener?.onDisconnect("Peer connection disconnected")  
32 - } 31 + iceConnectionChangeListener?.invoke(newState)
  32 + }
  33 +
  34 + override fun onOffer(sd: SessionDescription) {
  35 + client.sendOffer(sd)
33 } 36 }
34 37
35 override fun onStandardizedIceConnectionChange(newState: PeerConnection.IceConnectionState?) { 38 override fun onStandardizedIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
@@ -41,7 +44,6 @@ class PublisherTransportObserver( @@ -41,7 +44,6 @@ class PublisherTransportObserver(
41 override fun onSelectedCandidatePairChanged(event: CandidatePairChangeEvent?) { 44 override fun onSelectedCandidatePairChanged(event: CandidatePairChangeEvent?) {
42 } 45 }
43 46
44 -  
45 override fun onSignalingChange(p0: PeerConnection.SignalingState?) { 47 override fun onSignalingChange(p0: PeerConnection.SignalingState?) {
46 } 48 }
47 49
@@ -60,7 +62,8 @@ class PublisherTransportObserver( @@ -60,7 +62,8 @@ class PublisherTransportObserver(
60 override fun onRemoveStream(p0: MediaStream?) { 62 override fun onRemoveStream(p0: MediaStream?) {
61 } 63 }
62 64
63 - override fun onDataChannel(p0: DataChannel?) { 65 + override fun onDataChannel(dataChannel: DataChannel?) {
  66 + dataChannelListener?.invoke(dataChannel)
64 } 67 }
65 68
66 override fun onTrack(transceiver: RtpTransceiver?) { 69 override fun onTrack(transceiver: RtpTransceiver?) {
@@ -68,4 +71,5 @@ class PublisherTransportObserver( @@ -68,4 +71,5 @@ class PublisherTransportObserver(
68 71
69 override fun onAddTrack(p0: RtpReceiver?, p1: Array<out MediaStream>?) { 72 override fun onAddTrack(p0: RtpReceiver?, p1: Array<out MediaStream>?) {
70 } 73 }
  74 +
71 } 75 }
1 package io.livekit.android.room 1 package io.livekit.android.room
2 2
  3 +import android.os.SystemClock
3 import com.github.ajalt.timberkt.Timber 4 import com.github.ajalt.timberkt.Timber
4 import io.livekit.android.ConnectOptions 5 import io.livekit.android.ConnectOptions
5 import io.livekit.android.dagger.InjectionNames 6 import io.livekit.android.dagger.InjectionNames
  7 +import io.livekit.android.room.track.DataPublishReliability
6 import io.livekit.android.room.track.Track 8 import io.livekit.android.room.track.Track
7 import io.livekit.android.room.track.TrackException 9 import io.livekit.android.room.track.TrackException
  10 +import io.livekit.android.room.track.TrackPublication
8 import io.livekit.android.room.util.* 11 import io.livekit.android.room.util.*
9 import io.livekit.android.util.CloseableCoroutineScope 12 import io.livekit.android.util.CloseableCoroutineScope
10 import io.livekit.android.util.Either 13 import io.livekit.android.util.Either
@@ -15,6 +18,9 @@ import kotlinx.coroutines.launch @@ -15,6 +18,9 @@ import kotlinx.coroutines.launch
15 import livekit.LivekitModels 18 import livekit.LivekitModels
16 import livekit.LivekitRtc 19 import livekit.LivekitRtc
17 import org.webrtc.* 20 import org.webrtc.*
  21 +import java.net.ConnectException
  22 +import java.nio.ByteBuffer
  23 +import java.util.concurrent.TimeUnit
18 import javax.inject.Inject 24 import javax.inject.Inject
19 import javax.inject.Named 25 import javax.inject.Named
20 import javax.inject.Singleton 26 import javax.inject.Singleton
@@ -33,7 +39,7 @@ constructor( @@ -33,7 +39,7 @@ constructor(
33 private val pctFactory: PeerConnectionTransport.Factory, 39 private val pctFactory: PeerConnectionTransport.Factory,
34 @Named(InjectionNames.DISPATCHER_IO) ioDispatcher: CoroutineDispatcher, 40 @Named(InjectionNames.DISPATCHER_IO) ioDispatcher: CoroutineDispatcher,
35 ) : SignalClient.Listener, DataChannel.Observer { 41 ) : SignalClient.Listener, DataChannel.Observer {
36 - var listener: Listener? = null 42 + internal var listener: Listener? = null
37 internal var iceState: IceState = IceState.DISCONNECTED 43 internal var iceState: IceState = IceState.DISCONNECTED
38 set(value) { 44 set(value) {
39 val oldVal = field 45 val oldVal = field
@@ -55,7 +61,8 @@ constructor( @@ -55,7 +61,8 @@ constructor(
55 Timber.d { "publisher ICE disconnected" } 61 Timber.d { "publisher ICE disconnected" }
56 listener?.onDisconnect("Peer connection disconnected") 62 listener?.onDisconnect("Peer connection disconnected")
57 } 63 }
58 - else -> {} 64 + else -> {
  65 + }
59 } 66 }
60 } 67 }
61 private var wsRetries: Int = 0 68 private var wsRetries: Int = 0
@@ -64,25 +71,169 @@ constructor( @@ -64,25 +71,169 @@ constructor(
64 private var sessionUrl: String? = null 71 private var sessionUrl: String? = null
65 private var sessionToken: String? = null 72 private var sessionToken: String? = null
66 73
67 - private val publisherObserver = PublisherTransportObserver(this)  
68 - private val subscriberObserver = SubscriberTransportObserver(this) 74 + private val publisherObserver = PublisherTransportObserver(this, client)
  75 + private val subscriberObserver = SubscriberTransportObserver(this, client)
69 internal lateinit var publisher: PeerConnectionTransport 76 internal lateinit var publisher: PeerConnectionTransport
70 private lateinit var subscriber: PeerConnectionTransport 77 private lateinit var subscriber: PeerConnectionTransport
71 - internal var reliableDataChannel: DataChannel? = null  
72 - internal var lossyDataChannel: DataChannel? = null 78 + private var reliableDataChannel: DataChannel? = null
  79 + private var reliableDataChannelSub: DataChannel? = null
  80 + private var lossyDataChannel: DataChannel? = null
  81 + private var lossyDataChannelSub: DataChannel? = null
  82 +
  83 + private var isSubscriberPrimary = false
  84 + private var isClosed = true
  85 +
  86 + private var hasPublished = false
73 87
74 private val coroutineScope = CloseableCoroutineScope(SupervisorJob() + ioDispatcher) 88 private val coroutineScope = CloseableCoroutineScope(SupervisorJob() + ioDispatcher)
  89 +
75 init { 90 init {
76 client.listener = this 91 client.listener = this
77 } 92 }
78 93
79 - fun join(url: String, token: String, options: ConnectOptions?) { 94 + suspend fun join(url: String, token: String, options: ConnectOptions?): LivekitRtc.JoinResponse {
80 sessionUrl = url 95 sessionUrl = url
81 sessionToken = token 96 sessionToken = token
82 - client.join(url, token, options) 97 + val joinResponse = client.join(url, token, options)
  98 + isClosed = false
  99 +
  100 + isSubscriberPrimary = joinResponse.subscriberPrimary
  101 +
  102 + if (!this::publisher.isInitialized) {
  103 + configure(joinResponse)
  104 + }
  105 + // create offer
  106 + if (!this.isSubscriberPrimary) {
  107 + negotiate()
  108 + }
  109 + return joinResponse
  110 + }
  111 +
  112 + private suspend fun configure(joinResponse: LivekitRtc.JoinResponse) {
  113 + if (this::publisher.isInitialized || this::subscriber.isInitialized) {
  114 + // already configured
  115 + return
  116 + }
  117 +
  118 + // update ICE servers before creating PeerConnection
  119 + val iceServers = mutableListOf<PeerConnection.IceServer>()
  120 + for (serverInfo in joinResponse.iceServersList) {
  121 + val username = serverInfo.username ?: ""
  122 + val credential = serverInfo.credential ?: ""
  123 + iceServers.add(
  124 + PeerConnection.IceServer
  125 + .builder(serverInfo.urlsList)
  126 + .setUsername(username)
  127 + .setPassword(credential)
  128 + .createIceServer()
  129 + )
  130 + }
  131 +
  132 + if (iceServers.isEmpty()) {
  133 + iceServers.addAll(SignalClient.DEFAULT_ICE_SERVERS)
  134 + }
  135 + joinResponse.iceServersList.forEach {
  136 + Timber.v { "username = \"${it.username}\"" }
  137 + Timber.v { "credential = \"${it.credential}\"" }
  138 + Timber.v { "urls: " }
  139 + it.urlsList.forEach {
  140 + Timber.v { " $it" }
  141 + }
  142 + }
  143 +
  144 + // Setup peer connections
  145 + val rtcConfig = PeerConnection.RTCConfiguration(iceServers).apply {
  146 + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
  147 + continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
  148 + enableDtlsSrtp = true
  149 + }
  150 +
  151 + publisher = pctFactory.create(
  152 + rtcConfig,
  153 + publisherObserver,
  154 + publisherObserver,
  155 + )
  156 + subscriber = pctFactory.create(
  157 + rtcConfig,
  158 + subscriberObserver,
  159 + null,
  160 + )
  161 +
  162 + val iceConnectionStateListener: (PeerConnection.IceConnectionState?) -> Unit = { newState ->
  163 + val state =
  164 + newState ?: throw NullPointerException("unexpected null new state, what do?")
  165 + Timber.v { "onIceConnection new state: $newState" }
  166 + if (state == PeerConnection.IceConnectionState.CONNECTED) {
  167 + iceState = IceState.CONNECTED
  168 + } else if (state == PeerConnection.IceConnectionState.FAILED) {
  169 + // when we publish tracks, some WebRTC versions will send out disconnected events periodically
  170 + iceState = IceState.DISCONNECTED
  171 + listener?.onDisconnect("Peer connection disconnected")
  172 + }
  173 + }
  174 +
  175 + if (joinResponse.subscriberPrimary) {
  176 + // in subscriber primary mode, server side opens sub data channels.
  177 + publisherObserver.dataChannelListener = onDataChannel@{ dataChannel: DataChannel? ->
  178 + if (dataChannel == null) {
  179 + return@onDataChannel
  180 + }
  181 + when (dataChannel.label()) {
  182 + RELIABLE_DATA_CHANNEL_LABEL -> reliableDataChannelSub = dataChannel
  183 + LOSSY_DATA_CHANNEL_LABEL -> lossyDataChannelSub = dataChannel
  184 + else -> return@onDataChannel
  185 + }
  186 + dataChannel.registerObserver(this)
  187 + }
  188 + publisherObserver.iceConnectionChangeListener = iceConnectionStateListener
  189 + } else {
  190 + subscriberObserver.iceConnectionChangeListener = iceConnectionStateListener
  191 + }
  192 +
  193 + // data channels
  194 + val reliableInit = DataChannel.Init()
  195 + reliableInit.ordered = true
  196 + reliableDataChannel = publisher.peerConnection.createDataChannel(
  197 + RELIABLE_DATA_CHANNEL_LABEL,
  198 + reliableInit
  199 + )
  200 + reliableDataChannel!!.registerObserver(this)
  201 + val lossyInit = DataChannel.Init()
  202 + lossyInit.ordered = true
  203 + lossyInit.maxRetransmits = 0
  204 + lossyDataChannel = publisher.peerConnection.createDataChannel(
  205 + LOSSY_DATA_CHANNEL_LABEL,
  206 + lossyInit
  207 + )
  208 + lossyDataChannel!!.registerObserver(this)
  209 +
  210 + coroutineScope.launch {
  211 + val sdpOffer =
  212 + when (val outcome = publisher.peerConnection.createOffer(getPublisherOfferConstraints())) {
  213 + is Either.Left -> outcome.value
  214 + is Either.Right -> {
  215 + Timber.d { "error creating offer: ${outcome.value}" }
  216 + return@launch
  217 + }
  218 + }
  219 +
  220 + when (val outcome = publisher.peerConnection.setLocalDescription(sdpOffer)) {
  221 + is Either.Right -> {
  222 + Timber.d { "error setting local description: ${outcome.value}" }
  223 + return@launch
  224 + }
  225 + }
  226 +
  227 + client.sendOffer(sdpOffer)
  228 + }
83 } 229 }
84 230
85 - suspend fun addTrack(cid: String, name: String, kind: LivekitModels.TrackType, dimensions: Track.Dimensions? = null): LivekitModels.TrackInfo { 231 + suspend fun addTrack(
  232 + cid: String,
  233 + name: String,
  234 + kind: LivekitModels.TrackType,
  235 + dimensions: Track.Dimensions? = null
  236 + ): LivekitModels.TrackInfo {
86 if (pendingTrackResolvers[cid] != null) { 237 if (pendingTrackResolvers[cid] != null) {
87 throw TrackException.DuplicateTrackException("Track with same ID $cid has already been published!") 238 throw TrackException.DuplicateTrackException("Track with same ID $cid has already been published!")
88 } 239 }
@@ -108,7 +259,10 @@ constructor( @@ -108,7 +259,10 @@ constructor(
108 * reconnect Signal and PeerConnections 259 * reconnect Signal and PeerConnections
109 */ 260 */
110 internal fun reconnect() { 261 internal fun reconnect() {
111 - if (sessionUrl == null || sessionToken == null) { 262 + val url = sessionUrl
  263 + val token = sessionToken
  264 + if (url == null || token == null) {
  265 + Timber.w { "couldn't reconnect, no url or no token" }
112 return 266 return
113 } 267 }
114 if (iceState == IceState.DISCONNECTED || wsRetries >= MAX_SIGNAL_RETRIES) { 268 if (iceState == IceState.DISCONNECTED || wsRetries >= MAX_SIGNAL_RETRIES) {
@@ -124,13 +278,34 @@ constructor( @@ -124,13 +278,34 @@ constructor(
124 } 278 }
125 coroutineScope.launch { 279 coroutineScope.launch {
126 delay(startDelay) 280 delay(startDelay)
127 - val url = sessionUrl  
128 - val token = sessionToken  
129 - if (iceState != IceState.DISCONNECTED && url != null && token != null) {  
130 - val opts = ConnectOptions()  
131 - opts.reconnect = true  
132 - client.join(url, token, opts) 281 + if (iceState == IceState.DISCONNECTED) {
  282 + Timber.e { "Ice is disconnected" }
  283 + return@launch
  284 + }
  285 +
  286 + client.reconnect(url, token)
  287 +
  288 + Timber.v { "reconnected, restarting ICE" }
  289 + wsRetries = 0
  290 +
  291 + // trigger publisher reconnect
  292 + subscriber.restartingIce = true
  293 + // only restart publisher if it's needed
  294 + if (hasPublished) {
  295 + publisher.createAndSendOffer(
  296 + getPublisherOfferConstraints().apply {
  297 + with(mandatory){
  298 + add(
  299 + MediaConstraints.KeyValuePair(
  300 + MediaConstraintKeys.ICE_RESTART,
  301 + MediaConstraintKeys.TRUE
  302 + )
  303 + )
  304 + }
  305 + }
  306 + )
133 } 307 }
  308 +
134 } 309 }
135 } 310 }
136 311
@@ -140,7 +315,7 @@ constructor( @@ -140,7 +315,7 @@ constructor(
140 } 315 }
141 coroutineScope.launch { 316 coroutineScope.launch {
142 val sdpOffer = 317 val sdpOffer =
143 - when (val outcome = publisher.peerConnection.createOffer(getOfferConstraints())) { 318 + when (val outcome = publisher.peerConnection.createOffer(getPublisherOfferConstraints())) {
144 is Either.Left -> outcome.value 319 is Either.Left -> outcome.value
145 is Either.Right -> { 320 is Either.Right -> {
146 Timber.d { "error creating offer: ${outcome.value}" } 321 Timber.d { "error creating offer: ${outcome.value}" }
@@ -160,20 +335,75 @@ constructor( @@ -160,20 +335,75 @@ constructor(
160 } 335 }
161 } 336 }
162 337
163 - private fun getOfferConstraints(): MediaConstraints { 338 + internal suspend fun sendData(dataPacket: LivekitModels.DataPacket) {
  339 + ensurePublisherConnected()
  340 +
  341 + val buf = DataChannel.Buffer(
  342 + ByteBuffer.wrap(dataPacket.toByteArray()),
  343 + true,
  344 + )
  345 +
  346 + val channel = when (dataPacket.kind) {
  347 + LivekitModels.DataPacket.Kind.RELIABLE -> reliableDataChannel
  348 + LivekitModels.DataPacket.Kind.LOSSY -> lossyDataChannel
  349 + else -> null
  350 + } ?: throw TrackException.PublishException("channel not established for ${dataPacket.kind.name}")
  351 +
  352 + channel.send(buf)
  353 + }
  354 +
  355 + private suspend fun ensurePublisherConnected(){
  356 + if (!isSubscriberPrimary) {
  357 + return
  358 + }
  359 +
  360 + if (this.publisher.peerConnection.iceConnectionState() == PeerConnection.IceConnectionState.CONNECTED) {
  361 + return
  362 + }
  363 +
  364 + // start negotiation
  365 + this.negotiate()
  366 +
  367 + // wait until publisher ICE connected
  368 + val endTime = SystemClock.elapsedRealtime() + MAX_ICE_CONNECT_TIMEOUT_MS;
  369 + while (SystemClock.elapsedRealtime() < endTime) {
  370 + if (this.publisher.peerConnection.iceConnectionState() == PeerConnection.IceConnectionState.CONNECTED) {
  371 + return
  372 + }
  373 + delay(50)
  374 + }
  375 +
  376 + throw ConnectException("could not establish publisher connection")
  377 + }
  378 +
  379 + private fun getPublisherOfferConstraints(): MediaConstraints {
164 return MediaConstraints().apply { 380 return MediaConstraints().apply {
165 with(mandatory) { 381 with(mandatory) {
166 - add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false"))  
167 - add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "false")) 382 + add(
  383 + MediaConstraints.KeyValuePair(
  384 + MediaConstraintKeys.OFFER_TO_RECV_AUDIO,
  385 + MediaConstraintKeys.FALSE
  386 + )
  387 + )
  388 + add(
  389 + MediaConstraints.KeyValuePair(
  390 + MediaConstraintKeys.OFFER_TO_RECV_VIDEO,
  391 + MediaConstraintKeys.FALSE
  392 + )
  393 + )
168 if (iceState == IceState.RECONNECTING) { 394 if (iceState == IceState.RECONNECTING) {
169 - add(MediaConstraints.KeyValuePair("IceRestart", "true")) 395 + add(
  396 + MediaConstraints.KeyValuePair(
  397 + MediaConstraintKeys.ICE_RESTART,
  398 + MediaConstraintKeys.TRUE
  399 + )
  400 + )
170 } 401 }
171 } 402 }
172 } 403 }
173 } 404 }
174 405
175 - interface Listener {  
176 - fun onJoin(response: LivekitRtc.JoinResponse) 406 + internal interface Listener {
177 fun onIceConnected() 407 fun onIceConnected()
178 fun onIceReconnected() 408 fun onIceReconnected()
179 fun onAddTrack(track: MediaStreamTrack, streams: Array<out MediaStream>) 409 fun onAddTrack(track: MediaStreamTrack, streams: Array<out MediaStream>)
@@ -190,6 +420,7 @@ constructor( @@ -190,6 +420,7 @@ constructor(
190 private const val LOSSY_DATA_CHANNEL_LABEL = "_lossy" 420 private const val LOSSY_DATA_CHANNEL_LABEL = "_lossy"
191 internal const val MAX_DATA_PACKET_SIZE = 15000 421 internal const val MAX_DATA_PACKET_SIZE = 15000
192 private const val MAX_SIGNAL_RETRIES = 5 422 private const val MAX_SIGNAL_RETRIES = 5
  423 + private const val MAX_ICE_CONNECT_TIMEOUT_MS = 5000
193 424
194 internal val CONN_CONSTRAINTS = MediaConstraints().apply { 425 internal val CONN_CONSTRAINTS = MediaConstraints().apply {
195 with(optional) { 426 with(optional) {
@@ -200,90 +431,6 @@ constructor( @@ -200,90 +431,6 @@ constructor(
200 431
201 //---------------------------------- SignalClient.Listener --------------------------------------// 432 //---------------------------------- SignalClient.Listener --------------------------------------//
202 433
203 - override fun onJoin(info: LivekitRtc.JoinResponse) {  
204 - val iceServers = mutableListOf<PeerConnection.IceServer>()  
205 - for(serverInfo in info.iceServersList){  
206 - val username = serverInfo.username ?: ""  
207 - val credential = serverInfo.credential ?: ""  
208 - iceServers.add(  
209 - PeerConnection.IceServer  
210 - .builder(serverInfo.urlsList)  
211 - .setUsername(username)  
212 - .setPassword(credential)  
213 - .createIceServer()  
214 - )  
215 - }  
216 -  
217 - if (iceServers.isEmpty()) {  
218 - iceServers.addAll(SignalClient.DEFAULT_ICE_SERVERS)  
219 - }  
220 - info.iceServersList.forEach {  
221 - Timber.e{ "username = \"${it.username}\""}  
222 - Timber.e{ "credential = \"${it.credential}\""}  
223 - Timber.e{ "urls: "}  
224 - it.urlsList.forEach{  
225 - Timber.e{" $it"}  
226 - }  
227 - }  
228 -  
229 - val rtcConfig = PeerConnection.RTCConfiguration(iceServers).apply {  
230 - sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN  
231 - continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY  
232 - enableDtlsSrtp = true  
233 - }  
234 -  
235 - publisher = pctFactory.create(rtcConfig, publisherObserver)  
236 - subscriber = pctFactory.create(rtcConfig, subscriberObserver)  
237 -  
238 - val reliableInit = DataChannel.Init()  
239 - reliableInit.ordered = true  
240 - reliableDataChannel = publisher.peerConnection.createDataChannel(  
241 - RELIABLE_DATA_CHANNEL_LABEL,  
242 - reliableInit  
243 - )  
244 - reliableDataChannel!!.registerObserver(this)  
245 - val lossyInit = DataChannel.Init()  
246 - lossyInit.ordered = true  
247 - lossyInit.maxRetransmits = 1  
248 - lossyDataChannel = publisher.peerConnection.createDataChannel(  
249 - LOSSY_DATA_CHANNEL_LABEL,  
250 - lossyInit  
251 - )  
252 - lossyDataChannel!!.registerObserver(this)  
253 -  
254 - coroutineScope.launch {  
255 - val sdpOffer =  
256 - when (val outcome = publisher.peerConnection.createOffer(getOfferConstraints())) {  
257 - is Either.Left -> outcome.value  
258 - is Either.Right -> {  
259 - Timber.d { "error creating offer: ${outcome.value}" }  
260 - return@launch  
261 - }  
262 - }  
263 -  
264 - when (val outcome = publisher.peerConnection.setLocalDescription(sdpOffer)) {  
265 - is Either.Right -> {  
266 - Timber.d { "error setting local description: ${outcome.value}" }  
267 - return@launch  
268 - }  
269 - }  
270 -  
271 - client.sendOffer(sdpOffer)  
272 - }  
273 - listener?.onJoin(info)  
274 - }  
275 -  
276 - override fun onReconnected() {  
277 - Timber.v { "reconnected, restarting ICE" }  
278 - wsRetries = 0  
279 -  
280 - // trigger ICE restart  
281 - iceState = IceState.RECONNECTING  
282 - publisher.prepareForIceRestart()  
283 - subscriber.prepareForIceRestart()  
284 - negotiate()  
285 - }  
286 -  
287 override fun onAnswer(sessionDescription: SessionDescription) { 434 override fun onAnswer(sessionDescription: SessionDescription) {
288 Timber.v { "received server answer: ${sessionDescription.type}, ${publisher.peerConnection.signalingState()}" } 435 Timber.v { "received server answer: ${sessionDescription.type}, ${publisher.peerConnection.signalingState()}" }
289 coroutineScope.launch { 436 coroutineScope.launch {
@@ -65,16 +65,32 @@ constructor( @@ -65,16 +65,32 @@ constructor(
65 get() = mutableActiveSpeakers 65 get() = mutableActiveSpeakers
66 66
67 private var hasLostConnectivity: Boolean = false 67 private var hasLostConnectivity: Boolean = false
68 - private var connectContinuation: Continuation<Unit>? = null  
69 suspend fun connect(url: String, token: String, options: ConnectOptions?) { 68 suspend fun connect(url: String, token: String, options: ConnectOptions?) {
70 state = State.CONNECTING 69 state = State.CONNECTING
71 - engine.join(url, token, options) 70 + val response = engine.join(url, token, options)
  71 + Timber.i { "Connected to server, server version: ${response.serverVersion}, client version: ${Version.CLIENT_VERSION}" }
  72 +
  73 + sid = Sid(response.room.sid)
  74 + name = response.room.name
  75 +
  76 + if (!response.hasParticipant()) {
  77 + listener?.onFailedToConnect(this, RoomException.ConnectException("server didn't return any participants"))
  78 + return
  79 + }
  80 +
  81 + val lp = localParticipantFactory.create(response.participant)
  82 + lp.listener = this
  83 + localParticipant = lp
  84 + if (response.otherParticipantsList.isNotEmpty()) {
  85 + response.otherParticipantsList.forEach {
  86 + getOrCreateRemoteParticipant(it.sid, it)
  87 + }
  88 + }
72 val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 89 val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
73 val networkRequest = NetworkRequest.Builder() 90 val networkRequest = NetworkRequest.Builder()
74 .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 91 .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
75 .build() 92 .build()
76 cm.registerNetworkCallback(networkRequest, this) 93 cm.registerNetworkCallback(networkRequest, this)
77 - return suspendCoroutine { connectContinuation = it }  
78 } 94 }
79 95
80 fun disconnect() { 96 fun disconnect() {
@@ -240,36 +256,8 @@ constructor( @@ -240,36 +256,8 @@ constructor(
240 256
241 257
242 //----------------------------------- RTCEngine.Listener ------------------------------------// 258 //----------------------------------- RTCEngine.Listener ------------------------------------//
243 - /**  
244 - * @suppress  
245 - */  
246 - override fun onJoin(response: LivekitRtc.JoinResponse) {  
247 - Timber.i { "Connected to server, server version: ${response.serverVersion}, client version: ${Version.CLIENT_VERSION}" }  
248 -  
249 - sid = Sid(response.room.sid)  
250 - name = response.room.name  
251 -  
252 - if (!response.hasParticipant()) {  
253 - listener?.onFailedToConnect(this, RoomException.ConnectException("server didn't return any participants"))  
254 - connectContinuation?.resume(Unit)  
255 - connectContinuation = null  
256 - return  
257 - }  
258 -  
259 - val lp = localParticipantFactory.create(response.participant)  
260 - lp.listener = this  
261 - localParticipant = lp  
262 - if (response.otherParticipantsList.isNotEmpty()) {  
263 - response.otherParticipantsList.forEach {  
264 - getOrCreateRemoteParticipant(it.sid, it)  
265 - }  
266 - }  
267 - }  
268 -  
269 override fun onIceConnected() { 259 override fun onIceConnected() {
270 state = State.CONNECTED 260 state = State.CONNECTED
271 - connectContinuation?.resume(Unit)  
272 - connectContinuation = null  
273 } 261 }
274 262
275 override fun onIceReconnected() { 263 override fun onIceReconnected() {
@@ -6,7 +6,10 @@ import io.livekit.android.ConnectOptions @@ -6,7 +6,10 @@ import io.livekit.android.ConnectOptions
6 import io.livekit.android.Version 6 import io.livekit.android.Version
7 import io.livekit.android.dagger.InjectionNames 7 import io.livekit.android.dagger.InjectionNames
8 import io.livekit.android.room.track.Track 8 import io.livekit.android.room.track.Track
  9 +import io.livekit.android.util.Either
9 import io.livekit.android.util.safe 10 import io.livekit.android.util.safe
  11 +import kotlinx.coroutines.CancellableContinuation
  12 +import kotlinx.coroutines.suspendCancellableCoroutine
10 import kotlinx.serialization.decodeFromString 13 import kotlinx.serialization.decodeFromString
11 import kotlinx.serialization.encodeToString 14 import kotlinx.serialization.encodeToString
12 import kotlinx.serialization.json.Json 15 import kotlinx.serialization.json.Json
@@ -20,6 +23,8 @@ import org.webrtc.PeerConnection @@ -20,6 +23,8 @@ import org.webrtc.PeerConnection
20 import org.webrtc.SessionDescription 23 import org.webrtc.SessionDescription
21 import javax.inject.Inject 24 import javax.inject.Inject
22 import javax.inject.Named 25 import javax.inject.Named
  26 +import kotlin.coroutines.Continuation
  27 +import kotlin.coroutines.suspendCoroutine
23 28
24 /** 29 /**
25 * SignalClient to LiveKit WS servers 30 * SignalClient to LiveKit WS servers
@@ -32,6 +37,7 @@ constructor( @@ -32,6 +37,7 @@ constructor(
32 private val fromJsonProtobuf: JsonFormat.Parser, 37 private val fromJsonProtobuf: JsonFormat.Parser,
33 private val toJsonProtobuf: JsonFormat.Printer, 38 private val toJsonProtobuf: JsonFormat.Printer,
34 private val json: Json, 39 private val json: Json,
  40 + private val okHttpClient: OkHttpClient,
35 @Named(InjectionNames.SIGNAL_JSON_ENABLED) 41 @Named(InjectionNames.SIGNAL_JSON_ENABLED)
36 private val useJson: Boolean, 42 private val useJson: Boolean,
37 ) : WebSocketListener() { 43 ) : WebSocketListener() {
@@ -42,11 +48,31 @@ constructor( @@ -42,11 +48,31 @@ constructor(
42 var listener: Listener? = null 48 var listener: Listener? = null
43 private var lastUrl: String? = null 49 private var lastUrl: String? = null
44 50
45 - fun join( 51 + private var joinContinuation: CancellableContinuation<Either<LivekitRtc.JoinResponse, Unit>>? = null
  52 +
  53 + suspend fun join(
46 url: String, 54 url: String,
47 token: String, 55 token: String,
48 options: ConnectOptions?, 56 options: ConnectOptions?,
49 - ) { 57 + ) : LivekitRtc.JoinResponse {
  58 + val joinResponse = connect(url,token, options)
  59 + return (joinResponse as Either.Left).value
  60 + }
  61 +
  62 + suspend fun reconnect(url: String, token: String){
  63 + connect(
  64 + url,
  65 + token,
  66 + ConnectOptions()
  67 + .apply { reconnect = true }
  68 + )
  69 + }
  70 +
  71 + suspend fun connect(
  72 + url: String,
  73 + token: String,
  74 + options: ConnectOptions?
  75 + ) : Either<LivekitRtc.JoinResponse, Unit> {
50 var wsUrlString = "$url/rtc" + 76 var wsUrlString = "$url/rtc" +
51 "?protocol=$PROTOCOL_VERSION" + 77 "?protocol=$PROTOCOL_VERSION" +
52 "&access_token=$token" + 78 "&access_token=$token" +
@@ -70,12 +96,22 @@ constructor( @@ -70,12 +96,22 @@ constructor(
70 96
71 isConnected = false 97 isConnected = false
72 currentWs?.cancel() 98 currentWs?.cancel()
  99 + currentWs = null
  100 +
  101 + joinContinuation?.cancel()
  102 + joinContinuation = null
  103 +
73 lastUrl = wsUrlString 104 lastUrl = wsUrlString
74 105
75 val request = Request.Builder() 106 val request = Request.Builder()
76 .url(wsUrlString) 107 .url(wsUrlString)
77 .build() 108 .build()
78 currentWs = websocketFactory.newWebSocket(request, this) 109 currentWs = websocketFactory.newWebSocket(request, this)
  110 +
  111 + return suspendCancellableCoroutine {
  112 + // Wait for join response through WebSocketListener
  113 + joinContinuation = it
  114 + }
79 } 115 }
80 116
81 //--------------------------------- WebSocket Listener --------------------------------------// 117 //--------------------------------- WebSocket Listener --------------------------------------//
@@ -83,7 +119,7 @@ constructor( @@ -83,7 +119,7 @@ constructor(
83 if (isReconnecting) { 119 if (isReconnecting) {
84 isReconnecting = false 120 isReconnecting = false
85 isConnected = true 121 isConnected = true
86 - listener?.onReconnected() 122 + joinContinuation?.resumeWith(Result.success(Either.Right(Unit)))
87 } 123 }
88 } 124 }
89 125
@@ -123,7 +159,7 @@ constructor( @@ -123,7 +159,7 @@ constructor(
123 substring(2). 159 substring(2).
124 replaceFirst("/rtc?", "/rtc/validate?") 160 replaceFirst("/rtc?", "/rtc/validate?")
125 val request = Request.Builder().url(validationUrl).build() 161 val request = Request.Builder().url(validationUrl).build()
126 - val resp = OkHttpClient().newCall(request).execute() 162 + val resp = okHttpClient.newCall(request).execute()
127 if (!resp.isSuccessful) { 163 if (!resp.isSuccessful) {
128 reason = resp.body?.string() 164 reason = resp.body?.string()
129 } 165 }
@@ -290,7 +326,7 @@ constructor( @@ -290,7 +326,7 @@ constructor(
290 // Only handle joins if not connected. 326 // Only handle joins if not connected.
291 if (response.hasJoin()) { 327 if (response.hasJoin()) {
292 isConnected = true 328 isConnected = true
293 - listener?.onJoin(response.join) 329 + joinContinuation?.resumeWith(Result.success(Either.Left(response.join)))
294 } else { 330 } else {
295 Timber.e { "Received response while not connected. ${toJsonProtobuf.print(response)}" } 331 Timber.e { "Received response while not connected. ${toJsonProtobuf.print(response)}" }
296 } 332 }
@@ -351,8 +387,6 @@ constructor( @@ -351,8 +387,6 @@ constructor(
351 } 387 }
352 388
353 interface Listener { 389 interface Listener {
354 - fun onJoin(info: LivekitRtc.JoinResponse)  
355 - fun onReconnected()  
356 fun onAnswer(sessionDescription: SessionDescription) 390 fun onAnswer(sessionDescription: SessionDescription)
357 fun onOffer(sessionDescription: SessionDescription) 391 fun onOffer(sessionDescription: SessionDescription)
358 fun onTrickle(candidate: IceCandidate, target: LivekitRtc.SignalTarget) 392 fun onTrickle(candidate: IceCandidate, target: LivekitRtc.SignalTarget)
@@ -8,13 +8,15 @@ import org.webrtc.* @@ -8,13 +8,15 @@ import org.webrtc.*
8 * @suppress 8 * @suppress
9 */ 9 */
10 class SubscriberTransportObserver( 10 class SubscriberTransportObserver(
11 - private val engine: RTCEngine 11 + private val engine: RTCEngine,
  12 + private val client: SignalClient,
12 ) : PeerConnection.Observer { 13 ) : PeerConnection.Observer {
13 14
  15 + var iceConnectionChangeListener: ((PeerConnection.IceConnectionState?) -> Unit)? = null
14 16
15 override fun onIceCandidate(candidate: IceCandidate) { 17 override fun onIceCandidate(candidate: IceCandidate) {
16 Timber.v { "onIceCandidate: $candidate" } 18 Timber.v { "onIceCandidate: $candidate" }
17 - engine.client.sendCandidate(candidate, LivekitRtc.SignalTarget.SUBSCRIBER) 19 + client.sendCandidate(candidate, LivekitRtc.SignalTarget.SUBSCRIBER)
18 } 20 }
19 21
20 override fun onAddTrack(receiver: RtpReceiver, streams: Array<out MediaStream>) { 22 override fun onAddTrack(receiver: RtpReceiver, streams: Array<out MediaStream>) {
@@ -48,8 +50,9 @@ class SubscriberTransportObserver( @@ -48,8 +50,9 @@ class SubscriberTransportObserver(
48 override fun onSignalingChange(p0: PeerConnection.SignalingState?) { 50 override fun onSignalingChange(p0: PeerConnection.SignalingState?) {
49 } 51 }
50 52
51 - override fun onIceConnectionChange(p0: PeerConnection.IceConnectionState?) {  
52 - Timber.v { "onIceConnection new state: $p0" } 53 + override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
  54 + Timber.v { "onIceConnection new state: $newState" }
  55 + iceConnectionChangeListener?.invoke(newState)
53 } 56 }
54 57
55 override fun onIceConnectionReceivingChange(p0: Boolean) { 58 override fun onIceConnectionReceivingChange(p0: Boolean) {
@@ -152,7 +152,8 @@ internal constructor( @@ -152,7 +152,8 @@ internal constructor(
152 * @param reliability for delivery guarantee, use RELIABLE. for fastest delivery without guarantee, use LOSSY 152 * @param reliability for delivery guarantee, use RELIABLE. for fastest delivery without guarantee, use LOSSY
153 * @param destination list of participant SIDs to deliver the payload, null to deliver to everyone 153 * @param destination list of participant SIDs to deliver the payload, null to deliver to everyone
154 */ 154 */
155 - fun publishData(data: ByteArray, reliability: DataPublishReliability, destination: List<String>?) { 155 + @Suppress("unused")
  156 + suspend fun publishData(data: ByteArray, reliability: DataPublishReliability, destination: List<String>?) {
156 if (data.size > RTCEngine.MAX_DATA_PACKET_SIZE) { 157 if (data.size > RTCEngine.MAX_DATA_PACKET_SIZE) {
157 throw IllegalArgumentException("cannot publish data larger than " + RTCEngine.MAX_DATA_PACKET_SIZE) 158 throw IllegalArgumentException("cannot publish data larger than " + RTCEngine.MAX_DATA_PACKET_SIZE)
158 } 159 }
@@ -161,11 +162,6 @@ internal constructor( @@ -161,11 +162,6 @@ internal constructor(
161 DataPublishReliability.RELIABLE -> LivekitModels.DataPacket.Kind.RELIABLE 162 DataPublishReliability.RELIABLE -> LivekitModels.DataPacket.Kind.RELIABLE
162 DataPublishReliability.LOSSY -> LivekitModels.DataPacket.Kind.LOSSY 163 DataPublishReliability.LOSSY -> LivekitModels.DataPacket.Kind.LOSSY
163 } 164 }
164 - val channel = when (reliability) {  
165 - DataPublishReliability.RELIABLE -> engine.reliableDataChannel  
166 - DataPublishReliability.LOSSY -> engine.lossyDataChannel  
167 - } ?: throw TrackException.PublishException("data channel not established")  
168 -  
169 val packetBuilder = LivekitModels.UserPacket.newBuilder(). 165 val packetBuilder = LivekitModels.UserPacket.newBuilder().
170 setPayload(ByteString.copyFrom(data)). 166 setPayload(ByteString.copyFrom(data)).
171 setParticipantSid(sid) 167 setParticipantSid(sid)
@@ -176,12 +172,8 @@ internal constructor( @@ -176,12 +172,8 @@ internal constructor(
176 setUser(packetBuilder). 172 setUser(packetBuilder).
177 setKind(kind). 173 setKind(kind).
178 build() 174 build()
179 - val buf = DataChannel.Buffer(  
180 - ByteBuffer.wrap(dataPacket.toByteArray()),  
181 - true,  
182 - )  
183 175
184 - channel.send(buf) 176 + engine.sendData(dataPacket)
185 } 177 }
186 178
187 override fun updateFromInfo(info: LivekitModels.ParticipantInfo) { 179 override fun updateFromInfo(info: LivekitModels.ParticipantInfo) {
  1 +package io.livekit.android.room.util
  2 +
  3 +import org.webrtc.MediaConstraints
  4 +
  5 +object MediaConstraintKeys {
  6 + const val OFFER_TO_RECV_AUDIO = "OfferToReceiveAudio"
  7 + const val OFFER_TO_RECV_VIDEO = "OfferToReceiveVideo"
  8 + const val ICE_RESTART = "IceRestart"
  9 +
  10 + const val FALSE = "false"
  11 + const val TRUE = "true"
  12 +}
  13 +
  14 +fun MediaConstraints.findConstraint(key: String): String? {
  15 + return mandatory.firstOrNull { it.key == key }?.value
  16 + ?: optional.firstOrNull { it.key == key }?.value
  17 +}
  1 +package io.livekit.android.util
  2 +
  3 +import kotlinx.coroutines.*
  4 +
  5 +fun <T, R> debounce(
  6 + waitMs: Long = 300L,
  7 + coroutineScope: CoroutineScope,
  8 + destinationFunction: suspend (T) -> R
  9 +): (T) -> Unit {
  10 + var debounceJob: Deferred<R>? = null
  11 + return { param: T ->
  12 + debounceJob?.cancel()
  13 + debounceJob = coroutineScope.async {
  14 + delay(waitMs)
  15 + return@async destinationFunction(param)
  16 + }
  17 + }
  18 +}
  1 +package io.livekit.android.util
  2 +
  3 +import com.google.protobuf.MessageLite
  4 +import okio.ByteString
  5 +import okio.ByteString.Companion.toByteString
  6 +
  7 +fun MessageLite.toOkioByteString(): ByteString {
  8 + val byteArray = toByteArray()
  9 + return byteArray.toByteString(0, byteArray.size)
  10 +}
@@ -6,11 +6,9 @@ import io.livekit.android.room.mock.MockEglBase @@ -6,11 +6,9 @@ import io.livekit.android.room.mock.MockEglBase
6 import io.livekit.android.room.participant.LocalParticipant 6 import io.livekit.android.room.participant.LocalParticipant
7 import kotlinx.coroutines.ExperimentalCoroutinesApi 7 import kotlinx.coroutines.ExperimentalCoroutinesApi
8 import kotlinx.coroutines.launch 8 import kotlinx.coroutines.launch
9 -import kotlinx.coroutines.runBlocking  
10 import kotlinx.coroutines.test.TestCoroutineScope 9 import kotlinx.coroutines.test.TestCoroutineScope
11 -import kotlinx.coroutines.withTimeoutOrNull 10 +import kotlinx.coroutines.test.runBlockingTest
12 import livekit.LivekitModels 11 import livekit.LivekitModels
13 -import org.junit.Assert  
14 import org.junit.Before 12 import org.junit.Before
15 import org.junit.Rule 13 import org.junit.Rule
16 import org.junit.Test 14 import org.junit.Test
@@ -72,12 +70,8 @@ class RoomTest { @@ -72,12 +70,8 @@ class RoomTest {
72 ) 70 )
73 } 71 }
74 room.onIceConnected() 72 room.onIceConnected()
75 - runBlocking {  
76 - Assert.assertNotNull(  
77 - withTimeoutOrNull(1000) {  
78 - job.join()  
79 - }  
80 - ) 73 + runBlockingTest {
  74 + job.join()
81 } 75 }
82 } 76 }
83 } 77 }
1 package io.livekit.android.room 1 package io.livekit.android.room
2 2
3 import com.google.protobuf.util.JsonFormat 3 import com.google.protobuf.util.JsonFormat
  4 +import io.livekit.android.util.toOkioByteString
  5 +import kotlinx.coroutines.ExperimentalCoroutinesApi
  6 +import kotlinx.coroutines.async
  7 +import kotlinx.coroutines.test.TestCoroutineScope
  8 +import kotlinx.coroutines.test.runBlockingTest
4 import kotlinx.serialization.json.Json 9 import kotlinx.serialization.json.Json
5 import livekit.LivekitRtc 10 import livekit.LivekitRtc
6 import okhttp3.* 11 import okhttp3.*
7 -import okio.ByteString.Companion.toByteString 12 +import org.junit.Assert
8 import org.junit.Before 13 import org.junit.Before
9 import org.junit.Test 14 import org.junit.Test
10 import org.mockito.Mockito 15 import org.mockito.Mockito
11 -import org.mockito.kotlin.verify  
12 16
  17 +@ExperimentalCoroutinesApi
13 class SignalClientTest { 18 class SignalClientTest {
14 19
15 lateinit var wsFactory: MockWebsocketFactory 20 lateinit var wsFactory: MockWebsocketFactory
16 lateinit var client: SignalClient 21 lateinit var client: SignalClient
17 lateinit var listener: SignalClient.Listener 22 lateinit var listener: SignalClient.Listener
  23 + lateinit var okHttpClient: OkHttpClient
18 24
19 class MockWebsocketFactory : WebSocket.Factory { 25 class MockWebsocketFactory : WebSocket.Factory {
20 lateinit var ws: WebSocket 26 lateinit var ws: WebSocket
@@ -29,35 +35,64 @@ class SignalClientTest { @@ -29,35 +35,64 @@ class SignalClientTest {
29 @Before 35 @Before
30 fun setup() { 36 fun setup() {
31 wsFactory = MockWebsocketFactory() 37 wsFactory = MockWebsocketFactory()
  38 + okHttpClient = Mockito.mock(OkHttpClient::class.java)
32 client = SignalClient( 39 client = SignalClient(
33 wsFactory, 40 wsFactory,
34 JsonFormat.parser(), 41 JsonFormat.parser(),
35 JsonFormat.printer(), 42 JsonFormat.printer(),
36 Json, 43 Json,
37 - useJson = false 44 + useJson = false,
  45 + okHttpClient = okHttpClient,
38 ) 46 )
39 listener = Mockito.mock(SignalClient.Listener::class.java) 47 listener = Mockito.mock(SignalClient.Listener::class.java)
40 client.listener = listener 48 client.listener = listener
41 } 49 }
42 50
43 - fun join() {  
44 - client.join("http://www.example.com", "", null) 51 + private fun createOpenResponse(request: Request): Response {
  52 + return Response.Builder()
  53 + .request(request)
  54 + .code(200)
  55 + .protocol(Protocol.HTTP_2)
  56 + .message("")
  57 + .build()
45 } 58 }
46 59
47 @Test 60 @Test
48 fun joinAndResponse() { 61 fun joinAndResponse() {
49 - join() 62 + val job = TestCoroutineScope().async {
  63 + client.join("http://www.example.com", "", null)
  64 + }
50 client.onOpen( 65 client.onOpen(
51 wsFactory.ws, 66 wsFactory.ws,
52 - Response.Builder()  
53 - .request(wsFactory.request)  
54 - .code(200)  
55 - .protocol(Protocol.HTTP_2)  
56 - .message("")  
57 - .build() 67 + createOpenResponse(wsFactory.request)
58 ) 68 )
  69 + client.onMessage(wsFactory.ws, JOIN.toOkioByteString())
  70 +
  71 + runBlockingTest {
  72 + val response = job.await()
  73 + Assert.assertEquals(response, JOIN.join)
  74 + }
  75 + }
59 76
60 - val response = with(LivekitRtc.SignalResponse.newBuilder()) { 77 + @Test
  78 + fun reconnect() {
  79 + val job = TestCoroutineScope().async {
  80 + client.reconnect("http://www.example.com", "")
  81 + }
  82 + client.onOpen(
  83 + wsFactory.ws,
  84 + createOpenResponse(wsFactory.request)
  85 + )
  86 + runBlockingTest {
  87 + job.await()
  88 + }
  89 + }
  90 +
  91 + // mock data
  92 + companion object {
  93 + private val EXAMPLE_URL = "http://www.example.com"
  94 +
  95 + private val JOIN = with(LivekitRtc.SignalResponse.newBuilder()) {
61 join = with(joinBuilder) { 96 join = with(joinBuilder) {
62 room = with(roomBuilder) { 97 room = with(roomBuilder) {
63 name = "roomname" 98 name = "roomname"
@@ -68,11 +103,5 @@ class SignalClientTest { @@ -68,11 +103,5 @@ class SignalClientTest {
68 } 103 }
69 build() 104 build()
70 } 105 }
71 - val byteArray = response.toByteArray()  
72 - val byteString = byteArray.toByteString(0, byteArray.size)  
73 -  
74 - client.onMessage(wsFactory.ws, byteString)  
75 -  
76 - verify(listener).onJoin(response.join)  
77 } 106 }
78 } 107 }