From f22a18c30766012f3f36a70a1b0c7dac969668bd Mon Sep 17 00:00:00 2001
From: Adrian Niculescu <15037449+adrian-niculescu@users.noreply.github.com>
Date: Fri, 31 Jul 2026 14:43:22 +0300
Subject: [PATCH] Fixed full reconnect republish racing concurrent publishes of
the same track
---
.changeset/violet-crabs-brake.md | 5 +
.../detekt-baseline-release.xml | 2 +-
.../room/participant/LocalParticipant.kt | 207 ++++++-
.../room/track/LocalScreencastVideoTrack.kt | 30 +-
.../android/room/track/LocalVideoTrack.kt | 46 +-
.../io/livekit/android/room/track/Track.kt | 43 +-
.../room/ConcurrentPublishMockE2ETest.kt | 581 ++++++++++++++++++
7 files changed, 879 insertions(+), 35 deletions(-)
create mode 100644 .changeset/violet-crabs-brake.md
create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/ConcurrentPublishMockE2ETest.kt
diff --git a/.changeset/violet-crabs-brake.md b/.changeset/violet-crabs-brake.md
new file mode 100644
index 000000000..a8002317d
--- /dev/null
+++ b/.changeset/violet-crabs-brake.md
@@ -0,0 +1,5 @@
+---
+"client-sdk-android": patch
+---
+
+Fixed full reconnect republish racing concurrent publishes of the same track, which could leave the mic published but silent or silently unpublished.
diff --git a/livekit-android-sdk/detekt-baseline-release.xml b/livekit-android-sdk/detekt-baseline-release.xml
index 54ebf273d..5300deaed 100644
--- a/livekit-android-sdk/detekt-baseline-release.xml
+++ b/livekit-android-sdk/detekt-baseline-release.xml
@@ -13,7 +13,7 @@
CyclomaticComplexMethod:LocalParticipant.kt$LocalParticipant$@Throws(TrackException.PublishException::class) private suspend fun publishTrackImpl( track: Track, options: TrackPublishOptions, requestConfig: AddTrackRequest.Builder.() -> Unit, encodings: List<RtpParameters.Encoding> = emptyList(), publishListener: PublishListener? = null, ): LocalTrackPublication?
CyclomaticComplexMethod:LocalParticipant.kt$LocalParticipant$private fun computeVideoEncodings( isScreenShare: Boolean, dimensions: Track.Dimensions, options: VideoTrackPublishOptions, ): List<RtpParameters.Encoding>
CyclomaticComplexMethod:LocalParticipant.kt$LocalParticipant$private suspend fun setTrackEnabled( source: Track.Source, enabled: Boolean, screenCaptureParams: ScreenCaptureParams? = null, ): Boolean
- CyclomaticComplexMethod:LocalParticipant.kt$LocalParticipant$suspend fun publishVideoTrack( track: LocalVideoTrack, options: VideoTrackPublishOptions = VideoTrackPublishOptions( null, if (track.options.isScreencast) screenShareTrackPublishDefaults else videoTrackPublishDefaults, ), publishListener: PublishListener? = null, ): Boolean
+ CyclomaticComplexMethod:LocalParticipant.kt$LocalParticipant$private suspend fun publishVideoTrackImpl( track: LocalVideoTrack, options: VideoTrackPublishOptions = VideoTrackPublishOptions( null, if (track.options.isScreencast) screenShareTrackPublishDefaults else videoTrackPublishDefaults, ), publishListener: PublishListener? = null, ): Boolean
CyclomaticComplexMethod:LocalVideoTrack.kt$LocalVideoTrack$private fun setPublishingLayersForSender( sender: RtpSender, qualities: List<LivekitRtc.SubscribedQuality>, )
CyclomaticComplexMethod:NetworkInfo.kt$AndroidNetworkInfo$override fun getNetworkType(): NetworkType
CyclomaticComplexMethod:PeerConnectionTransport.kt$@VisibleForTesting fun ensureCodecBitrates( media: MediaDescription, trackBitrates: Map<TrackBitrateInfoKey, TrackBitrateInfo>, )
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt
index 5abbc922c..60f9cbb7f 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt
@@ -86,6 +86,7 @@ import livekit.org.webrtc.SurfaceTextureHelper
import livekit.org.webrtc.VideoCapturer
import livekit.org.webrtc.VideoProcessor
import java.util.Collections
+import java.util.concurrent.ConcurrentHashMap
import javax.inject.Named
import kotlin.math.max
import kotlin.math.min
@@ -130,11 +131,20 @@ internal constructor(
private val jobs = mutableMapOf()
- // For ensuring that only one caller can execute setTrackEnabled at a time.
- // Without it, there's a potential to create multiple of the same source,
+ // Serializes all publishes and setTrackEnabled calls for a source. Without it,
+ // concurrent publishes of the same track can stop a track another path is
+ // publishing, there's a potential to create multiple of the same source, and
// Camera has deadlock issues with multiple CameraCapturers trying to activate/stop.
private val sourcePubLocks = Track.Source.entries.associateWith { Mutex() }
+ // Tracks the SDK stopped after a failed publish, keyed to the enabled-state
+ // revision of that stop. A successful publish clears only a marker that
+ // predates it, and a restart applies only while the revision is unchanged, so
+ // any consumer enabled-state transition invalidates the marker. Only marked
+ // tracks may be restarted when republishing; a track the consumer stopped
+ // stays stopped.
+ private val tracksStoppedByFailedPublish = ConcurrentHashMap