Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/swift-frogs-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"client-sdk-android": patch
---

Fix silent mic race between Room.connect audio publish and setMicrophoneEnabled
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -357,23 +361,31 @@ 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
}
}

Track.Source.MICROPHONE -> {
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
}
}

Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -191,6 +197,69 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
}
}

@Test
fun connectWithAudioDoesNotStopConcurrentlyEnabledMic() = runTest {
val context = ApplicationProvider.getApplicationContext<Context>()
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()
Expand Down
Loading