diff --git a/.changeset/idempotent-add-simulcast-track.md b/.changeset/idempotent-add-simulcast-track.md new file mode 100644 index 000000000..6a429e994 --- /dev/null +++ b/.changeset/idempotent-add-simulcast-track.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +Fix crash when a duplicate SubscribedQualityUpdate requests a backup codec that is already added. addSimulcastTrack now skips the duplicate instead of throwing IllegalStateException ("VP8 already added!"), matching the JS SDK behavior. 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 75ac69c11..619a540c8 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 @@ -1273,7 +1273,10 @@ internal constructor( return } val (newOptions, newEncodings) = result - val simulcastTrack = track.addSimulcastTrack(codec, newEncodings) + // A duplicate SubscribedQualityUpdate can request a codec that is already being + // published (or mid-publish, before the sender is attached). Treat it as a no-op + // instead of throwing, so a repeated update doesn't crash the session. + val simulcastTrack = track.addSimulcastTrack(codec, newEncodings) ?: return val transceiverInit = RtpTransceiverInit( RtpTransceiver.RtpTransceiverDirection.SEND_ONLY, diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt index 358ed6fb9..074e2ce08 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrack.kt @@ -430,9 +430,10 @@ constructor( return newCodecs } - internal fun addSimulcastTrack(codec: VideoCodec, encodings: List): SimulcastTrackInfo { + internal fun addSimulcastTrack(codec: VideoCodec, encodings: List): SimulcastTrackInfo? { if (this.simulcastCodecs.containsKey(codec)) { - throw IllegalStateException("$codec already added!") + LKLog.w { "$codec already added, skipping." } + return null } val simulcastTrackInfo = SimulcastTrackInfo( codec = codec.codecName, 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 0498b8ed1..43869ea83 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 @@ -340,6 +340,36 @@ class LocalParticipantMockE2ETest : MockE2ETest() { assertEquals(4, getPublisherPeerConnection().transceivers.size) } + @Test + fun addSimulcastTrackForAlreadyAddedCodecIsNoOp() = runTest { + val videoTrack = createLocalTrack() + + val first = videoTrack.addSimulcastTrack(VideoCodec.VP8, emptyList()) + val second = videoTrack.addSimulcastTrack(VideoCodec.VP8, emptyList()) + + assertNotNull(first) + assertNull(second) + } + + @Test + fun duplicateSubscribedQualityUpdateDoesNotRepublishBackupCodec() = runTest { + room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy( + videoCodec = VideoCodec.VP9.codecName, + scalabilityMode = "L3T3", + backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName), + ) + + connect() + val videoTrack = createLocalTrack() + room.localParticipant.publishVideoTrack(videoTrack) + + val trackSid = room.localParticipant.videoTrackPublications.first().first.sid + receiveSubscribedQualityUpdate(trackSid) + receiveSubscribedQualityUpdate(trackSid) + + assertEquals(2, getPublisherPeerConnection().transceivers.size) + } + @Test fun disposeDisposesVideoSource() { val source = mock(VideoSource::class.java)