David Zhao

Handle network changes with reconnection, v0.6.0

@@ -23,7 +23,7 @@ kotlin.code.style=official @@ -23,7 +23,7 @@ kotlin.code.style=official
23 ############################################################### 23 ###############################################################
24 24
25 GROUP=io.livekit 25 GROUP=io.livekit
26 -VERSION_NAME=0.5.1 26 +VERSION_NAME=0.6.0
27 27
28 POM_DESCRIPTION=Android SDK for WebRTC communication 28 POM_DESCRIPTION=Android SDK for WebRTC communication
29 29
1 -<manifest package="io.livekit.android" /> 1 +<manifest package="io.livekit.android"
  2 + xmlns:android="http://schemas.android.com/apk/res/android">
  3 +
  4 + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  5 + <uses-permission android:name="android.permission.INTERNET" />
  6 +</manifest>
@@ -19,13 +19,13 @@ class LiveKit { @@ -19,13 +19,13 @@ class LiveKit {
19 options: ConnectOptions, 19 options: ConnectOptions,
20 listener: RoomListener? 20 listener: RoomListener?
21 ): Room { 21 ): Room {
22 - 22 + val ctx = appContext.applicationContext
23 val component = DaggerLiveKitComponent 23 val component = DaggerLiveKitComponent
24 .factory() 24 .factory()
25 - .create(appContext.applicationContext) 25 + .create(ctx)
26 26
27 val room = component.roomFactory() 27 val room = component.roomFactory()
28 - .create(options) 28 + .create(options, ctx)
29 room.listener = listener 29 room.listener = listener
30 room.connect(url, token) 30 room.connect(url, token)
31 31
@@ -23,12 +23,11 @@ class PublisherTransportObserver( @@ -23,12 +23,11 @@ class PublisherTransportObserver(
23 override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) { 23 override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) {
24 val state = newState ?: throw NullPointerException("unexpected null new state, what do?") 24 val state = newState ?: throw NullPointerException("unexpected null new state, what do?")
25 Timber.v { "onIceConnection new state: $newState" } 25 Timber.v { "onIceConnection new state: $newState" }
26 - if (state == PeerConnection.IceConnectionState.CONNECTED && !engine.iceConnected) {  
27 - engine.iceConnected = true  
28 - engine.listener?.onICEConnected() 26 + if (state == PeerConnection.IceConnectionState.CONNECTED) {
  27 + engine.iceState = IceState.CONNECTED
29 } else if (state == PeerConnection.IceConnectionState.FAILED) { 28 } else if (state == PeerConnection.IceConnectionState.FAILED) {
30 // when we publish tracks, some WebRTC versions will send out disconnected events periodically 29 // when we publish tracks, some WebRTC versions will send out disconnected events periodically
31 - engine.iceConnected = false 30 + engine.iceState = IceState.DISCONNECTED
32 engine.listener?.onDisconnect("Peer connection disconnected") 31 engine.listener?.onDisconnect("Peer connection disconnected")
33 } 32 }
34 } 33 }
@@ -22,6 +22,7 @@ import javax.inject.Inject @@ -22,6 +22,7 @@ import javax.inject.Inject
22 import javax.inject.Named 22 import javax.inject.Named
23 23
24 /** 24 /**
  25 + * SignalClient to LiveKit WS servers
25 * @suppress 26 * @suppress
26 */ 27 */
27 class RTCClient 28 class RTCClient
@@ -37,24 +38,39 @@ constructor( @@ -37,24 +38,39 @@ constructor(
37 var isConnected = false 38 var isConnected = false
38 private set 39 private set
39 private var currentWs: WebSocket? = null 40 private var currentWs: WebSocket? = null
  41 + private var isReconnecting: Boolean = false
40 var listener: Listener? = null 42 var listener: Listener? = null
41 43
42 fun join( 44 fun join(
43 url: String, 45 url: String,
44 token: String, 46 token: String,
  47 + reconnect: Boolean = false
45 ) { 48 ) {
46 - val wsUrlString = "$url/rtc?protocol=$PROTOCOL_VERSION&access_token=$token" 49 + var wsUrlString = "$url/rtc?protocol=$PROTOCOL_VERSION&access_token=$token"
  50 + if (reconnect) {
  51 + wsUrlString += "&reconnect=1"
  52 + }
47 Timber.i { "connecting to $wsUrlString" } 53 Timber.i { "connecting to $wsUrlString" }
48 54
  55 + isReconnecting = reconnect
  56 + isConnected = false
  57 + currentWs?.cancel()
  58 +
49 val request = Request.Builder() 59 val request = Request.Builder()
50 .url(wsUrlString) 60 .url(wsUrlString)
51 .build() 61 .build()
52 currentWs = websocketFactory.newWebSocket(request, this) 62 currentWs = websocketFactory.newWebSocket(request, this)
53 } 63 }
54 64
  65 + //--------------------------------- WebSocket Listener --------------------------------------//
55 override fun onOpen(webSocket: WebSocket, response: Response) { 66 override fun onOpen(webSocket: WebSocket, response: Response) {
56 - Timber.v { response.message }  
57 super.onOpen(webSocket, response) 67 super.onOpen(webSocket, response)
  68 +
  69 + if (isReconnecting) {
  70 + isReconnecting = false
  71 + isConnected = true
  72 + listener?.onReconnected()
  73 + }
58 } 74 }
59 75
60 override fun onMessage(webSocket: WebSocket, text: String) { 76 override fun onMessage(webSocket: WebSocket, text: String) {
@@ -91,6 +107,7 @@ constructor( @@ -91,6 +107,7 @@ constructor(
91 super.onFailure(webSocket, t, response) 107 super.onFailure(webSocket, t, response)
92 } 108 }
93 109
  110 + //------------------------------- End WebSocket Listener ------------------------------------//
94 111
95 fun fromProtoSessionDescription(sd: LivekitRtc.SessionDescription): SessionDescription { 112 fun fromProtoSessionDescription(sd: LivekitRtc.SessionDescription): SessionDescription {
96 val rtcSdpType = when (sd.type) { 113 val rtcSdpType = when (sd.type) {
@@ -292,6 +309,7 @@ constructor( @@ -292,6 +309,7 @@ constructor(
292 309
293 interface Listener { 310 interface Listener {
294 fun onJoin(info: LivekitRtc.JoinResponse) 311 fun onJoin(info: LivekitRtc.JoinResponse)
  312 + fun onReconnected()
295 fun onAnswer(sessionDescription: SessionDescription) 313 fun onAnswer(sessionDescription: SessionDescription)
296 fun onOffer(sessionDescription: SessionDescription) 314 fun onOffer(sessionDescription: SessionDescription)
297 fun onTrickle(candidate: IceCandidate, target: LivekitRtc.SignalTarget) 315 fun onTrickle(candidate: IceCandidate, target: LivekitRtc.SignalTarget)
@@ -8,6 +8,7 @@ import io.livekit.android.util.CloseableCoroutineScope @@ -8,6 +8,7 @@ import io.livekit.android.util.CloseableCoroutineScope
8 import io.livekit.android.util.Either 8 import io.livekit.android.util.Either
9 import kotlinx.coroutines.CoroutineDispatcher 9 import kotlinx.coroutines.CoroutineDispatcher
10 import kotlinx.coroutines.SupervisorJob 10 import kotlinx.coroutines.SupervisorJob
  11 +import kotlinx.coroutines.delay
11 import kotlinx.coroutines.launch 12 import kotlinx.coroutines.launch
12 import livekit.LivekitModels 13 import livekit.LivekitModels
13 import livekit.LivekitRtc 14 import livekit.LivekitRtc
@@ -30,12 +31,36 @@ constructor( @@ -30,12 +31,36 @@ constructor(
30 private val pctFactory: PeerConnectionTransport.Factory, 31 private val pctFactory: PeerConnectionTransport.Factory,
31 @Named(InjectionNames.DISPATCHER_IO) ioDispatcher: CoroutineDispatcher, 32 @Named(InjectionNames.DISPATCHER_IO) ioDispatcher: CoroutineDispatcher,
32 ) : RTCClient.Listener, DataChannel.Observer { 33 ) : RTCClient.Listener, DataChannel.Observer {
33 -  
34 var listener: Listener? = null 34 var listener: Listener? = null
35 - var rtcConnected: Boolean = false  
36 - var iceConnected: Boolean = false 35 + internal var iceState: IceState = IceState.DISCONNECTED
  36 + set(value) {
  37 + val oldVal = field
  38 + field = value
  39 + if (value == oldVal) {
  40 + return
  41 + }
  42 + when (value) {
  43 + IceState.CONNECTED -> {
  44 + if (oldVal == IceState.DISCONNECTED) {
  45 + Timber.d { "publisher ICE connected" }
  46 + listener?.onIceConnected()
  47 + } else if (oldVal == IceState.RECONNECTING) {
  48 + Timber.d { "publisher ICE reconnected" }
  49 + listener?.onIceReconnected()
  50 + }
  51 + }
  52 + IceState.DISCONNECTED -> {
  53 + Timber.d { "publisher ICE disconnected" }
  54 + listener?.onDisconnect("Peer connection disconnected")
  55 + }
  56 + else -> {}
  57 + }
  58 + }
  59 + private var wsRetries: Int = 0
37 private val pendingTrackResolvers: MutableMap<String, Continuation<LivekitModels.TrackInfo>> = 60 private val pendingTrackResolvers: MutableMap<String, Continuation<LivekitModels.TrackInfo>> =
38 mutableMapOf() 61 mutableMapOf()
  62 + private var sessionUrl: String? = null
  63 + private var sessionToken: String? = null
39 64
40 private val publisherObserver = PublisherTransportObserver(this) 65 private val publisherObserver = PublisherTransportObserver(this)
41 private val subscriberObserver = SubscriberTransportObserver(this) 66 private val subscriberObserver = SubscriberTransportObserver(this)
@@ -50,6 +75,8 @@ constructor( @@ -50,6 +75,8 @@ constructor(
50 } 75 }
51 76
52 fun join(url: String, token: String) { 77 fun join(url: String, token: String) {
  78 + sessionUrl = url
  79 + sessionToken = token
53 client.join(url, token) 80 client.join(url, token)
54 } 81 }
55 82
@@ -75,13 +102,39 @@ constructor( @@ -75,13 +102,39 @@ constructor(
75 client.close() 102 client.close()
76 } 103 }
77 104
78 - fun negotiate() { 105 + /**
  106 + * reconnect Signal and PeerConnections
  107 + */
  108 + internal fun reconnect() {
  109 + if (sessionUrl == null || sessionToken == null) {
  110 + return
  111 + }
  112 + if (iceState == IceState.DISCONNECTED || wsRetries >= MAX_SIGNAL_RETRIES) {
  113 + Timber.w { "could not connect to signal after max attempts, giving up" }
  114 + close()
  115 + listener?.onDisconnect("could not reconnect after limit")
  116 + return
  117 + }
  118 +
  119 + var startDelay = wsRetries.toLong() * wsRetries * 500
  120 + if (startDelay > 5000) {
  121 + startDelay = 5000
  122 + }
  123 + coroutineScope.launch {
  124 + delay(startDelay)
  125 + if (iceState != IceState.DISCONNECTED && sessionUrl != null && sessionToken != null) {
  126 + client.join(sessionUrl!!, sessionToken!!, true)
  127 + }
  128 + }
  129 + }
  130 +
  131 + internal fun negotiate() {
79 if (!client.isConnected) { 132 if (!client.isConnected) {
80 return 133 return
81 } 134 }
82 coroutineScope.launch { 135 coroutineScope.launch {
83 val sdpOffer = 136 val sdpOffer =
84 - when (val outcome = publisher.peerConnection.createOffer(OFFER_CONSTRAINTS)) { 137 + when (val outcome = publisher.peerConnection.createOffer(getOfferConstraints())) {
85 is Either.Left -> outcome.value 138 is Either.Left -> outcome.value
86 is Either.Right -> { 139 is Either.Right -> {
87 Timber.d { "error creating offer: ${outcome.value}" } 140 Timber.d { "error creating offer: ${outcome.value}" }
@@ -101,16 +154,23 @@ constructor( @@ -101,16 +154,23 @@ constructor(
101 } 154 }
102 } 155 }
103 156
104 - private fun onRTCConnected() {  
105 - Timber.v { "RTC Connected" }  
106 - rtcConnected = true 157 + private fun getOfferConstraints(): MediaConstraints {
  158 + return MediaConstraints().apply {
  159 + with(mandatory) {
  160 + add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false"))
  161 + add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "false"))
  162 + if (iceState == IceState.RECONNECTING) {
  163 + add(MediaConstraints.KeyValuePair("IceRestart", "true"))
  164 + }
  165 + }
  166 + }
107 } 167 }
108 168
109 interface Listener { 169 interface Listener {
110 fun onJoin(response: LivekitRtc.JoinResponse) 170 fun onJoin(response: LivekitRtc.JoinResponse)
111 - fun onICEConnected() 171 + fun onIceConnected()
  172 + fun onIceReconnected()
112 fun onAddTrack(track: MediaStreamTrack, streams: Array<out MediaStream>) 173 fun onAddTrack(track: MediaStreamTrack, streams: Array<out MediaStream>)
113 -// fun onPublishLocalTrack(cid: String, track: LivekitModels.TrackInfo)  
114 fun onUpdateParticipants(updates: List<LivekitModels.ParticipantInfo>) 174 fun onUpdateParticipants(updates: List<LivekitModels.ParticipantInfo>)
115 fun onUpdateSpeakers(speakers: List<LivekitRtc.SpeakerInfo>) 175 fun onUpdateSpeakers(speakers: List<LivekitRtc.SpeakerInfo>)
116 fun onDisconnect(reason: String) 176 fun onDisconnect(reason: String)
@@ -122,15 +182,7 @@ constructor( @@ -122,15 +182,7 @@ constructor(
122 private const val RELIABLE_DATA_CHANNEL_LABEL = "_reliable" 182 private const val RELIABLE_DATA_CHANNEL_LABEL = "_reliable"
123 private const val LOSSY_DATA_CHANNEL_LABEL = "_lossy" 183 private const val LOSSY_DATA_CHANNEL_LABEL = "_lossy"
124 internal const val MAX_DATA_PACKET_SIZE = 15000 184 internal const val MAX_DATA_PACKET_SIZE = 15000
125 -  
126 - private val OFFER_CONSTRAINTS = MediaConstraints().apply {  
127 - with(mandatory) {  
128 - add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false"))  
129 - add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "false"))  
130 - }  
131 - }  
132 -  
133 - private val MEDIA_CONSTRAINTS = MediaConstraints() 185 + private const val MAX_SIGNAL_RETRIES = 5
134 186
135 internal val CONN_CONSTRAINTS = MediaConstraints().apply { 187 internal val CONN_CONSTRAINTS = MediaConstraints().apply {
136 with(optional) { 188 with(optional) {
@@ -155,7 +207,7 @@ constructor( @@ -155,7 +207,7 @@ constructor(
155 ) 207 )
156 } 208 }
157 209
158 - if(iceServers.isEmpty()){ 210 + if (iceServers.isEmpty()) {
159 iceServers.addAll(RTCClient.DEFAULT_ICE_SERVERS) 211 iceServers.addAll(RTCClient.DEFAULT_ICE_SERVERS)
160 } 212 }
161 info.iceServersList.forEach { 213 info.iceServersList.forEach {
@@ -192,7 +244,7 @@ constructor( @@ -192,7 +244,7 @@ constructor(
192 244
193 coroutineScope.launch { 245 coroutineScope.launch {
194 val sdpOffer = 246 val sdpOffer =
195 - when (val outcome = publisher.peerConnection.createOffer(OFFER_CONSTRAINTS)) { 247 + when (val outcome = publisher.peerConnection.createOffer(getOfferConstraints())) {
196 is Either.Left -> outcome.value 248 is Either.Left -> outcome.value
197 is Either.Right -> { 249 is Either.Right -> {
198 Timber.d { "error creating offer: ${outcome.value}" } 250 Timber.d { "error creating offer: ${outcome.value}" }
@@ -212,14 +264,25 @@ constructor( @@ -212,14 +264,25 @@ constructor(
212 listener?.onJoin(info) 264 listener?.onJoin(info)
213 } 265 }
214 266
  267 + override fun onReconnected() {
  268 + Timber.v { "reconnected, restarting ICE" }
  269 + wsRetries = 0
  270 +
  271 + // trigger ICE restart
  272 + iceState = IceState.RECONNECTING
  273 + negotiate()
  274 + }
  275 +
215 override fun onAnswer(sessionDescription: SessionDescription) { 276 override fun onAnswer(sessionDescription: SessionDescription) {
216 Timber.v { "received server answer: ${sessionDescription.type}, ${publisher.peerConnection.signalingState()}" } 277 Timber.v { "received server answer: ${sessionDescription.type}, ${publisher.peerConnection.signalingState()}" }
217 coroutineScope.launch { 278 coroutineScope.launch {
218 Timber.i { sessionDescription.toString() } 279 Timber.i { sessionDescription.toString() }
219 when (val outcome = publisher.setRemoteDescription(sessionDescription)) { 280 when (val outcome = publisher.setRemoteDescription(sessionDescription)) {
220 is Either.Left -> { 281 is Either.Left -> {
221 - if (!rtcConnected) {  
222 - onRTCConnected() 282 + // when reconnecting, ICE might not have disconnected and won't trigger
  283 + // our connected callback, so we'll take a shortcut and set it to active
  284 + if (iceState == IceState.RECONNECTING) {
  285 + iceState = IceState.CONNECTED
223 } 286 }
224 } 287 }
225 is Either.Right -> { 288 is Either.Right -> {
@@ -243,7 +306,7 @@ constructor( @@ -243,7 +306,7 @@ constructor(
243 } 306 }
244 307
245 val answer = run { 308 val answer = run {
246 - when (val outcome = subscriber.peerConnection.createAnswer(OFFER_CONSTRAINTS)) { 309 + when (val outcome = subscriber.peerConnection.createAnswer(MediaConstraints())) {
247 is Either.Left -> outcome.value 310 is Either.Left -> outcome.value
248 is Either.Right -> { 311 is Either.Right -> {
249 Timber.e { "error creating answer: ${outcome.value}" } 312 Timber.e { "error creating answer: ${outcome.value}" }
@@ -275,11 +338,10 @@ constructor( @@ -275,11 +338,10 @@ constructor(
275 } 338 }
276 339
277 override fun onLocalTrackPublished(response: LivekitRtc.TrackPublishedResponse) { 340 override fun onLocalTrackPublished(response: LivekitRtc.TrackPublishedResponse) {
278 - val signalCid = response.cid ?: run { 341 + val cid = response.cid ?: run {
279 Timber.e { "local track published with null cid?" } 342 Timber.e { "local track published with null cid?" }
280 return 343 return
281 } 344 }
282 - val cid = signalCid  
283 345
284 val track = response.track 346 val track = response.track
285 if (track == null) { 347 if (track == null) {
@@ -293,7 +355,6 @@ constructor( @@ -293,7 +355,6 @@ constructor(
293 return 355 return
294 } 356 }
295 cont.resume(response.track) 357 cont.resume(response.track)
296 -// listener?.onPublishLocalTrack(cid, track)  
297 } 358 }
298 359
299 override fun onParticipantUpdate(updates: List<LivekitModels.ParticipantInfo>) { 360 override fun onParticipantUpdate(updates: List<LivekitModels.ParticipantInfo>) {
@@ -345,4 +406,10 @@ constructor( @@ -345,4 +406,10 @@ constructor(
345 } 406 }
346 } 407 }
347 } 408 }
  409 +}
  410 +
  411 + internal enum class IceState {
  412 + DISCONNECTED,
  413 + RECONNECTING,
  414 + CONNECTED,
348 } 415 }
1 package io.livekit.android.room 1 package io.livekit.android.room
2 2
  3 +import android.content.Context
  4 +import android.net.ConnectivityManager
  5 +import android.net.Network
  6 +import android.net.NetworkCapabilities
  7 +import android.net.NetworkRequest
3 import com.github.ajalt.timberkt.Timber 8 import com.github.ajalt.timberkt.Timber
4 import dagger.assisted.Assisted 9 import dagger.assisted.Assisted
5 import dagger.assisted.AssistedFactory 10 import dagger.assisted.AssistedFactory
@@ -22,10 +27,11 @@ class Room @@ -22,10 +27,11 @@ class Room
22 @AssistedInject 27 @AssistedInject
23 constructor( 28 constructor(
24 @Assisted private val connectOptions: ConnectOptions, 29 @Assisted private val connectOptions: ConnectOptions,
  30 + @Assisted private val context: Context,
25 private val engine: RTCEngine, 31 private val engine: RTCEngine,
26 private val eglBase: EglBase, 32 private val eglBase: EglBase,
27 private val localParticipantFactory: LocalParticipant.Factory 33 private val localParticipantFactory: LocalParticipant.Factory
28 -) : RTCEngine.Listener, ParticipantListener { 34 +) : RTCEngine.Listener, ParticipantListener, ConnectivityManager.NetworkCallback() {
29 init { 35 init {
30 engine.listener = this 36 engine.listener = this
31 } 37 }
@@ -57,10 +63,16 @@ constructor( @@ -57,10 +63,16 @@ constructor(
57 val activeSpeakers: List<Participant> 63 val activeSpeakers: List<Participant>
58 get() = mutableActiveSpeakers 64 get() = mutableActiveSpeakers
59 65
  66 + private var hasLostConnectivity: Boolean = false
60 private var connectContinuation: Continuation<Unit>? = null 67 private var connectContinuation: Continuation<Unit>? = null
61 suspend fun connect(url: String, token: String) { 68 suspend fun connect(url: String, token: String) {
  69 + state = State.CONNECTING
62 engine.join(url, token) 70 engine.join(url, token)
63 - 71 + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
  72 + val networkRequest = NetworkRequest.Builder()
  73 + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
  74 + .build()
  75 + cm.registerNetworkCallback(networkRequest, this)
64 return suspendCoroutine { connectContinuation = it } 76 return suspendCoroutine { connectContinuation = it }
65 } 77 }
66 78
@@ -136,7 +148,18 @@ constructor( @@ -136,7 +148,18 @@ constructor(
136 listener?.onActiveSpeakersChanged(speakers, this) 148 listener?.onActiveSpeakersChanged(speakers, this)
137 } 149 }
138 150
  151 + private fun reconnect() {
  152 + if (state == State.RECONNECTING) {
  153 + return
  154 + }
  155 + state = State.RECONNECTING
  156 + engine.reconnect()
  157 + listener?.onReconnecting(this)
  158 + }
  159 +
139 private fun handleDisconnect() { 160 private fun handleDisconnect() {
  161 + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
  162 + cm.unregisterNetworkCallback(this)
140 for (pub in localParticipant.tracks.values) { 163 for (pub in localParticipant.tracks.values) {
141 pub.track?.stop() 164 pub.track?.stop()
142 } 165 }
@@ -149,6 +172,7 @@ constructor( @@ -149,6 +172,7 @@ constructor(
149 engine.close() 172 engine.close()
150 state = State.DISCONNECTED 173 state = State.DISCONNECTED
151 listener?.onDisconnect(this, null) 174 listener?.onDisconnect(this, null)
  175 + listener = null
152 } 176 }
153 177
154 /** 178 /**
@@ -156,9 +180,33 @@ constructor( @@ -156,9 +180,33 @@ constructor(
156 */ 180 */
157 @AssistedFactory 181 @AssistedFactory
158 interface Factory { 182 interface Factory {
159 - fun create(connectOptions: ConnectOptions): Room 183 + fun create(connectOptions: ConnectOptions, context: Context): Room
  184 + }
  185 +
  186 + //------------------------------------- NetworkCallback -------------------------------------//
  187 +
  188 + /**
  189 + * @suppress
  190 + */
  191 + override fun onLost(network: Network) {
  192 + // lost connection, flip to reconnecting
  193 + hasLostConnectivity = true
  194 + }
  195 +
  196 + /**
  197 + * @suppress
  198 + */
  199 + override fun onAvailable(network: Network) {
  200 + // only actually reconnect after connection is re-established
  201 + if (!hasLostConnectivity) {
  202 + return
  203 + }
  204 + Timber.i { "network connection available, reconnecting" }
  205 + reconnect()
  206 + hasLostConnectivity = false
160 } 207 }
161 208
  209 +
162 //----------------------------------- RTCEngine.Listener ------------------------------------// 210 //----------------------------------- RTCEngine.Listener ------------------------------------//
163 /** 211 /**
164 * @suppress 212 * @suppress
@@ -186,12 +234,17 @@ constructor( @@ -186,12 +234,17 @@ constructor(
186 } 234 }
187 } 235 }
188 236
189 - override fun onICEConnected() { 237 + override fun onIceConnected() {
190 state = State.CONNECTED 238 state = State.CONNECTED
191 connectContinuation?.resume(Unit) 239 connectContinuation?.resume(Unit)
192 connectContinuation = null 240 connectContinuation = null
193 } 241 }
194 242
  243 + override fun onIceReconnected() {
  244 + state = State.CONNECTED
  245 + listener?.onReconnected(this)
  246 + }
  247 +
195 /** 248 /**
196 * @suppress 249 * @suppress
197 */ 250 */
@@ -346,6 +399,17 @@ constructor( @@ -346,6 +399,17 @@ constructor(
346 */ 399 */
347 interface RoomListener { 400 interface RoomListener {
348 /** 401 /**
  402 + * A network change has been detected and LiveKit attempts to reconnect to the room
  403 + * When reconnect attempts succeed, the room state will be kept, including tracks that are subscribed/published
  404 + */
  405 + fun onReconnecting(room: Room) {}
  406 +
  407 + /**
  408 + * The reconnect attempt had been successful
  409 + */
  410 + fun onReconnected(room: Room) {}
  411 +
  412 + /**
349 * Disconnected from room 413 * Disconnected from room
350 */ 414 */
351 fun onDisconnect(room: Room, error: Exception?) {} 415 fun onDisconnect(room: Room, error: Exception?) {}