From 9ea3e265d74c96d2056430b5d8b2908408e38b79 Mon Sep 17 00:00:00 2001 From: davidliu Date: Wed, 29 Jul 2026 23:31:11 +0900 Subject: [PATCH 01/18] LocalDataTrackManager impl --- gradle/libs.versions.toml | 5 +- livekit-android-sdk/build.gradle | 1 + .../src/main/AndroidManifest.xml | 7 +- .../java/io/livekit/android/room/RTCEngine.kt | 50 +++++++ .../io/livekit/android/room/SignalClient.kt | 14 +- .../room/datatrack/DataTrackManager.kt | 126 ++++++++++++++++++ .../src/main/AndroidManifest.xml | 6 +- settings.gradle | 1 + 8 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManager.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c3caf7b0..d13518683 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,8 +32,11 @@ noise = "2.0.0" lifecycleProcess = "2.8.7" agp = "8.7.2" kotlin = "1.9.25" - +livekit-uniffi = "0.0.1" [libraries] + + +livekit-uniffi = { module = "io.livekit:livekit-uniffi-android", version.ref = "livekit-uniffi" } android-jain-sip-ri = { module = "javax.sip:android-jain-sip-ri", version.ref = "androidJainSipRi" } androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" } diff --git a/livekit-android-sdk/build.gradle b/livekit-android-sdk/build.gradle index dbc69f736..b86ece0ab 100644 --- a/livekit-android-sdk/build.gradle +++ b/livekit-android-sdk/build.gradle @@ -118,6 +118,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation libs.coroutines.lib implementation libs.kotlinx.serialization.json + implementation libs.livekit.uniffi api libs.webrtc api libs.okhttp.lib implementation libs.okhttp.coroutines diff --git a/livekit-android-sdk/src/main/AndroidManifest.xml b/livekit-android-sdk/src/main/AndroidManifest.xml index 800317f7f..59d51eb48 100644 --- a/livekit-android-sdk/src/main/AndroidManifest.xml +++ b/livekit-android-sdk/src/main/AndroidManifest.xml @@ -14,7 +14,11 @@ limitations under the License. --> - + + + + @@ -31,3 +35,4 @@ android:stopWithTask="true" /> + 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 19d3464f9..1c43401c2 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 @@ -29,6 +29,7 @@ import io.livekit.android.e2ee.E2EEManager import io.livekit.android.e2ee.EncryptedPacket import io.livekit.android.events.DisconnectReason import io.livekit.android.events.convert +import io.livekit.android.room.datatrack.DataTrackManager import io.livekit.android.room.network.DefaultReconnectPolicy import io.livekit.android.room.network.ReconnectContext import io.livekit.android.room.network.ReconnectPolicy @@ -119,6 +120,7 @@ internal constructor( private val ioDispatcher: CoroutineDispatcher, private val rtcThreadToken: RTCThreadToken, private val dataPacketCryptorFactory: DataPacketCryptorManager.Factory, + private val dataTrackManager: DataTrackManager, ) : SignalClient.Listener { internal var listener: Listener? = null @@ -463,6 +465,7 @@ internal constructor( regionUrlProvider = null abortPendingPublishTracks() closeResources(reason) + dataTrackManager.close() connectionState = ConnectionState.DISCONNECTED synchronized(reliableStateLock) { @@ -690,6 +693,9 @@ internal constructor( // Is connected, notify and return. regionUrlProvider?.clearAttemptedRegions() client.onPCConnected() + if (isFullReconnect) { + dataTrackManager.republishTracks() + } listener?.onPostReconnect(isFullReconnect) return@launch } @@ -1282,6 +1288,50 @@ internal constructor( listener?.onLocalTrackUnpublished(trackUnpublished) } + override fun onPublishDataTrackResponse(response: LivekitRtc.SignalResponse) { + dataTrackManager.handleSfuPublishResponse(response.toByteArray()) + } + + override fun onRequestResponse(response: LivekitRtc.SignalResponse) { + dataTrackManager.handleSfuRequestResponse(response.toByteArray()) + } + + /** + * Forwards an encoded [LivekitRtc.SignalRequest] produced by the UniFFI data track manager. + */ + internal fun sendDataTrackSignalRequest(requestBytes: ByteArray) { + // Publishing a data track requires the publisher PC / reliable data channel. + if (!hasPublished) { + hasPublished = true + negotiatePublisher() + } + client.sendEncodedRequest(requestBytes) + } + + /** + * Sends serialized data-track packets on the reliable data channel. + * + * Packets belonging to one application frame are sent back-to-back; drop behavior when the + * channel is not open is left to the UniFFI manager / caller. + */ + internal fun sendDataTrackPackets(packets: List) { + if (packets.isEmpty()) { + return + } + val channel = dataChannelForKind(LivekitModels.DataPacket.Kind.RELIABLE) + if (channel == null || channel.state() != DataChannel.State.OPEN) { + LKLog.w { "reliable data channel not open; dropping ${packets.size} data-track packet(s)" } + return + } + for (packet in packets) { + val buf = DataChannel.Buffer(ByteBuffer.wrap(packet), true) + if (!channel.send(buf)) { + LKLog.w { "failed to send data track packet (${packet.size} bytes)" } + return + } + } + } + // --------------------------------- DataChannel.Observer ------------------------------------// fun onBufferedAmountChange(dataChannel: DataChannel, previousAmount: Long) { 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 3f2432705..5c1cdc801 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 @@ -651,6 +651,13 @@ constructor( } } + /** + * Sends a previously encoded [LivekitRtc.SignalRequest] (e.g. from UniFFI data track manager). + */ + internal fun sendEncodedRequest(requestBytes: ByteArray) { + sendRequest(LivekitRtc.SignalRequest.parseFrom(requestBytes)) + } + private fun sendRequestImpl(request: LivekitRtc.SignalRequest) { LKLog.v { "sending request: $request" } if (!isConnected || currentWs == null) { @@ -849,7 +856,8 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.REQUEST_RESPONSE -> { - // TODO + // Pass the full SignalResponse — UniFFI deserializes and filters data-track related ones. + listener?.onRequestResponse(response) } LivekitRtc.SignalResponse.MessageCase.ROOM_MOVED -> { @@ -865,7 +873,7 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.PUBLISH_DATA_TRACK_RESPONSE -> { - // TODO + listener?.onPublishDataTrackResponse(response) } LivekitRtc.SignalResponse.MessageCase.UNPUBLISH_DATA_TRACK_RESPONSE -> { @@ -970,6 +978,8 @@ constructor( fun onRefreshToken(token: String) fun onLocalTrackUnpublished(trackUnpublished: LivekitRtc.TrackUnpublishedResponse) fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed) + fun onPublishDataTrackResponse(response: LivekitRtc.SignalResponse) {} + fun onRequestResponse(response: LivekitRtc.SignalResponse) {} } /** diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManager.kt new file mode 100644 index 000000000..5e5a48c67 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManager.kt @@ -0,0 +1,126 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.room.RTCEngine +import io.livekit.android.util.LKLog +import io.livekit.uniffi.DataTrackInfo +import io.livekit.uniffi.DataTrackOptions +import io.livekit.uniffi.HandleSignalResponseException +import io.livekit.uniffi.LocalDataTrack +import io.livekit.uniffi.LocalDataTrackManager +import io.livekit.uniffi.LocalDataTrackManagerDelegate +import io.livekit.uniffi.PublishException +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * Owns the UniFFI [LocalDataTrackManager] and bridges its transport callbacks into [RTCEngine]. + * + * Signal requests / SFU responses and data-track packets are forwarded through the engine so the + * Rust manager stays decoupled from WebRTC and WebSocket details. + * + * @suppress + */ +@Singleton +class DataTrackManager +@Inject +constructor( + private val engineProvider: Provider, +) { + private val lock = Any() + private var localManager: LocalDataTrackManager? = null + + private val delegate = object : LocalDataTrackManagerDelegate { + override fun onSignalRequest(request: ByteArray) { + engineProvider.get().sendDataTrackSignalRequest(request) + } + + override fun onPacketsAvailable(packets: List) { + engineProvider.get().sendDataTrackPackets(packets) + } + } + + /** + * Publishes a data track with the given options. + * + * @throws PublishException if the SFU rejects the publication or the request fails. + */ + @Throws(PublishException::class) + suspend fun publishTrack(options: DataTrackOptions): LocalDataTrack { + return ensureManager().publishTrack(options) + } + + /** + * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing + * `PublishDataTrackResponse` to the UniFFI manager. + */ + fun handleSfuPublishResponse(responseBytes: ByteArray) { + val manager = localManager ?: return + try { + manager.handleSfuPublishResponse(responseBytes) + } catch (e: HandleSignalResponseException) { + LKLog.w(e) { "Failed to handle PublishDataTrackResponse" } + } + } + + /** + * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing `RequestResponse` + * to the UniFFI manager. Non-data-track request responses are ignored by the manager. + */ + fun handleSfuRequestResponse(responseBytes: ByteArray) { + val manager = localManager ?: return + try { + manager.handleSfuRequestResponse(responseBytes) + } catch (e: HandleSignalResponseException) { + LKLog.w(e) { "Failed to handle RequestResponse for data tracks" } + } + } + + /** + * Republish all tracks after a full reconnect so the SFU recognizes existing publications. + */ + fun republishTracks() { + localManager?.republishTracks() + } + + /** + * Returns info for all currently published data tracks. + */ + suspend fun queryTracks(): List { + return localManager?.queryTracks() ?: emptyList() + } + + /** + * Shuts down the underlying UniFFI manager. A subsequent [publishTrack] creates a new one. + */ + fun close() { + synchronized(lock) { + localManager?.close() + localManager = null + } + } + + private fun ensureManager(): LocalDataTrackManager { + synchronized(lock) { + localManager?.let { return it } + // Encryption provider wiring is left for a follow-up (E2EE). + return LocalDataTrackManager(delegate, null).also { localManager = it } + } + } +} diff --git a/livekit-android-test/src/main/AndroidManifest.xml b/livekit-android-test/src/main/AndroidManifest.xml index 8bdb7e14b..c80920c0d 100644 --- a/livekit-android-test/src/main/AndroidManifest.xml +++ b/livekit-android-test/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ - + + + + diff --git a/settings.gradle b/settings.gradle index b5882cbb9..e5a958974 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,6 +17,7 @@ dependencyResolutionManagement { google() mavenCentral() maven { url 'https://jitpack.io' } + mavenLocal() } } include ':sample-app', ':sample-app-compose', ':livekit-android-sdk' From 64a5d4a827e5c1337b4729a739ff700b952da808 Mon Sep 17 00:00:00 2001 From: davidliu Date: Wed, 29 Jul 2026 23:32:57 +0900 Subject: [PATCH 02/18] Move to OutgoingDataTrackManager --- .../{DataTrackManager.kt => OutgoingDataTrackManager.kt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/{DataTrackManager.kt => OutgoingDataTrackManager.kt} (99%) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt similarity index 99% rename from livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManager.kt rename to livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt index 5e5a48c67..fcfea5fa6 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt @@ -38,7 +38,7 @@ import javax.inject.Singleton * @suppress */ @Singleton -class DataTrackManager +class OutgoingDataTrackManager @Inject constructor( private val engineProvider: Provider, From fea2afe94de5047dfd820b943690a7cbdac6ad6b Mon Sep 17 00:00:00 2001 From: davidliu Date: Wed, 29 Jul 2026 23:33:22 +0900 Subject: [PATCH 03/18] more migration --- .../main/java/io/livekit/android/room/RTCEngine.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 1c43401c2..4bab7f237 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 @@ -29,7 +29,7 @@ import io.livekit.android.e2ee.E2EEManager import io.livekit.android.e2ee.EncryptedPacket import io.livekit.android.events.DisconnectReason import io.livekit.android.events.convert -import io.livekit.android.room.datatrack.DataTrackManager +import io.livekit.android.room.datatrack.OutgoingDataTrackManager import io.livekit.android.room.network.DefaultReconnectPolicy import io.livekit.android.room.network.ReconnectContext import io.livekit.android.room.network.ReconnectPolicy @@ -120,7 +120,7 @@ internal constructor( private val ioDispatcher: CoroutineDispatcher, private val rtcThreadToken: RTCThreadToken, private val dataPacketCryptorFactory: DataPacketCryptorManager.Factory, - private val dataTrackManager: DataTrackManager, + private val outgoingDataTrackManager: OutgoingDataTrackManager, ) : SignalClient.Listener { internal var listener: Listener? = null @@ -465,7 +465,7 @@ internal constructor( regionUrlProvider = null abortPendingPublishTracks() closeResources(reason) - dataTrackManager.close() + outgoingDataTrackManager.close() connectionState = ConnectionState.DISCONNECTED synchronized(reliableStateLock) { @@ -694,7 +694,7 @@ internal constructor( regionUrlProvider?.clearAttemptedRegions() client.onPCConnected() if (isFullReconnect) { - dataTrackManager.republishTracks() + outgoingDataTrackManager.republishTracks() } listener?.onPostReconnect(isFullReconnect) return@launch @@ -1289,11 +1289,11 @@ internal constructor( } override fun onPublishDataTrackResponse(response: LivekitRtc.SignalResponse) { - dataTrackManager.handleSfuPublishResponse(response.toByteArray()) + outgoingDataTrackManager.handleSfuPublishResponse(response.toByteArray()) } override fun onRequestResponse(response: LivekitRtc.SignalResponse) { - dataTrackManager.handleSfuRequestResponse(response.toByteArray()) + outgoingDataTrackManager.handleSfuRequestResponse(response.toByteArray()) } /** From 76a596407c0a5da7f412faf72d20a3f7e53967f1 Mon Sep 17 00:00:00 2001 From: davidliu Date: Thu, 30 Jul 2026 21:06:35 +0900 Subject: [PATCH 04/18] Remote data track manager --- .../java/io/livekit/android/room/RTCEngine.kt | 90 +++++++++++- .../io/livekit/android/room/SignalClient.kt | 3 +- .../datatrack/IncomingDataTrackManager.kt | 128 ++++++++++++++++++ 3 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt 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 4bab7f237..5387bfb7a 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 @@ -29,6 +29,7 @@ import io.livekit.android.e2ee.E2EEManager import io.livekit.android.e2ee.EncryptedPacket import io.livekit.android.events.DisconnectReason import io.livekit.android.events.convert +import io.livekit.android.room.datatrack.IncomingDataTrackManager import io.livekit.android.room.datatrack.OutgoingDataTrackManager import io.livekit.android.room.network.DefaultReconnectPolicy import io.livekit.android.room.network.ReconnectContext @@ -121,6 +122,7 @@ internal constructor( private val rtcThreadToken: RTCThreadToken, private val dataPacketCryptorFactory: DataPacketCryptorManager.Factory, private val outgoingDataTrackManager: OutgoingDataTrackManager, + private val incomingDataTrackManager: IncomingDataTrackManager, ) : SignalClient.Listener { internal var listener: Listener? = null @@ -176,6 +178,7 @@ internal constructor( private var connectOptions: ConnectOptions? = null private var lastRoomOptions: RoomOptions? = null private var participantSid: String? = null + private var localParticipantIdentity: String? = null internal val serverVersion: Semver? get() = client.serverVersion @@ -193,6 +196,8 @@ internal constructor( private var reliableDataChannelSub: DataChannel? = null private var lossyDataChannel: DataChannel? = null private var lossyDataChannelSub: DataChannel? = null + private var dataTrackDataChannel: DataChannel? = null + private var dataTrackDataChannelSub: DataChannel? = null private var reliableDataChannelManager: DataChannelManager? = null private var reliableBufferedAmountJob: Job? = null private var reliableDataChannelSubManager: DataChannelManager? = null @@ -261,6 +266,12 @@ internal constructor( val joinResponse = client.join(url, token, options, roomOptions) ensureActive() + if (joinResponse.hasParticipant()) { + localParticipantIdentity = joinResponse.participant.identity + } + // UniFFI AAR lacks handleSfuJoinResponse; feed other participants as a ParticipantUpdate. + feedJoinDataTracks(joinResponse) + listener?.onJoinResponse(joinResponse) isClosed = false listener?.onSignalConnected(false) @@ -326,6 +337,7 @@ internal constructor( when (dataChannel.label()) { RELIABLE_DATA_CHANNEL_LABEL -> reliableDataChannelSub = dataChannel LOSSY_DATA_CHANNEL_LABEL -> lossyDataChannelSub = dataChannel + DATA_TRACK_DATA_CHANNEL_LABEL -> dataTrackDataChannelSub = dataChannel else -> return@onDataChannel } dataChannel.registerObserver(DataChannelObserver(dataChannel)) @@ -379,6 +391,20 @@ internal constructor( dataChannel.registerObserver(lossyDataChannelManager) } } + + ensureActive() + // Lossy unordered channel for data-track packets (same Init as lossy). + val dataTrackInit = DataChannel.Init() + dataTrackInit.ordered = false + dataTrackInit.maxRetransmits = 0 + dataTrackDataChannel = publisher?.withPeerConnection { + createDataChannel( + DATA_TRACK_DATA_CHANNEL_LABEL, + dataTrackInit, + ).also { dataChannel -> + dataChannel.registerObserver(DataChannelObserver(dataChannel)) + } + } } } } @@ -462,10 +488,12 @@ internal constructor( connectOptions = null lastRoomOptions = null participantSid = null + localParticipantIdentity = null regionUrlProvider = null abortPendingPublishTracks() closeResources(reason) outgoingDataTrackManager.close() + incomingDataTrackManager.close() connectionState = ConnectionState.DISCONNECTED synchronized(reliableStateLock) { @@ -500,6 +528,8 @@ internal constructor( lossyDataChannelSubManager?.dispose() lossyDataChannelSubManager = null lossyDataChannelSub = null + dataTrackDataChannel = null + dataTrackDataChannelSub = null isSubscriberPrimary = false } } @@ -695,6 +725,7 @@ internal constructor( client.onPCConnected() if (isFullReconnect) { outgoingDataTrackManager.republishTracks() + incomingDataTrackManager.resendSubscriptionUpdates() } listener?.onPostReconnect(isFullReconnect) return@launch @@ -1062,6 +1093,15 @@ internal constructor( */ @VisibleForTesting const val LOSSY_DATA_CHANNEL_LABEL = "_lossy" + + /** + * Dedicated data channel for LiveKit data-track packets. + * + * @suppress + */ + @VisibleForTesting + const val DATA_TRACK_DATA_CHANNEL_LABEL = "_data_track" + internal const val TARGET_DATA_PACKET_SIZE = 15 * 1024 // 15 KB /** @@ -1200,6 +1240,18 @@ internal constructor( override fun onParticipantUpdate(updates: List) { listener?.onUpdateParticipants(updates) + val identity = localParticipantIdentity + if (identity != null) { + val responseBytes = LivekitRtc.SignalResponse.newBuilder() + .setUpdate( + LivekitRtc.ParticipantUpdate.newBuilder() + .addAllParticipants(updates) + .build(), + ) + .build() + .toByteArray() + incomingDataTrackManager.handleSfuParticipantUpdate(responseBytes, identity) + } } override fun onSpeakersChanged(speakers: List) { @@ -1296,11 +1348,15 @@ internal constructor( outgoingDataTrackManager.handleSfuRequestResponse(response.toByteArray()) } + override fun onDataTrackSubscriberHandles(response: LivekitRtc.SignalResponse) { + incomingDataTrackManager.handleSubscriberHandles(response.toByteArray()) + } + /** - * Forwards an encoded [LivekitRtc.SignalRequest] produced by the UniFFI data track manager. + * Forwards an encoded [LivekitRtc.SignalRequest] produced by a UniFFI data track manager. */ internal fun sendDataTrackSignalRequest(requestBytes: ByteArray) { - // Publishing a data track requires the publisher PC / reliable data channel. + // Data-track publish / subscribe signaling requires the publisher PC / `_data_track` DC. if (!hasPublished) { hasPublished = true negotiatePublisher() @@ -1309,7 +1365,7 @@ internal constructor( } /** - * Sends serialized data-track packets on the reliable data channel. + * Sends serialized data-track packets on the dedicated `_data_track` data channel. * * Packets belonging to one application frame are sent back-to-back; drop behavior when the * channel is not open is left to the UniFFI manager / caller. @@ -1318,9 +1374,9 @@ internal constructor( if (packets.isEmpty()) { return } - val channel = dataChannelForKind(LivekitModels.DataPacket.Kind.RELIABLE) + val channel = dataTrackDataChannel if (channel == null || channel.state() != DataChannel.State.OPEN) { - LKLog.w { "reliable data channel not open; dropping ${packets.size} data-track packet(s)" } + LKLog.w { "data track channel not open; dropping ${packets.size} packet(s)" } return } for (packet in packets) { @@ -1332,6 +1388,26 @@ internal constructor( } } + /** + * Feeds remote participants from [JoinResponse] into [IncomingDataTrackManager] as a + * synthetic participant update (the published UniFFI AAR has no join-specific entry point). + */ + private fun feedJoinDataTracks(joinResponse: JoinResponse) { + val identity = localParticipantIdentity ?: return + if (joinResponse.otherParticipantsList.isEmpty()) { + return + } + val responseBytes = LivekitRtc.SignalResponse.newBuilder() + .setUpdate( + LivekitRtc.ParticipantUpdate.newBuilder() + .addAllParticipants(joinResponse.otherParticipantsList) + .build(), + ) + .build() + .toByteArray() + incomingDataTrackManager.handleSfuParticipantUpdate(responseBytes, identity) + } + // --------------------------------- DataChannel.Observer ------------------------------------// fun onBufferedAmountChange(dataChannel: DataChannel, previousAmount: Long) { @@ -1344,6 +1420,10 @@ internal constructor( if (buffer == null) { return } + if (dataChannel.label() == DATA_TRACK_DATA_CHANNEL_LABEL) { + incomingDataTrackManager.handlePacketReceived(ByteString.copyFrom(buffer.data).toByteArray()) + return + } var dp = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffer.data)) if (dp.sequence > 0 && dp.participantSid.isNotEmpty()) { 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 5c1cdc801..aeefe173c 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 @@ -881,7 +881,7 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.DATA_TRACK_SUBSCRIBER_HANDLES -> { - // TODO + listener?.onDataTrackSubscriberHandles(response) } LivekitRtc.SignalResponse.MessageCase.MESSAGE_NOT_SET, @@ -980,6 +980,7 @@ constructor( fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed) fun onPublishDataTrackResponse(response: LivekitRtc.SignalResponse) {} fun onRequestResponse(response: LivekitRtc.SignalResponse) {} + fun onDataTrackSubscriberHandles(response: LivekitRtc.SignalResponse) {} } /** diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt new file mode 100644 index 000000000..35668ee45 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt @@ -0,0 +1,128 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.room.RTCEngine +import io.livekit.android.util.LKLog +import io.livekit.uniffi.DataTrackSid +import io.livekit.uniffi.HandleSignalResponseException +import io.livekit.uniffi.RemoteDataTrack +import io.livekit.uniffi.RemoteDataTrackManager +import io.livekit.uniffi.RemoteDataTrackManagerDelegate +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * Owns the UniFFI [RemoteDataTrackManager] and bridges its transport callbacks into [RTCEngine]. + * + * SFU participant / subscriber-handle responses and `_data_track` channel packets are forwarded + * into the Rust manager; subscription signal requests are sent back out through the engine. + * + * @suppress + */ +@Singleton +class IncomingDataTrackManager +@Inject +constructor( + private val engineProvider: Provider, +) { + /** + * Optional listener for remote data-track publication events. + */ + interface Listener { + fun onTrackPublished(track: RemoteDataTrack) + fun onTrackUnpublished(sid: DataTrackSid) + } + + var listener: Listener? = null + + private val lock = Any() + private var remoteManager: RemoteDataTrackManager? = null + + private val delegate = object : RemoteDataTrackManagerDelegate { + override fun onSignalRequest(request: ByteArray) { + engineProvider.get().sendDataTrackSignalRequest(request) + } + + override fun onTrackPublished(track: RemoteDataTrack) { + listener?.onTrackPublished(track) + ?: LKLog.d { "Remote data track published: ${track.info().sid}" } + } + + override fun onTrackUnpublished(sid: DataTrackSid) { + listener?.onTrackUnpublished(sid) + ?: LKLog.d { "Remote data track unpublished: $sid" } + } + } + + /** + * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing a `ParticipantUpdate` + * (or an equivalent update built from join `otherParticipants`) to the UniFFI manager. + */ + fun handleSfuParticipantUpdate(responseBytes: ByteArray, localParticipantIdentity: String) { + try { + ensureManager().handleSfuParticipantUpdate(responseBytes, localParticipantIdentity) + } catch (e: HandleSignalResponseException) { + LKLog.w(e) { "Failed to handle participant update for data tracks" } + } + } + + /** + * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing + * `DataTrackSubscriberHandles` to the UniFFI manager. + */ + fun handleSubscriberHandles(responseBytes: ByteArray) { + try { + ensureManager().handleSubscriberHandles(responseBytes) + } catch (e: HandleSignalResponseException) { + LKLog.w(e) { "Failed to handle DataTrackSubscriberHandles" } + } + } + + /** + * Forwards a packet received on the `_data_track` data channel to the UniFFI manager. + */ + fun handlePacketReceived(packet: ByteArray) { + ensureManager().handlePacketReceived(packet) + } + + /** + * Resend subscription updates after a full reconnect so the SFU knows which tracks are subscribed. + */ + fun resendSubscriptionUpdates() { + remoteManager?.resendSubscriptionUpdates() + } + + /** + * Shuts down the underlying UniFFI manager. A subsequent handle call creates a new one. + */ + fun close() { + synchronized(lock) { + remoteManager?.close() + remoteManager = null + } + } + + private fun ensureManager(): RemoteDataTrackManager { + synchronized(lock) { + remoteManager?.let { return it } + // Decryption provider wiring is left for a follow-up (E2EE). + return RemoteDataTrackManager(delegate, null).also { remoteManager = it } + } + } +} From 713a7928661093a12331b8b8c0dae9fc05ae21cb Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 17 Aug 2026 23:16:14 +0900 Subject: [PATCH 05/18] sdk wrappers --- .../room/datatrack/DataTrackException.kt | 143 +++++++++++ .../android/room/datatrack/DataTrackFrame.kt | 78 ++++++ .../android/room/datatrack/DataTrackInfo.kt | 45 ++++ .../room/datatrack/DataTrackPublishOptions.kt | 31 +++ .../android/room/datatrack/DataTrackSchema.kt | 240 ++++++++++++++++++ .../android/room/datatrack/DataTrackSid.kt | 31 +++ .../android/room/datatrack/DataTrackStream.kt | 55 ++++ .../datatrack/IncomingDataTrackManager.kt | 25 +- .../android/room/datatrack/LocalDataTrack.kt | 147 +++++++++++ .../datatrack/OutgoingDataTrackManager.kt | 30 ++- .../android/room/datatrack/RemoteDataTrack.kt | 102 ++++++++ 11 files changed, 909 insertions(+), 18 deletions(-) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrame.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackInfo.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublishOptions.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSid.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackStream.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt new file mode 100644 index 000000000..47bc1732e --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt @@ -0,0 +1,143 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import uniffi.livekit_datatrack.DataTrackSubscribeException as FfiSubscribeException +import uniffi.livekit_datatrack.PublishException as FfiPublishException +import uniffi.livekit_datatrack.PushFrameErrorReason as FfiPushFrameErrorReason + +/** + * An error raised while publishing a [LocalDataTrack]. + */ +sealed class DataTrackPublishException(message: String, cause: Throwable? = null) : Exception(message, cause) { + /** + * The participant is not permitted to publish data tracks. + */ + class NotAllowed(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * A data track with the same name is already published by this participant. + */ + class DuplicateName(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * The requested track name is invalid. + */ + class InvalidName(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * The SFU did not respond to the publish request in time. + */ + class Timeout(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * The maximum number of data tracks for this participant has been reached. + */ + class LimitReached(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * The connection was lost before the publish completed. + */ + class Disconnected(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * The track's schema metadata is invalid. + */ + class InvalidSchema(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * An unexpected internal error occurred. + */ + class Internal(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) +} + +/** + * The reason a frame could not be pushed via [LocalDataTrack.tryPush]. + */ +sealed class DataTrackPushFrameException(message: String, cause: Throwable? = null) : Exception(message, cause) { + /** + * The track has been unpublished, by either the local participant or the SFU. + */ + class TrackUnpublished(message: String, cause: Throwable? = null) : DataTrackPushFrameException(message, cause) + + /** + * The send queue is full; the frame was not enqueued. + * + * The rejected [frame] — the same instance that was pushed, not a copy — comes back so it can + * be retried or re-queued. Mainly for [LocalDataTrack.send], where frames come from a [kotlinx.coroutines.flow.Flow] + * and the caller holds no reference of its own. + */ + class QueueFull( + message: String, + val frame: DataTrackFrame, + cause: Throwable? = null, + ) : DataTrackPushFrameException(message, cause) + + /** + * An unexpected internal error occurred. + */ + class Internal(message: String, cause: Throwable? = null) : DataTrackPushFrameException(message, cause) +} + +/** + * An error raised while subscribing to a [RemoteDataTrack]. + */ +sealed class DataTrackSubscribeException(message: String, cause: Throwable? = null) : Exception(message, cause) { + /** + * The track was unpublished before the subscription completed. + */ + class Unpublished(message: String, cause: Throwable? = null) : DataTrackSubscribeException(message, cause) + + /** + * The SFU did not respond to the subscribe request in time. + */ + class Timeout(message: String, cause: Throwable? = null) : DataTrackSubscribeException(message, cause) + + /** + * The connection was lost before the subscription completed. + */ + class Disconnected(message: String, cause: Throwable? = null) : DataTrackSubscribeException(message, cause) + + /** + * An unexpected internal error occurred. + */ + class Internal(message: String, cause: Throwable? = null) : DataTrackSubscribeException(message, cause) +} + +@Suppress("CyclomaticComplexMethod") // Mechanical 1:1 mapping of UniFFI publish error cases. +internal fun FfiPublishException.toSdk(): DataTrackPublishException = when (this) { + is FfiPublishException.NotAllowed -> DataTrackPublishException.NotAllowed(message ?: "", this) + is FfiPublishException.DuplicateName -> DataTrackPublishException.DuplicateName(message ?: "", this) + is FfiPublishException.InvalidName -> DataTrackPublishException.InvalidName(message ?: "", this) + is FfiPublishException.Timeout -> DataTrackPublishException.Timeout(message ?: "", this) + is FfiPublishException.LimitReached -> DataTrackPublishException.LimitReached(message ?: "", this) + is FfiPublishException.Disconnected -> DataTrackPublishException.Disconnected(message ?: "", this) + is FfiPublishException.InvalidSchema -> DataTrackPublishException.InvalidSchema(message ?: "", this) + is FfiPublishException.Internal -> DataTrackPublishException.Internal(message ?: "", this) +} + +internal fun FfiPushFrameErrorReason.toSdk(frame: DataTrackFrame): DataTrackPushFrameException = when (this) { + is FfiPushFrameErrorReason.TrackUnpublished -> DataTrackPushFrameException.TrackUnpublished(message ?: "", this) + is FfiPushFrameErrorReason.QueueFull -> DataTrackPushFrameException.QueueFull(message ?: "", frame, this) +} + +internal fun FfiSubscribeException.toSdk(): DataTrackSubscribeException = when (this) { + is FfiSubscribeException.Unpublished -> DataTrackSubscribeException.Unpublished(message ?: "", this) + is FfiSubscribeException.Timeout -> DataTrackSubscribeException.Timeout(message ?: "", this) + is FfiSubscribeException.Disconnected -> DataTrackSubscribeException.Disconnected(message ?: "", this) + is FfiSubscribeException.Internal -> DataTrackSubscribeException.Internal(message ?: "", this) +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrame.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrame.kt new file mode 100644 index 000000000..cb3a9d6c7 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrame.kt @@ -0,0 +1,78 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.uniffi.DataTrackFrame as FfiDataTrackFrame + +/** + * A single unit of application data sent or received over a data track. + * + * @param payload The application payload carried by this frame. + * @param userTimestamp Optional sender-provided timestamp, opaque to the SDK and carried + * end-to-end unmodified. Publisher and subscriber agree on what it means, so a sensor's clock + * works as well as wall time. [now] and [durationSinceTimestampMs] are the exception — they + * read it as milliseconds since the Unix epoch. + */ +class DataTrackFrame( + val payload: ByteArray, + val userTimestamp: Long? = null, +) { + /** + * How long ago the frame was stamped, in milliseconds, or `null` if it carries no timestamp + * or the timestamp lies in the future. + * + * Assumes [userTimestamp] is a Unix timestamp in milliseconds, as set by [now]. + */ + val durationSinceTimestampMs: Long? + get() { + val timestamp = userTimestamp ?: return null + val elapsed = System.currentTimeMillis() - timestamp + return elapsed.takeIf { it >= 0 } + } + + internal constructor(ffi: FfiDataTrackFrame) : this( + payload = ffi.payload, + userTimestamp = ffi.userTimestamp?.toLong(), + ) + + internal fun toFfi(): FfiDataTrackFrame = FfiDataTrackFrame( + payload = payload, + userTimestamp = userTimestamp?.toULong(), + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DataTrackFrame) return false + return payload.contentEquals(other.payload) && userTimestamp == other.userTimestamp + } + + override fun hashCode(): Int { + var result = payload.contentHashCode() + result = 31 * result + (userTimestamp?.hashCode() ?: 0) + return result + } + + companion object { + /** + * Creates a frame stamped with the current time, in milliseconds since the Unix epoch. + */ + @JvmStatic + fun now(payload: ByteArray): DataTrackFrame { + return DataTrackFrame(payload, System.currentTimeMillis()) + } + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackInfo.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackInfo.kt new file mode 100644 index 000000000..cd165ff4b --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackInfo.kt @@ -0,0 +1,45 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.uniffi.DataTrackInfo as FfiDataTrackInfo + +/** + * Metadata describing a published data track. + * + * @param sid Server-assigned unique identifier for the track. Not stable across a publisher's + * full reconnect; see [DataTrackSid]. + * @param name Name chosen by the publisher; unique per participant. + * @param usesE2ee Whether the track's frames are end-to-end encrypted. + * @param schema Schema describing the track's frames, if the publisher declared one. + * @param frameEncoding Encoding of the track's frames, if the publisher declared one. + */ +data class DataTrackInfo( + val sid: DataTrackSid, + val name: String, + val usesE2ee: Boolean, + val schema: DataTrackSchemaId?, + val frameEncoding: DataTrackFrameEncoding?, +) { + internal constructor(ffi: FfiDataTrackInfo) : this( + sid = DataTrackSid(ffi.sid), + name = ffi.name, + usesE2ee = ffi.usesE2ee, + schema = ffi.schema?.let { DataTrackSchemaId(it) }, + frameEncoding = ffi.frameEncoding?.let { DataTrackFrameEncoding.fromFfi(it) }, + ) +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublishOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublishOptions.kt new file mode 100644 index 000000000..2a1b0d70b --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublishOptions.kt @@ -0,0 +1,31 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +/** + * Options for publishing a data track. + * + * A schema always describes frames in a specific encoding, so [frameEncoding] should be set + * whenever [schema] is. The declared metadata is surfaced to subscribers via [DataTrackInfo]. + * + * @param schema Schema describing the track's frames. + * @param frameEncoding Encoding of the track's frames. + */ +data class DataTrackPublishOptions( + val schema: DataTrackSchemaId? = null, + val frameEncoding: DataTrackFrameEncoding? = null, +) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt new file mode 100644 index 000000000..2c6e5e71b --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt @@ -0,0 +1,240 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.uniffi.DataTrackSchemaId as FfiSchemaId +import uniffi.livekit_datatrack.DataTrackFrameEncoding as FfiFrameEncoding +import uniffi.livekit_datatrack.DataTrackSchemaEncoding as FfiSchemaEncoding + +/** + * Identifies the schema describing a data track's frames. + * + * @param name Schema name, unique within the room. + * @param encoding Encoding of the schema definition itself. + */ +data class DataTrackSchemaId( + val name: String, + val encoding: DataTrackSchemaEncoding, +) { + internal constructor(ffi: FfiSchemaId) : this( + name = ffi.name, + encoding = DataTrackSchemaEncoding.fromFfi(ffi.encoding), + ) + + internal fun toFfi(): FfiSchemaId = FfiSchemaId( + name = name, + encoding = encoding.toFfi(), + ) +} + +/** + * Encoding of a data track schema definition. + * + * Identifiers naming a well-known encoding always map to that case, so a custom encoding cannot + * shadow one. + */ +sealed class DataTrackSchemaEncoding { + /** + * Stable string form. Identifiers naming a well-known encoding always map to that case. + */ + abstract val identifier: String + + /** Protocol Buffers schema (`.proto`), describing `protobuf`-encoded frames. */ + data object Protobuf : DataTrackSchemaEncoding() { + override val identifier: String = "protobuf" + } + + /** FlatBuffers schema (`.fbs`), describing `flatbuffer`-encoded frames. */ + data object Flatbuffer : DataTrackSchemaEncoding() { + override val identifier: String = "flatbuffer" + } + + /** ROS 1 message definition, describing `ros1`-encoded frames. */ + data object Ros1Msg : DataTrackSchemaEncoding() { + override val identifier: String = "ros1msg" + } + + /** ROS 2 message definition, describing `cdr`-encoded frames. */ + data object Ros2Msg : DataTrackSchemaEncoding() { + override val identifier: String = "ros2msg" + } + + /** ROS 2 IDL definition, describing `cdr`-encoded frames. */ + data object Ros2Idl : DataTrackSchemaEncoding() { + override val identifier: String = "ros2idl" + } + + /** OMG IDL definition, describing `cdr`-encoded frames. */ + data object OmgIdl : DataTrackSchemaEncoding() { + override val identifier: String = "omgidl" + } + + /** JSON Schema, describing `json`-encoded frames. */ + data object JsonSchema : DataTrackSchemaEncoding() { + override val identifier: String = "jsonschema" + } + + /** Another well-known encoding not known to this client version. */ + data object Other : DataTrackSchemaEncoding() { + override val identifier: String = "other" + } + + /** + * An application-specific encoding identified by [identifier]. + */ + data class Custom(override val identifier: String) : DataTrackSchemaEncoding() + + internal fun toFfi(): FfiSchemaEncoding = when (this) { + Protobuf -> FfiSchemaEncoding.Protobuf + Flatbuffer -> FfiSchemaEncoding.Flatbuffer + Ros1Msg -> FfiSchemaEncoding.Ros1Msg + Ros2Msg -> FfiSchemaEncoding.Ros2Msg + Ros2Idl -> FfiSchemaEncoding.Ros2Idl + OmgIdl -> FfiSchemaEncoding.OmgIdl + JsonSchema -> FfiSchemaEncoding.JsonSchema + Other -> FfiSchemaEncoding.Other + is Custom -> FfiSchemaEncoding.Custom(identifier) + } + + companion object { + /** + * Creates an encoding from its [identifier]; unrecognized identifiers become [Custom]. + */ + fun fromIdentifier(identifier: String): DataTrackSchemaEncoding = when (identifier) { + "protobuf" -> Protobuf + "flatbuffer" -> Flatbuffer + "ros1msg" -> Ros1Msg + "ros2msg" -> Ros2Msg + "ros2idl" -> Ros2Idl + "omgidl" -> OmgIdl + "jsonschema" -> JsonSchema + "other" -> Other + else -> Custom(identifier) + } + + internal fun fromFfi(ffi: FfiSchemaEncoding): DataTrackSchemaEncoding = when (ffi) { + FfiSchemaEncoding.Protobuf -> Protobuf + FfiSchemaEncoding.Flatbuffer -> Flatbuffer + FfiSchemaEncoding.Ros1Msg -> Ros1Msg + FfiSchemaEncoding.Ros2Msg -> Ros2Msg + FfiSchemaEncoding.Ros2Idl -> Ros2Idl + FfiSchemaEncoding.OmgIdl -> OmgIdl + FfiSchemaEncoding.JsonSchema -> JsonSchema + FfiSchemaEncoding.Other -> Other + is FfiSchemaEncoding.Custom -> Custom(ffi.v1) + } + } +} + +/** + * Encoding of the frames sent over a data track. + * + * Identifiers naming a well-known encoding always map to that case, so a custom encoding cannot + * shadow one. + */ +sealed class DataTrackFrameEncoding { + /** + * Stable string form. Identifiers naming a well-known encoding always map to that case. + */ + abstract val identifier: String + + /** ROS 1. */ + data object Ros1 : DataTrackFrameEncoding() { + override val identifier: String = "ros1" + } + + /** CDR (ROS 2 / OMG IDL). */ + data object Cdr : DataTrackFrameEncoding() { + override val identifier: String = "cdr" + } + + /** Protocol Buffers. */ + data object Protobuf : DataTrackFrameEncoding() { + override val identifier: String = "protobuf" + } + + /** FlatBuffers. */ + data object Flatbuffer : DataTrackFrameEncoding() { + override val identifier: String = "flatbuffer" + } + + /** CBOR, self-describing. */ + data object Cbor : DataTrackFrameEncoding() { + override val identifier: String = "cbor" + } + + /** MessagePack, self-describing. */ + data object Msgpack : DataTrackFrameEncoding() { + override val identifier: String = "msgpack" + } + + /** JSON, self-describing. */ + data object Json : DataTrackFrameEncoding() { + override val identifier: String = "json" + } + + /** Another well-known encoding not known to this client version. */ + data object Other : DataTrackFrameEncoding() { + override val identifier: String = "other" + } + + /** + * An application-specific encoding identified by [identifier]. + */ + data class Custom(override val identifier: String) : DataTrackFrameEncoding() + + internal fun toFfi(): FfiFrameEncoding = when (this) { + Ros1 -> FfiFrameEncoding.Ros1 + Cdr -> FfiFrameEncoding.Cdr + Protobuf -> FfiFrameEncoding.Protobuf + Flatbuffer -> FfiFrameEncoding.Flatbuffer + Cbor -> FfiFrameEncoding.Cbor + Msgpack -> FfiFrameEncoding.Msgpack + Json -> FfiFrameEncoding.Json + Other -> FfiFrameEncoding.Other + is Custom -> FfiFrameEncoding.Custom(identifier) + } + + companion object { + /** + * Creates an encoding from its [identifier]; unrecognized identifiers become [Custom]. + */ + fun fromIdentifier(identifier: String): DataTrackFrameEncoding = when (identifier) { + "ros1" -> Ros1 + "cdr" -> Cdr + "protobuf" -> Protobuf + "flatbuffer" -> Flatbuffer + "cbor" -> Cbor + "msgpack" -> Msgpack + "json" -> Json + "other" -> Other + else -> Custom(identifier) + } + + internal fun fromFfi(ffi: FfiFrameEncoding): DataTrackFrameEncoding = when (ffi) { + FfiFrameEncoding.Ros1 -> Ros1 + FfiFrameEncoding.Cdr -> Cdr + FfiFrameEncoding.Protobuf -> Protobuf + FfiFrameEncoding.Flatbuffer -> Flatbuffer + FfiFrameEncoding.Cbor -> Cbor + FfiFrameEncoding.Msgpack -> Msgpack + FfiFrameEncoding.Json -> Json + FfiFrameEncoding.Other -> Other + is FfiFrameEncoding.Custom -> Custom(ffi.v1) + } + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSid.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSid.kt new file mode 100644 index 000000000..8440fd2b0 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSid.kt @@ -0,0 +1,31 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import kotlinx.serialization.Serializable + +/** + * A server-assigned data track identifier. + * + * SIDs are not stable across a publisher's full reconnect: the track object survives and its SID + * is rewritten in place. Prefer [RemoteDataTrack.name] when keying a map of remote tracks. + */ +@Serializable +@JvmInline +value class DataTrackSid(val value: String) { + override fun toString(): String = value +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackStream.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackStream.kt new file mode 100644 index 000000000..571b311b9 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackStream.kt @@ -0,0 +1,55 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import io.livekit.uniffi.DataTrackStream as FfiDataTrackStream + +/** + * A stream of frames received from a subscribed [RemoteDataTrack]. + * + * Collect [flow] or call [next] repeatedly. The stream ends when the track is unpublished or the + * subscription is cancelled. [flow] is a single consumer: frames are not replayed, and a second + * collector only sees frames that arrive after it starts. + * + * ``` + * val stream = remoteTrack.subscribe() + * stream.flow.collect { frame -> process(frame.payload) } + * ``` + */ +class DataTrackStream internal constructor( + private val impl: FfiDataTrackStream, +) { + /** + * Returns the next frame, or `null` once the stream ends (the track is unpublished or the + * subscription is cancelled). + */ + suspend fun next(): DataTrackFrame? { + return impl.next()?.let { DataTrackFrame(it) } + } + + /** + * A [Flow] of incoming frames. Completes normally when the stream ends. + */ + val flow: Flow = flow { + while (true) { + val frame = next() ?: break + emit(frame) + } + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt index 35668ee45..e45333e4a 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt @@ -18,14 +18,13 @@ package io.livekit.android.room.datatrack import io.livekit.android.room.RTCEngine import io.livekit.android.util.LKLog -import io.livekit.uniffi.DataTrackSid import io.livekit.uniffi.HandleSignalResponseException -import io.livekit.uniffi.RemoteDataTrack import io.livekit.uniffi.RemoteDataTrackManager import io.livekit.uniffi.RemoteDataTrackManagerDelegate import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton +import io.livekit.uniffi.RemoteDataTrack as FfiRemoteDataTrack /** * Owns the UniFFI [RemoteDataTrackManager] and bridges its transport callbacks into [RTCEngine]. @@ -59,20 +58,32 @@ constructor( engineProvider.get().sendDataTrackSignalRequest(request) } - override fun onTrackPublished(track: RemoteDataTrack) { - listener?.onTrackPublished(track) + override fun onTrackPublished(track: FfiRemoteDataTrack) { + listener?.onTrackPublished(RemoteDataTrack(track)) ?: LKLog.d { "Remote data track published: ${track.info().sid}" } } - override fun onTrackUnpublished(sid: DataTrackSid) { - listener?.onTrackUnpublished(sid) + override fun onTrackUnpublished(sid: String) { + listener?.onTrackUnpublished(DataTrackSid(sid)) ?: LKLog.d { "Remote data track unpublished: $sid" } } } + /** + * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing a `JoinResponse` + * to the UniFFI manager so pre-existing remote data tracks are discovered. + */ + fun handleSfuJoinResponse(responseBytes: ByteArray) { + try { + ensureManager().handleSfuJoinResponse(responseBytes) + } catch (e: HandleSignalResponseException) { + LKLog.w(e) { "Failed to handle JoinResponse for data tracks" } + } + } + /** * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing a `ParticipantUpdate` - * (or an equivalent update built from join `otherParticipants`) to the UniFFI manager. + * to the UniFFI manager. */ fun handleSfuParticipantUpdate(responseBytes: ByteArray, localParticipantIdentity: String) { try { diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt new file mode 100644 index 000000000..b8db22638 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt @@ -0,0 +1,147 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.util.rethrowIfCancellationSignal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.takeWhile +import uniffi.livekit_datatrack.PushFrameErrorReason +import java.io.Closeable +import io.livekit.uniffi.LocalDataTrack as FfiLocalDataTrack + +/** + * A data track published by the local participant. + * + * The publication follows this object's lifetime: releasing the last reference unpublishes the + * track, as does calling [unpublish] or [close]. [Closeable] so `use { }` scopes a publication. + * + * ``` + * val track = room.localParticipant.publishDataTrack("telemetry") + * track.tryPush(DataTrackFrame(payload)) + * track.unpublish() + * ``` + */ +class LocalDataTrack internal constructor( + private val impl: FfiLocalDataTrack, +) : Closeable { + /** + * Whether the track is currently published. Becomes `false` after [unpublish] or if the SFU + * unpublishes it. + */ + val isPublished: Boolean + get() = impl.isPublished() + + /** + * Metadata for this track. + */ + val info: DataTrackInfo + get() = DataTrackInfo(impl.info()) + + /** + * Pushes a frame to subscribers. + * + * Non-blocking. Throws [DataTrackPushFrameException.TrackUnpublished] if the track was + * unpublished by the local participant or the SFU, or if the room is no longer connected; + * [DataTrackPushFrameException.QueueFull] if frames are being pushed faster than they can + * be sent, which hands the rejected frame back. + * + * @throws DataTrackPushFrameException if the frame could not be enqueued. + */ + @Throws(DataTrackPushFrameException::class) + fun tryPush(frame: DataTrackFrame) { + try { + impl.tryPush(frame.toFfi()) + } catch (e: PushFrameErrorReason) { + throw e.toSdk(frame) + } catch (e: Exception) { + // The bindings can't decode the reason a push was rejected — the error type is + // defined in a different UniFFI component — and report an internal error instead. + // The call did fail, and only two things cause that, so recover the one that + // applies rather than leaking an FFI-internal error through the public API. + e.rethrowIfCancellationSignal() + throw if (isPublished) { + DataTrackPushFrameException.QueueFull("The send queue is full", frame, e) + } else { + DataTrackPushFrameException.TrackUnpublished("The track is no longer published", e) + } + } + } + + /** + * Unpublishes the track. Subsequent [tryPush] calls throw. + */ + fun unpublish() { + impl.unpublish() + } + + /** + * Waits until the track is unpublished, by either the local participant or the SFU. + * + * Use this to trigger follow-up work once the track is no longer published. Returns + * immediately if it is already unpublished. + */ + suspend fun waitForUnpublish() { + impl.waitForUnpublish() + } + + /** + * Unpublishes the track. Same as [unpublish]; provided so `use { }` scopes a publication. + */ + override fun close() { + unpublish() + } + + /** + * Policy for [send] when the send queue is full. + */ + enum class FrameDropPolicy { + /** Propagate [DataTrackPushFrameException.QueueFull] to the caller. */ + THROW, + + /** Silently skip the frame. */ + DROP, + } + + /** + * Sends frames from [frames] until the flow completes or the track is unpublished. + * + * @param onQueueFull How to handle a full send queue. Defaults to [FrameDropPolicy.DROP]. + * @throws DataTrackPushFrameException if [onQueueFull] is [FrameDropPolicy.THROW] and the + * queue is full. + */ + @Throws(DataTrackPushFrameException::class) + suspend fun send( + frames: Flow, + onQueueFull: FrameDropPolicy = FrameDropPolicy.DROP, + ) { + var sending = true + frames.takeWhile { sending && isPublished }.collect { frame -> + try { + tryPush(frame) + } catch (e: DataTrackPushFrameException) { + when (e) { + is DataTrackPushFrameException.TrackUnpublished -> sending = false + is DataTrackPushFrameException.QueueFull -> + if (onQueueFull == FrameDropPolicy.THROW) { + throw e + } + is DataTrackPushFrameException.Internal -> throw e + } + } + } + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt index fcfea5fa6..0b316eec9 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt @@ -18,13 +18,11 @@ package io.livekit.android.room.datatrack import io.livekit.android.room.RTCEngine import io.livekit.android.util.LKLog -import io.livekit.uniffi.DataTrackInfo import io.livekit.uniffi.DataTrackOptions import io.livekit.uniffi.HandleSignalResponseException -import io.livekit.uniffi.LocalDataTrack import io.livekit.uniffi.LocalDataTrackManager import io.livekit.uniffi.LocalDataTrackManagerDelegate -import io.livekit.uniffi.PublishException +import uniffi.livekit_datatrack.PublishException import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -57,13 +55,22 @@ constructor( } /** - * Publishes a data track with the given options. + * Publishes a data track with the given name and options. * - * @throws PublishException if the SFU rejects the publication or the request fails. + * @throws DataTrackPublishException if the SFU rejects the publication or the request fails. */ - @Throws(PublishException::class) - suspend fun publishTrack(options: DataTrackOptions): LocalDataTrack { - return ensureManager().publishTrack(options) + @Throws(DataTrackPublishException::class) + suspend fun publishTrack(name: String, options: DataTrackPublishOptions? = null): LocalDataTrack { + val ffiOptions = DataTrackOptions( + name = name, + schema = options?.schema?.toFfi(), + frameEncoding = options?.frameEncoding?.toFfi(), + ) + try { + return LocalDataTrack(ensureManager().publishTrack(ffiOptions)) + } catch (e: PublishException) { + throw e.toSdk() + } } /** @@ -100,10 +107,11 @@ constructor( } /** - * Returns info for all currently published data tracks. + * Returns serialized `PublishDataTrackResponse` messages for currently published tracks, + * suitable for [livekit.LivekitRtc.SyncState.publishDataTracks]. */ - suspend fun queryTracks(): List { - return localManager?.queryTracks() ?: emptyList() + suspend fun publishResponsesForSyncState(): List { + return localManager?.publishResponsesForSyncState() ?: emptyList() } /** diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt new file mode 100644 index 000000000..aac5bbafd --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt @@ -0,0 +1,102 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import androidx.annotation.IntRange +import io.livekit.android.room.participant.Participant +import io.livekit.uniffi.DataTrackSubscribeOptions +import io.livekit.uniffi.RemoteDataTrack as FfiRemoteDataTrack +import uniffi.livekit_datatrack.DataTrackSubscribeException as FfiSubscribeException + +/** + * A data track published by a remote participant. + * + * Call [subscribe] to start receiving frames. + * + * ``` + * val stream = remoteTrack.subscribe() + * stream.flow.collect { frame -> process(frame.payload) } + * ``` + */ +class RemoteDataTrack internal constructor( + private val impl: FfiRemoteDataTrack, +) { + /** + * Identity of the participant publishing this track. + */ + val publisherIdentity: Participant.Identity = Participant.Identity(impl.publisherIdentity()) + + /** + * Name chosen by the publisher; unique per participant. + * + * This is a stable identifier across reconnects, unlike [DataTrackInfo.sid]. + */ + val name: String = impl.info().name + + /** + * Whether the track is currently published by the remote participant. + */ + val isPublished: Boolean + get() = impl.isPublished() + + /** + * Metadata for this track. + */ + val info: DataTrackInfo + get() = DataTrackInfo(impl.info()) + + /** + * Waits until the track is unpublished, by either the publisher or the SFU. + * + * Use this to trigger follow-up work once the track is no longer published. Returns + * immediately if it is already unpublished. + */ + suspend fun waitForUnpublish() { + impl.waitForUnpublish() + } + + /** + * Subscribes to the track and returns a [DataTrackStream] of incoming frames. + * + * Subscribing more than once is allowed: the streams share one pipeline, each receives every + * frame from the moment it subscribes (nothing is replayed), and later calls don't change + * the buffer size. + * + * @param bufferSize Maximum number of received frames buffered internally before the oldest + * is dropped. Values below 1 are clamped to 1. + * @throws DataTrackSubscribeException if the subscription cannot be established. + */ + @JvmOverloads + @Throws(DataTrackSubscribeException::class) + suspend fun subscribe( + @IntRange(from = 1) bufferSize: Int = DEFAULT_BUFFER_SIZE, + ): DataTrackStream { + val options = DataTrackSubscribeOptions(bufferSize = bufferSize.coerceAtLeast(1).toUInt()) + try { + return DataTrackStream(impl.subscribeWithOptions(options)) + } catch (e: FfiSubscribeException) { + throw e.toSdk() + } + } + + companion object { + /** + * Default subscribe-side buffer, in frames. + */ + const val DEFAULT_BUFFER_SIZE: Int = 16 + } +} From 9c9a673aad63d4e0f53cab7f9995becc9566a589 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 17 Aug 2026 23:20:36 +0900 Subject: [PATCH 06/18] update uniffi to use mavencentral artifact --- gradle/libs.versions.toml | 2 +- .../java/io/livekit/android/room/RTCEngine.kt | 29 +++++-------------- settings.gradle | 1 - 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d13518683..692b1c208 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ noise = "2.0.0" lifecycleProcess = "2.8.7" agp = "8.7.2" kotlin = "1.9.25" -livekit-uniffi = "0.0.1" +livekit-uniffi = "0.1.8" [libraries] 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 5387bfb7a..5c18231e9 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 @@ -269,8 +269,13 @@ internal constructor( if (joinResponse.hasParticipant()) { localParticipantIdentity = joinResponse.participant.identity } - // UniFFI AAR lacks handleSfuJoinResponse; feed other participants as a ParticipantUpdate. - feedJoinDataTracks(joinResponse) + // Discover pre-existing remote data tracks from the join response. + incomingDataTrackManager.handleSfuJoinResponse( + LivekitRtc.SignalResponse.newBuilder() + .setJoin(joinResponse) + .build() + .toByteArray(), + ) listener?.onJoinResponse(joinResponse) isClosed = false @@ -1388,26 +1393,6 @@ internal constructor( } } - /** - * Feeds remote participants from [JoinResponse] into [IncomingDataTrackManager] as a - * synthetic participant update (the published UniFFI AAR has no join-specific entry point). - */ - private fun feedJoinDataTracks(joinResponse: JoinResponse) { - val identity = localParticipantIdentity ?: return - if (joinResponse.otherParticipantsList.isEmpty()) { - return - } - val responseBytes = LivekitRtc.SignalResponse.newBuilder() - .setUpdate( - LivekitRtc.ParticipantUpdate.newBuilder() - .addAllParticipants(joinResponse.otherParticipantsList) - .build(), - ) - .build() - .toByteArray() - incomingDataTrackManager.handleSfuParticipantUpdate(responseBytes, identity) - } - // --------------------------------- DataChannel.Observer ------------------------------------// fun onBufferedAmountChange(dataChannel: DataChannel, previousAmount: Long) { diff --git a/settings.gradle b/settings.gradle index e5a958974..b5882cbb9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,7 +17,6 @@ dependencyResolutionManagement { google() mavenCentral() maven { url 'https://jitpack.io' } - mavenLocal() } } include ':sample-app', ':sample-app-compose', ':livekit-android-sdk' From 251d7c12546e1f27b3b1fbfff3935c714aaf5eed Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 17 Aug 2026 23:23:12 +0900 Subject: [PATCH 07/18] publishDataTrack api --- .../main/java/io/livekit/android/room/Room.kt | 3 + .../room/participant/LocalParticipant.kt | 70 +++++++++++++++++++ 2 files changed, 73 insertions(+) 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 9d5c71bf3..45675df69 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 @@ -351,6 +351,9 @@ constructor( */ var reconnectPolicy: ReconnectPolicy by engine::reconnectPolicy + /** + * The local participant. + */ val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = false).apply { internalListener = this@Room } 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 a1fe88614..fa56db584 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 @@ -37,6 +37,10 @@ import io.livekit.android.room.RTCEngine import io.livekit.android.room.Room import io.livekit.android.room.TrackBitrateInfo import io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager +import io.livekit.android.room.datatrack.DataTrackPublishException +import io.livekit.android.room.datatrack.DataTrackPublishOptions +import io.livekit.android.room.datatrack.LocalDataTrack +import io.livekit.android.room.datatrack.OutgoingDataTrackManager import io.livekit.android.room.isSVCCodec import io.livekit.android.room.rpc.RpcClientManager import io.livekit.android.room.rpc.RpcManager @@ -109,6 +113,7 @@ internal constructor( @Named(InjectionNames.SENDER) private val capabilitiesGetter: CapabilitiesGetter, private val outgoingDataStreamManager: OutgoingDataStreamManager, + private val outgoingDataTrackManager: OutgoingDataTrackManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, ) : Participant(Sid(""), null, coroutineDispatcher), @@ -975,6 +980,71 @@ internal constructor( eventBus.postEvent(ParticipantEvent.LocalTrackUnpublished(this, publication), scope) } + /** + * Publishes a data track, allowing this participant to send frames to subscribers. + * + * The publication follows the returned track's lifetime: keep a reference for as long as the + * track should stay published — releasing the last reference unpublishes it, as does calling + * [LocalDataTrack.unpublish] or [LocalDataTrack.close]. + * + * ``` + * val track = room.localParticipant.publishDataTrack("telemetry") + * track.tryPush(DataTrackFrame(payload)) + * track.unpublish() + * ``` + * + * @param name Track name visible to other participants. Must be unique per publisher. + * @param options Optional encoding and schema metadata, surfaced to subscribers via + * [io.livekit.android.room.datatrack.DataTrackInfo]. + * @return A [LocalDataTrack] used to push frames via [LocalDataTrack.tryPush]. + * @throws DataTrackPublishException if the track cannot be published. + * + * When self-hosting the LiveKit SFU, a [DataTrackPublishException.Timeout] may indicate a + * release that predates data track support. + */ + @JvmOverloads + @Throws(DataTrackPublishException::class) + suspend fun publishDataTrack( + name: String, + options: DataTrackPublishOptions? = null, + ): LocalDataTrack { + if (engine.connectionState == ConnectionState.DISCONNECTED) { + throw DataTrackPublishException.Disconnected("Not connected to a room") + } + return outgoingDataTrackManager.publishTrack(name, options) + } + + /** + * Publishes a data track for the duration of [block], then unpublishes it automatically. + * + * The track is unpublished when [block] returns, throws, or the calling coroutine is cancelled. + * + * ``` + * room.localParticipant.withDataTrack("telemetry") { track -> + * track.tryPush(DataTrackFrame(payload)) + * } + * ``` + * + * @param name Track name visible to other participants. Must be unique per publisher. + * @param options Optional encoding and schema metadata; see [publishDataTrack]. + * @param block Receives the published track; the track is unpublished when it returns or throws. + * @return The value returned by [block]. + * @throws DataTrackPublishException if the track cannot be published. + */ + @Throws(DataTrackPublishException::class) + suspend fun withDataTrack( + name: String, + options: DataTrackPublishOptions? = null, + block: suspend (LocalDataTrack) -> T, + ): T { + val track = publishDataTrack(name, options) + try { + return block(track) + } finally { + track.unpublish() + } + } + /** * Publish a new data payload to the room. Data will be forwarded to each participant in the room. * Each payload must not exceed 65535 bytes (64KB - 1) in size. From 587de9082b97cfb45f377709ca504d8e6edebf29 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 17 Aug 2026 23:35:54 +0900 Subject: [PATCH 08/18] Use Results instead of throwing exceptions --- .../android/room/datatrack/LocalDataTrack.kt | 70 +++++++++++-------- .../datatrack/OutgoingDataTrackManager.kt | 18 +++-- .../android/room/datatrack/RemoteDataTrack.kt | 24 ++++--- .../room/participant/LocalParticipant.kt | 34 +++++---- 4 files changed, 86 insertions(+), 60 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt index b8db22638..84842226b 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt @@ -16,6 +16,7 @@ package io.livekit.android.room.datatrack +import androidx.annotation.CheckResult import io.livekit.android.util.rethrowIfCancellationSignal import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.takeWhile @@ -30,9 +31,11 @@ import io.livekit.uniffi.LocalDataTrack as FfiLocalDataTrack * track, as does calling [unpublish] or [close]. [Closeable] so `use { }` scopes a publication. * * ``` - * val track = room.localParticipant.publishDataTrack("telemetry") - * track.tryPush(DataTrackFrame(payload)) - * track.unpublish() + * val result = room.localParticipant.publishDataTrack("telemetry") + * result.onSuccess { track -> + * track.tryPush(DataTrackFrame(payload)) + * track.unpublish() + * } * ``` */ class LocalDataTrack internal constructor( @@ -54,35 +57,40 @@ class LocalDataTrack internal constructor( /** * Pushes a frame to subscribers. * - * Non-blocking. Throws [DataTrackPushFrameException.TrackUnpublished] if the track was + * Non-blocking. Fails with [DataTrackPushFrameException.TrackUnpublished] if the track was * unpublished by the local participant or the SFU, or if the room is no longer connected; * [DataTrackPushFrameException.QueueFull] if frames are being pushed faster than they can - * be sent, which hands the rejected frame back. + * be sent, which hands the rejected frame back on the exception. * - * @throws DataTrackPushFrameException if the frame could not be enqueued. + * @return A successful [Result] if the frame was enqueued, or a failure containing + * [DataTrackPushFrameException]. */ - @Throws(DataTrackPushFrameException::class) - fun tryPush(frame: DataTrackFrame) { - try { + @CheckResult + fun tryPush(frame: DataTrackFrame): Result { + return try { impl.tryPush(frame.toFfi()) + Result.success(Unit) } catch (e: PushFrameErrorReason) { - throw e.toSdk(frame) + Result.failure(e.toSdk(frame)) } catch (e: Exception) { // The bindings can't decode the reason a push was rejected — the error type is // defined in a different UniFFI component — and report an internal error instead. // The call did fail, and only two things cause that, so recover the one that // applies rather than leaking an FFI-internal error through the public API. e.rethrowIfCancellationSignal() - throw if (isPublished) { - DataTrackPushFrameException.QueueFull("The send queue is full", frame, e) - } else { - DataTrackPushFrameException.TrackUnpublished("The track is no longer published", e) - } + Result.failure( + if (isPublished) { + DataTrackPushFrameException.QueueFull("The send queue is full", frame, e) + } else { + DataTrackPushFrameException.TrackUnpublished("The track is no longer published", e) + }, + ) } } /** - * Unpublishes the track. Subsequent [tryPush] calls throw. + * Unpublishes the track. Subsequent [tryPush] calls fail with + * [DataTrackPushFrameException.TrackUnpublished]. */ fun unpublish() { impl.unpublish() @@ -109,8 +117,8 @@ class LocalDataTrack internal constructor( * Policy for [send] when the send queue is full. */ enum class FrameDropPolicy { - /** Propagate [DataTrackPushFrameException.QueueFull] to the caller. */ - THROW, + /** Fail the send with [DataTrackPushFrameException.QueueFull]. */ + FAIL, /** Silently skip the frame. */ DROP, @@ -120,28 +128,30 @@ class LocalDataTrack internal constructor( * Sends frames from [frames] until the flow completes or the track is unpublished. * * @param onQueueFull How to handle a full send queue. Defaults to [FrameDropPolicy.DROP]. - * @throws DataTrackPushFrameException if [onQueueFull] is [FrameDropPolicy.THROW] and the - * queue is full. + * @return A successful [Result] if every frame was sent or dropped per [onQueueFull], or a + * failure containing [DataTrackPushFrameException] if [onQueueFull] is [FrameDropPolicy.FAIL] + * and the queue is full. */ - @Throws(DataTrackPushFrameException::class) + @CheckResult suspend fun send( frames: Flow, onQueueFull: FrameDropPolicy = FrameDropPolicy.DROP, - ) { + ): Result { var sending = true - frames.takeWhile { sending && isPublished }.collect { frame -> - try { - tryPush(frame) - } catch (e: DataTrackPushFrameException) { - when (e) { + var failure: DataTrackPushFrameException? = null + frames.takeWhile { sending && isPublished && failure == null }.collect { frame -> + tryPush(frame).onFailure { error -> + when (error) { is DataTrackPushFrameException.TrackUnpublished -> sending = false is DataTrackPushFrameException.QueueFull -> - if (onQueueFull == FrameDropPolicy.THROW) { - throw e + if (onQueueFull == FrameDropPolicy.FAIL) { + failure = error } - is DataTrackPushFrameException.Internal -> throw e + is DataTrackPushFrameException -> failure = error + else -> failure = DataTrackPushFrameException.Internal(error.message ?: "", error) } } } + return failure?.let { Result.failure(it) } ?: Result.success(Unit) } } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt index 0b316eec9..912a59976 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt @@ -16,8 +16,10 @@ package io.livekit.android.room.datatrack +import androidx.annotation.CheckResult import io.livekit.android.room.RTCEngine import io.livekit.android.util.LKLog +import io.livekit.android.util.rethrowIfCancellationSignal import io.livekit.uniffi.DataTrackOptions import io.livekit.uniffi.HandleSignalResponseException import io.livekit.uniffi.LocalDataTrackManager @@ -57,19 +59,23 @@ constructor( /** * Publishes a data track with the given name and options. * - * @throws DataTrackPublishException if the SFU rejects the publication or the request fails. + * @return A successful [Result] containing the published track, or a failure containing + * [DataTrackPublishException]. */ - @Throws(DataTrackPublishException::class) - suspend fun publishTrack(name: String, options: DataTrackPublishOptions? = null): LocalDataTrack { + @CheckResult + suspend fun publishTrack(name: String, options: DataTrackPublishOptions? = null): Result { val ffiOptions = DataTrackOptions( name = name, schema = options?.schema?.toFfi(), frameEncoding = options?.frameEncoding?.toFfi(), ) - try { - return LocalDataTrack(ensureManager().publishTrack(ffiOptions)) + return try { + Result.success(LocalDataTrack(ensureManager().publishTrack(ffiOptions))) } catch (e: PublishException) { - throw e.toSdk() + Result.failure(e.toSdk()) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + Result.failure(DataTrackPublishException.Internal(e.message ?: "", e)) } } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt index aac5bbafd..b55271504 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt @@ -16,8 +16,10 @@ package io.livekit.android.room.datatrack +import androidx.annotation.CheckResult import androidx.annotation.IntRange import io.livekit.android.room.participant.Participant +import io.livekit.android.util.rethrowIfCancellationSignal import io.livekit.uniffi.DataTrackSubscribeOptions import io.livekit.uniffi.RemoteDataTrack as FfiRemoteDataTrack import uniffi.livekit_datatrack.DataTrackSubscribeException as FfiSubscribeException @@ -28,8 +30,9 @@ import uniffi.livekit_datatrack.DataTrackSubscribeException as FfiSubscribeExcep * Call [subscribe] to start receiving frames. * * ``` - * val stream = remoteTrack.subscribe() - * stream.flow.collect { frame -> process(frame.payload) } + * remoteTrack.subscribe().onSuccess { stream -> + * stream.flow.collect { frame -> process(frame.payload) } + * } * ``` */ class RemoteDataTrack internal constructor( @@ -78,18 +81,21 @@ class RemoteDataTrack internal constructor( * * @param bufferSize Maximum number of received frames buffered internally before the oldest * is dropped. Values below 1 are clamped to 1. - * @throws DataTrackSubscribeException if the subscription cannot be established. + * @return A successful [Result] containing the [DataTrackStream], or a failure containing + * [DataTrackSubscribeException]. */ - @JvmOverloads - @Throws(DataTrackSubscribeException::class) + @CheckResult suspend fun subscribe( @IntRange(from = 1) bufferSize: Int = DEFAULT_BUFFER_SIZE, - ): DataTrackStream { + ): Result { val options = DataTrackSubscribeOptions(bufferSize = bufferSize.coerceAtLeast(1).toUInt()) - try { - return DataTrackStream(impl.subscribeWithOptions(options)) + return try { + Result.success(DataTrackStream(impl.subscribeWithOptions(options))) } catch (e: FfiSubscribeException) { - throw e.toSdk() + Result.failure(e.toSdk()) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + Result.failure(DataTrackSubscribeException.Internal(e.message ?: "", e)) } } 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 fa56db584..85386fca9 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 @@ -988,28 +988,29 @@ internal constructor( * [LocalDataTrack.unpublish] or [LocalDataTrack.close]. * * ``` - * val track = room.localParticipant.publishDataTrack("telemetry") - * track.tryPush(DataTrackFrame(payload)) - * track.unpublish() + * val result = room.localParticipant.publishDataTrack("telemetry") + * result.onSuccess { track -> + * track.tryPush(DataTrackFrame(payload)) + * track.unpublish() + * } * ``` * * @param name Track name visible to other participants. Must be unique per publisher. * @param options Optional encoding and schema metadata, surfaced to subscribers via * [io.livekit.android.room.datatrack.DataTrackInfo]. - * @return A [LocalDataTrack] used to push frames via [LocalDataTrack.tryPush]. - * @throws DataTrackPublishException if the track cannot be published. + * @return A successful [Result] containing the published [LocalDataTrack], or a failure + * containing [DataTrackPublishException]. * * When self-hosting the LiveKit SFU, a [DataTrackPublishException.Timeout] may indicate a * release that predates data track support. */ - @JvmOverloads - @Throws(DataTrackPublishException::class) + @CheckResult suspend fun publishDataTrack( name: String, options: DataTrackPublishOptions? = null, - ): LocalDataTrack { + ): Result { if (engine.connectionState == ConnectionState.DISCONNECTED) { - throw DataTrackPublishException.Disconnected("Not connected to a room") + return Result.failure(DataTrackPublishException.Disconnected("Not connected to a room")) } return outgoingDataTrackManager.publishTrack(name, options) } @@ -1028,18 +1029,21 @@ internal constructor( * @param name Track name visible to other participants. Must be unique per publisher. * @param options Optional encoding and schema metadata; see [publishDataTrack]. * @param block Receives the published track; the track is unpublished when it returns or throws. - * @return The value returned by [block]. - * @throws DataTrackPublishException if the track cannot be published. + * @return A successful [Result] containing the value returned by [block], or a failure if + * the track cannot be published or [block] throws. */ - @Throws(DataTrackPublishException::class) + @CheckResult suspend fun withDataTrack( name: String, options: DataTrackPublishOptions? = null, block: suspend (LocalDataTrack) -> T, - ): T { - val track = publishDataTrack(name, options) + ): Result { + val track = publishDataTrack(name, options).getOrElse { return Result.failure(it) } try { - return block(track) + return Result.success(block(track)) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + return Result.failure(e) } finally { track.unpublish() } From 4815193674b36b002824b6c3ec1e90551ef91bbd Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Aug 2026 00:00:58 +0900 Subject: [PATCH 09/18] clean up LocalDataTrack to more similarly match swift api --- .../room/datatrack/DataTrackException.kt | 4 +- .../android/room/datatrack/LocalDataTrack.kt | 92 ++++++++----- .../room/participant/LocalParticipant.kt | 2 +- .../room/datatrack/LocalDataTrackSendTest.kt | 124 ++++++++++++++++++ 4 files changed, 185 insertions(+), 37 deletions(-) create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/LocalDataTrackSendTest.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt index 47bc1732e..426ad3bf6 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt @@ -78,8 +78,8 @@ sealed class DataTrackPushFrameException(message: String, cause: Throwable? = nu * The send queue is full; the frame was not enqueued. * * The rejected [frame] — the same instance that was pushed, not a copy — comes back so it can - * be retried or re-queued. Mainly for [LocalDataTrack.send], where frames come from a [kotlinx.coroutines.flow.Flow] - * and the caller holds no reference of its own. + * be retried or re-queued. Mainly for [LocalDataTrack.send], where frames come from a + * [kotlinx.coroutines.flow.Flow] and the caller holds no reference of its own. */ class QueueFull( message: String, diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt index 84842226b..bf002c972 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt @@ -21,14 +21,15 @@ import io.livekit.android.util.rethrowIfCancellationSignal import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.takeWhile import uniffi.livekit_datatrack.PushFrameErrorReason -import java.io.Closeable import io.livekit.uniffi.LocalDataTrack as FfiLocalDataTrack /** - * A data track published by the local participant. + * A data track published by the local participant. Obtain one from + * [io.livekit.android.room.participant.LocalParticipant.publishDataTrack], then push frames with + * [tryPush]. * - * The publication follows this object's lifetime: releasing the last reference unpublishes the - * track, as does calling [unpublish] or [close]. [Closeable] so `use { }` scopes a publication. + * The publication follows this object's lifetime: keep a reference for as long as the track should + * stay published — releasing the last reference unpublishes it, as does calling [unpublish]. * * ``` * val result = room.localParticipant.publishDataTrack("telemetry") @@ -40,12 +41,12 @@ import io.livekit.uniffi.LocalDataTrack as FfiLocalDataTrack */ class LocalDataTrack internal constructor( private val impl: FfiLocalDataTrack, -) : Closeable { +) : DataTrackFrameSink { /** * Whether the track is currently published. Becomes `false` after [unpublish] or if the SFU * unpublishes it. */ - val isPublished: Boolean + override val isPublished: Boolean get() = impl.isPublished() /** @@ -66,7 +67,7 @@ class LocalDataTrack internal constructor( * [DataTrackPushFrameException]. */ @CheckResult - fun tryPush(frame: DataTrackFrame): Result { + override fun tryPush(frame: DataTrackFrame): Result { return try { impl.tryPush(frame.toFfi()) Result.success(Unit) @@ -106,13 +107,6 @@ class LocalDataTrack internal constructor( impl.waitForUnpublish() } - /** - * Unpublishes the track. Same as [unpublish]; provided so `use { }` scopes a publication. - */ - override fun close() { - unpublish() - } - /** * Policy for [send] when the send queue is full. */ @@ -125,33 +119,63 @@ class LocalDataTrack internal constructor( } /** - * Sends frames from [frames] until the flow completes or the track is unpublished. + * Sends frames from [frames] until it ends or the track is unpublished. * * @param onQueueFull How to handle a full send queue. Defaults to [FrameDropPolicy.DROP]. - * @return A successful [Result] if every frame was sent or dropped per [onQueueFull], or a - * failure containing [DataTrackPushFrameException] if [onQueueFull] is [FrameDropPolicy.FAIL] - * and the queue is full. + * @return A successful [Result] if every frame was sent or dropped per [onQueueFull], or if + * the track is unpublished mid-send. A failure containing [DataTrackPushFrameException] if + * [onQueueFull] is [FrameDropPolicy.FAIL] and the queue is full. */ @CheckResult suspend fun send( frames: Flow, onQueueFull: FrameDropPolicy = FrameDropPolicy.DROP, - ): Result { - var sending = true - var failure: DataTrackPushFrameException? = null - frames.takeWhile { sending && isPublished && failure == null }.collect { frame -> - tryPush(frame).onFailure { error -> - when (error) { - is DataTrackPushFrameException.TrackUnpublished -> sending = false - is DataTrackPushFrameException.QueueFull -> - if (onQueueFull == FrameDropPolicy.FAIL) { - failure = error - } - is DataTrackPushFrameException -> failure = error - else -> failure = DataTrackPushFrameException.Internal(error.message ?: "", error) - } + ): Result = sendFrames(frames, onQueueFull) +} + +/** + * The slice of a publication the sequence send drives — a seam so the queue-full policy is + * unit-testable, since saturating a live pipeline to observe it is inherently timing-dependent. + * + * @suppress + */ +internal interface DataTrackFrameSink { + val isPublished: Boolean + fun tryPush(frame: DataTrackFrame): Result +} + +internal suspend fun DataTrackFrameSink.sendFrames( + source: Flow, + onQueueFull: LocalDataTrack.FrameDropPolicy, +): Result { + var outcome: Result? = null + source.takeWhile { isPublished && outcome == null }.collect { frame -> + outcome = sendOne(frame, onQueueFull) + } + return outcome ?: Result.success(Unit) +} + +/** + * @return `null` to keep sending, or a [Result] that ends the send — success if the track was + * unpublished, failure otherwise. + */ +private fun DataTrackFrameSink.sendOne( + frame: DataTrackFrame, + onQueueFull: LocalDataTrack.FrameDropPolicy, +): Result? { + if (!isPublished) return Result.success(Unit) + val error = tryPush(frame).exceptionOrNull() ?: return null + // The track can be unpublished between the check above and the push; end the send as + // documented rather than surfacing an error. + return when (error) { + is DataTrackPushFrameException.TrackUnpublished -> Result.success(Unit) + is DataTrackPushFrameException.QueueFull -> + if (onQueueFull == LocalDataTrack.FrameDropPolicy.FAIL) { + Result.failure(error) + } else { + null } - } - return failure?.let { Result.failure(it) } ?: Result.success(Unit) + is DataTrackPushFrameException -> Result.failure(error) + else -> Result.failure(DataTrackPushFrameException.Internal(error.message ?: "", error)) } } 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 85386fca9..1f1763c03 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 @@ -985,7 +985,7 @@ internal constructor( * * The publication follows the returned track's lifetime: keep a reference for as long as the * track should stay published — releasing the last reference unpublishes it, as does calling - * [LocalDataTrack.unpublish] or [LocalDataTrack.close]. + * [LocalDataTrack.unpublish]. * * ``` * val result = room.localParticipant.publishDataTrack("telemetry") diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/LocalDataTrackSendTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/LocalDataTrackSendTest.kt new file mode 100644 index 000000000..cb1cebac2 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/LocalDataTrackSendTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.BaseTest +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A sink that rejects on demand, so the queue-full policy can be observed without saturating a + * live pipeline (which would make the outcome depend on how fast the SFU drains). + */ +private class RecordingSink( + published: Boolean = true, + private val unpublishAfter: Int? = null, + private val reject: (DataTrackFrame) -> Boolean = { false }, +) : DataTrackFrameSink { + val offered = mutableListOf() + val accepted = mutableListOf() + + override var isPublished: Boolean = published + private set + + override fun tryPush(frame: DataTrackFrame): Result { + offered.add(frame) + if (reject(frame)) { + return Result.failure( + DataTrackPushFrameException.QueueFull("The send queue is full", frame), + ) + } + accepted.add(frame) + if (unpublishAfter != null && accepted.size >= unpublishAfter) { + isPublished = false + } + return Result.success(Unit) + } +} + +class LocalDataTrackSendTest : BaseTest() { + + @Test + fun dropSkipsRejectedFrames() = runTest { + val sink = RecordingSink { it.payload.contentEquals(byteArrayOf(2)) } + + val result = sink.sendFrames(frames(5), LocalDataTrack.FrameDropPolicy.DROP) + + assertTrue(result.isSuccess) + assertEquals(5, sink.offered.size) + assertPayloads(listOf(0, 1, 3, 4), sink.accepted) + } + + @Test + fun failStopsAtRejectedFrame() = runTest { + val sink = RecordingSink { it.payload.contentEquals(byteArrayOf(2)) } + + val result = sink.sendFrames(frames(5), LocalDataTrack.FrameDropPolicy.FAIL) + + val error = result.exceptionOrNull() as DataTrackPushFrameException.QueueFull + assertArrayEquals(byteArrayOf(2), error.frame.payload) + assertPayloads(listOf(0, 1), sink.accepted) + } + + @Test + fun unpublishingEndsSendQuietlyWhenDropping() = runTest { + unpublishingEndsSendQuietly(LocalDataTrack.FrameDropPolicy.DROP) + } + + @Test + fun unpublishingEndsSendQuietlyWhenFailing() = runTest { + unpublishingEndsSendQuietly(LocalDataTrack.FrameDropPolicy.FAIL) + } + + @Test + fun unpublishedTrackSendsNothing() = runTest { + val sink = RecordingSink(published = false) + + val result = sink.sendFrames(frames(3), LocalDataTrack.FrameDropPolicy.FAIL) + + assertTrue(result.isSuccess) + assertTrue(sink.offered.isEmpty()) + } + + private suspend fun unpublishingEndsSendQuietly(policy: LocalDataTrack.FrameDropPolicy) { + val sink = RecordingSink(unpublishAfter = 1) + + val result = sink.sendFrames(frames(5), policy) + + assertTrue(result.isSuccess) + assertEquals(1, sink.accepted.size) + } + + companion object { + private fun frames(count: Int): Flow = flow { + for (index in 0 until count) { + emit(DataTrackFrame(byteArrayOf(index.toByte()))) + } + } + + private fun assertPayloads(expected: List, frames: List) { + assertEquals(expected.size, frames.size) + expected.zip(frames).forEach { (value, frame) -> + assertArrayEquals(byteArrayOf(value.toByte()), frame.payload) + } + } + } +} From ed3dc29225c86223bf66774adafcfd7bf50a53b5 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Aug 2026 00:32:46 +0900 Subject: [PATCH 10/18] data track schema --- .../io/livekit/android/room/SignalClient.kt | 114 ++++++++++++++++++ .../room/datatrack/DataTrackException.kt | 32 +++++ .../android/room/datatrack/DataTrackSchema.kt | 32 +++++ .../room/participant/LocalParticipant.kt | 66 ++++++++++ .../livekit/android/room/SignalClientTest.kt | 100 +++++++++++++++ .../datatrack/DataTrackSchemaMockE2ETest.kt | 33 +++++ .../room/datatrack/DataTrackSchemaTest.kt | 43 +++++++ protocol | 2 +- 8 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaMockE2ETest.kt create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaTest.kt 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 aeefe173c..9a0152592 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 @@ -21,6 +21,7 @@ import com.vdurmont.semver4j.Semver import io.livekit.android.ConnectOptions import io.livekit.android.RoomOptions import io.livekit.android.dagger.InjectionNames +import io.livekit.android.room.datatrack.DataTrackSchemaException import io.livekit.android.room.participant.ParticipantTrackPermission import io.livekit.android.room.track.Track import io.livekit.android.stats.NetworkInfo @@ -28,11 +29,14 @@ import io.livekit.android.stats.getClientInfo import io.livekit.android.util.CloseableCoroutineScope import io.livekit.android.util.Either import io.livekit.android.util.LKLog +import io.livekit.android.util.TimeoutException +import io.livekit.android.util.rethrowIfCancellationSignal import io.livekit.android.util.toHttpUrl import io.livekit.android.util.toWebsocketUrl import io.livekit.android.util.withDeadline import io.livekit.android.webrtc.toProtoSessionDescription import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -60,11 +64,14 @@ import okhttp3.WebSocketListener import okio.ByteString import okio.ByteString.Companion.toByteString import java.util.Date +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton import kotlin.coroutines.resumeWithException import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds /** * SignalClient to LiveKit WS servers @@ -122,6 +129,9 @@ constructor( private var pingIntervalDurationMillis: Long = 0 private var rtt: Long = 0 + private val nextDataBlobRequestId = AtomicInteger(0) + private val dataBlobCompleters = ConcurrentHashMap>() + var connectionState: ConnectionState = ConnectionState.DISCONNECTED /** @@ -641,6 +651,76 @@ constructor( sendRequest(request) } + /** + * Stores a blob on the server under [key], replacing nothing — a key can only be written once. + */ + internal suspend fun sendStoreDataBlob(key: LivekitModels.DataBlobKey, contents: ByteArray): Result { + return sendIdCorrelatedRequest { requestId -> + LivekitRtc.SignalRequest.newBuilder() + .setStoreDataBlobRequest( + LivekitRtc.StoreDataBlobRequest.newBuilder() + .setRequestId(requestId) + .setBlob( + LivekitModels.DataBlob.newBuilder() + .setKey(key) + .setContents(com.google.protobuf.ByteString.copyFrom(contents)), + ), + ) + .build() + }.map { } + } + + /** + * Reads back a blob [participantIdentity] stored under [key]. + */ + internal suspend fun sendGetDataBlob( + key: LivekitModels.DataBlobKey, + participantIdentity: String, + ): Result { + return sendIdCorrelatedRequest { requestId -> + LivekitRtc.SignalRequest.newBuilder() + .setGetDataBlobRequest( + LivekitRtc.GetDataBlobRequest.newBuilder() + .setRequestId(requestId) + .setParticipantIdentity(participantIdentity) + .setKey(key), + ) + .build() + } + } + + /** + * Sends a request the SFU answers by echoing its id, and waits for that answer. + */ + private suspend fun sendIdCorrelatedRequest( + build: (Int) -> LivekitRtc.SignalRequest, + ): Result { + if (!isConnected) { + return Result.failure(DataTrackSchemaException.Disconnected("Not connected to a room")) + } + val requestId = nextDataBlobRequestId.incrementAndGet() + val deferred = CompletableDeferred() + dataBlobCompleters[requestId] = deferred + try { + sendRequest(build(requestId)) + return withDeadline(DATA_BLOB_REQUEST_TIMEOUT) { + Result.success(deferred.await()) + } + } catch (e: TimeoutException) { + return Result.failure( + DataTrackSchemaException.Timeout("Timed out waiting for data blob response", e), + ) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + return Result.failure( + e as? DataTrackSchemaException + ?: DataTrackSchemaException.Internal(e.message ?: "", e), + ) + } finally { + dataBlobCompleters.remove(requestId) + } + } + private fun sendRequest(request: LivekitRtc.SignalRequest) { val skipQueue = skipQueueTypes.contains(request.messageCase) @@ -856,6 +936,19 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.REQUEST_RESPONSE -> { + val requestResponse = response.requestResponse + val reason = requestResponse.reason + val isFailure = reason != LivekitRtc.RequestResponse.Reason.OK && + reason != LivekitRtc.RequestResponse.Reason.QUEUED + if (isFailure) { + val completer = dataBlobCompleters.remove(requestResponse.requestId) + if (completer != null) { + val message = requestResponse.message.ifEmpty { + "Request rejected (reason ${reason.number})" + } + completer.completeExceptionally(DataTrackSchemaException.Rejected(message)) + } + } // Pass the full SignalResponse — UniFFI deserializes and filters data-track related ones. listener?.onRequestResponse(response) } @@ -884,6 +977,15 @@ constructor( listener?.onDataTrackSubscriberHandles(response) } + LivekitRtc.SignalResponse.MessageCase.STORE_DATA_BLOB_RESPONSE -> { + dataBlobCompleters.remove(response.storeDataBlobResponse.requestId) + ?.complete(ByteArray(0)) + } + + LivekitRtc.SignalResponse.MessageCase.GET_DATA_BLOB_RESPONSE -> { + dataBlobCompleters.remove(response.getDataBlobResponse.requestId) + ?.complete(response.getDataBlobResponse.blob.contents.toByteArray()) + } LivekitRtc.SignalResponse.MessageCase.MESSAGE_NOT_SET, null, -> { @@ -920,6 +1022,16 @@ constructor( pongJob = null } + private fun failPendingDataBlobRequests() { + val pending = dataBlobCompleters.values.toList() + dataBlobCompleters.clear() + pending.forEach { completer -> + completer.completeExceptionally( + DataTrackSchemaException.Disconnected("Not connected to a room"), + ) + } + } + /** * Closes out any existing websocket connection, and cleans up used resources. * @@ -930,6 +1042,7 @@ constructor( LKLog.v(Exception()) { "Closing SignalClient: code = $code, reason = $reason" } isConnected = false isReconnecting = false + failPendingDataBlobRequests() if (::coroutineScope.isInitialized) { coroutineScope.close() } @@ -1035,6 +1148,7 @@ constructor( // iceServer("stun:stun4.l.google.com:19302"), ) private const val SIGNAL_CONNECT_TIMEOUT = 10000 + private val DATA_BLOB_REQUEST_TIMEOUT = 5.seconds const val CLOSE_REASON_NORMAL_CLOSURE = 1000 const val CLOSE_REASON_PING_TIMEOUT = 3000 const val CLOSE_REASON_WEBSOCKET_FAILURE = 3500 diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt index 426ad3bf6..488d28fcf 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt @@ -118,6 +118,38 @@ sealed class DataTrackSubscribeException(message: String, cause: Throwable? = nu class Internal(message: String, cause: Throwable? = null) : DataTrackSubscribeException(message, cause) } +/** + * An error raised while storing or resolving a data track schema via + * [io.livekit.android.room.participant.LocalParticipant.defineSchema] / + * [io.livekit.android.room.participant.LocalParticipant.getSchema]. + */ +sealed class DataTrackSchemaException(message: String, cause: Throwable? = null) : Exception(message, cause) { + /** + * The connection was lost before the request completed, or the participant is not connected. + */ + class Disconnected(message: String, cause: Throwable? = null) : DataTrackSchemaException(message, cause) + + /** + * The SFU rejected the request (for example the schema was never defined). + */ + class Rejected(message: String, cause: Throwable? = null) : DataTrackSchemaException(message, cause) + + /** + * The stored definition is not valid UTF-8. + */ + class InvalidDefinition(message: String, cause: Throwable? = null) : DataTrackSchemaException(message, cause) + + /** + * The SFU did not respond in time. + */ + class Timeout(message: String, cause: Throwable? = null) : DataTrackSchemaException(message, cause) + + /** + * An unexpected internal error occurred. + */ + class Internal(message: String, cause: Throwable? = null) : DataTrackSchemaException(message, cause) +} + @Suppress("CyclomaticComplexMethod") // Mechanical 1:1 mapping of UniFFI publish error cases. internal fun FfiPublishException.toSdk(): DataTrackPublishException = when (this) { is FfiPublishException.NotAllowed -> DataTrackPublishException.NotAllowed(message ?: "", this) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt index 2c6e5e71b..601f8523e 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt @@ -16,6 +16,8 @@ package io.livekit.android.room.datatrack +import livekit.LivekitModels +import livekit.LivekitModels.DataTrackSchemaEncoding.WellKnownSchemaEncoding import io.livekit.uniffi.DataTrackSchemaId as FfiSchemaId import uniffi.livekit_datatrack.DataTrackFrameEncoding as FfiFrameEncoding import uniffi.livekit_datatrack.DataTrackSchemaEncoding as FfiSchemaEncoding @@ -39,6 +41,20 @@ data class DataTrackSchemaId( name = name, encoding = encoding.toFfi(), ) + + internal fun toProto(): LivekitModels.DataTrackSchemaId = + LivekitModels.DataTrackSchemaId.newBuilder() + .setName(name) + .setEncoding(encoding.toProto()) + .build() + + /** + * As a data blob key, for storing and reading back the schema's definition. + */ + internal val blobKey: LivekitModels.DataBlobKey + get() = LivekitModels.DataBlobKey.newBuilder() + .setSchemaId(toProto()) + .build() } /** @@ -110,6 +126,22 @@ sealed class DataTrackSchemaEncoding { is Custom -> FfiSchemaEncoding.Custom(identifier) } + internal fun toProto(): LivekitModels.DataTrackSchemaEncoding { + val builder = LivekitModels.DataTrackSchemaEncoding.newBuilder() + when (this) { + Protobuf -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF + Flatbuffer -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER + Ros1Msg -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG + Ros2Msg -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG + Ros2Idl -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL + OmgIdl -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL + JsonSchema -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA + Other -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED + is Custom -> builder.custom = identifier + } + return builder.build() + } + companion object { /** * Creates an encoding from its [identifier]; unrecognized identifiers become [Custom]. 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 1f1763c03..ff87cd870 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 @@ -39,6 +39,8 @@ import io.livekit.android.room.TrackBitrateInfo import io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager import io.livekit.android.room.datatrack.DataTrackPublishException import io.livekit.android.room.datatrack.DataTrackPublishOptions +import io.livekit.android.room.datatrack.DataTrackSchemaException +import io.livekit.android.room.datatrack.DataTrackSchemaId import io.livekit.android.room.datatrack.LocalDataTrack import io.livekit.android.room.datatrack.OutgoingDataTrackManager import io.livekit.android.room.isSVCCodec @@ -89,6 +91,9 @@ import livekit.org.webrtc.RtpTransceiver.RtpTransceiverInit import livekit.org.webrtc.SurfaceTextureHelper import livekit.org.webrtc.VideoCapturer import livekit.org.webrtc.VideoProcessor +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.CodingErrorAction import java.util.Collections import javax.inject.Named import kotlin.math.max @@ -1015,6 +1020,56 @@ internal constructor( return outgoingDataTrackManager.publishTrack(name, options) } + /** + * Stores the definition of a data track schema, making it available to subscribers. + * + * Define a schema before publishing any data track that references it, so subscribers can + * resolve it by ID via [getSchema]. Treat a definition as write-once — whether redefining an + * existing one is rejected is up to the server. + * + * ``` + * val schema = DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema) + * room.localParticipant.defineSchema(schema, definition) + * room.localParticipant.publishDataTrack( + * "reading", + * DataTrackPublishOptions(schema, DataTrackFrameEncoding.Json), + * ) + * ``` + * + * @param id Identifies the schema; the same ID goes into [DataTrackPublishOptions]. + * @param definition The definition, stored as-is. It is neither parsed nor validated against + * its [DataTrackSchemaId.encoding], so it's up to the caller to keep it well-formed. + * @return A successful [Result] if the schema was stored, or a failure containing + * [DataTrackSchemaException]. + */ + @CheckResult + suspend fun defineSchema(id: DataTrackSchemaId, definition: String): Result { + if (engine.connectionState == ConnectionState.DISCONNECTED) { + return Result.failure(DataTrackSchemaException.Disconnected("Not connected to a room")) + } + return engine.client.sendStoreDataBlob(id.blobKey, definition.toByteArray(Charsets.UTF_8)) + } + + /** + * Retrieves the definition a participant [defineSchema]'d for a schema its data tracks + * reference. + * + * @param id Identifies the schema, as carried by [io.livekit.android.room.datatrack.DataTrackInfo.schema]. + * @param publishedBy Identity of the participant that defined it. + * @return A successful [Result] containing the definition, or a failure containing + * [DataTrackSchemaException]. + */ + @CheckResult + suspend fun getSchema(id: DataTrackSchemaId, publishedBy: Identity): Result { + if (engine.connectionState == ConnectionState.DISCONNECTED) { + return Result.failure(DataTrackSchemaException.Disconnected("Not connected to a room")) + } + val bytes = engine.client.sendGetDataBlob(id.blobKey, publishedBy.value) + .getOrElse { return Result.failure(it) } + return decodeUtf8(bytes)?.let { Result.success(it) } + ?: Result.failure(DataTrackSchemaException.InvalidDefinition("Schema definition is not valid UTF-8")) + } + /** * Publishes a data track for the duration of [block], then unpublishes it automatically. * @@ -1750,6 +1805,17 @@ internal fun VideoTrackPublishOptions.hasBackupCodec(): Boolean { private val backupCodecs = listOf(VideoCodec.VP8.codecName, VideoCodec.H264.codecName) private fun isBackupCodec(codecName: String) = backupCodecs.contains(codecName) +private fun decodeUtf8(bytes: ByteArray): String? { + val decoder = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + return try { + decoder.decode(ByteBuffer.wrap(bytes)).toString() + } catch (_: CharacterCodingException) { + null + } +} + /** * A handler that processes an RPC request and returns a string * that will be sent back to the requester. The payload must diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt index a10dfe3fa..8e9a76a07 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt @@ -16,6 +16,9 @@ package io.livekit.android.room +import io.livekit.android.room.datatrack.DataTrackSchemaEncoding +import io.livekit.android.room.datatrack.DataTrackSchemaException +import io.livekit.android.room.datatrack.DataTrackSchemaId import io.livekit.android.stats.NetworkInfo import io.livekit.android.stats.NetworkType import io.livekit.android.test.BaseTest @@ -41,6 +44,7 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.WebSocketListener +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -54,6 +58,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argThat import org.mockito.kotlin.never import org.mockito.kotlin.times +import com.google.protobuf.ByteString as PbByteString @ExperimentalCoroutinesApi class SignalClientTest : BaseTest() { @@ -501,6 +506,101 @@ class SignalClientTest : BaseTest() { } } + @Test + fun storeDataBlobSucceeds() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + + val key = DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema).blobKey + val storeJob = async { client.sendStoreDataBlob(key, "{}".toByteArray()) } + yield() + + val sent = LivekitRtc.SignalRequest.parseFrom(wsFactory.ws.sentRequests.last().toPBByteString()) + assertTrue(sent.hasStoreDataBlobRequest()) + val requestId = sent.storeDataBlobRequest.requestId + + client.onMessage( + wsFactory.ws, + LivekitRtc.SignalResponse.newBuilder() + .setStoreDataBlobResponse( + LivekitRtc.StoreDataBlobResponse.newBuilder() + .setRequestId(requestId) + .setKey(key), + ) + .build() + .toOkioByteString(), + ) + + assertTrue(storeJob.await().isSuccess) + } + + @Test + fun getDataBlobReturnsContents() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + + val key = DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema).blobKey + val contents = """{"type":"object"}""".toByteArray() + val getJob = async { client.sendGetDataBlob(key, "publisher") } + yield() + + val sent = LivekitRtc.SignalRequest.parseFrom(wsFactory.ws.sentRequests.last().toPBByteString()) + assertTrue(sent.hasGetDataBlobRequest()) + assertEquals("publisher", sent.getDataBlobRequest.participantIdentity) + + client.onMessage( + wsFactory.ws, + LivekitRtc.SignalResponse.newBuilder() + .setGetDataBlobResponse( + LivekitRtc.GetDataBlobResponse.newBuilder() + .setRequestId(sent.getDataBlobRequest.requestId) + .setBlob( + LivekitModels.DataBlob.newBuilder() + .setKey(key) + .setContents(PbByteString.copyFrom(contents)), + ), + ) + .build() + .toOkioByteString(), + ) + + assertArrayEquals(contents, getJob.await().getOrThrow()) + } + + @Test + fun dataBlobRequestFailureCompletesWithRejected() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + + val key = DataTrackSchemaId("missing.v1", DataTrackSchemaEncoding.Protobuf).blobKey + val getJob = async { client.sendGetDataBlob(key, "publisher") } + yield() + + val sent = LivekitRtc.SignalRequest.parseFrom(wsFactory.ws.sentRequests.last().toPBByteString()) + client.onMessage( + wsFactory.ws, + LivekitRtc.SignalResponse.newBuilder() + .setRequestResponse( + LivekitRtc.RequestResponse.newBuilder() + .setRequestId(sent.getDataBlobRequest.requestId) + .setReason(LivekitRtc.RequestResponse.Reason.NOT_FOUND) + .setMessage("not found"), + ) + .build() + .toOkioByteString(), + ) + + val error = getJob.await().exceptionOrNull() + assertTrue(error is DataTrackSchemaException.Rejected) + assertEquals("not found", error?.message) + } + // mock data companion object } diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaMockE2ETest.kt new file mode 100644 index 000000000..4b7a24d14 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaMockE2ETest.kt @@ -0,0 +1,33 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.MockE2ETest +import org.junit.Assert.assertTrue +import org.junit.Test + +class DataTrackSchemaMockE2ETest : MockE2ETest() { + + @Test + fun defineSchemaFailsWhenDisconnected() = runTest { + val result = room.localParticipant.defineSchema( + DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema), + "{}", + ) + assertTrue(result.exceptionOrNull() is DataTrackSchemaException.Disconnected) + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaTest.kt new file mode 100644 index 000000000..c36cefd46 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.BaseTest +import livekit.LivekitModels.DataTrackSchemaEncoding.WellKnownSchemaEncoding +import org.junit.Assert.assertEquals +import org.junit.Test + +class DataTrackSchemaTest : BaseTest() { + + @Test + fun blobKeyCarriesNameAndWellKnownEncoding() { + val schema = DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema) + val key = schema.blobKey + + assertEquals("reading.v1", key.schemaId.name) + assertEquals( + WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA, + key.schemaId.encoding.wellKnown, + ) + } + + @Test + fun blobKeyUsesCustomFieldForCustomEncoding() { + val schema = DataTrackSchemaId("x", DataTrackSchemaEncoding.Custom("myenc")) + assertEquals("myenc", schema.blobKey.schemaId.encoding.custom) + } +} diff --git a/protocol b/protocol index 8381f2180..2172178d2 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 8381f2180c45ab926b3ebf19df0608f1dadcac1e +Subproject commit 2172178d20d4c8d14d6873b4e9560cc38cf48c0c From a8c99d9229e16207b94419f13845c7fa8c3bc2c8 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Aug 2026 00:51:39 +0900 Subject: [PATCH 11/18] Factories to inject the actual and mock data track manager factories --- .../io/livekit/android/dagger/RTCModule.kt | 20 ++++ .../room/datatrack/DataTrackManagerFactory.kt | 40 +++++++ .../datatrack/IncomingDataTrackManager.kt | 15 +-- .../datatrack/OutgoingDataTrackManager.kt | 15 +-- livekit-android-test/build.gradle | 1 + .../io/livekit/android/test/MockE2ETest.kt | 8 +- .../test/mock/dagger/TestLiveKitComponent.kt | 8 +- .../android/test/mock/dagger/TestRTCModule.kt | 30 +++++- .../datatrack/MockLocalDataTrackManager.kt | 100 ++++++++++++++++++ .../datatrack/MockRemoteDataTrackManager.kt | 65 ++++++++++++ .../datatrack/DataTrackManagerMockE2ETest.kt | 48 +++++++++ 11 files changed, 333 insertions(+), 17 deletions(-) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt create mode 100644 livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt create mode 100644 livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt index e8d78fa43..45a1776f8 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt @@ -36,6 +36,8 @@ import io.livekit.android.audio.NoAudioRecordPrewarmer import io.livekit.android.e2ee.DataPacketCryptorManager import io.livekit.android.e2ee.DataPacketCryptorManagerImpl import io.livekit.android.memory.CloseableManager +import io.livekit.android.room.datatrack.LocalDataTrackManagerFactory +import io.livekit.android.room.datatrack.RemoteDataTrackManagerFactory import io.livekit.android.util.LKLog import io.livekit.android.util.LoggingLevel import io.livekit.android.webrtc.CustomAudioProcessingFactory @@ -46,6 +48,8 @@ import io.livekit.android.webrtc.peerconnection.RTCThreadToken import io.livekit.android.webrtc.peerconnection.RTCThreadTokenImpl import io.livekit.android.webrtc.peerconnection.executeBlockingOnRTCThread import io.livekit.android.webrtc.peerconnection.executeOnRTCThread +import io.livekit.uniffi.LocalDataTrackManager +import io.livekit.uniffi.RemoteDataTrackManager import livekit.org.webrtc.AudioProcessingFactory import livekit.org.webrtc.EglBase import livekit.org.webrtc.Logging @@ -384,6 +388,22 @@ internal object RTCModule { return DataPacketCryptorManagerImpl.Factory } + @Provides + fun localDataTrackManagerFactory(): LocalDataTrackManagerFactory { + return LocalDataTrackManagerFactory { delegate -> + // Encryption provider wiring is left for a follow-up (E2EE). + LocalDataTrackManager(delegate, null) + } + } + + @Provides + fun remoteDataTrackManagerFactory(): RemoteDataTrackManagerFactory { + return RemoteDataTrackManagerFactory { delegate -> + // Decryption provider wiring is left for a follow-up (E2EE). + RemoteDataTrackManager(delegate, null) + } + } + @Provides @Singleton fun peerConnectionFactory( diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt new file mode 100644 index 000000000..152aa3edf --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt @@ -0,0 +1,40 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.uniffi.LocalDataTrackManagerDelegate +import io.livekit.uniffi.LocalDataTrackManagerInterface +import io.livekit.uniffi.RemoteDataTrackManagerDelegate +import io.livekit.uniffi.RemoteDataTrackManagerInterface + +/** + * Creates UniFFI [io.livekit.uniffi.LocalDataTrackManager] instances. + * + * @suppress + */ +fun interface LocalDataTrackManagerFactory { + fun create(delegate: LocalDataTrackManagerDelegate): LocalDataTrackManagerInterface +} + +/** + * Creates UniFFI [io.livekit.uniffi.RemoteDataTrackManager] instances. + * + * @suppress + */ +fun interface RemoteDataTrackManagerFactory { + fun create(delegate: RemoteDataTrackManagerDelegate): RemoteDataTrackManagerInterface +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt index e45333e4a..c4549d1ca 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt @@ -19,15 +19,16 @@ package io.livekit.android.room.datatrack import io.livekit.android.room.RTCEngine import io.livekit.android.util.LKLog import io.livekit.uniffi.HandleSignalResponseException -import io.livekit.uniffi.RemoteDataTrackManager import io.livekit.uniffi.RemoteDataTrackManagerDelegate +import io.livekit.uniffi.RemoteDataTrackManagerInterface import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton import io.livekit.uniffi.RemoteDataTrack as FfiRemoteDataTrack /** - * Owns the UniFFI [RemoteDataTrackManager] and bridges its transport callbacks into [RTCEngine]. + * Owns the UniFFI [io.livekit.uniffi.RemoteDataTrackManager] and bridges its transport callbacks + * into [RTCEngine]. * * SFU participant / subscriber-handle responses and `_data_track` channel packets are forwarded * into the Rust manager; subscription signal requests are sent back out through the engine. @@ -39,6 +40,7 @@ class IncomingDataTrackManager @Inject constructor( private val engineProvider: Provider, + private val remoteDataTrackManagerFactory: RemoteDataTrackManagerFactory, ) { /** * Optional listener for remote data-track publication events. @@ -51,7 +53,7 @@ constructor( var listener: Listener? = null private val lock = Any() - private var remoteManager: RemoteDataTrackManager? = null + private var remoteManager: RemoteDataTrackManagerInterface? = null private val delegate = object : RemoteDataTrackManagerDelegate { override fun onSignalRequest(request: ByteArray) { @@ -124,16 +126,15 @@ constructor( */ fun close() { synchronized(lock) { - remoteManager?.close() + (remoteManager as? AutoCloseable)?.close() remoteManager = null } } - private fun ensureManager(): RemoteDataTrackManager { + private fun ensureManager(): RemoteDataTrackManagerInterface { synchronized(lock) { remoteManager?.let { return it } - // Decryption provider wiring is left for a follow-up (E2EE). - return RemoteDataTrackManager(delegate, null).also { remoteManager = it } + return remoteDataTrackManagerFactory.create(delegate).also { remoteManager = it } } } } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt index 912a59976..d905d0fed 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt @@ -22,15 +22,16 @@ import io.livekit.android.util.LKLog import io.livekit.android.util.rethrowIfCancellationSignal import io.livekit.uniffi.DataTrackOptions import io.livekit.uniffi.HandleSignalResponseException -import io.livekit.uniffi.LocalDataTrackManager import io.livekit.uniffi.LocalDataTrackManagerDelegate +import io.livekit.uniffi.LocalDataTrackManagerInterface import uniffi.livekit_datatrack.PublishException import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton /** - * Owns the UniFFI [LocalDataTrackManager] and bridges its transport callbacks into [RTCEngine]. + * Owns the UniFFI [io.livekit.uniffi.LocalDataTrackManager] and bridges its transport callbacks + * into [RTCEngine]. * * Signal requests / SFU responses and data-track packets are forwarded through the engine so the * Rust manager stays decoupled from WebRTC and WebSocket details. @@ -42,9 +43,10 @@ class OutgoingDataTrackManager @Inject constructor( private val engineProvider: Provider, + private val localDataTrackManagerFactory: LocalDataTrackManagerFactory, ) { private val lock = Any() - private var localManager: LocalDataTrackManager? = null + private var localManager: LocalDataTrackManagerInterface? = null private val delegate = object : LocalDataTrackManagerDelegate { override fun onSignalRequest(request: ByteArray) { @@ -125,16 +127,15 @@ constructor( */ fun close() { synchronized(lock) { - localManager?.close() + (localManager as? AutoCloseable)?.close() localManager = null } } - private fun ensureManager(): LocalDataTrackManager { + private fun ensureManager(): LocalDataTrackManagerInterface { synchronized(lock) { localManager?.let { return it } - // Encryption provider wiring is left for a follow-up (E2EE). - return LocalDataTrackManager(delegate, null).also { localManager = it } + return localDataTrackManagerFactory.create(delegate).also { localManager = it } } } } diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle index 1629569f0..749b42cea 100644 --- a/livekit-android-test/build.gradle +++ b/livekit-android-test/build.gradle @@ -108,6 +108,7 @@ dokkaHtml { dependencies { implementation(project(":livekit-android-sdk")) + implementation libs.livekit.uniffi implementation libs.coroutines.lib implementation libs.kotlinx.serialization.json api libs.okhttp.lib diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt b/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt index d6dcf9c9d..48fff3c17 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 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. @@ -26,6 +26,8 @@ import io.livekit.android.test.mock.TestData import io.livekit.android.test.mock.dagger.DaggerTestLiveKitComponent import io.livekit.android.test.mock.dagger.TestCoroutinesModule import io.livekit.android.test.mock.dagger.TestLiveKitComponent +import io.livekit.android.test.mock.room.datatrack.MockLocalDataTrackManagerFactory +import io.livekit.android.test.mock.room.datatrack.MockRemoteDataTrackManagerFactory import io.livekit.android.util.flow import io.livekit.android.util.toOkioByteString import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -51,6 +53,8 @@ abstract class MockE2ETest : BaseTest() { lateinit var context: Context lateinit var room: Room lateinit var wsFactory: MockWebSocketFactory + lateinit var localDataTrackManagerFactory: MockLocalDataTrackManagerFactory + lateinit var remoteDataTrackManagerFactory: MockRemoteDataTrackManagerFactory @Before fun mocksSetup() { @@ -65,6 +69,8 @@ abstract class MockE2ETest : BaseTest() { enableMetrics = false } wsFactory = component.websocketFactory() + localDataTrackManagerFactory = component.localDataTrackManagerFactory() + remoteDataTrackManagerFactory = component.remoteDataTrackManagerFactory() } @After diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt index e3f7f29b2..d31d617cc 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 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. @@ -26,6 +26,8 @@ import io.livekit.android.dagger.MemoryModule import io.livekit.android.room.RTCEngine import io.livekit.android.test.mock.MockNetworkCallbackRegistry import io.livekit.android.test.mock.MockWebSocketFactory +import io.livekit.android.test.mock.room.datatrack.MockLocalDataTrackManagerFactory +import io.livekit.android.test.mock.room.datatrack.MockRemoteDataTrackManagerFactory import javax.inject.Singleton @Singleton @@ -48,6 +50,10 @@ interface TestLiveKitComponent : LiveKitComponent { fun networkCallbackRegistry(): MockNetworkCallbackRegistry + fun localDataTrackManagerFactory(): MockLocalDataTrackManagerFactory + + fun remoteDataTrackManagerFactory(): MockRemoteDataTrackManagerFactory + @Component.Factory interface Factory { fun create( diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt index 0ed771c7e..8929c6b73 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 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. @@ -30,10 +30,14 @@ import io.livekit.android.dagger.CapabilitiesGetter import io.livekit.android.dagger.InjectionNames import io.livekit.android.e2ee.DataPacketCryptorManager import io.livekit.android.e2ee.KeyProvider +import io.livekit.android.room.datatrack.LocalDataTrackManagerFactory +import io.livekit.android.room.datatrack.RemoteDataTrackManagerFactory import io.livekit.android.test.mock.MockAudioDeviceModule import io.livekit.android.test.mock.MockAudioProcessingController import io.livekit.android.test.mock.MockEglBase import io.livekit.android.test.mock.e2ee.ReversingDataPacketCryptorManager +import io.livekit.android.test.mock.room.datatrack.MockLocalDataTrackManagerFactory +import io.livekit.android.test.mock.room.datatrack.MockRemoteDataTrackManagerFactory import io.livekit.android.webrtc.PeerConnectionFactoryManager import io.livekit.android.webrtc.peerconnection.RTCThreadToken import livekit.org.webrtc.EglBase @@ -138,4 +142,28 @@ object TestRTCModule { return ReversingDataPacketCryptorManager() } } + + @Provides + @Singleton + fun mockLocalDataTrackManagerFactory(): MockLocalDataTrackManagerFactory { + return MockLocalDataTrackManagerFactory() + } + + @Provides + @Singleton + fun localDataTrackManagerFactory( + factory: MockLocalDataTrackManagerFactory, + ): LocalDataTrackManagerFactory = factory + + @Provides + @Singleton + fun mockRemoteDataTrackManagerFactory(): MockRemoteDataTrackManagerFactory { + return MockRemoteDataTrackManagerFactory() + } + + @Provides + @Singleton + fun remoteDataTrackManagerFactory( + factory: MockRemoteDataTrackManagerFactory, + ): RemoteDataTrackManagerFactory = factory } diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt new file mode 100644 index 000000000..e26c58465 --- /dev/null +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt @@ -0,0 +1,100 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.test.mock.room.datatrack + +import io.livekit.android.room.datatrack.LocalDataTrackManagerFactory +import io.livekit.uniffi.DataTrackFrame +import io.livekit.uniffi.DataTrackInfo +import io.livekit.uniffi.DataTrackOptions +import io.livekit.uniffi.LocalDataTrack +import io.livekit.uniffi.LocalDataTrackManagerDelegate +import io.livekit.uniffi.LocalDataTrackManagerInterface +import io.livekit.uniffi.NoHandle + +class MockLocalDataTrackManagerFactory : LocalDataTrackManagerFactory { + /** + * The most recently created manager. + */ + lateinit var manager: MockLocalDataTrackManager + + override fun create(delegate: LocalDataTrackManagerDelegate): LocalDataTrackManagerInterface { + return MockLocalDataTrackManager(delegate).also { manager = it } + } +} + +class MockLocalDataTrackManager( + val delegate: LocalDataTrackManagerDelegate, +) : LocalDataTrackManagerInterface, AutoCloseable { + val publishedTracks = mutableListOf() + val handledPublishResponses = mutableListOf() + val handledRequestResponses = mutableListOf() + var closed = false + private set + + override fun handleSfuPublishResponse(res: ByteArray) { + handledPublishResponses.add(res) + } + + override fun handleSfuRequestResponse(res: ByteArray) { + handledRequestResponses.add(res) + } + + override suspend fun publishResponsesForSyncState(): List = emptyList() + + override suspend fun publishTrack(options: DataTrackOptions): LocalDataTrack { + return MockFfiLocalDataTrack(name = options.name).also { publishedTracks.add(it) } + } + + override fun republishTracks() {} + + override fun close() { + closed = true + publishedTracks.forEach { it.unpublish() } + } +} + +/** + * UniFFI [LocalDataTrack] stand-in that does not touch native code. + */ +class MockFfiLocalDataTrack( + name: String, + sid: String = "DT_mock", +) : LocalDataTrack(NoHandle) { + private var published = true + private val trackInfo = DataTrackInfo( + sid = sid, + name = name, + usesE2ee = false, + schema = null, + frameEncoding = null, + ) + val pushedFrames = mutableListOf() + + override fun info(): DataTrackInfo = trackInfo + + override fun isPublished(): Boolean = published + + override fun tryPush(frame: DataTrackFrame) { + pushedFrames.add(frame) + } + + override fun unpublish() { + published = false + } + + override suspend fun waitForUnpublish() {} +} diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt new file mode 100644 index 000000000..711c8cb52 --- /dev/null +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt @@ -0,0 +1,65 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.test.mock.room.datatrack + +import io.livekit.android.room.datatrack.RemoteDataTrackManagerFactory +import io.livekit.uniffi.RemoteDataTrackManagerDelegate +import io.livekit.uniffi.RemoteDataTrackManagerInterface + +class MockRemoteDataTrackManagerFactory : RemoteDataTrackManagerFactory { + /** + * The most recently created manager. + */ + lateinit var manager: MockRemoteDataTrackManager + + override fun create(delegate: RemoteDataTrackManagerDelegate): RemoteDataTrackManagerInterface { + return MockRemoteDataTrackManager(delegate).also { manager = it } + } +} + +class MockRemoteDataTrackManager( + val delegate: RemoteDataTrackManagerDelegate, +) : RemoteDataTrackManagerInterface, AutoCloseable { + val handledJoinResponses = mutableListOf() + val handledParticipantUpdates = mutableListOf() + val handledSubscriberHandles = mutableListOf() + val handledPackets = mutableListOf() + var closed = false + private set + + override fun handlePacketReceived(packet: ByteArray) { + handledPackets.add(packet) + } + + override fun handleSfuJoinResponse(res: ByteArray) { + handledJoinResponses.add(res) + } + + override fun handleSfuParticipantUpdate(res: ByteArray, localParticipantIdentity: String) { + handledParticipantUpdates.add(res) + } + + override fun handleSubscriberHandles(res: ByteArray) { + handledSubscriberHandles.add(res) + } + + override fun resendSubscriptionUpdates() {} + + override fun close() { + closed = true + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt new file mode 100644 index 000000000..a22432da4 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt @@ -0,0 +1,48 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.room.Room +import io.livekit.android.test.MockE2ETest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DataTrackManagerMockE2ETest : MockE2ETest() { + + @Test + fun connectForwardsJoinToInjectedRemoteManager() = runTest { + connect() + + assertEquals(Room.State.CONNECTED, room.state) + val remote = remoteDataTrackManagerFactory.manager + assertTrue(remote.handledJoinResponses.isNotEmpty()) + } + + @Test + fun publishDataTrackUsesInjectedLocalManager() = runTest { + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + assertEquals("telemetry", result.getOrThrow().info.name) + + val local = localDataTrackManagerFactory.manager + assertEquals(1, local.publishedTracks.size) + assertEquals("telemetry", local.publishedTracks.single().info().name) + } +} From 9b103b433b247c00ab8fae64d7ff652891cedbce Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Aug 2026 01:43:06 +0900 Subject: [PATCH 12/18] wire up remote participant data tracks --- .../android/events/ParticipantEvent.kt | 20 ++- .../io/livekit/android/events/RoomEvent.kt | 30 ++++ .../main/java/io/livekit/android/room/Room.kt | 53 ++++++ .../room/datatrack/IncomingDataTrackEvent.kt | 38 +++++ .../datatrack/IncomingDataTrackManager.kt | 45 +++-- .../room/participant/RemoteParticipant.kt | 90 ++++++++++ .../datatrack/MockRemoteDataTrackManager.kt | 63 +++++++ .../java/io/livekit/android/room/RoomTest.kt | 12 ++ .../datatrack/DataTrackManagerMockE2ETest.kt | 155 ++++++++++++++++++ 9 files changed, 494 insertions(+), 12 deletions(-) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackEvent.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/events/ParticipantEvent.kt b/livekit-android-sdk/src/main/java/io/livekit/android/events/ParticipantEvent.kt index d189cc9dc..550f432c4 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/events/ParticipantEvent.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/events/ParticipantEvent.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 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. @@ -16,6 +16,8 @@ package io.livekit.android.events +import io.livekit.android.room.datatrack.DataTrackSid +import io.livekit.android.room.datatrack.RemoteDataTrack import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.room.participant.Participant import io.livekit.android.room.participant.ParticipantPermission @@ -120,6 +122,22 @@ sealed class ParticipantEvent(open val participant: Participant) : Event() { class TrackUnpublished(override val participant: RemoteParticipant, val publication: RemoteTrackPublication) : ParticipantEvent(participant) + /** + * A [RemoteParticipant] published a data track. + */ + class DataTrackPublished( + override val participant: RemoteParticipant, + val track: RemoteDataTrack, + ) : ParticipantEvent(participant) + + /** + * A [RemoteParticipant] unpublished a data track. + */ + class DataTrackUnpublished( + override val participant: RemoteParticipant, + val sid: DataTrackSid, + ) : ParticipantEvent(participant) + /** * Subscribed to a new track */ diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt b/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt index 4f2ca30b5..cbbc7a95a 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt @@ -19,6 +19,8 @@ package io.livekit.android.events import io.livekit.android.annotations.Beta import io.livekit.android.e2ee.E2EEState import io.livekit.android.room.Room +import io.livekit.android.room.datatrack.DataTrackSid +import io.livekit.android.room.datatrack.RemoteDataTrack import io.livekit.android.room.participant.ConnectionQuality import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.room.participant.Participant @@ -156,6 +158,34 @@ sealed class RoomEvent(val room: Room) : Event() { class TrackUnpublished(room: Room, val publication: TrackPublication, val participant: Participant) : RoomEvent(room) + /** + * A [RemoteParticipant] published a data track. + * + * ``` + * room.events.collect { event -> + * if (event is RoomEvent.DataTrackPublished) { + * event.track.subscribe().onSuccess { stream -> + * stream.flow.collect { frame -> process(frame.payload) } + * } + * } + * } + * ``` + */ + class DataTrackPublished( + room: Room, + val participant: RemoteParticipant, + val track: RemoteDataTrack, + ) : RoomEvent(room) + + /** + * A [RemoteParticipant] unpublished a data track. + */ + class DataTrackUnpublished( + room: Room, + val participant: RemoteParticipant, + val sid: DataTrackSid, + ) : RoomEvent(room) + /** * The [LocalParticipant] has subscribed to a new track. This event will always fire as * long as new tracks are ready for use. 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 45675df69..0ce8a6fd8 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 @@ -48,6 +48,10 @@ import io.livekit.android.events.collect import io.livekit.android.memory.CloseableManager import io.livekit.android.renderer.TextureViewRenderer import io.livekit.android.room.datastream.incoming.IncomingDataStreamManager +import io.livekit.android.room.datatrack.DataTrackSid +import io.livekit.android.room.datatrack.IncomingDataTrackEvent +import io.livekit.android.room.datatrack.IncomingDataTrackManager +import io.livekit.android.room.datatrack.RemoteDataTrack import io.livekit.android.room.metrics.collectMetrics import io.livekit.android.room.network.NetworkCallbackManagerFactory import io.livekit.android.room.network.ReconnectPolicy @@ -150,6 +154,7 @@ constructor( private val connectionWarmer: ConnectionWarmer, private val audioRecordPrewarmer: AudioRecordPrewarmer, private val incomingDataStreamManager: IncomingDataStreamManager, + private val incomingDataTrackManager: IncomingDataTrackManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, private val remoteParticipantFactory: RemoteParticipant.Factory, @@ -486,6 +491,7 @@ constructor( // Setup local participant. localParticipant.reinitialize(options) setupLocalParticipantEventHandling() + setupIncomingDataTrackEventHandling() if (roomOptions.e2eeOptions != null) { e2eeManager = e2EEManagerFactory.create(roomOptions.e2eeOptions.keyProvider).apply { @@ -704,6 +710,7 @@ constructor( getOrCreateRemoteParticipant(Participant.Identity(info.identity), info) } } + reattachRemoteDataTracks() } private fun setupLocalParticipantEventHandling() { @@ -792,14 +799,50 @@ constructor( } } + private fun setupIncomingDataTrackEventHandling() { + coroutineScope.launch { + incomingDataTrackManager.events.collect { event -> + when (event) { + is IncomingDataTrackEvent.TrackPublished -> attachRemoteDataTrack(event.track) + is IncomingDataTrackEvent.TrackUnpublished -> unpublishRemoteDataTrack(event.sid, event.track) + } + } + } + } + + private fun attachRemoteDataTrack(track: RemoteDataTrack) { + val participant = remoteParticipants[track.publisherIdentity] + if (participant == null) { + LKLog.d { "Data track published by not-yet-known participant ${track.publisherIdentity}" } + return + } + participant.addDataTrack(track) + } + + private fun unpublishRemoteDataTrack(sid: DataTrackSid, track: RemoteDataTrack) { + val participant = remoteParticipants[track.publisherIdentity] ?: return + participant.unpublishDataTrack(sid) + eventBus.postEvent(RoomEvent.DataTrackUnpublished(this, participant, sid), coroutineScope) + } + + private fun reattachRemoteDataTracks() { + for (track in incomingDataTrackManager.snapshotRemoteTracks()) { + attachRemoteDataTrack(track) + } + } + private fun handleParticipantDisconnect(identity: Participant.Identity) { val newParticipants = mutableRemoteParticipants.toMutableMap() val removedParticipant = newParticipants.remove(identity) ?: return + val unpublishedDataSids = removedParticipant.unpublishDataTracks() removedParticipant.trackPublications.values.toList().forEach { publication -> removedParticipant.unpublishTrack(publication.sid, true) } mutableRemoteParticipants = newParticipants + for (sid in unpublishedDataSids) { + eventBus.postEvent(RoomEvent.DataTrackUnpublished(this, removedParticipant, sid), coroutineScope) + } eventBus.postEvent(RoomEvent.ParticipantDisconnected(this, removedParticipant), coroutineScope) localParticipant.handleParticipantDisconnect(identity) @@ -857,6 +900,14 @@ constructor( } } + is ParticipantEvent.DataTrackPublished -> emitWhenConnected( + RoomEvent.DataTrackPublished( + room = this@Room, + participant = it.participant, + track = it.track, + ), + ) + is ParticipantEvent.TrackStreamStateChanged -> eventBus.postEvent( RoomEvent.TrackStreamStateChanged( this@Room, @@ -1272,6 +1323,7 @@ constructor( } } } + reattachRemoteDataTracks() } /** @@ -1474,6 +1526,7 @@ constructor( */ override fun onFullReconnecting() { localParticipant.prepareForFullReconnect() + remoteParticipants.values.forEach { it.detachDataTracks() } remoteParticipants.keys.toMutableSet() // copy keys to avoid concurrent modifications. .forEach { identity -> handleParticipantDisconnect(identity) } } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackEvent.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackEvent.kt new file mode 100644 index 000000000..e85efb4b2 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackEvent.kt @@ -0,0 +1,38 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +/** + * Events emitted by [IncomingDataTrackManager] when the UniFFI remote manager reports + * publication changes. + * + * @suppress + */ +internal sealed class IncomingDataTrackEvent { + /** + * A remote data track is available to subscribe. The publisher may not be in the room yet. + */ + class TrackPublished(val track: RemoteDataTrack) : IncomingDataTrackEvent() + + /** + * A remote data track with [sid] is no longer published. + */ + class TrackUnpublished( + val sid: DataTrackSid, + val track: RemoteDataTrack, + ) : IncomingDataTrackEvent() +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt index c4549d1ca..7006843eb 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt @@ -16,6 +16,7 @@ package io.livekit.android.room.datatrack +import io.livekit.android.events.BroadcastEventBus import io.livekit.android.room.RTCEngine import io.livekit.android.util.LKLog import io.livekit.uniffi.HandleSignalResponseException @@ -33,6 +34,9 @@ import io.livekit.uniffi.RemoteDataTrack as FfiRemoteDataTrack * SFU participant / subscriber-handle responses and `_data_track` channel packets are forwarded * into the Rust manager; subscription signal requests are sent back out through the engine. * + * Publication events are emitted on [events]. The publisher may not be in the room yet; callers + * should park the track until [io.livekit.android.room.participant.RemoteParticipant] exists. + * * @suppress */ @Singleton @@ -42,18 +46,16 @@ constructor( private val engineProvider: Provider, private val remoteDataTrackManagerFactory: RemoteDataTrackManagerFactory, ) { + private val eventBus = BroadcastEventBus() + /** - * Optional listener for remote data-track publication events. + * Publication and unpublication events from the UniFFI remote manager. */ - interface Listener { - fun onTrackPublished(track: RemoteDataTrack) - fun onTrackUnpublished(sid: DataTrackSid) - } - - var listener: Listener? = null + internal val events = eventBus.readOnly() private val lock = Any() private var remoteManager: RemoteDataTrackManagerInterface? = null + private val remoteTracks = mutableListOf() private val delegate = object : RemoteDataTrackManagerDelegate { override fun onSignalRequest(request: ByteArray) { @@ -61,13 +63,33 @@ constructor( } override fun onTrackPublished(track: FfiRemoteDataTrack) { - listener?.onTrackPublished(RemoteDataTrack(track)) - ?: LKLog.d { "Remote data track published: ${track.info().sid}" } + val wrapped = RemoteDataTrack(track) + synchronized(lock) { + remoteTracks.add(wrapped) + } + eventBus.tryPostEvent(IncomingDataTrackEvent.TrackPublished(wrapped)) } override fun onTrackUnpublished(sid: String) { - listener?.onTrackUnpublished(DataTrackSid(sid)) - ?: LKLog.d { "Remote data track unpublished: $sid" } + val dataTrackSid = DataTrackSid(sid) + val unpublished = synchronized(lock) { + val matches = remoteTracks.filter { it.info.sid == dataTrackSid } + remoteTracks.removeAll { track -> matches.any { it === track } } + matches + } + for (track in unpublished) { + eventBus.tryPostEvent(IncomingDataTrackEvent.TrackUnpublished(dataTrackSid, track)) + } + } + } + + /** + * Remote data tracks currently known to the UniFFI manager, including those whose publisher + * is not yet in the room. + */ + internal fun snapshotRemoteTracks(): List { + synchronized(lock) { + return remoteTracks.toList() } } @@ -128,6 +150,7 @@ constructor( synchronized(lock) { (remoteManager as? AutoCloseable)?.close() remoteManager = null + remoteTracks.clear() } } 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 d80867af2..52c27277f 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 @@ -23,6 +23,8 @@ import io.livekit.android.dagger.InjectionNames import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.room.SignalClient +import io.livekit.android.room.datatrack.DataTrackSid +import io.livekit.android.room.datatrack.RemoteDataTrack import io.livekit.android.room.track.KIND_AUDIO import io.livekit.android.room.track.KIND_VIDEO import io.livekit.android.room.track.RemoteAudioTrack @@ -31,7 +33,9 @@ import io.livekit.android.room.track.RemoteVideoTrack import io.livekit.android.room.track.Track import io.livekit.android.room.track.TrackException import io.livekit.android.util.CloseableCoroutineScope +import io.livekit.android.util.FlowObservable import io.livekit.android.util.LKLog +import io.livekit.android.util.flowDelegate import io.livekit.android.webrtc.RTCStatsGetter import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.SupervisorJob @@ -93,6 +97,27 @@ class RemoteParticipant( ): RemoteParticipant } private val coroutineScope = CloseableCoroutineScope(defaultDispatcher + SupervisorJob()) + private val dataTracksLock = Any() + + /** + * Data tracks published by this participant, keyed by track name. + * + * Names are the stable identifier: a track's SID rotates when the publisher republishes + * after a full reconnect (the track object itself survives). + * + * ``` + * val track = participant.dataTracks["telemetry"] + * track?.subscribe()?.onSuccess { stream -> + * stream.flow.collect { frame -> process(frame.payload) } + * } + * ``` + * + * Changes can be observed by using [io.livekit.android.util.flow] + */ + @FlowObservable + @get:FlowObservable + var dataTracks: Map by flowDelegate(emptyMap()) + private set /** * Get a track publication with the corresponding sid. @@ -260,4 +285,69 @@ class RemoteParticipant( internal fun onDataReceived(event: RoomEvent.DataReceived) { eventBus.postEvent(ParticipantEvent.DataReceived(this, event.data, event.topic, event.encryptionType), scope) } + + /** + * Adds the track, returning `false` if this exact track is already attached. + */ + internal fun addDataTrack(track: RemoteDataTrack): Boolean { + val attached = synchronized(dataTracksLock) { + if (dataTracks.values.any { it === track }) { + return@synchronized false + } + dataTracks = dataTracks + (track.name to track) + true + } + if (attached) { + eventBus.postEvent(ParticipantEvent.DataTrackPublished(this, track), scope) + } + return attached + } + + internal fun removeDataTrack(sid: DataTrackSid): RemoteDataTrack? { + // `info.sid` is an FFI call; resolve the instance before taking the lock. + val track = dataTracks.values.firstOrNull { it.info.sid == sid } ?: return null + synchronized(dataTracksLock) { + if (dataTracks.values.none { it === track }) { + return null + } + dataTracks = dataTracks - track.name + return track + } + } + + /** + * Removes the track and emits [ParticipantEvent.DataTrackUnpublished], even if it was not + * attached (for example after a full reconnect detached it). + */ + internal fun unpublishDataTrack(sid: DataTrackSid) { + removeDataTrack(sid) + eventBus.postEvent(ParticipantEvent.DataTrackUnpublished(this, sid), scope) + } + + /** + * Unpublishes every attached data track and emits an unpublish event for each. + * + * @return The SIDs that were unpublished, for the room to emit matching [io.livekit.android.events.RoomEvent]s. + */ + internal fun unpublishDataTracks(): List { + val previous = synchronized(dataTracksLock) { + dataTracks.also { dataTracks = emptyMap() } + } + val sids = previous.values.map { it.info.sid } + for (sid in sids) { + eventBus.postEvent(ParticipantEvent.DataTrackUnpublished(this, sid), scope) + } + return sids + } + + /** + * Drops attached data tracks without notifying. Used when the tracks outlive this participant + * object: a full reconnect recreates participants, but the incoming manager keeps its tracks + * and re-attaches them. + */ + internal fun detachDataTracks() { + synchronized(dataTracksLock) { + dataTracks = emptyMap() + } + } } diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt index 711c8cb52..0735ece51 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt @@ -17,6 +17,11 @@ package io.livekit.android.test.mock.room.datatrack import io.livekit.android.room.datatrack.RemoteDataTrackManagerFactory +import io.livekit.uniffi.DataTrackInfo +import io.livekit.uniffi.DataTrackStream +import io.livekit.uniffi.DataTrackSubscribeOptions +import io.livekit.uniffi.NoHandle +import io.livekit.uniffi.RemoteDataTrack import io.livekit.uniffi.RemoteDataTrackManagerDelegate import io.livekit.uniffi.RemoteDataTrackManagerInterface @@ -59,7 +64,65 @@ class MockRemoteDataTrackManager( override fun resendSubscriptionUpdates() {} + /** + * Fires [RemoteDataTrackManagerDelegate.onTrackPublished] as the UniFFI manager would. + */ + fun simulateTrackPublished( + name: String, + publisherIdentity: String, + sid: String = "DT_mock", + ): MockFfiRemoteDataTrack { + val track = MockFfiRemoteDataTrack( + name = name, + publisherIdentity = publisherIdentity, + sid = sid, + ) + delegate.onTrackPublished(track) + return track + } + + /** + * Fires [RemoteDataTrackManagerDelegate.onTrackUnpublished] as the UniFFI manager would. + */ + fun simulateTrackUnpublished(sid: String) { + delegate.onTrackUnpublished(sid) + } + override fun close() { closed = true } } + +/** + * UniFFI [RemoteDataTrack] stand-in that does not touch native code. + */ +class MockFfiRemoteDataTrack( + name: String, + publisherIdentity: String, + sid: String = "DT_mock", +) : RemoteDataTrack(NoHandle) { + private val trackInfo = DataTrackInfo( + sid = sid, + name = name, + usesE2ee = false, + schema = null, + frameEncoding = null, + ) + private val identity = publisherIdentity + + override fun info(): DataTrackInfo = trackInfo + + override fun isPublished(): Boolean = true + + override fun publisherIdentity(): String = identity + + override suspend fun subscribe(): DataTrackStream { + throw UnsupportedOperationException("subscribe is not supported in tests") + } + + override suspend fun subscribeWithOptions(options: DataTrackSubscribeOptions): DataTrackStream { + throw UnsupportedOperationException("subscribe is not supported in tests") + } + + override suspend fun waitForUnpublish() {} +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt index 9a71cb630..cde24d5f2 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt @@ -30,6 +30,8 @@ import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.memory.CloseableManager import io.livekit.android.room.datastream.incoming.IncomingDataStreamManagerImpl +import io.livekit.android.room.datatrack.IncomingDataTrackEvent +import io.livekit.android.room.datatrack.IncomingDataTrackManager import io.livekit.android.room.network.NetworkCallbackManagerImpl import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.test.assert.assertIsClassList @@ -91,6 +93,9 @@ class RoomTest { @Mock lateinit var regionUrlProviderFactory: RegionUrlProvider.Factory + @Mock + lateinit var incomingDataTrackManager: IncomingDataTrackManager + lateinit var networkCallbackRegistry: MockNetworkCallbackRegistry var eglBase: EglBase = MockEglBase() @@ -114,6 +119,12 @@ class RoomTest { fun setup() { context = ApplicationProvider.getApplicationContext() networkCallbackRegistry = MockNetworkCallbackRegistry() + whenever(incomingDataTrackManager.events).thenReturn( + object : EventListenable { + override val events: SharedFlow = MutableSharedFlow() + }, + ) + whenever(incomingDataTrackManager.snapshotRemoteTracks()).thenReturn(emptyList()) room = Room( context = context, engine = rtcEngine, @@ -136,6 +147,7 @@ class RoomTest { connectionWarmer = MockConnectionWarmer(), audioRecordPrewarmer = NoAudioRecordPrewarmer(), incomingDataStreamManager = IncomingDataStreamManagerImpl(), + incomingDataTrackManager = incomingDataTrackManager, rpcClientManager = io.livekit.android.room.rpc.RpcClientManager( engine = rtcEngine, outgoingDataStreamManager = Mockito.mock(io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager::class.java), diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt index a22432da4..0ad91b57a 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt @@ -16,12 +16,23 @@ package io.livekit.android.room.datatrack +import io.livekit.android.events.ParticipantEvent +import io.livekit.android.events.RoomEvent import io.livekit.android.room.Room +import io.livekit.android.room.participant.Participant 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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class DataTrackManagerMockE2ETest : MockE2ETest() { @Test @@ -45,4 +56,148 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { assertEquals(1, local.publishedTracks.size) assertEquals("telemetry", local.publishedTracks.single().info().name) } + + @Test + fun remoteDataTrackPublishedAttachesToParticipant() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + val participant = remoteParticipant() + val roomCollector = EventCollector(room.events, coroutineRule.scope) + val participantCollector = EventCollector(participant.events, coroutineRule.scope) + + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_test", + ) + advanceUntilIdle() + + val attached = participant.dataTracks["telemetry"] + assertNotNull(attached) + assertEquals("telemetry", attached!!.name) + assertEquals(DataTrackSid("DT_test"), attached.info.sid) + + val roomEvents = roomCollector.stopCollecting() + val participantEvents = participantCollector.stopCollecting() + + assertEquals(1, roomEvents.size) + assertIsClass(RoomEvent.DataTrackPublished::class.java, roomEvents.first()) + val roomEvent = roomEvents.first() as RoomEvent.DataTrackPublished + assertEquals(participant, roomEvent.participant) + assertEquals(attached, roomEvent.track) + + assertEquals(1, participantEvents.size) + assertIsClass(ParticipantEvent.DataTrackPublished::class.java, participantEvents.first()) + } + + @Test + fun remoteDataTrackPublishedBeforeParticipantIsParkedThenAttached() = runTest { + connect() + + val parkedCollector = EventCollector(room.events, coroutineRule.scope) + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "parked", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_parked", + ) + advanceUntilIdle() + + assertTrue(room.remoteParticipants.isEmpty()) + assertTrue(parkedCollector.stopCollecting().none { it is RoomEvent.DataTrackPublished }) + + val attachedCollector = EventCollector(room.events, coroutineRule.scope) + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + val participant = remoteParticipant() + val attached = participant.dataTracks["parked"] + assertNotNull(attached) + assertEquals("parked", attached!!.name) + + val events = attachedCollector.stopCollecting() + assertTrue(events.any { it is RoomEvent.DataTrackPublished }) + } + + @Test + fun remoteDataTrackUnpublishedRemovesFromParticipant() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + val participant = remoteParticipant() + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_unpub", + ) + advanceUntilIdle() + assertNotNull(participant.dataTracks["telemetry"]) + + val roomCollector = EventCollector(room.events, coroutineRule.scope) + val participantCollector = EventCollector(participant.events, coroutineRule.scope) + + remoteDataTrackManagerFactory.manager.simulateTrackUnpublished("DT_unpub") + advanceUntilIdle() + + assertNull(participant.dataTracks["telemetry"]) + + val roomEvents = roomCollector.stopCollecting() + val participantEvents = participantCollector.stopCollecting() + + assertEquals(1, roomEvents.size) + assertIsClass(RoomEvent.DataTrackUnpublished::class.java, roomEvents.first()) + assertEquals(DataTrackSid("DT_unpub"), (roomEvents.first() as RoomEvent.DataTrackUnpublished).sid) + + assertEquals(1, participantEvents.size) + assertIsClass(ParticipantEvent.DataTrackUnpublished::class.java, participantEvents.first()) + } + + @Test + fun participantDisconnectUnpublishesDataTracks() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_leave", + ) + advanceUntilIdle() + + val roomCollector = EventCollector(room.events, coroutineRule.scope) + simulateMessageFromServer(TestData.PARTICIPANT_DISCONNECT) + advanceUntilIdle() + + val events = roomCollector.stopCollecting() + assertTrue(events.any { it is RoomEvent.DataTrackUnpublished && it.sid == DataTrackSid("DT_leave") }) + assertTrue(events.any { it is RoomEvent.ParticipantDisconnected }) + assertTrue(room.remoteParticipants.isEmpty()) + } + + @Test + fun fullReconnectDetachesDataTracksWithoutUnpublishEvent() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_reconnect", + ) + advanceUntilIdle() + + val roomCollector = EventCollector(room.events, coroutineRule.scope) + room.onFullReconnecting() + advanceUntilIdle() + + val events = roomCollector.stopCollecting() + assertTrue(events.none { it is RoomEvent.DataTrackUnpublished }) + } + + private fun remoteParticipant() = + room.remoteParticipants[Participant.Identity(TestData.REMOTE_PARTICIPANT.identity)]!! } From 424bb14e42cbeee1b057272baa298713e33e9c05 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Aug 2026 01:58:35 +0900 Subject: [PATCH 13/18] wiring for UNPUBLISH_DATA_TRACK_RESPONSE --- .../main/java/io/livekit/android/room/RTCEngine.kt | 4 ++++ .../main/java/io/livekit/android/room/SignalClient.kt | 3 ++- .../room/datatrack/OutgoingDataTrackManager.kt | 11 +++++++++++ .../java/io/livekit/android/test/mock/TestData.kt | 2 +- 4 files changed, 18 insertions(+), 2 deletions(-) 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 5c18231e9..e5a65ed0c 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 @@ -1349,6 +1349,10 @@ internal constructor( outgoingDataTrackManager.handleSfuPublishResponse(response.toByteArray()) } + override fun onUnpublishDataTrackResponse(response: LivekitRtc.SignalResponse) { + outgoingDataTrackManager.handleSfuUnpublishResponse(response.toByteArray()) + } + override fun onRequestResponse(response: LivekitRtc.SignalResponse) { outgoingDataTrackManager.handleSfuRequestResponse(response.toByteArray()) } 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 9a0152592..8d2df3b60 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 @@ -970,7 +970,7 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.UNPUBLISH_DATA_TRACK_RESPONSE -> { - // TODO + listener?.onUnpublishDataTrackResponse(response) } LivekitRtc.SignalResponse.MessageCase.DATA_TRACK_SUBSCRIBER_HANDLES -> { @@ -1092,6 +1092,7 @@ constructor( fun onLocalTrackUnpublished(trackUnpublished: LivekitRtc.TrackUnpublishedResponse) fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed) fun onPublishDataTrackResponse(response: LivekitRtc.SignalResponse) {} + fun onUnpublishDataTrackResponse(response: LivekitRtc.SignalResponse) {} fun onRequestResponse(response: LivekitRtc.SignalResponse) {} fun onDataTrackSubscriberHandles(response: LivekitRtc.SignalResponse) {} } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt index d905d0fed..bd67a6076 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt @@ -94,6 +94,17 @@ constructor( } } + /** + * Receives a serialized [livekit.LivekitRtc.SignalResponse] containing + * `UnpublishDataTrackResponse`. + * + * UniFFI does not consume this message. Local unpublish is applied by + * [LocalDataTrack.unpublish] before the SFU acks. + */ + fun handleSfuUnpublishResponse(responseBytes: ByteArray) { + // UniFFI does not consume UnpublishDataTrackResponse. + } + /** * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing `RequestResponse` * to the UniFFI manager. Non-data-track request responses are ignored by the manager. diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt index c5137cf94..460902906 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 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. From e8c8f342ff550edf4d8c693a0b2303ac60553c20 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Aug 2026 02:16:36 +0900 Subject: [PATCH 14/18] quick reconnect handling --- .../java/io/livekit/android/room/RTCEngine.kt | 17 +++++- .../main/java/io/livekit/android/room/Room.kt | 4 +- .../datatrack/IncomingDataTrackManager.kt | 2 +- .../datatrack/MockLocalDataTrackManager.kt | 16 ++++- .../datatrack/MockRemoteDataTrackManager.kt | 6 +- .../datatrack/DataTrackManagerMockE2ETest.kt | 59 +++++++++++++++++++ 6 files changed, 96 insertions(+), 8 deletions(-) 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 e5a65ed0c..166fe3545 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 @@ -730,8 +730,8 @@ internal constructor( client.onPCConnected() if (isFullReconnect) { outgoingDataTrackManager.republishTracks() - incomingDataTrackManager.resendSubscriptionUpdates() } + incomingDataTrackManager.resendSubscriptionUpdates() listener?.onPostReconnect(isFullReconnect) return@launch } @@ -1075,7 +1075,7 @@ internal constructor( fun onSubscribedQualityUpdate(subscribedQualityUpdate: LivekitRtc.SubscribedQualityUpdate) fun onSubscriptionPermissionUpdate(subscriptionPermissionUpdate: LivekitRtc.SubscriptionPermissionUpdate) fun onSubscriptionError(subscriptionResponse: LivekitRtc.SubscriptionResponse) - fun onSignalConnected(isResume: Boolean) + suspend fun onSignalConnected(isResume: Boolean) fun onFullReconnecting() suspend fun onPostReconnect(isFullReconnect: Boolean) fun onLocalTrackUnpublished(trackUnpublished: LivekitRtc.TrackUnpublishedResponse) @@ -1515,7 +1515,7 @@ internal constructor( } } - fun sendSyncState( + suspend fun sendSyncState( subscription: LivekitRtc.UpdateSubscription, publishedTracks: List, ) { @@ -1548,6 +1548,16 @@ internal constructor( } } + val publishDataTracks = outgoingDataTrackManager.publishResponsesForSyncState().mapNotNull { bytes -> + try { + LivekitRtc.PublishDataTrackResponse.parseFrom(bytes) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + LKLog.w(e) { "Failed to parse PublishDataTrackResponse for sync state" } + null + } + } + val syncState = with(LivekitRtc.SyncState.newBuilder()) { if (answer != null) { setAnswer(answer) @@ -1557,6 +1567,7 @@ internal constructor( } setSubscription(subscription) addAllPublishTracks(publishedTracks) + addAllPublishDataTracks(publishDataTracks) addAllDataChannels(dataChannelInfos) addAllDatachannelReceiveStates(dataChannelReceiveStates) build() 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 0ce8a6fd8..c2a80eb7e 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 @@ -1099,7 +1099,7 @@ constructor( incomingDataStreamManager.clearOpenStreams() } - private fun sendSyncState() { + private suspend fun sendSyncState() { // Whether we're sending subscribed tracks or tracks to unsubscribe. val sendUnsub = connectOptions.autoSubscribe val participantTracksList = mutableListOf() @@ -1514,7 +1514,7 @@ constructor( /** * @suppress */ - override fun onSignalConnected(isResume: Boolean) { + override suspend fun onSignalConnected(isResume: Boolean) { if (isResume) { // during resume reconnection, need to send sync state upon signal connection. sendSyncState() diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt index 7006843eb..94c21ce39 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt @@ -137,7 +137,7 @@ constructor( } /** - * Resend subscription updates after a full reconnect so the SFU knows which tracks are subscribed. + * Resend subscription updates after reconnect so the SFU re-issues subscriber handles. */ fun resendSubscriptionUpdates() { remoteManager?.resendSubscriptionUpdates() diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt index e26c58465..9457504a4 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt @@ -24,6 +24,8 @@ import io.livekit.uniffi.LocalDataTrack import io.livekit.uniffi.LocalDataTrackManagerDelegate import io.livekit.uniffi.LocalDataTrackManagerInterface import io.livekit.uniffi.NoHandle +import livekit.LivekitModels +import livekit.LivekitRtc class MockLocalDataTrackManagerFactory : LocalDataTrackManagerFactory { /** @@ -53,7 +55,19 @@ class MockLocalDataTrackManager( handledRequestResponses.add(res) } - override suspend fun publishResponsesForSyncState(): List = emptyList() + override suspend fun publishResponsesForSyncState(): List { + return publishedTracks.filter { it.isPublished() }.map { track -> + LivekitRtc.PublishDataTrackResponse.newBuilder() + .setInfo( + LivekitModels.DataTrackInfo.newBuilder() + .setSid(track.info().sid) + .setName(track.info().name) + .build(), + ) + .build() + .toByteArray() + } + } override suspend fun publishTrack(options: DataTrackOptions): LocalDataTrack { return MockFfiLocalDataTrack(name = options.name).also { publishedTracks.add(it) } diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt index 0735ece51..79e85710a 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt @@ -45,6 +45,8 @@ class MockRemoteDataTrackManager( val handledPackets = mutableListOf() var closed = false private set + var resendSubscriptionUpdatesCount = 0 + private set override fun handlePacketReceived(packet: ByteArray) { handledPackets.add(packet) @@ -62,7 +64,9 @@ class MockRemoteDataTrackManager( handledSubscriberHandles.add(res) } - override fun resendSubscriptionUpdates() {} + override fun resendSubscriptionUpdates() { + resendSubscriptionUpdatesCount++ + } /** * Fires [RemoteDataTrackManagerDelegate.onTrackPublished] as the UniFFI manager would. diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt index 0ad91b57a..3e8b214fa 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt @@ -18,14 +18,18 @@ package io.livekit.android.room.datatrack import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent +import io.livekit.android.room.ReconnectType import io.livekit.android.room.Room +import io.livekit.android.room.SignalClient import io.livekit.android.room.participant.Participant 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.util.toPBByteString import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle +import livekit.LivekitRtc import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -198,6 +202,61 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { assertTrue(events.none { it is RoomEvent.DataTrackUnpublished }) } + @Test + fun softReconnectIncludesPublishedDataTracksInSyncState() = runTest { + room.setReconnectionType(ReconnectType.FORCE_SOFT_RECONNECT) + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val syncState = wsFactory.ws.sentRequests + .map { LivekitRtc.SignalRequest.parseFrom(it.toPBByteString()) } + .firstOrNull { it.hasSyncState() } + ?.syncState + assertNotNull(syncState) + assertEquals(1, syncState!!.publishDataTracksCount) + assertEquals("telemetry", syncState.getPublishDataTracks(0).info.name) + assertEquals("DT_mock", syncState.getPublishDataTracks(0).info.sid) + } + + @Test + fun softReconnectResendsDataTrackSubscriptions() = runTest { + room.setReconnectionType(ReconnectType.FORCE_SOFT_RECONNECT) + connect() + + val remote = remoteDataTrackManagerFactory.manager + assertEquals(0, remote.resendSubscriptionUpdatesCount) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + assertEquals(1, remote.resendSubscriptionUpdatesCount) + } + + private fun reconnectWebsocket() { + wsFactory.listener.onOpen(wsFactory.ws, createOpenResponse(wsFactory.request)) + val softReconnectParam = wsFactory.request.url + .queryParameter(SignalClient.CONNECT_QUERY_RECONNECT) + ?.toIntOrNull() + ?: 0 + + if (softReconnectParam == 0) { + simulateMessageFromServer(TestData.JOIN) + } else { + simulateMessageFromServer(TestData.RECONNECT) + } + } + private fun remoteParticipant() = room.remoteParticipants[Participant.Identity(TestData.REMOTE_PARTICIPANT.identity)]!! } From b26ca2d6e3d57b2a308327afef38e7f701142c48 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Aug 2026 03:02:06 +0900 Subject: [PATCH 15/18] wait until publisher open --- .../java/io/livekit/android/room/RTCEngine.kt | 49 +++++++++++- .../datatrack/OutgoingDataTrackManager.kt | 16 +++- .../android/webrtc/DataChannelManager.kt | 11 +++ .../datatrack/MockLocalDataTrackManager.kt | 16 +++- .../datatrack/DataTrackManagerMockE2ETest.kt | 79 +++++++++++++++++++ .../android/webrtc/DataChannelManagerTest.kt | 36 +++++++++ 6 files changed, 202 insertions(+), 5 deletions(-) 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 166fe3545..cc4712344 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 @@ -197,6 +197,7 @@ internal constructor( private var lossyDataChannel: DataChannel? = null private var lossyDataChannelSub: DataChannel? = null private var dataTrackDataChannel: DataChannel? = null + private var dataTrackDataChannelManager: DataChannelManager? = null private var dataTrackDataChannelSub: DataChannel? = null private var reliableDataChannelManager: DataChannelManager? = null private var reliableBufferedAmountJob: Job? = null @@ -285,8 +286,10 @@ internal constructor( configure(joinResponse, options) - // create offer - if (!isSubscriberPrimary || joinResponse.fastPublish) { + // Subscriber-primary defers the publisher PC until something is published. After a full + // reconnect `hasPublished` is still set, so re-negotiate here — otherwise the ICE wait + // stalls and data-track republish never runs. + if (!isSubscriberPrimary || joinResponse.fastPublish || hasPublished) { negotiatePublisher() } client.onReadyForResponses() @@ -407,7 +410,13 @@ internal constructor( DATA_TRACK_DATA_CHANNEL_LABEL, dataTrackInit, ).also { dataChannel -> - dataChannel.registerObserver(DataChannelObserver(dataChannel)) + val dataChannelManager = DataChannelManager( + dataChannel, + DataChannelObserver(dataChannel), + rtcThreadToken, + ) + dataTrackDataChannelManager = dataChannelManager + dataChannel.registerObserver(dataChannelManager) } } } @@ -533,6 +542,8 @@ internal constructor( lossyDataChannelSubManager?.dispose() lossyDataChannelSubManager = null lossyDataChannelSub = null + dataTrackDataChannelManager?.dispose() + dataTrackDataChannelManager = null dataTrackDataChannel = null dataTrackDataChannelSub = null isSubscriberPrimary = false @@ -941,6 +952,38 @@ internal constructor( ) } + /** + * Negotiates the publisher if needed and waits until the `_data_track` channel is open. + * + * Data-track publish must not proceed until then: [sendDataTrackPackets] drops packets while + * the channel is not [DataChannel.State.OPEN]. + */ + @Throws(exceptionClasses = [RoomException.ConnectException::class]) + internal suspend fun ensureDataTrackPublisherConnected() { + if (publisher == null) { + throw RoomException.ConnectException("Publisher isn't setup yet! Is the room connected?") + } + + if (isSubscriberPrimary && + publisher?.isConnected() != true && + publisher?.iceConnectionState() != PeerConnection.IceConnectionState.CHECKING + ) { + negotiatePublisher() + } + + val channelManager = dataTrackDataChannelManager + ?: throw RoomException.ConnectException( + "Publisher data track channel not established; is the room connected?", + ) + val opened = withTimeoutOrNull(MAX_ICE_CONNECT_TIMEOUT_MS.toLong()) { + channelManager.waitUntilOpen() + true + } + if (opened != true) { + throw RoomException.ConnectException("Timed out establishing the publisher data track channel") + } + } + private fun dataChannelManagerForKind(kind: LivekitModels.DataPacket.Kind): DataChannelManager? = when (kind) { LivekitModels.DataPacket.Kind.RELIABLE -> reliableDataChannelManager diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt index bd67a6076..1bd4ae05a 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt @@ -18,6 +18,7 @@ package io.livekit.android.room.datatrack import androidx.annotation.CheckResult import io.livekit.android.room.RTCEngine +import io.livekit.android.room.RoomException import io.livekit.android.util.LKLog import io.livekit.android.util.rethrowIfCancellationSignal import io.livekit.uniffi.DataTrackOptions @@ -71,6 +72,19 @@ constructor( schema = options?.schema?.toFfi(), frameEncoding = options?.frameEncoding?.toFfi(), ) + try { + engineProvider.get().ensureDataTrackPublisherConnected() + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + val message = e.message ?: "Lost the connection while establishing the publisher data track channel" + return Result.failure( + if (e is RoomException.ConnectException && message.startsWith("Timed out")) { + DataTrackPublishException.Timeout(message, e) + } else { + DataTrackPublishException.Disconnected(message, e) + }, + ) + } return try { Result.success(LocalDataTrack(ensureManager().publishTrack(ffiOptions))) } catch (e: PublishException) { @@ -98,7 +112,7 @@ constructor( * Receives a serialized [livekit.LivekitRtc.SignalResponse] containing * `UnpublishDataTrackResponse`. * - * UniFFI does not consume this message. Local unpublish is applied by + * UniFFI does not consume this message yet. Local unpublish is applied by * [LocalDataTrack.unpublish] before the SFU acks. */ fun handleSfuUnpublishResponse(responseBytes: ByteArray) { diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/DataChannelManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/DataChannelManager.kt index 78bdbb183..7eaa7c179 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/DataChannelManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/DataChannelManager.kt @@ -24,6 +24,7 @@ import io.livekit.android.util.flowDelegate import io.livekit.android.webrtc.peerconnection.RTCThreadToken import io.livekit.android.webrtc.peerconnection.executeOnRTCThread import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.takeWhile import livekit.org.webrtc.DataChannel @@ -58,6 +59,16 @@ class DataChannelManager( .collect() } + suspend fun waitUntilOpen() { + if (state == DataChannel.State.OPEN) { + return + } + val signal = ::disposed.flow.map { if (it) Unit else null } + ::state.flow + .cancelOnSignal(signal) + .first { it == DataChannel.State.OPEN } + } + override fun onBufferedAmountChange(previousAmount: Long) { bufferedAmount = dataChannel.bufferedAmount() } diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt index 9457504a4..e774a1df0 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt @@ -70,10 +70,24 @@ class MockLocalDataTrackManager( } override suspend fun publishTrack(options: DataTrackOptions): LocalDataTrack { + val request = LivekitRtc.SignalRequest.newBuilder() + .setPublishDataTrackRequest( + LivekitRtc.PublishDataTrackRequest.newBuilder() + .setName(options.name) + .build(), + ) + .build() + .toByteArray() + delegate.onSignalRequest(request) return MockFfiLocalDataTrack(name = options.name).also { publishedTracks.add(it) } } - override fun republishTracks() {} + var republishTracksCount = 0 + private set + + override fun republishTracks() { + republishTracksCount++ + } override fun close() { closed = true diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt index 3e8b214fa..c48617298 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt @@ -18,6 +18,7 @@ package io.livekit.android.room.datatrack import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent +import io.livekit.android.room.RTCEngine import io.livekit.android.room.ReconnectType import io.livekit.android.room.Room import io.livekit.android.room.SignalClient @@ -25,11 +26,17 @@ import io.livekit.android.room.participant.Participant 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.MockDataChannel +import io.livekit.android.test.mock.SignalRequestHandler import io.livekit.android.test.mock.TestData import io.livekit.android.test.util.toPBByteString import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.yield import livekit.LivekitRtc +import livekit.org.webrtc.DataChannel +import livekit.org.webrtc.PeerConnection import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -61,6 +68,31 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { assertEquals("telemetry", local.publishedTracks.single().info().name) } + @Test + fun publishDataTrackWaitsForPublisherChannelOpen() = runTest { + connect() + val channel = publisherDataTrackChannel() + channel.state = DataChannel.State.CONNECTING + + val publish = async { room.localParticipant.publishDataTrack("telemetry") } + yield() + assertTrue(publish.isActive) + + channel.state = DataChannel.State.OPEN + val result = publish.await() + assertTrue(result.isSuccess) + } + + @Test + fun publishDataTrackTimesOutIfPublisherChannelNeverOpens() = runTest { + connect() + publisherDataTrackChannel().state = DataChannel.State.CONNECTING + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DataTrackPublishException.Timeout) + } + @Test fun remoteDataTrackPublishedAttachesToParticipant() = runTest { connect() @@ -202,9 +234,35 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { assertTrue(events.none { it is RoomEvent.DataTrackUnpublished }) } + @Test + fun fullReconnectRenegotiatesPublisherForDataTrack() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + + val local = localDataTrackManagerFactory.manager + assertEquals(0, local.republishTracksCount) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + assertEquals(1, local.republishTracksCount) + assertEquals( + PeerConnection.PeerConnectionState.CONNECTED, + getPublisherPeerConnection().connectionState(), + ) + } + @Test fun softReconnectIncludesPublishedDataTracksInSyncState() = runTest { room.setReconnectionType(ReconnectType.FORCE_SOFT_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) connect() val result = room.localParticipant.publishDataTrack("telemetry") @@ -243,6 +301,27 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { assertEquals(1, remote.resendSubscriptionUpdatesCount) } + private val publisherOfferHandler: SignalRequestHandler = { request -> + if (request.hasOffer()) { + val answer = with(LivekitRtc.SignalResponse.newBuilder()) { + answer = with(LivekitRtc.SessionDescription.newBuilder()) { + sdp = "remote_answer" + type = "answer" + id = request.offer.id + build() + } + build() + } + wsFactory.receiveMessage(answer) + true + } else { + false + } + } + + private fun publisherDataTrackChannel() = + getPublisherPeerConnection().dataChannels[RTCEngine.DATA_TRACK_DATA_CHANNEL_LABEL] as MockDataChannel + private fun reconnectWebsocket() { wsFactory.listener.onOpen(wsFactory.ws, createOpenResponse(wsFactory.request)) val softReconnectParam = wsFactory.request.url diff --git a/livekit-android-test/src/test/java/io/livekit/android/webrtc/DataChannelManagerTest.kt b/livekit-android-test/src/test/java/io/livekit/android/webrtc/DataChannelManagerTest.kt index 51b5212c9..1523f7be9 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/webrtc/DataChannelManagerTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/webrtc/DataChannelManagerTest.kt @@ -120,6 +120,42 @@ class DataChannelManagerTest : BaseTest() { waiter.await() } + @Test + fun waitUntilOpen_completesWhenAlreadyOpen() = runTest { + val channel = MockDataChannel("dc") + val manager = DataChannelManager(channel, NOOP_OBSERVER, MockRTCThreadToken()) + channel.registerObserver(manager) + manager.waitUntilOpen() + } + + @Test + fun waitUntilOpen_completesWhenChannelOpens() = runTest { + val channel = MockDataChannel("dc") + val manager = DataChannelManager(channel, NOOP_OBSERVER, MockRTCThreadToken()) + channel.registerObserver(manager) + channel.state = DataChannel.State.CONNECTING + val waiter = async { manager.waitUntilOpen() } + yield() + channel.state = DataChannel.State.OPEN + waiter.await() + } + + @Test + fun waitUntilOpen_cancelledWhenDisposedWhileWaiting() = runTest { + val channel = MockDataChannel("dc") + val manager = DataChannelManager(channel, NOOP_OBSERVER, MockRTCThreadToken()) + channel.registerObserver(manager) + channel.state = DataChannel.State.CONNECTING + val waiter = async { manager.waitUntilOpen() } + yield() + manager.dispose() + try { + waiter.await() + fail("expected CancellationException") + } catch (_: CancellationException) { + } + } + @Test fun waitForBufferedAmountLow_cancelledWhenDisposedWhileWaiting() = runTest { val channel = MockDataChannel("dc") From aff6491bfe38d90ca9bbc2f71bf19b977bfbbe09 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Aug 2026 03:30:04 +0900 Subject: [PATCH 16/18] DataTrackFrameSender --- .../java/io/livekit/android/room/RTCEngine.kt | 53 +++-- .../room/datatrack/DataTrackFrameSender.kt | 148 ++++++++++++++ .../datatrack/DataTrackFrameSenderTest.kt | 191 ++++++++++++++++++ .../datatrack/DataTrackManagerMockE2ETest.kt | 32 +++ 4 files changed, 406 insertions(+), 18 deletions(-) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrameSender.kt create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackFrameSenderTest.kt 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 cc4712344..7a6072901 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 @@ -29,6 +29,8 @@ import io.livekit.android.e2ee.E2EEManager import io.livekit.android.e2ee.EncryptedPacket import io.livekit.android.events.DisconnectReason import io.livekit.android.events.convert +import io.livekit.android.room.datatrack.DataChannelManagerSendChannel +import io.livekit.android.room.datatrack.DataTrackFrameSender import io.livekit.android.room.datatrack.IncomingDataTrackManager import io.livekit.android.room.datatrack.OutgoingDataTrackManager import io.livekit.android.room.network.DefaultReconnectPolicy @@ -61,6 +63,7 @@ import io.livekit.android.webrtc.isConnected import io.livekit.android.webrtc.isDisconnected import io.livekit.android.webrtc.peerconnection.RTCThreadToken import io.livekit.android.webrtc.peerconnection.executeBlockingOnRTCThread +import io.livekit.android.webrtc.peerconnection.executeOnRTCThread import io.livekit.android.webrtc.peerconnection.launchBlockingOnRTCThread import io.livekit.android.webrtc.toProtoSessionDescription import kotlinx.coroutines.CoroutineDispatcher @@ -199,6 +202,8 @@ internal constructor( private var dataTrackDataChannel: DataChannel? = null private var dataTrackDataChannelManager: DataChannelManager? = null private var dataTrackDataChannelSub: DataChannel? = null + private val dataTrackFrameSender = DataTrackFrameSender() + private var dataTrackPumpJob: Job? = null private var reliableDataChannelManager: DataChannelManager? = null private var reliableBufferedAmountJob: Job? = null private var reliableDataChannelSubManager: DataChannelManager? = null @@ -417,6 +422,20 @@ internal constructor( ) dataTrackDataChannelManager = dataChannelManager dataChannel.registerObserver(dataChannelManager) + dataTrackFrameSender.attach(DataChannelManagerSendChannel(dataChannelManager)) + dataTrackPumpJob?.cancel() + dataTrackPumpJob = coroutineScope.launch { + launch { + dataChannelManager::bufferedAmount.flow.collect { + pumpDataTrackFrames() + } + } + launch { + dataChannelManager::state.flow.collect { + pumpDataTrackFrames() + } + } + } } } } @@ -542,6 +561,9 @@ internal constructor( lossyDataChannelSubManager?.dispose() lossyDataChannelSubManager = null lossyDataChannelSub = null + dataTrackPumpJob?.cancel() + dataTrackPumpJob = null + dataTrackFrameSender.attach(null) dataTrackDataChannelManager?.dispose() dataTrackDataChannelManager = null dataTrackDataChannel = null @@ -955,8 +977,8 @@ internal constructor( /** * Negotiates the publisher if needed and waits until the `_data_track` channel is open. * - * Data-track publish must not proceed until then: [sendDataTrackPackets] drops packets while - * the channel is not [DataChannel.State.OPEN]. + * Data-track publish must not proceed until then: [sendDataTrackPackets] queues at most one + * frame while the channel is not [DataChannel.State.OPEN]. */ @Throws(exceptionClasses = [RoomException.ConnectException::class]) internal suspend fun ensureDataTrackPublisherConnected() { @@ -1417,26 +1439,21 @@ internal constructor( } /** - * Sends serialized data-track packets on the dedicated `_data_track` data channel. + * Queues serialized data-track packets on the dedicated `_data_track` data channel. * - * Packets belonging to one application frame are sent back-to-back; drop behavior when the - * channel is not open is left to the UniFFI manager / caller. + * Packets belonging to one application frame are metered as a unit (drop-oldest, one frame + * in flight) once the channel is [DataChannel.State.OPEN] and buffered amount is at or below + * [DataTrackFrameSender.LOW_WATER_MARK]. */ internal fun sendDataTrackPackets(packets: List) { - if (packets.isEmpty()) { - return + executeOnRTCThread(rtcThreadToken) { + dataTrackFrameSender.sendOrQueue(packets) } - val channel = dataTrackDataChannel - if (channel == null || channel.state() != DataChannel.State.OPEN) { - LKLog.w { "data track channel not open; dropping ${packets.size} packet(s)" } - return - } - for (packet in packets) { - val buf = DataChannel.Buffer(ByteBuffer.wrap(packet), true) - if (!channel.send(buf)) { - LKLog.w { "failed to send data track packet (${packet.size} bytes)" } - return - } + } + + private fun pumpDataTrackFrames() { + executeOnRTCThread(rtcThreadToken) { + dataTrackFrameSender.pump() } } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrameSender.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrameSender.kt new file mode 100644 index 000000000..343ab0ccb --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrameSender.kt @@ -0,0 +1,148 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.util.LKLog +import io.livekit.android.webrtc.DataChannelManager +import livekit.org.webrtc.DataChannel +import java.nio.ByteBuffer + +/** + * The slice of the RTC data channel the outbound drain drives — a seam so the drain logic is + * unit-testable ([livekit.org.webrtc.DataChannel] can't be constructed without a live peer + * connection). + * + * @suppress + */ +internal interface DataTrackSendChannel { + val bufferedAmount: Long + val isOpen: Boolean + fun send(packet: ByteArray): Boolean +} + +/** + * [DataTrackSendChannel] backed by the publisher `_data_track` [DataChannelManager]. + * + * [bufferedAmount] is read live from the native channel so the pump can meter after each send; + * [DataChannelManager.bufferedAmount] only updates on the buffered-amount callback. + * + * @suppress + */ +internal class DataChannelManagerSendChannel( + private val manager: DataChannelManager, +) : DataTrackSendChannel { + override val bufferedAmount: Long + get() = manager.dataChannel.bufferedAmount() + + override val isOpen: Boolean + get() = manager.state == DataChannel.State.OPEN + + override fun send(packet: ByteArray): Boolean { + val buffer = DataChannel.Buffer(ByteBuffer.wrap(packet), true) + return manager.dataChannel.send(buffer) + } +} + +/** + * Drop-oldest outbound drain for data-track frames. + * + * Packets are metered into the channel on buffered-amount events instead of dumped, keeping the + * SCTP buffer near [LOW_WATER_MARK] (so a frame of any size streams out safely) and bounding send + * latency: at most one frame waits while another drains, and a newer frame evicts the waiting + * one. Frames are handled whole — a partial frame is never left on the wire. + * + * Not thread-safe: the owner confines all calls to the RTC thread. + * + * @suppress + */ +internal class DataTrackFrameSender { + companion object { + /** + * Resume sending when the channel buffer drains to this level; parity with + * `DATA_TRACK_BUFFERED_AMOUNT_LOW_THRESHOLD` in rust-sdks. + */ + const val LOW_WATER_MARK: Long = 8 * 1024 + } + + private var channel: DataTrackSendChannel? = null + + /** Freshest queued frame (capacity one — a newer frame evicts it). */ + private var pendingFrame: List? = null + + /** Packets of the frame currently draining, in FIFO order. */ + private val inFlight = ArrayDeque() + + private var pumping = false + + /** + * Attaches the channel this sender drains into, dropping frames queued for the previous one + * (they belong to a torn-down transport). + */ + fun attach(channel: DataTrackSendChannel?) { + this.channel = channel + pendingFrame = null + inFlight.clear() + } + + /** + * Queues a frame's packets for sending, evicting a previously queued frame (drop-oldest). + */ + fun sendOrQueue(packets: List) { + if (packets.isEmpty()) { + return + } + val evicted = pendingFrame + if (evicted != null) { + LKLog.d { "Evicted queued data track frame (${evicted.size} packets) in favor of a newer one" } + } + pendingFrame = packets.map { it.copyOf() } + pump() + } + + /** + * Feeds packets to the channel while it has headroom, promoting the queued frame when the + * in-flight one is fully handed off. + */ + fun pump() { + if (pumping) { + return + } + pumping = true + try { + val channel = channel ?: return + if (!channel.isOpen) { + return + } + while (channel.bufferedAmount <= LOW_WATER_MARK) { + if (inFlight.isEmpty()) { + val next = pendingFrame ?: return + pendingFrame = null + inFlight.addAll(next) + } + val packet = inFlight.firstOrNull() ?: return + if (!channel.send(packet)) { + LKLog.d { "Data track channel rejected packet; dropping the rest of the frame" } + inFlight.clear() + return + } + inFlight.removeFirst() + } + } finally { + pumping = false + } + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackFrameSenderTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackFrameSenderTest.kt new file mode 100644 index 000000000..7bffb1cf5 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackFrameSenderTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.BaseTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +private class FakeSendChannel : DataTrackSendChannel { + override var bufferedAmount: Long = 0 + override var isOpen = true + var acceptsSends = true + val sent = mutableListOf() + + override fun send(packet: ByteArray): Boolean { + if (!acceptsSends) { + return false + } + sent.add(packet) + bufferedAmount += packet.size + return true + } + + /** Simulates the transport flushing its buffer (the trigger for a buffered-amount callback). */ + fun drain() { + bufferedAmount = 0 + } +} + +/** + * Pins the outbound drain's semantics, which are deliberately aligned (and deliberately not) + * with the other SDKs: + * + * - **rust-sdks** (`DataChannelSender`): the same design — drop-oldest with a capacity-one frame + * queue, whole-frame atomicity, packets metered on buffered-amount events with an 8 KiB + * low-water mark. These tests mirror its invariants. + * - **client-sdk-js** (`LossyDataChannel` with `bufferFullBehavior: 'wait'`): shares the + * whole-frame atomicity and watermark pacing, but blocks the producer under load instead of + * dropping — its engine awaits sends, so overload backpressures the frame producer. Android's + * producer is a fire-and-forget FFI callback with no backpressure channel, so freshest-wins + * eviction is used instead (as in rust-sdks / Swift). + */ +class DataTrackFrameSenderTest : BaseTest() { + + private lateinit var channel: FakeSendChannel + private lateinit var sender: DataTrackFrameSender + + @Before + fun setUpSender() { + channel = FakeSendChannel() + sender = DataTrackFrameSender() + sender.attach(channel) + } + + @Test + fun sendsImmediatelyWithHeadroom() { + sender.sendOrQueue(frame(1, packets = 3)) + assertEquals(3, channel.sent.size) + } + + /** + * The whole frame goes out even when it is far larger than the buffer headroom: packets are + * metered per drain instead of dumped, so there is no sender-imposed max frame size. + */ + @Test + fun largeFrameStreamsWithinHeadroom() { + val packetSize = 64000 + sender.sendOrQueue(frame(1, packets = 50, packetSize = packetSize)) + var pumps = 0 + while (channel.sent.size < 50 && pumps < 100) { + // Each drain admits exactly one over-watermark packet, so the buffer never holds more + // than one packet beyond the low-water mark. + assertTrue(channel.bufferedAmount <= DataTrackFrameSender.LOW_WATER_MARK + packetSize) + channel.drain() + sender.pump() + pumps++ + } + assertEquals(50, channel.sent.size) + } + + /** + * A newer frame evicts the queued (not yet started) one — freshest wins. + */ + @Test + fun dropsOldestQueuedFrame() { + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + sender.sendOrQueue(frame(1)) + sender.sendOrQueue(frame(2)) + assertTrue(channel.sent.isEmpty()) + + channel.drain() + sender.pump() + assertEquals(listOf(2.toByte()), channel.sent.map { it.first() }) + } + + /** + * An in-flight frame is never abandoned mid-send: its remaining packets go out before a + * newer frame, and packets of two frames never interleave. + */ + @Test + fun inFlightFrameCompletesBeforeNewerFrame() { + sender.sendOrQueue(frame(1, packets = 3, packetSize = 64000)) + assertEquals(1, channel.sent.size) + + sender.sendOrQueue(frame(2, packets = 2, packetSize = 64000)) + while (channel.sent.size < 5) { + channel.drain() + sender.pump() + } + assertEquals( + listOf(1.toByte(), 1.toByte(), 1.toByte(), 2.toByte(), 2.toByte()), + channel.sent.map { it.first() }, + ) + } + + /** + * Attaching a channel drops frames queued for the previous one (stale frames belong to a + * dead transport). + */ + @Test + fun attachClearsQueuedFrames() { + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + sender.sendOrQueue(frame(1)) + + val newChannel = FakeSendChannel() + sender.attach(newChannel) + sender.pump() + assertTrue(newChannel.sent.isEmpty()) + + sender.sendOrQueue(frame(2)) + assertEquals(listOf(2.toByte()), newChannel.sent.map { it.first() }) + } + + /** A rejected send drops the rest of the frame without wedging the pump. */ + @Test + fun rejectedSendDropsFrameOnly() { + channel.acceptsSends = false + sender.sendOrQueue(frame(1, packets = 3)) + assertTrue(channel.sent.isEmpty()) + + channel.acceptsSends = true + sender.sendOrQueue(frame(2)) + assertEquals(listOf(2.toByte()), channel.sent.map { it.first() }) + } + + /** An empty packet batch must not evict a queued frame. */ + @Test + fun emptyBatchIsIgnored() { + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + sender.sendOrQueue(frame(1)) + sender.sendOrQueue(emptyList()) + + channel.drain() + sender.pump() + assertEquals(listOf(1.toByte()), channel.sent.map { it.first() }) + } + + /** Nothing is sent while the channel is closed; opening drains the queue. */ + @Test + fun queuedFrameDrainsOnceOpen() { + channel.isOpen = false + sender.sendOrQueue(frame(1)) + assertTrue(channel.sent.isEmpty()) + + channel.isOpen = true + sender.pump() + assertEquals(listOf(1.toByte()), channel.sent.map { it.first() }) + } + + companion object { + /** One packet per frame, tagged for identification. */ + private fun frame(tag: Byte, packets: Int = 1, packetSize: Int = 100): List = + List(packets) { ByteArray(packetSize) { tag } } + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt index c48617298..c3f00fb77 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.yield import livekit.LivekitRtc import livekit.org.webrtc.DataChannel import livekit.org.webrtc.PeerConnection +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -83,6 +84,37 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { assertTrue(result.isSuccess) } + @Test + fun dataTrackPacketsWaitForLowWaterThenSend() = runTest { + connect() + val channel = publisherDataTrackChannel() + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + + room.engine.sendDataTrackPackets(listOf(byteArrayOf(1))) + assertTrue(channel.sentPayloads.isEmpty()) + + channel.bufferedAmount = 0 + advanceUntilIdle() + assertEquals(1, channel.sentPayloads.size) + assertArrayEquals(byteArrayOf(1), channel.sentPayloads.single()) + } + + @Test + fun dataTrackPacketsDropOldestQueuedFrame() = runTest { + connect() + val channel = publisherDataTrackChannel() + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + + room.engine.sendDataTrackPackets(listOf(byteArrayOf(1))) + room.engine.sendDataTrackPackets(listOf(byteArrayOf(2))) + assertTrue(channel.sentPayloads.isEmpty()) + + channel.bufferedAmount = 0 + advanceUntilIdle() + assertEquals(1, channel.sentPayloads.size) + assertArrayEquals(byteArrayOf(2), channel.sentPayloads.single()) + } + @Test fun publishDataTrackTimesOutIfPublisherChannelNeverOpens() = runTest { connect() From c0f1fa8374fdb90d7d0212aacdf92d95579b9a3f Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Aug 2026 03:45:54 +0900 Subject: [PATCH 17/18] hook up e2ee --- .../io/livekit/android/dagger/RTCModule.kt | 10 ++- .../livekit/android/e2ee/DataTrackCryptor.kt | 65 +++++++++++++++++++ .../io/livekit/android/e2ee/E2EEManager.kt | 11 ++++ .../room/datatrack/DataTrackManagerFactory.kt | 12 +++- .../datatrack/IncomingDataTrackManager.kt | 4 +- .../datatrack/OutgoingDataTrackManager.kt | 13 +++- .../datatrack/MockLocalDataTrackManager.kt | 13 +++- .../datatrack/MockRemoteDataTrackManager.kt | 13 +++- .../android/e2ee/DataTrackCryptorTest.kt | 57 ++++++++++++++++ .../datatrack/DataTrackManagerMockE2ETest.kt | 32 +++++++++ 10 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/e2ee/DataTrackCryptor.kt create mode 100644 livekit-android-test/src/test/java/io/livekit/android/e2ee/DataTrackCryptorTest.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt index 45a1776f8..622356314 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt @@ -390,17 +390,15 @@ internal object RTCModule { @Provides fun localDataTrackManagerFactory(): LocalDataTrackManagerFactory { - return LocalDataTrackManagerFactory { delegate -> - // Encryption provider wiring is left for a follow-up (E2EE). - LocalDataTrackManager(delegate, null) + return LocalDataTrackManagerFactory { delegate, encryptionProvider -> + LocalDataTrackManager(delegate, encryptionProvider) } } @Provides fun remoteDataTrackManagerFactory(): RemoteDataTrackManagerFactory { - return RemoteDataTrackManagerFactory { delegate -> - // Decryption provider wiring is left for a follow-up (E2EE). - RemoteDataTrackManager(delegate, null) + return RemoteDataTrackManagerFactory { delegate, decryptionProvider -> + RemoteDataTrackManager(delegate, decryptionProvider) } } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/DataTrackCryptor.kt b/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/DataTrackCryptor.kt new file mode 100644 index 000000000..998dfa63c --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/DataTrackCryptor.kt @@ -0,0 +1,65 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.e2ee + +import io.livekit.android.room.participant.Participant +import uniffi.livekit_datatrack.DecryptionException +import uniffi.livekit_datatrack.DecryptionProvider +import uniffi.livekit_datatrack.EncryptedPayload +import uniffi.livekit_datatrack.EncryptionException +import uniffi.livekit_datatrack.EncryptionProvider + +/** + * Bridges UniFFI data-track [EncryptionProvider] / [DecryptionProvider] to [E2EEManager]. + * + * Adds no key handling of its own — encryption rides [E2EEManager]'s existing AES-GCM data path + * (the same [DataPacketCryptorManager] used for data-channel payloads). The manager is resolved + * per call so one assigned after connecting still applies. + * + * @suppress + */ +internal class DataTrackCryptor( + private val e2eeManagerProvider: () -> E2EEManager?, +) : EncryptionProvider, DecryptionProvider { + + override fun encrypt(payload: ByteArray): EncryptedPayload { + val manager = requireManager { message -> EncryptionException.Failed(message) } + val packet = manager.encrypt(payload) + ?: throw EncryptionException.Failed("Failed to encrypt data track payload") + return EncryptedPayload( + payload = packet.payload, + iv = packet.iv, + keyIndex = packet.keyIndex.toUByte(), + ) + } + + override fun decrypt(payload: EncryptedPayload, senderIdentity: String): ByteArray { + val manager = requireManager { message -> DecryptionException.Failed(message) } + val packet = EncryptedPacket( + payload = payload.payload, + iv = payload.iv, + keyIndex = payload.keyIndex.toInt(), + ) + return manager.decrypt(Participant.Identity(senderIdentity), packet) + ?: throw DecryptionException.Failed("Failed to decrypt data track payload") + } + + private fun requireManager(failed: (String) -> T): E2EEManager { + return e2eeManagerProvider() + ?: throw failed("Room has no E2EE manager") + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/E2EEManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/E2EEManager.kt index bc3fc77b4..40913db63 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/E2EEManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/E2EEManager.kt @@ -32,6 +32,7 @@ import io.livekit.android.room.track.RemoteVideoTrack import io.livekit.android.room.track.Track import io.livekit.android.room.track.TrackPublication import io.livekit.android.util.LKLog +import livekit.LivekitModels.Encryption import livekit.org.webrtc.FrameCryptor import livekit.org.webrtc.FrameCryptor.FrameCryptionState import livekit.org.webrtc.FrameCryptorAlgorithm @@ -73,6 +74,16 @@ constructor( return enabled && dataChannelEncryptionEnabled } + /** + * Whether data-track frames should be encrypted: the runtime flag plus a configured + * encryption type (unlike the data-channel gate, which also requires + * [dataChannelEncryptionEnabled]). + */ + internal fun isDataTrackEncryptionEnabled(): Boolean { + val type = room?.e2eeOptions?.encryptionType ?: return false + return enabled && type != Encryption.Type.NONE + } + fun keyProvider(): KeyProvider { return this.keyProvider } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt index 152aa3edf..319cfdf9b 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt @@ -20,6 +20,8 @@ import io.livekit.uniffi.LocalDataTrackManagerDelegate import io.livekit.uniffi.LocalDataTrackManagerInterface import io.livekit.uniffi.RemoteDataTrackManagerDelegate import io.livekit.uniffi.RemoteDataTrackManagerInterface +import uniffi.livekit_datatrack.DecryptionProvider +import uniffi.livekit_datatrack.EncryptionProvider /** * Creates UniFFI [io.livekit.uniffi.LocalDataTrackManager] instances. @@ -27,7 +29,10 @@ import io.livekit.uniffi.RemoteDataTrackManagerInterface * @suppress */ fun interface LocalDataTrackManagerFactory { - fun create(delegate: LocalDataTrackManagerDelegate): LocalDataTrackManagerInterface + fun create( + delegate: LocalDataTrackManagerDelegate, + encryptionProvider: EncryptionProvider?, + ): LocalDataTrackManagerInterface } /** @@ -36,5 +41,8 @@ fun interface LocalDataTrackManagerFactory { * @suppress */ fun interface RemoteDataTrackManagerFactory { - fun create(delegate: RemoteDataTrackManagerDelegate): RemoteDataTrackManagerInterface + fun create( + delegate: RemoteDataTrackManagerDelegate, + decryptionProvider: DecryptionProvider?, + ): RemoteDataTrackManagerInterface } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt index 94c21ce39..dd3d8ca48 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt @@ -16,6 +16,7 @@ package io.livekit.android.room.datatrack +import io.livekit.android.e2ee.DataTrackCryptor import io.livekit.android.events.BroadcastEventBus import io.livekit.android.room.RTCEngine import io.livekit.android.util.LKLog @@ -56,6 +57,7 @@ constructor( private val lock = Any() private var remoteManager: RemoteDataTrackManagerInterface? = null private val remoteTracks = mutableListOf() + private val cryptor = DataTrackCryptor { engineProvider.get().e2EEManager } private val delegate = object : RemoteDataTrackManagerDelegate { override fun onSignalRequest(request: ByteArray) { @@ -157,7 +159,7 @@ constructor( private fun ensureManager(): RemoteDataTrackManagerInterface { synchronized(lock) { remoteManager?.let { return it } - return remoteDataTrackManagerFactory.create(delegate).also { remoteManager = it } + return remoteDataTrackManagerFactory.create(delegate, cryptor).also { remoteManager = it } } } } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt index 1bd4ae05a..a5c30f5b9 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt @@ -17,6 +17,7 @@ package io.livekit.android.room.datatrack import androidx.annotation.CheckResult +import io.livekit.android.e2ee.DataTrackCryptor import io.livekit.android.room.RTCEngine import io.livekit.android.room.RoomException import io.livekit.android.util.LKLog @@ -48,6 +49,7 @@ constructor( ) { private val lock = Any() private var localManager: LocalDataTrackManagerInterface? = null + private val cryptor = DataTrackCryptor { engineProvider.get().e2EEManager } private val delegate = object : LocalDataTrackManagerDelegate { override fun onSignalRequest(request: ByteArray) { @@ -160,7 +162,16 @@ constructor( private fun ensureManager(): LocalDataTrackManagerInterface { synchronized(lock) { localManager?.let { return it } - return localDataTrackManagerFactory.create(delegate).also { localManager = it } + // Whether frames are encrypted is fixed when the manager is built: unlike data + // channel payloads (a per-message property), data track encryption is a track-level + // protocol property that subscribers key their decryption on. The cryptor is passed + // only when E2EE is on — its presence is what marks published tracks as encrypted + // ([DataTrackInfo.usesE2ee]). + val encryptionProvider = cryptor.takeIf { + engineProvider.get().e2EEManager?.isDataTrackEncryptionEnabled() == true + } + return localDataTrackManagerFactory.create(delegate, encryptionProvider) + .also { localManager = it } } } } diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt index e774a1df0..6dbe983bf 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt @@ -26,6 +26,7 @@ import io.livekit.uniffi.LocalDataTrackManagerInterface import io.livekit.uniffi.NoHandle import livekit.LivekitModels import livekit.LivekitRtc +import uniffi.livekit_datatrack.EncryptionProvider class MockLocalDataTrackManagerFactory : LocalDataTrackManagerFactory { /** @@ -33,7 +34,17 @@ class MockLocalDataTrackManagerFactory : LocalDataTrackManagerFactory { */ lateinit var manager: MockLocalDataTrackManager - override fun create(delegate: LocalDataTrackManagerDelegate): LocalDataTrackManagerInterface { + /** + * Encryption provider passed into the last [create] call. + */ + var lastEncryptionProvider: EncryptionProvider? = null + private set + + override fun create( + delegate: LocalDataTrackManagerDelegate, + encryptionProvider: EncryptionProvider?, + ): LocalDataTrackManagerInterface { + lastEncryptionProvider = encryptionProvider return MockLocalDataTrackManager(delegate).also { manager = it } } } diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt index 79e85710a..c933cf2ee 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt @@ -24,6 +24,7 @@ import io.livekit.uniffi.NoHandle import io.livekit.uniffi.RemoteDataTrack import io.livekit.uniffi.RemoteDataTrackManagerDelegate import io.livekit.uniffi.RemoteDataTrackManagerInterface +import uniffi.livekit_datatrack.DecryptionProvider class MockRemoteDataTrackManagerFactory : RemoteDataTrackManagerFactory { /** @@ -31,7 +32,17 @@ class MockRemoteDataTrackManagerFactory : RemoteDataTrackManagerFactory { */ lateinit var manager: MockRemoteDataTrackManager - override fun create(delegate: RemoteDataTrackManagerDelegate): RemoteDataTrackManagerInterface { + /** + * Decryption provider passed into the last [create] call. + */ + var lastDecryptionProvider: DecryptionProvider? = null + private set + + override fun create( + delegate: RemoteDataTrackManagerDelegate, + decryptionProvider: DecryptionProvider?, + ): RemoteDataTrackManagerInterface { + lastDecryptionProvider = decryptionProvider return MockRemoteDataTrackManager(delegate).also { manager = it } } } diff --git a/livekit-android-test/src/test/java/io/livekit/android/e2ee/DataTrackCryptorTest.kt b/livekit-android-test/src/test/java/io/livekit/android/e2ee/DataTrackCryptorTest.kt new file mode 100644 index 000000000..f4bf24d1c --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/e2ee/DataTrackCryptorTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.e2ee + +import io.livekit.android.test.BaseTest +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import uniffi.livekit_datatrack.DecryptionException +import uniffi.livekit_datatrack.EncryptedPayload +import uniffi.livekit_datatrack.EncryptionException + +class DataTrackCryptorTest : BaseTest() { + + @Test + fun encryptThrowsWhenThereIsNoE2eeManager() { + val cryptor = DataTrackCryptor { null } + try { + cryptor.encrypt(byteArrayOf(1, 2, 3)) + fail("expected EncryptionException.Failed") + } catch (e: EncryptionException.Failed) { + assertTrue(e.message!!.contains("E2EE manager")) + } + } + + @Test + fun decryptThrowsWhenThereIsNoE2eeManager() { + val cryptor = DataTrackCryptor { null } + try { + cryptor.decrypt( + EncryptedPayload( + payload = byteArrayOf(1), + iv = byteArrayOf(2), + keyIndex = 0u, + ), + "sender", + ) + fail("expected DecryptionException.Failed") + } catch (e: DecryptionException.Failed) { + assertTrue(e.message!!.contains("E2EE manager")) + } + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt index c3f00fb77..c46a4ef84 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt @@ -16,6 +16,7 @@ package io.livekit.android.room.datatrack +import io.livekit.android.e2ee.E2EEOptions import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.room.RTCEngine @@ -29,6 +30,7 @@ import io.livekit.android.test.events.EventCollector import io.livekit.android.test.mock.MockDataChannel import io.livekit.android.test.mock.SignalRequestHandler import io.livekit.android.test.mock.TestData +import io.livekit.android.test.mock.e2ee.NoopKeyProvider import io.livekit.android.test.util.toPBByteString import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -67,6 +69,36 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { val local = localDataTrackManagerFactory.manager assertEquals(1, local.publishedTracks.size) assertEquals("telemetry", local.publishedTracks.single().info().name) + assertNull(localDataTrackManagerFactory.lastEncryptionProvider) + } + + @Test + fun incomingDataTrackAlwaysReceivesDecryptionProvider() = runTest { + connect() + assertNotNull(remoteDataTrackManagerFactory.lastDecryptionProvider) + } + + @Test + fun publishDataTrackPassesEncryptionProviderWhenE2eeEnabled() = runTest { + room.e2eeOptions = E2EEOptions(keyProvider = NoopKeyProvider()) + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + + // Tests use ReversingDataPacketCryptorManager by default. + val encryptionProvider = localDataTrackManagerFactory.lastEncryptionProvider + assertNotNull(encryptionProvider) + val encrypted = encryptionProvider!!.encrypt(byteArrayOf(1, 2, 3)) + assertArrayEquals(byteArrayOf(3, 2, 1), encrypted.payload) + + val decryptionProvider = remoteDataTrackManagerFactory.lastDecryptionProvider + assertNotNull(decryptionProvider) + val decrypted = decryptionProvider!!.decrypt( + encrypted, + room.localParticipant.identity!!.value, + ) + assertArrayEquals(byteArrayOf(1, 2, 3), decrypted) } @Test From 380b1d79f603c77ebd35e0b1f19448c1f7e9840d Mon Sep 17 00:00:00 2001 From: davidliu Date: Fri, 21 Aug 2026 00:51:27 +0900 Subject: [PATCH 18/18] fix data tracks not properly flowing after reconnect --- .../java/io/livekit/android/room/RTCEngine.kt | 325 +++++++++++++----- .../main/java/io/livekit/android/room/Room.kt | 7 +- .../io/livekit/android/room/SignalClient.kt | 55 ++- .../datatrack/IncomingDataTrackManager.kt | 7 +- .../livekit/android/room/SignalClientTest.kt | 1 + .../datatrack/DataTrackManagerMockE2ETest.kt | 156 +++++++++ 6 files changed, 445 insertions(+), 106 deletions(-) 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 7a6072901..c720d94fc 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 @@ -72,6 +72,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.first import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -204,6 +205,25 @@ internal constructor( private var dataTrackDataChannelSub: DataChannel? = null private val dataTrackFrameSender = DataTrackFrameSender() private var dataTrackPumpJob: Job? = null + + /** + * Session-scoped gate for `_data_track` publisher-channel readiness. Rearmed (not failed) when + * the channel is swapped on reconnect, so a [ensureDataTrackPublisherConnected] issued in that + * window waits for the replacement instead of racing a torn-down transport. Closed only on a + * real [close], which unblocks waiters as a disconnect. + */ + @FlowObservable + @get:FlowObservable + private var dataTrackPublisherChannelGate by flowDelegate(DataTrackPublisherChannelGate.WAITING) + + /** + * Whether the subscriber `_data_track` is OPEN. Full reconnect waits for this before + * [IncomingDataTrackManager.resendSubscriptionUpdates] so handles/packets hit an observed + * channel. Soft reconnect typically stays OPEN. + */ + @FlowObservable + @get:FlowObservable + private var dataTrackSubscriberChannelOpen by flowDelegate(false) private var reliableDataChannelManager: DataChannelManager? = null private var reliableBufferedAmountJob: Job? = null private var reliableDataChannelSubManager: DataChannelManager? = null @@ -266,6 +286,8 @@ internal constructor( options: ConnectOptions, roomOptions: RoomOptions, ): JoinResponse = coroutineScope { + // New session (or full reconnect): the previous gate may still be CLOSED from [close]. + dataTrackPublisherChannelGate = DataTrackPublisherChannelGate.WAITING if (connectionState == ConnectionState.DISCONNECTED) { connectionState = ConnectionState.CONNECTING } @@ -275,15 +297,17 @@ internal constructor( if (joinResponse.hasParticipant()) { localParticipantIdentity = joinResponse.participant.identity } - // Discover pre-existing remote data tracks from the join response. + // Participants first, then the original join bytes (Swift order): UniFFI discovers + // tracks once publishers are registered, and re-encoding would drop newer fields. + listener?.onJoinResponse(joinResponse) incomingDataTrackManager.handleSfuJoinResponse( - LivekitRtc.SignalResponse.newBuilder() - .setJoin(joinResponse) - .build() - .toByteArray(), + client.lastJoinEncoded + ?: LivekitRtc.SignalResponse.newBuilder() + .setJoin(joinResponse) + .build() + .toByteArray(), ) - - listener?.onJoinResponse(joinResponse) + listener?.reattachRemoteDataTracks() isClosed = false listener?.onSignalConnected(false) @@ -338,9 +362,19 @@ internal constructor( val connectionStateListener: PeerConnectionStateListener = { newState -> LKLog.v { "onIceConnection new state: $newState" } if (newState.isConnected()) { - connectionState = ConnectionState.CONNECTED + // Stay RECONNECTING/RESUMING until post-ICE data-track resubscribe + // finishes (Swift sets `.connected` only after `handleReconnect`). + if (connectionState != ConnectionState.RECONNECTING && + connectionState != ConnectionState.RESUMING + ) { + connectionState = ConnectionState.CONNECTED + } } else if (newState.isDisconnected()) { - connectionState = ConnectionState.DISCONNECTED + if (connectionState != ConnectionState.RECONNECTING && + connectionState != ConnectionState.RESUMING + ) { + connectionState = ConnectionState.DISCONNECTED + } } } @@ -350,7 +384,10 @@ internal constructor( when (dataChannel.label()) { RELIABLE_DATA_CHANNEL_LABEL -> reliableDataChannelSub = dataChannel LOSSY_DATA_CHANNEL_LABEL -> lossyDataChannelSub = dataChannel - DATA_TRACK_DATA_CHANNEL_LABEL -> dataTrackDataChannelSub = dataChannel + DATA_TRACK_DATA_CHANNEL_LABEL -> { + setSubscriberDataTrackChannel(dataChannel) + return@onDataChannel + } else -> return@onDataChannel } dataChannel.registerObserver(DataChannelObserver(dataChannel)) @@ -406,38 +443,7 @@ internal constructor( } ensureActive() - // Lossy unordered channel for data-track packets (same Init as lossy). - val dataTrackInit = DataChannel.Init() - dataTrackInit.ordered = false - dataTrackInit.maxRetransmits = 0 - dataTrackDataChannel = publisher?.withPeerConnection { - createDataChannel( - DATA_TRACK_DATA_CHANNEL_LABEL, - dataTrackInit, - ).also { dataChannel -> - val dataChannelManager = DataChannelManager( - dataChannel, - DataChannelObserver(dataChannel), - rtcThreadToken, - ) - dataTrackDataChannelManager = dataChannelManager - dataChannel.registerObserver(dataChannelManager) - dataTrackFrameSender.attach(DataChannelManagerSendChannel(dataChannelManager)) - dataTrackPumpJob?.cancel() - dataTrackPumpJob = coroutineScope.launch { - launch { - dataChannelManager::bufferedAmount.flow.collect { - pumpDataTrackFrames() - } - } - launch { - dataChannelManager::state.flow.collect { - pumpDataTrackFrames() - } - } - } - } - } + createPublisherDataTrackChannel() } } } @@ -512,6 +518,7 @@ internal constructor( } LKLog.v { "Close - $reason" } isClosed = true + dataTrackPublisherChannelGate = DataTrackPublisherChannelGate.CLOSED reconnectingJob?.cancel() reconnectingJob = null coroutineScope.close() @@ -568,6 +575,13 @@ internal constructor( dataTrackDataChannelManager = null dataTrackDataChannel = null dataTrackDataChannelSub = null + dataTrackSubscriberChannelOpen = false + // The publisher channel is dead; re-arm the open gate — a publish issued + // before the replacement channel arrives waits for it instead of proceeding + // against the torn-down transport. Skip if the session itself is closing. + if (dataTrackPublisherChannelGate != DataTrackPublisherChannelGate.CLOSED) { + dataTrackPublisherChannelGate = DataTrackPublisherChannelGate.WAITING + } isSubscriberPrimary = false } } @@ -743,13 +757,14 @@ internal constructor( val subscriberConnected = subscriber?.isConnected() == true val publisherConnected = !hasPublished || publisher?.isConnected() == true - if ((connectionState == ConnectionState.CONNECTED || connectionState == ConnectionState.RESUMING) && - subscriberConnected && - publisherConnected + if (subscriberConnected && + publisherConnected && + ( + connectionState == ConnectionState.CONNECTED || + connectionState == ConnectionState.RESUMING || + connectionState == ConnectionState.RECONNECTING + ) ) { - if (connectionState == ConnectionState.RESUMING) { - connectionState = ConnectionState.CONNECTED - } if (lastMessageSeq != null) { resendReliableMessagesForResume(lastMessageSeq).onFailure { e -> LKLog.w(e) { @@ -758,13 +773,18 @@ internal constructor( } } } - // Is connected, notify and return. regionUrlProvider?.clearAttemptedRegions() client.onPCConnected() if (isFullReconnect) { outgoingDataTrackManager.republishTracks() } + waitUntilInboundDataTrackChannelReady() incomingDataTrackManager.resendSubscriptionUpdates() + if (connectionState == ConnectionState.RESUMING || + connectionState == ConnectionState.RECONNECTING + ) { + connectionState = ConnectionState.CONNECTED + } listener?.onPostReconnect(isFullReconnect) return@launch } @@ -979,30 +999,164 @@ internal constructor( * * Data-track publish must not proceed until then: [sendDataTrackPackets] queues at most one * frame while the channel is not [DataChannel.State.OPEN]. + * + * The wait is bound to the session, not a specific [DataChannelManager]: transport teardown + * (full reconnect) rearms the gate so an in-flight publish waits for the replacement channel + * instead of failing against a disposed one. A real [close] fails the wait as a disconnect. */ @Throws(exceptionClasses = [RoomException.ConnectException::class]) internal suspend fun ensureDataTrackPublisherConnected() { - if (publisher == null) { - throw RoomException.ConnectException("Publisher isn't setup yet! Is the room connected?") + if (isClosed || dataTrackPublisherChannelGate == DataTrackPublisherChannelGate.CLOSED) { + throw RoomException.ConnectException( + "Lost the connection while establishing the publisher data track channel", + ) } - if (isSubscriberPrimary && - publisher?.isConnected() != true && - publisher?.iceConnectionState() != PeerConnection.IceConnectionState.CHECKING - ) { - negotiatePublisher() + // Always mark publish intent so a full reconnect's joinImpl renegotiates even if this + // wait started against a torn-down publisher transport. + if (isSubscriberPrimary) { + val publisherTransport = publisher + val iceChecking = publisherTransport?.iceConnectionState() == + PeerConnection.IceConnectionState.CHECKING + if (publisherTransport?.isConnected() != true && !iceChecking) { + negotiatePublisher() + } } - val channelManager = dataTrackDataChannelManager - ?: throw RoomException.ConnectException( - "Publisher data track channel not established; is the room connected?", + if (dataTrackPublisherChannelGate == DataTrackPublisherChannelGate.OPEN) { + return + } + + val gate = withTimeoutOrNull(MAX_ICE_CONNECT_TIMEOUT_MS.toLong()) { + ::dataTrackPublisherChannelGate.flow + .first { it != DataTrackPublisherChannelGate.WAITING } + } + when (gate) { + DataTrackPublisherChannelGate.OPEN -> return + DataTrackPublisherChannelGate.CLOSED -> throw RoomException.ConnectException( + "Lost the connection while establishing the publisher data track channel", + ) + DataTrackPublisherChannelGate.WAITING, null -> throw RoomException.ConnectException( + "Timed out establishing the publisher data track channel", ) - val opened = withTimeoutOrNull(MAX_ICE_CONNECT_TIMEOUT_MS.toLong()) { - channelManager.waitUntilOpen() - true } - if (opened != true) { - throw RoomException.ConnectException("Timed out establishing the publisher data track channel") + } + + private fun updateDataTrackPublisherChannelGate(open: Boolean) { + if (dataTrackPublisherChannelGate == DataTrackPublisherChannelGate.CLOSED) { + return + } + val live = dataTrackDataChannelManager?.takeIf { !it.disposed } + dataTrackPublisherChannelGate = if (open && live != null) { + DataTrackPublisherChannelGate.OPEN + } else { + DataTrackPublisherChannelGate.WAITING + } + } + + /** + * Creates the publisher `_data_track` channel and [setPublisherDataTrackChannel]s it. + */ + private suspend fun createPublisherDataTrackChannel() { + val dataTrackInit = DataChannel.Init() + dataTrackInit.ordered = false + dataTrackInit.maxRetransmits = 0 + dataTrackDataChannel = publisher?.withPeerConnection { + createDataChannel( + DATA_TRACK_DATA_CHANNEL_LABEL, + dataTrackInit, + ).also { dataChannel -> + setPublisherDataTrackChannel(dataChannel) + } + } + } + + /** + * Attaches [dataChannel] as the publisher `_data_track` transport: same frame sender, new + * SCTP association. A full reconnect's replacement arrives unopened; waiters on + * [ensureDataTrackPublisherConnected] keep waiting until this channel hits OPEN. + */ + private fun setPublisherDataTrackChannel(dataChannel: DataChannel) { + val dataChannelManager = DataChannelManager( + dataChannel, + DataChannelObserver(dataChannel), + rtcThreadToken, + ) + dataTrackDataChannelManager = dataChannelManager + // Wrapper so the open-gate tracks state on the observer thread, not after a coroutine + // hop — a publish must not race a channel that just left OPEN. + dataChannel.registerObserver( + object : DataChannel.Observer { + override fun onBufferedAmountChange(previousAmount: Long) { + dataChannelManager.onBufferedAmountChange(previousAmount) + } + + override fun onStateChange() { + dataChannelManager.onStateChange() + updateDataTrackPublisherChannelGate( + dataChannelManager.state == DataChannel.State.OPEN, + ) + } + + override fun onMessage(buffer: DataChannel.Buffer) { + dataChannelManager.onMessage(buffer) + } + }, + ) + // Frames queued for the old channel belong to the torn-down transport. + dataTrackFrameSender.attach(DataChannelManagerSendChannel(dataChannelManager)) + updateDataTrackPublisherChannelGate( + dataChannelManager.state == DataChannel.State.OPEN, + ) + dataTrackPumpJob?.cancel() + dataTrackPumpJob = coroutineScope.launch { + launch { + dataChannelManager::bufferedAmount.flow.collect { + pumpDataTrackFrames() + } + } + launch { + dataChannelManager::state.flow.collect { + pumpDataTrackFrames() + } + } + } + } + + /** + * Adopts the subscriber `_data_track` channel. Retain it so native callbacks outlive this + * call, and route received packets into the incoming UniFFI manager. The manager is not told + * about the channel itself — only packets. + */ + private fun setSubscriberDataTrackChannel(dataChannel: DataChannel) { + dataTrackDataChannelSub = dataChannel + dataChannel.registerObserver(DataChannelObserver(dataChannel)) + dataTrackSubscriberChannelOpen = dataChannel.state() == DataChannel.State.OPEN + } + + /** + * After ICE is up, wait until inbound `_data_track` can receive before resending + * subscription updates. The reconnect job stays in RECONNECTING/RESUMING until this + * returns so Room.CONNECTED is not published early. Times out rather than failing + * reconnect if the channel never opens (older servers, data-track unused). + */ + private suspend fun waitUntilInboundDataTrackChannelReady() { + val needsInbound = incomingDataTrackManager.snapshotRemoteTracks().isNotEmpty() || + dataTrackDataChannelSub != null + if (!needsInbound) { + return + } + if (isSubscriberPrimary) { + if (dataTrackSubscriberChannelOpen) { + return + } + withTimeoutOrNull(MAX_ICE_CONNECT_TIMEOUT_MS.toLong()) { + ::dataTrackSubscriberChannelOpen.flow.first { it } + } + } else if (dataTrackPublisherChannelGate != DataTrackPublisherChannelGate.OPEN) { + withTimeoutOrNull(MAX_ICE_CONNECT_TIMEOUT_MS.toLong()) { + ::dataTrackPublisherChannelGate.flow.first { it == DataTrackPublisherChannelGate.OPEN } + } } } @@ -1128,6 +1282,7 @@ internal constructor( fun onEngineDisconnected(reason: DisconnectReason) fun onFailToConnect(error: Throwable) fun onJoinResponse(response: JoinResponse) + fun reattachRemoteDataTracks() {} fun onAddTrack(receiver: RtpReceiver, track: MediaStreamTrack, streams: Array) fun onUpdateParticipants(updates: List) fun onActiveSpeakersUpdate(speakers: List) @@ -1308,20 +1463,13 @@ internal constructor( listener?.onLocalTrackSubscribed(trackSubscribed) } - override fun onParticipantUpdate(updates: List) { + override fun onParticipantUpdate(updates: List, encoded: ByteArray) { listener?.onUpdateParticipants(updates) val identity = localParticipantIdentity if (identity != null) { - val responseBytes = LivekitRtc.SignalResponse.newBuilder() - .setUpdate( - LivekitRtc.ParticipantUpdate.newBuilder() - .addAllParticipants(updates) - .build(), - ) - .build() - .toByteArray() - incomingDataTrackManager.handleSfuParticipantUpdate(responseBytes, identity) + incomingDataTrackManager.handleSfuParticipantUpdate(encoded, identity) } + listener?.reattachRemoteDataTracks() } override fun onSpeakersChanged(speakers: List) { @@ -1410,20 +1558,20 @@ internal constructor( listener?.onLocalTrackUnpublished(trackUnpublished) } - override fun onPublishDataTrackResponse(response: LivekitRtc.SignalResponse) { - outgoingDataTrackManager.handleSfuPublishResponse(response.toByteArray()) + override fun onPublishDataTrackResponse(encoded: ByteArray) { + outgoingDataTrackManager.handleSfuPublishResponse(encoded) } - override fun onUnpublishDataTrackResponse(response: LivekitRtc.SignalResponse) { - outgoingDataTrackManager.handleSfuUnpublishResponse(response.toByteArray()) + override fun onUnpublishDataTrackResponse(encoded: ByteArray) { + outgoingDataTrackManager.handleSfuUnpublishResponse(encoded) } - override fun onRequestResponse(response: LivekitRtc.SignalResponse) { - outgoingDataTrackManager.handleSfuRequestResponse(response.toByteArray()) + override fun onRequestResponse(encoded: ByteArray) { + outgoingDataTrackManager.handleSfuRequestResponse(encoded) } - override fun onDataTrackSubscriberHandles(response: LivekitRtc.SignalResponse) { - incomingDataTrackManager.handleSubscriberHandles(response.toByteArray()) + override fun onDataTrackSubscriberHandles(encoded: ByteArray) { + incomingDataTrackManager.handleSubscriberHandles(encoded) } /** @@ -1463,6 +1611,9 @@ internal constructor( } fun onStateChange(dataChannel: DataChannel) { + if (dataChannel === dataTrackDataChannelSub) { + dataTrackSubscriberChannelOpen = dataChannel.state() == DataChannel.State.OPEN + } } fun onMessage(dataChannel: DataChannel, buffer: DataChannel.Buffer?) { @@ -1714,6 +1865,16 @@ internal constructor( subscriber!!.peerConnection } +/** + * Publisher `_data_track` channel readiness. [WAITING] and [OPEN] cycle across reconnects; + * [CLOSED] is terminal for the session. + */ +private enum class DataTrackPublisherChannelGate { + WAITING, + OPEN, + CLOSED, +} + /** * @suppress */ 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 c2a80eb7e..b3b16abf4 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 @@ -710,7 +710,6 @@ constructor( getOrCreateRemoteParticipant(Participant.Identity(info.identity), info) } } - reattachRemoteDataTracks() } private fun setupLocalParticipantEventHandling() { @@ -825,7 +824,10 @@ constructor( eventBus.postEvent(RoomEvent.DataTrackUnpublished(this, participant, sid), coroutineScope) } - private fun reattachRemoteDataTracks() { + /** + * @suppress + */ + override fun reattachRemoteDataTracks() { for (track in incomingDataTrackManager.snapshotRemoteTracks()) { attachRemoteDataTrack(track) } @@ -1323,7 +1325,6 @@ constructor( } } } - reattachRemoteDataTracks() } /** 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 8d2df3b60..a084f5d2d 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 @@ -119,7 +119,15 @@ constructor( /** * @see [onReadyForResponses] */ - private val responseFlow = MutableSharedFlow>(Int.MAX_VALUE) + private val responseFlow = MutableSharedFlow(Int.MAX_VALUE) + + /** + * Wire bytes of the Join [LivekitRtc.SignalResponse] from the last successful [join]. + * UniFFI parses these itself so re-encoding the decoded join cannot drop newer fields. + */ + @Volatile + internal var lastJoinEncoded: ByteArray? = null + private set private val responseFlowJobLock = Object() private var responseFlowJob: Job? = null @@ -268,9 +276,9 @@ constructor( synchronized(responseFlowJobLock) { if (responseFlowJob == null) { responseFlowJob = coroutineScope.launch { - responseFlow.collect { (ws, response) -> + responseFlow.collect { incoming -> responseFlow.resetReplayCache() - handleSignalResponseImpl(ws, response) + handleSignalResponseImpl(incoming.ws, incoming.response, incoming.encoded) } } } @@ -322,7 +330,7 @@ constructor( .mergeFrom(byteArray) val response = signalResponseBuilder.build() - handleSignalResponse(webSocket, response) + handleSignalResponse(webSocket, response, byteArray) } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { @@ -752,7 +760,7 @@ constructor( } } - private fun handleSignalResponse(ws: WebSocket, response: LivekitRtc.SignalResponse) { + private fun handleSignalResponse(ws: WebSocket, response: LivekitRtc.SignalResponse, encoded: ByteArray) { if (ws != currentWs) { return } @@ -778,11 +786,12 @@ constructor( edition = ServerInfo.Edition.fromProto(response.join.serverInfo.edition), version = serverVersion ) + lastJoinEncoded = encoded joinContinuation?.resumeWith(Result.success(ConnectResult.Join(response.join))) joinContinuation = null } else if (response.hasLeave()) { // Some reconnects may immediately send leave back without a join response first. - handleSignalResponseImpl(ws, response) + handleSignalResponseImpl(ws, response, encoded) val cont = joinContinuation joinContinuation = null cont?.resumeWithException( @@ -824,10 +833,10 @@ constructor( return } } - responseFlow.tryEmit(ws to response) + responseFlow.tryEmit(IncomingSignal(ws, response, encoded)) } - private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse) { + private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse, encoded: ByteArray) { if (ws != currentWs) { LKLog.v { "received message from old websocket, discarding." } return @@ -858,7 +867,7 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.UPDATE -> { - listener?.onParticipantUpdate(response.update.participantsList) + listener?.onParticipantUpdate(response.update.participantsList, encoded) } LivekitRtc.SignalResponse.MessageCase.TRACK_SUBSCRIBED -> { @@ -950,7 +959,7 @@ constructor( } } // Pass the full SignalResponse — UniFFI deserializes and filters data-track related ones. - listener?.onRequestResponse(response) + listener?.onRequestResponse(encoded) } LivekitRtc.SignalResponse.MessageCase.ROOM_MOVED -> { @@ -966,15 +975,15 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.PUBLISH_DATA_TRACK_RESPONSE -> { - listener?.onPublishDataTrackResponse(response) + listener?.onPublishDataTrackResponse(encoded) } LivekitRtc.SignalResponse.MessageCase.UNPUBLISH_DATA_TRACK_RESPONSE -> { - listener?.onUnpublishDataTrackResponse(response) + listener?.onUnpublishDataTrackResponse(encoded) } LivekitRtc.SignalResponse.MessageCase.DATA_TRACK_SUBSCRIBER_HANDLES -> { - listener?.onDataTrackSubscriberHandles(response) + listener?.onDataTrackSubscriberHandles(encoded) } LivekitRtc.SignalResponse.MessageCase.STORE_DATA_BLOB_RESPONSE -> { @@ -1076,7 +1085,7 @@ constructor( fun onServerOffer(sessionDescription: SessionDescription, offerId: Int) fun onTrickle(candidate: IceCandidate, target: LivekitRtc.SignalTarget) fun onLocalTrackPublished(response: LivekitRtc.TrackPublishedResponse) - fun onParticipantUpdate(updates: List) + fun onParticipantUpdate(updates: List, encoded: ByteArray) fun onSpeakersChanged(speakers: List) fun onClose(reason: String, code: Int) fun onRemoteMuteChanged(trackSid: String, muted: Boolean) @@ -1091,12 +1100,22 @@ constructor( fun onRefreshToken(token: String) fun onLocalTrackUnpublished(trackUnpublished: LivekitRtc.TrackUnpublishedResponse) fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed) - fun onPublishDataTrackResponse(response: LivekitRtc.SignalResponse) {} - fun onUnpublishDataTrackResponse(response: LivekitRtc.SignalResponse) {} - fun onRequestResponse(response: LivekitRtc.SignalResponse) {} - fun onDataTrackSubscriberHandles(response: LivekitRtc.SignalResponse) {} + fun onPublishDataTrackResponse(encoded: ByteArray) {} + fun onUnpublishDataTrackResponse(encoded: ByteArray) {} + fun onRequestResponse(encoded: ByteArray) {} + fun onDataTrackSubscriberHandles(encoded: ByteArray) {} } + /** + * A signal message together with the websocket bytes it arrived as. + * Data-track managers parse the encoded form themselves. + */ + private class IncomingSignal( + val ws: WebSocket, + val response: LivekitRtc.SignalResponse, + val encoded: ByteArray, + ) + /** * Result of waiting for the initial signal response after opening the WebSocket. * Join always yields [Join]; reconnect yields [Reconnect] or [OtherResponse]. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt index dd3d8ca48..e463505b5 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt @@ -97,7 +97,8 @@ constructor( /** * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing a `JoinResponse` - * to the UniFFI manager so pre-existing remote data tracks are discovered. + * to the UniFFI manager so pre-existing remote data tracks are discovered. Pass the + * websocket bytes as received; re-encoding a decoded copy can drop newer fields. */ fun handleSfuJoinResponse(responseBytes: ByteArray) { try { @@ -109,7 +110,7 @@ constructor( /** * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing a `ParticipantUpdate` - * to the UniFFI manager. + * to the UniFFI manager. Pass the websocket bytes as received. */ fun handleSfuParticipantUpdate(responseBytes: ByteArray, localParticipantIdentity: String) { try { @@ -121,7 +122,7 @@ constructor( /** * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing - * `DataTrackSubscriberHandles` to the UniFFI manager. + * `DataTrackSubscriberHandles` to the UniFFI manager. Pass the websocket bytes as received. */ fun handleSubscriberHandles(responseBytes: ByteArray) { try { diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt index 8e9a76a07..27bbce2ce 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt @@ -130,6 +130,7 @@ class SignalClientTest : BaseTest() { val response = job.await() assertEquals(true, client.isConnected) assertEquals(response, JOIN.join) + assertArrayEquals(JOIN.toByteArray(), client.lastJoinEncoded) } @Test diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt index c46a4ef84..92a44eac5 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt @@ -41,14 +41,55 @@ import livekit.org.webrtc.DataChannel import livekit.org.webrtc.PeerConnection import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import java.nio.ByteBuffer @OptIn(ExperimentalCoroutinesApi::class) class DataTrackManagerMockE2ETest : MockE2ETest() { + @Test + fun subscriberDataTrackChannelForwardsPacketsToIncomingManager() = runTest { + connect() + val channel = openSubscriberDataTrackChannel() + val payload = byteArrayOf(9, 8, 7) + receiveDataTrackPacket(channel, payload) + + val packets = remoteDataTrackManagerFactory.manager.handledPackets + assertEquals(1, packets.size) + assertArrayEquals(payload, packets.single()) + } + + @Test + fun fullReconnectForwardsPacketsOnReplacementSubscriberDataTrackChannel() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + val original = openSubscriberDataTrackChannel() + receiveDataTrackPacket(original, byteArrayOf(1)) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val remote = remoteDataTrackManagerFactory.manager + assertFalse(remote.closed) + + val replacement = openSubscriberDataTrackChannel() + assertNotSame(original, replacement) + receiveDataTrackPacket(replacement, byteArrayOf(2)) + + assertEquals(2, remote.handledPackets.size) + assertArrayEquals(byteArrayOf(1), remote.handledPackets[0]) + assertArrayEquals(byteArrayOf(2), remote.handledPackets[1]) + } + @Test fun connectForwardsJoinToInjectedRemoteManager() = runTest { connect() @@ -56,6 +97,7 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { assertEquals(Room.State.CONNECTED, room.state) val remote = remoteDataTrackManagerFactory.manager assertTrue(remote.handledJoinResponses.isNotEmpty()) + assertArrayEquals(TestData.JOIN.toByteArray(), remote.handledJoinResponses.first()) } @Test @@ -162,6 +204,10 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { connect() simulateMessageFromServer(TestData.PARTICIPANT_JOIN) advanceUntilIdle() + assertArrayEquals( + TestData.PARTICIPANT_JOIN.toByteArray(), + remoteDataTrackManagerFactory.manager.handledParticipantUpdates.last(), + ) val participant = remoteParticipant() val roomCollector = EventCollector(room.events, coroutineRule.scope) @@ -298,6 +344,71 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { assertTrue(events.none { it is RoomEvent.DataTrackUnpublished }) } + @Test + fun publishDataTrackWaitsForReplacementChannelAcrossFullReconnect() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + publisherDataTrackChannel().state = DataChannel.State.CONNECTING + + val publish = async { room.localParticipant.publishDataTrack("telemetry") } + yield() + assertTrue(publish.isActive) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + yield() + assertTrue(publish.isActive) + + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val result = publish.await() + assertTrue(result.isSuccess) + assertEquals("telemetry", result.getOrThrow().info.name) + } + + @Test + fun publishDataTrackFailsIfDisconnectedWhileWaitingForChannel() = runTest { + connect() + publisherDataTrackChannel().state = DataChannel.State.CONNECTING + + val publish = async { room.localParticipant.publishDataTrack("telemetry") } + yield() + assertTrue(publish.isActive) + + room.disconnect() + advanceUntilIdle() + + val result = publish.await() + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DataTrackPublishException.Disconnected) + } + + @Test + fun fullReconnectSendsDataTrackPacketsOnReplacementChannel() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + val original = publisherDataTrackChannel() + room.engine.sendDataTrackPackets(listOf(byteArrayOf(1))) + assertEquals(1, original.sentPayloads.size) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val replacement = publisherDataTrackChannel() + assertNotSame(original, replacement) + room.engine.sendDataTrackPackets(listOf(byteArrayOf(2))) + advanceUntilIdle() + assertEquals(1, replacement.sentPayloads.size) + assertArrayEquals(byteArrayOf(2), replacement.sentPayloads.single()) + } + @Test fun fullReconnectRenegotiatesPublisherForDataTrack() = runTest { room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) @@ -365,6 +476,39 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { assertEquals(1, remote.resendSubscriptionUpdatesCount) } + @Test + fun fullReconnectResendsSubscriptionsAfterSubscriberDataTrackOpens() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + ) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + testScheduler.runCurrent() + + val remote = remoteDataTrackManagerFactory.manager + assertEquals(0, remote.resendSubscriptionUpdatesCount) + assertEquals(Room.State.RECONNECTING, room.state) + + val channel = MockDataChannel(RTCEngine.DATA_TRACK_DATA_CHANNEL_LABEL) + channel.state = DataChannel.State.CONNECTING + getSubscriberPeerConnection().observer?.onDataChannel(channel) + testScheduler.runCurrent() + assertEquals(0, remote.resendSubscriptionUpdatesCount) + assertEquals(Room.State.RECONNECTING, room.state) + + channel.state = DataChannel.State.OPEN + advanceUntilIdle() + assertEquals(1, remote.resendSubscriptionUpdatesCount) + assertEquals(Room.State.CONNECTED, room.state) + } + private val publisherOfferHandler: SignalRequestHandler = { request -> if (request.hasOffer()) { val answer = with(LivekitRtc.SignalResponse.newBuilder()) { @@ -386,6 +530,18 @@ class DataTrackManagerMockE2ETest : MockE2ETest() { private fun publisherDataTrackChannel() = getPublisherPeerConnection().dataChannels[RTCEngine.DATA_TRACK_DATA_CHANNEL_LABEL] as MockDataChannel + private fun openSubscriberDataTrackChannel(): MockDataChannel { + val channel = MockDataChannel(RTCEngine.DATA_TRACK_DATA_CHANNEL_LABEL) + getSubscriberPeerConnection().observer?.onDataChannel(channel) + return channel + } + + private fun receiveDataTrackPacket(channel: MockDataChannel, payload: ByteArray) { + channel.simulateBufferReceived( + DataChannel.Buffer(ByteBuffer.wrap(payload), true), + ) + } + private fun reconnectWebsocket() { wsFactory.listener.onOpen(wsFactory.ws, createOpenResponse(wsFactory.request)) val softReconnectParam = wsFactory.request.url