正在显示
12 个修改的文件
包含
513 行增加
和
223 行删除
| 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 | } |
-
请 注册 或 登录 后发表评论