From ec7c14a0bf9fa5f077cbc8ae8a3e2c5ef4d47bfb Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 8 Jun 2026 19:02:14 +0900 Subject: [PATCH] Handle SUBSCRIPTION_RESPONSE message from server --- .changeset/dirty-cherries-thank.md | 5 ++ .../java/io/livekit/android/room/RTCEngine.kt | 5 ++ .../main/java/io/livekit/android/room/Room.kt | 10 ++++ .../io/livekit/android/room/SignalClient.kt | 15 ++--- .../room/participant/RemoteParticipant.kt | 21 +++++++ .../room/RoomParticipantEventMockE2ETest.kt | 55 ++++++++++++++++++- 6 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 .changeset/dirty-cherries-thank.md diff --git a/.changeset/dirty-cherries-thank.md b/.changeset/dirty-cherries-thank.md new file mode 100644 index 000000000..30c865d91 --- /dev/null +++ b/.changeset/dirty-cherries-thank.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +Emit `TrackSubscriptionFailed` events through `Room` and `RemoteParticipant` when the server detects a subscription failure diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 9329ac1a4..dee0adab0 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -1030,6 +1030,7 @@ internal constructor( fun onStreamStateUpdate(streamStates: List) fun onSubscribedQualityUpdate(subscribedQualityUpdate: LivekitRtc.SubscribedQualityUpdate) fun onSubscriptionPermissionUpdate(subscriptionPermissionUpdate: LivekitRtc.SubscriptionPermissionUpdate) + fun onSubscriptionError(subscriptionResponse: LivekitRtc.SubscriptionResponse) fun onSignalConnected(isResume: Boolean) fun onFullReconnecting() suspend fun onPostReconnect(isFullReconnect: Boolean) @@ -1266,6 +1267,10 @@ internal constructor( listener?.onSubscriptionPermissionUpdate(subscriptionPermissionUpdate) } + override fun onSubscriptionError(subscriptionResponse: LivekitRtc.SubscriptionResponse) { + listener?.onSubscriptionError(subscriptionResponse) + } + override fun onRefreshToken(token: String) { sessionToken = token regionUrlProvider?.token = token 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 0b8b1f6d1..9d5c71bf3 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 @@ -1430,6 +1430,16 @@ constructor( participant.onSubscriptionPermissionUpdate(subscriptionPermissionUpdate) } + /** + * @suppress + */ + override fun onSubscriptionError(subscriptionResponse: LivekitRtc.SubscriptionResponse) { + val participant = remoteParticipants.values + .firstOrNull { it.trackPublications.containsKey(subscriptionResponse.trackSid) } as? RemoteParticipant + ?: return + participant.onSubscriptionError(subscriptionResponse) + } + /** * @suppress */ diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt index bc9b50603..5951becc6 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt @@ -833,19 +833,13 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.SUBSCRIPTION_RESPONSE -> { - // TODO + listener?.onSubscriptionError(response.subscriptionResponse) } LivekitRtc.SignalResponse.MessageCase.REQUEST_RESPONSE -> { // TODO } - LivekitRtc.SignalResponse.MessageCase.MESSAGE_NOT_SET, - null, - -> { - LKLog.v { "empty messageCase!" } - } - LivekitRtc.SignalResponse.MessageCase.ROOM_MOVED -> { // TODO } @@ -869,6 +863,12 @@ constructor( LivekitRtc.SignalResponse.MessageCase.DATA_TRACK_SUBSCRIBER_HANDLES -> { // TODO } + + LivekitRtc.SignalResponse.MessageCase.MESSAGE_NOT_SET, + null, + -> { + LKLog.v { "empty messageCase!" } + } } } @@ -954,6 +954,7 @@ constructor( fun onStreamStateUpdate(streamStates: List) fun onSubscribedQualityUpdate(subscribedQualityUpdate: LivekitRtc.SubscribedQualityUpdate) fun onSubscriptionPermissionUpdate(subscriptionPermissionUpdate: LivekitRtc.SubscriptionPermissionUpdate) + fun onSubscriptionError(subscriptionResponse: LivekitRtc.SubscriptionResponse) fun onRefreshToken(token: String) fun onLocalTrackUnpublished(trackUnpublished: LivekitRtc.TrackUnpublishedResponse) fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt index c0ac0e6e3..d80867af2 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt @@ -235,6 +235,27 @@ class RemoteParticipant( } } + internal fun onSubscriptionError(subscriptionResponse: LivekitRtc.SubscriptionResponse) { + val trackSid = subscriptionResponse.trackSid + if (trackPublications[trackSid] !is RemoteTrackPublication) { + return + } + + val exception = subscriptionErrorException(subscriptionResponse.err) + internalListener?.onTrackSubscriptionFailed(trackSid, exception, this) + eventBus.postEvent(ParticipantEvent.TrackSubscriptionFailed(this, trackSid, exception), scope) + } + + private fun subscriptionErrorException(error: LivekitModels.SubscriptionError): TrackException { + return when (error) { + LivekitModels.SubscriptionError.SE_CODEC_UNSUPPORTED -> TrackException.MediaException("Codec not supported") + LivekitModels.SubscriptionError.SE_TRACK_NOTFOUND -> TrackException.InvalidTrackStateException("Track not found") + LivekitModels.SubscriptionError.SE_UNKNOWN, + LivekitModels.SubscriptionError.UNRECOGNIZED, + -> TrackException.InvalidTrackStateException("Subscription failed") + } + } + // Internal methods just for posting events. internal fun onDataReceived(event: RoomEvent.DataReceived) { eventBus.postEvent(ParticipantEvent.DataReceived(this, event.data, event.topic, event.encryptionType), scope) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/RoomParticipantEventMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/RoomParticipantEventMockE2ETest.kt index 2019a594d..5b87a42e5 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/RoomParticipantEventMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/RoomParticipantEventMockE2ETest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,13 +19,16 @@ package io.livekit.android.room import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.room.participant.AudioTrackPublishOptions +import io.livekit.android.room.participant.RemoteParticipant import io.livekit.android.room.track.Track +import io.livekit.android.room.track.TrackException import io.livekit.android.test.MockE2ETest import io.livekit.android.test.assert.assertIsClass import io.livekit.android.test.events.EventCollector import io.livekit.android.test.mock.TestData import io.livekit.android.test.mock.room.track.createMockLocalAudioTrack import kotlinx.coroutines.ExperimentalCoroutinesApi +import livekit.LivekitModels import livekit.LivekitRtc import livekit.LivekitRtc.ParticipantUpdate import livekit.LivekitRtc.SignalResponse @@ -115,4 +118,54 @@ class RoomParticipantEventMockE2ETest : MockE2ETest() { assertIsClass(ParticipantEvent.LocalTrackSubscribed::class.java, participantEvents[0]) } } + + @Test + fun trackSubscriptionFailed() = runTest { + connect() + + wsFactory.receiveMessage(TestData.PARTICIPANT_JOIN) + + val remoteParticipant = room.getParticipantBySid(TestData.REMOTE_PARTICIPANT.sid) as RemoteParticipant + val roomCollector = EventCollector(room.events, coroutineRule.scope) + val participantCollector = EventCollector(remoteParticipant.events, coroutineRule.scope) + + wsFactory.receiveMessage( + with(SignalResponse.newBuilder()) { + subscriptionResponse = with(LivekitRtc.SubscriptionResponse.newBuilder()) { + trackSid = TestData.REMOTE_AUDIO_TRACK.sid + err = LivekitModels.SubscriptionError.SE_CODEC_UNSUPPORTED + build() + } + build() + }, + ) + + val roomEvents = roomCollector.stopCollecting() + val participantEvents = participantCollector.stopCollecting() + + // Verify room events + run { + assertEquals(1, roomEvents.size) + assertIsClass(RoomEvent.TrackSubscriptionFailed::class.java, roomEvents[0]) + + val event = roomEvents.first() as RoomEvent.TrackSubscriptionFailed + assertEquals(room, event.room) + assertEquals(TestData.REMOTE_AUDIO_TRACK.sid, event.sid) + assertEquals(remoteParticipant, event.participant) + assertIsClass(TrackException.MediaException::class.java, event.exception) + assertEquals("Codec not supported", event.exception.message) + } + + // Verify participant events + run { + assertEquals(1, participantEvents.size) + assertIsClass(ParticipantEvent.TrackSubscriptionFailed::class.java, participantEvents[0]) + + val event = participantEvents.first() as ParticipantEvent.TrackSubscriptionFailed + assertEquals(remoteParticipant, event.participant) + assertEquals(TestData.REMOTE_AUDIO_TRACK.sid, event.sid) + assertIsClass(TrackException.MediaException::class.java, event.exception) + assertEquals("Codec not supported", event.exception.message) + } + } }