diff --git a/.changeset/cyan-ways-tie.md b/.changeset/cyan-ways-tie.md new file mode 100644 index 000000000..e63675f2c --- /dev/null +++ b/.changeset/cyan-ways-tie.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +Fix resume not working sometimes after connection loss/gain diff --git a/.changeset/shiny-hornets-shake.md b/.changeset/shiny-hornets-shake.md new file mode 100644 index 000000000..d325a011a --- /dev/null +++ b/.changeset/shiny-hornets-shake.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": minor +--- + +Add setting custom reconnect policy diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt index a73e90690..1919cd2c3 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package io.livekit.android import io.livekit.android.e2ee.E2EEOptions import io.livekit.android.room.Room +import io.livekit.android.room.network.ReconnectPolicy import io.livekit.android.room.participant.AudioTrackPublishDefaults import io.livekit.android.room.participant.VideoTrackPublishDefaults import io.livekit.android.room.track.LocalAudioTrackOptions @@ -45,4 +46,9 @@ data class RoomOptions( val videoTrackPublishDefaults: VideoTrackPublishDefaults? = null, val screenShareTrackCaptureDefaults: LocalVideoTrackOptions? = null, val screenShareTrackPublishDefaults: VideoTrackPublishDefaults? = null, + + /** + * @see [Room.reconnectPolicy] + */ + val reconnectPolicy: ReconnectPolicy? = null, ) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 972dffa62..43c2a5777 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -29,6 +29,9 @@ import io.livekit.android.e2ee.E2EEManager import io.livekit.android.e2ee.EncryptedPacket import io.livekit.android.events.DisconnectReason import io.livekit.android.events.convert +import io.livekit.android.room.network.DefaultReconnectPolicy +import io.livekit.android.room.network.ReconnectContext +import io.livekit.android.room.network.ReconnectPolicy import io.livekit.android.room.participant.Participant import io.livekit.android.room.participant.ParticipantTrackPermission import io.livekit.android.room.track.TrackException @@ -99,6 +102,7 @@ import javax.inject.Singleton import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -158,6 +162,8 @@ internal constructor( @Volatile private var fullReconnectOnNext = false + internal var reconnectPolicy: ReconnectPolicy = DefaultReconnectPolicy() + private val pendingTrackResolvers: MutableMap> = mutableMapOf() @@ -526,6 +532,8 @@ internal constructor( var hasReconnectedOnce = false val reconnectStartTime = SystemClock.elapsedRealtime() + val reconnectPolicy = this@RTCEngine.reconnectPolicy + for (retries in 0 until MAX_RECONNECT_RETRIES) { // First try use previously valid url. if (retries != 0) { @@ -546,9 +554,14 @@ internal constructor( break } - var startDelay = 100 + retries.toLong() * retries * 500 - if (startDelay > 5000) { - startDelay = 5000 + val reconnectContext = ReconnectContext( + retryCount = retries, + elapsedTime = (SystemClock.elapsedRealtime() - reconnectStartTime).milliseconds, + ) + val startDelay = reconnectPolicy.getNextRetryDelay(reconnectContext) + if (startDelay == null) { + LKLog.i { "cancelling reconnection due to policy." } + break } LKLog.i { "Reconnecting to signal, attempt ${retries + 1}" } @@ -644,9 +657,15 @@ internal constructor( break } - if (connectionState == ConnectionState.CONNECTED && - (!hasPublished || publisher?.isConnected() == true) + val subscriberConnected = subscriber?.isConnected() == true + val publisherConnected = !hasPublished || publisher?.isConnected() == true + if ((connectionState == ConnectionState.CONNECTED || connectionState == ConnectionState.RESUMING) && + subscriberConnected && + publisherConnected ) { + if (connectionState == ConnectionState.RESUMING) { + connectionState = ConnectionState.CONNECTED + } if (lastMessageSeq != null) { resendReliableMessagesForResume(lastMessageSeq) } @@ -979,7 +998,7 @@ internal constructor( @VisibleForTesting const val LOSSY_DATA_CHANNEL_LABEL = "_lossy" internal const val MAX_DATA_PACKET_SIZE = 15 * 1024 // 15 KB - private const val MAX_RECONNECT_RETRIES = 10 + private const val MAX_RECONNECT_RETRIES = 30 private const val MAX_RECONNECT_TIMEOUT = 60 * 1000 private const val MAX_ICE_CONNECT_TIMEOUT_MS = 20000 diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt index d4c0d6136..77de0cfb6 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt @@ -50,6 +50,7 @@ import io.livekit.android.renderer.TextureViewRenderer import io.livekit.android.room.datastream.incoming.IncomingDataStreamManager import io.livekit.android.room.metrics.collectMetrics import io.livekit.android.room.network.NetworkCallbackManagerFactory +import io.livekit.android.room.network.ReconnectPolicy import io.livekit.android.room.participant.AudioTrackPublishDefaults import io.livekit.android.room.participant.ConnectionQuality import io.livekit.android.room.participant.LocalParticipant @@ -317,6 +318,11 @@ constructor( */ var screenShareTrackPublishDefaults: VideoTrackPublishDefaults by defaultsManager::screenShareTrackPublishDefaults + /** + * [ReconnectPolicy] to use when reconnecting to the server. + */ + var reconnectPolicy: ReconnectPolicy by engine::reconnectPolicy + val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = false).apply { internalListener = this@Room } @@ -613,6 +619,9 @@ constructor( options.screenShareTrackPublishDefaults?.let { screenShareTrackPublishDefaults = it } + options.reconnectPolicy?.let { + reconnectPolicy = it + } adaptiveStream = options.adaptiveStream dynacast = options.dynacast e2eeOptions = options.e2eeOptions diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/network/DefaultReconnectPolicy.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/network/DefaultReconnectPolicy.kt new file mode 100644 index 000000000..5593d3134 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/network/DefaultReconnectPolicy.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.network + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * A reconnect policy that takes in a list of delays to iterate through. + */ +class DefaultReconnectPolicy( + /** + * The list of delays to use. If the number of retries exceeds the size of the list, + * reconnection is cancelled. + * + * Defaults to aggressively retrying multiple times before exponentially backing off, up to 5 seconds. + */ + val retryDelays: List = DEFAULT_RETRY_DELAYS, + /** + * The max total time to try reconnecting. Defaults to 60 seconds. + */ + val maxReconnectionTimeout: Duration = DEFAULT_MAX_RECONNECTION_TIMEOUT, +) : ReconnectPolicy { + override fun getNextRetryDelay(context: ReconnectContext): Duration? { + if (context.retryCount >= retryDelays.size) { + return null + } + + if (context.elapsedTime > maxReconnectionTimeout) { + return null + } + + return retryDelays[context.retryCount] + } + + companion object { + + val DEFAULT_MAX_RECONNECTION_TIMEOUT = 60.seconds + + val DEFAULT_MAX_RETRY_DELAY = 5.seconds + + val DEFAULT_RETRY_DELAYS = listOf( + 100.milliseconds, + 300.milliseconds, // Aggressively try to reconnect a couple of times. Wifi -> LTE handoff can randomly take a while. + 300.milliseconds, + 500.milliseconds, + 500.milliseconds, + 500.milliseconds, + (2 * 2 * 300).milliseconds, + (3 * 3 * 300).milliseconds, + (4 * 4 * 300).milliseconds, + DEFAULT_MAX_RETRY_DELAY, + DEFAULT_MAX_RETRY_DELAY, + DEFAULT_MAX_RETRY_DELAY, + DEFAULT_MAX_RETRY_DELAY, + DEFAULT_MAX_RETRY_DELAY, + DEFAULT_MAX_RETRY_DELAY, + DEFAULT_MAX_RETRY_DELAY, + DEFAULT_MAX_RETRY_DELAY, + ) + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/network/ReconnectPolicy.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/network/ReconnectPolicy.kt new file mode 100644 index 000000000..eb68f540a --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/network/ReconnectPolicy.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.network + +import kotlin.time.Duration + +/** + * Policy for reconnections that determines the delay between retries. + */ +interface ReconnectPolicy { + /** + * Called after a disconnect is detected, and between each reconnect attempt. + * + * Note: To prevent infinitely retrying, there is a hard cap of 30 retries, regardless of policy. + * + * @return The [Duration] to delay before the next reconnect attempt, or null to cancel reconnections. + * + */ + fun getNextRetryDelay(context: ReconnectContext): Duration? +} + +data class ReconnectContext( + /** + * The number of failed reconnect attempts. 0 means this is the first reconnect attempt. + */ + val retryCount: Int, + + /** + * Elapsed amount of time in milliseconds since the disconnect. + */ + val elapsedTime: Duration, +)