From 57ad583b1267c407ebee70dba01f8d614e46efbe Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:09:54 +0200 Subject: [PATCH 1/2] Fix silent mic race between Room.connect audio publish and setMicrophoneEnabled When connecting with ConnectOptions.audio = true while the app enables the mic as soon as the room reports CONNECTED, both paths published the same default audio track. Room.connect's publish did not take the per-source publish lock, so the losing path's duplicate publish failed and its failure branch stopped the just-published track, leaving the publication unmuted but sending silence until the mic was toggled. - Route Room.connect's audio/video publishes through setMicrophoneEnabled/ setCameraEnabled so they serialize on the source publish lock, where "already published" unmutes instead of failing. - In setTrackEnabled, don't stop a track whose publish was rejected as a duplicate: it is live and owned by the concurrent publisher. - Log a warning in publishTrackImpl's previously silent duplicate branch. Co-Authored-By: Claude Fable 5 --- .../main/java/io/livekit/android/room/Room.kt | 16 ++--- .../room/participant/LocalParticipant.kt | 27 ++++++-- .../LocalParticipantMockE2ETest.kt | 69 +++++++++++++++++++ 3 files changed, 94 insertions(+), 18 deletions(-) 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 9d5c71bf..10aa8782 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 @@ -557,27 +557,21 @@ constructor( ensureActive() networkCallbackManager.registerCallback() if (options.audio) { - val audioTrack = localParticipant.getOrCreateDefaultAudioTrack() - audioTrack.prewarm() var cancelPreconnect: (() -> Unit)? = null if (audioTrackPublishDefaults.preconnect) { cancelPreconnect = startPreconnectAudioJob(roomScope = coroutineScope) } - if (!localParticipant.publishAudioTrack(audioTrack)) { - audioTrack.stop() - audioTrack.stopPrewarm() + // Enable through setMicrophoneEnabled rather than publishing directly, + // so that this serializes with any concurrent enable calls from the app + // once the room state flips to CONNECTED. + if (!localParticipant.setMicrophoneEnabled(true)) { cancelPreconnect?.invoke() } } ensureActive() if (options.video) { - val videoTrack = localParticipant.getOrCreateDefaultVideoTrack() - videoTrack.startCapture() - if (!localParticipant.publishVideoTrack(videoTrack)) { - videoTrack.stopCapture() - videoTrack.stop() - } + localParticipant.setCameraEnabled(true) } coroutineScope.launch { 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 a1fe8861..5abbc922 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 @@ -333,6 +333,10 @@ internal constructor( return setTrackEnabled(Track.Source.SCREEN_SHARE, enabled, screenCaptureParams) } + private fun isTrackPublished(track: Track): Boolean { + return localTrackPublications.any { it.track == track } + } + private suspend fun setTrackEnabled( source: Track.Source, enabled: Boolean, @@ -357,11 +361,15 @@ internal constructor( val track = getOrCreateDefaultVideoTrack() track.start() track.startCapture() - if (!publishVideoTrack(track)) { + if (publishVideoTrack(track)) { + success = true + } else if (isTrackPublished(track)) { + // A concurrent publish outside the pub lock won the race; + // the track is live, so leave it alone. + success = true + } else { track.stopCapture() track.stop() - } else { - success = true } } @@ -369,11 +377,15 @@ internal constructor( val track = getOrCreateDefaultAudioTrack() track.prewarm() track.start() - if (!publishAudioTrack(track)) { + if (publishAudioTrack(track)) { + success = true + } else if (isTrackPublished(track)) { + // A concurrent publish outside the pub lock won the race; + // the track is live, so leave it alone. + success = true + } else { track.stop() track.stopPrewarm() - } else { - success = true } } @@ -650,7 +662,8 @@ internal constructor( @Suppress("NAME_SHADOWING") var encodings = encodings - if (localTrackPublications.any { it.track == track }) { + if (isTrackPublished(track)) { + LKLog.w { "Track has already been published, not publishing again." } onPublishFailure(TrackException.PublishException("Track has already been published"), triggerEvent = false) return null } diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt index 1f300ca7..5270bb70 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt @@ -21,10 +21,12 @@ import android.app.Application import android.content.Context import androidx.test.core.app.ApplicationProvider import com.google.protobuf.ByteString +import io.livekit.android.ConnectOptions import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.room.DefaultsManager import io.livekit.android.room.RTCEngine +import io.livekit.android.room.Room import io.livekit.android.room.RoomException import io.livekit.android.room.track.LocalVideoTrack import io.livekit.android.room.track.LocalVideoTrackOptions @@ -55,9 +57,12 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import livekit.LivekitModels import livekit.LivekitModels.DataPacket import livekit.LivekitRtc @@ -67,6 +72,7 @@ import livekit.org.webrtc.RtpParameters import livekit.org.webrtc.VideoSource import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -191,6 +197,69 @@ class LocalParticipantMockE2ETest : MockE2ETest() { } } + @Test + fun connectWithAudioDoesNotStopConcurrentlyEnabledMic() = runTest { + val context = ApplicationProvider.getApplicationContext() + val shadowApplication = Shadows.shadowOf(context as Application) + shadowApplication.grantPermissions(Manifest.permission.RECORD_AUDIO) + + // Withhold the add track response so the connect job's audio publish + // is still in flight when the room state flips to CONNECTED. + var deferredAddTrack: LivekitRtc.AddTrackRequest? = null + wsFactory.registerSignalRequestHandler { request -> + if (request.hasAddTrack() && deferredAddTrack == null) { + deferredAddTrack = request.addTrack + true + } else { + false + } + } + + // Mirrors apps that enable the mic as soon as the room reports connected. + var micEnableResult: Boolean? = null + val micJob = launch { + room::state.flow + .takeWhile { it != Room.State.CONNECTED } + .collect() + micEnableResult = room.localParticipant.setMicrophoneEnabled(true) + } + + val connectJob = launch { + room.connect( + url = TestData.EXAMPLE_URL, + token = "", + options = ConnectOptions(audio = true), + ) + } + prepareSignal(TestData.JOIN) + runCurrent() + assertNotNull(deferredAddTrack) + + connectPeerConnection() + runCurrent() + + // Deliver the deferred response, letting the connect job's publish finish. + wsFactory.receiveMessage( + with(LivekitRtc.SignalResponse.newBuilder()) { + trackPublished = with(LivekitRtc.TrackPublishedResponse.newBuilder()) { + cid = deferredAddTrack!!.cid + track = TestData.LOCAL_AUDIO_TRACK + build() + } + build() + }, + ) + runCurrent() + connectJob.join() + micJob.join() + + assertEquals(true, micEnableResult) + val pub = room.localParticipant.getTrackPublication(Track.Source.MICROPHONE) + assertNotNull(pub) + assertFalse(pub!!.muted) + assertTrue(pub.track?.enabled == true) + } + @Test fun publishVideoTrackRequest() = runTest { connect() From 66b81b8023a8fe35d1314984976169a44301234a Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:24:19 +0200 Subject: [PATCH 2/2] Changeset --- .changeset/swift-frogs-drive.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/swift-frogs-drive.md diff --git a/.changeset/swift-frogs-drive.md b/.changeset/swift-frogs-drive.md new file mode 100644 index 00000000..7014c8b0 --- /dev/null +++ b/.changeset/swift-frogs-drive.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +Fix silent mic race between Room.connect audio publish and setMicrophoneEnabled