From 39f3ce4a7a453ff95dba8b750600ff4b9390de71 Mon Sep 17 00:00:00 2001 From: Davi Bittencourt <85559208+davibittencourtome@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:42:28 -0300 Subject: [PATCH] fix: skip duplicate backup codec publish instead of throwing A duplicate SubscribedQualityUpdate arriving while the backup codec publication is still in flight (sender not yet attached) re-enters addSimulcastTrack and crashed the app with IllegalStateException("VP8 already added!"). Make addSimulcastTrack idempotent: log and return null for an already-added codec, and bail out of publishAdditionalCodecForTrack so no duplicate transceiver or AddTrackRequest is created. Mirrors the JS SDK behavior. Dedup is per track instance, so a track republished after a reconnect still publishes its backup codec normally. Fixes #1000 --- .changeset/idempotent-add-simulcast-track.md | 5 ++++ .../room/participant/LocalParticipant.kt | 5 +++- .../android/room/track/LocalVideoTrack.kt | 5 ++-- .../LocalParticipantMockE2ETest.kt | 30 +++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 .changeset/idempotent-add-simulcast-track.md 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)