diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c3caf7b0..692b1c208 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.1.8" [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/dagger/RTCModule.kt b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt index e8d78fa43..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 @@ -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,20 @@ internal object RTCModule { return DataPacketCryptorManagerImpl.Factory } + @Provides + fun localDataTrackManagerFactory(): LocalDataTrackManagerFactory { + return LocalDataTrackManagerFactory { delegate, encryptionProvider -> + LocalDataTrackManager(delegate, encryptionProvider) + } + } + + @Provides + fun remoteDataTrackManagerFactory(): RemoteDataTrackManagerFactory { + return RemoteDataTrackManagerFactory { delegate, decryptionProvider -> + RemoteDataTrackManager(delegate, decryptionProvider) + } + } + @Provides @Singleton fun peerConnectionFactory( 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/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/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 19d3464f9..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 @@ -29,6 +29,10 @@ 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 import io.livekit.android.room.network.ReconnectContext import io.livekit.android.room.network.ReconnectPolicy @@ -59,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 @@ -67,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 @@ -119,6 +125,8 @@ internal constructor( private val ioDispatcher: CoroutineDispatcher, private val rtcThreadToken: RTCThreadToken, private val dataPacketCryptorFactory: DataPacketCryptorManager.Factory, + private val outgoingDataTrackManager: OutgoingDataTrackManager, + private val incomingDataTrackManager: IncomingDataTrackManager, ) : SignalClient.Listener { internal var listener: Listener? = null @@ -174,6 +182,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 @@ -191,6 +200,30 @@ internal constructor( private var reliableDataChannelSub: DataChannel? = null 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 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 @@ -253,13 +286,28 @@ 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 } val joinResponse = client.join(url, token, options, roomOptions) ensureActive() + if (joinResponse.hasParticipant()) { + localParticipantIdentity = joinResponse.participant.identity + } + // 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( + client.lastJoinEncoded + ?: LivekitRtc.SignalResponse.newBuilder() + .setJoin(joinResponse) + .build() + .toByteArray(), + ) + listener?.reattachRemoteDataTracks() isClosed = false listener?.onSignalConnected(false) @@ -267,8 +315,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() @@ -312,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 + } } } @@ -324,6 +384,10 @@ internal constructor( when (dataChannel.label()) { RELIABLE_DATA_CHANNEL_LABEL -> reliableDataChannelSub = dataChannel LOSSY_DATA_CHANNEL_LABEL -> lossyDataChannelSub = dataChannel + DATA_TRACK_DATA_CHANNEL_LABEL -> { + setSubscriberDataTrackChannel(dataChannel) + return@onDataChannel + } else -> return@onDataChannel } dataChannel.registerObserver(DataChannelObserver(dataChannel)) @@ -377,6 +441,9 @@ internal constructor( dataChannel.registerObserver(lossyDataChannelManager) } } + + ensureActive() + createPublisherDataTrackChannel() } } } @@ -451,6 +518,7 @@ internal constructor( } LKLog.v { "Close - $reason" } isClosed = true + dataTrackPublisherChannelGate = DataTrackPublisherChannelGate.CLOSED reconnectingJob?.cancel() reconnectingJob = null coroutineScope.close() @@ -460,9 +528,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) { @@ -497,6 +568,20 @@ internal constructor( lossyDataChannelSubManager?.dispose() lossyDataChannelSubManager = null lossyDataChannelSub = null + dataTrackPumpJob?.cancel() + dataTrackPumpJob = null + dataTrackFrameSender.attach(null) + dataTrackDataChannelManager?.dispose() + 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 } } @@ -672,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) { @@ -687,9 +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 } @@ -899,6 +994,172 @@ 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] 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 (isClosed || dataTrackPublisherChannelGate == DataTrackPublisherChannelGate.CLOSED) { + throw RoomException.ConnectException( + "Lost the connection while establishing the publisher data track channel", + ) + } + + // 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() + } + } + + 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", + ) + } + } + + 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 } + } + } + } + private fun dataChannelManagerForKind(kind: LivekitModels.DataPacket.Kind): DataChannelManager? = when (kind) { LivekitModels.DataPacket.Kind.RELIABLE -> reliableDataChannelManager @@ -1021,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) @@ -1033,7 +1295,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) @@ -1056,6 +1318,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 /** @@ -1192,8 +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) { + incomingDataTrackManager.handleSfuParticipantUpdate(encoded, identity) + } + listener?.reattachRemoteDataTracks() } override fun onSpeakersChanged(speakers: List) { @@ -1282,18 +1558,72 @@ internal constructor( listener?.onLocalTrackUnpublished(trackUnpublished) } + override fun onPublishDataTrackResponse(encoded: ByteArray) { + outgoingDataTrackManager.handleSfuPublishResponse(encoded) + } + + override fun onUnpublishDataTrackResponse(encoded: ByteArray) { + outgoingDataTrackManager.handleSfuUnpublishResponse(encoded) + } + + override fun onRequestResponse(encoded: ByteArray) { + outgoingDataTrackManager.handleSfuRequestResponse(encoded) + } + + override fun onDataTrackSubscriberHandles(encoded: ByteArray) { + incomingDataTrackManager.handleSubscriberHandles(encoded) + } + + /** + * Forwards an encoded [LivekitRtc.SignalRequest] produced by a UniFFI data track manager. + */ + internal fun sendDataTrackSignalRequest(requestBytes: ByteArray) { + // Data-track publish / subscribe signaling requires the publisher PC / `_data_track` DC. + if (!hasPublished) { + hasPublished = true + negotiatePublisher() + } + client.sendEncodedRequest(requestBytes) + } + + /** + * Queues serialized data-track packets on the dedicated `_data_track` data channel. + * + * 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) { + executeOnRTCThread(rtcThreadToken) { + dataTrackFrameSender.sendOrQueue(packets) + } + } + + private fun pumpDataTrackFrames() { + executeOnRTCThread(rtcThreadToken) { + dataTrackFrameSender.pump() + } + } + // --------------------------------- DataChannel.Observer ------------------------------------// fun onBufferedAmountChange(dataChannel: DataChannel, previousAmount: Long) { } fun onStateChange(dataChannel: DataChannel) { + if (dataChannel === dataTrackDataChannelSub) { + dataTrackSubscriberChannelOpen = dataChannel.state() == DataChannel.State.OPEN + } } fun onMessage(dataChannel: DataChannel, buffer: DataChannel.Buffer?) { 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()) { @@ -1396,7 +1726,7 @@ internal constructor( } } - fun sendSyncState( + suspend fun sendSyncState( subscription: LivekitRtc.UpdateSubscription, publishedTracks: List, ) { @@ -1429,6 +1759,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) @@ -1438,6 +1778,7 @@ internal constructor( } setSubscription(subscription) addAllPublishTracks(publishedTracks) + addAllPublishDataTracks(publishDataTracks) addAllDataChannels(dataChannelInfos) addAllDatachannelReceiveStates(dataChannelReceiveStates) build() @@ -1524,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 9d5c71bf3..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 @@ -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, @@ -351,6 +356,9 @@ constructor( */ var reconnectPolicy: ReconnectPolicy by engine::reconnectPolicy + /** + * The local participant. + */ val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = false).apply { internalListener = this@Room } @@ -483,6 +491,7 @@ constructor( // Setup local participant. localParticipant.reinitialize(options) setupLocalParticipantEventHandling() + setupIncomingDataTrackEventHandling() if (roomOptions.e2eeOptions != null) { e2eeManager = e2EEManagerFactory.create(roomOptions.e2eeOptions.keyProvider).apply { @@ -789,14 +798,53 @@ 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) + } + + /** + * @suppress + */ + override 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) @@ -854,6 +902,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, @@ -1045,7 +1101,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() @@ -1459,7 +1515,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() @@ -1471,6 +1527,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/SignalClient.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt index 3f2432705..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 @@ -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 @@ -112,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 @@ -122,6 +137,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 /** @@ -258,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) } } } @@ -312,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) { @@ -641,6 +659,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) @@ -651,6 +739,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) { @@ -665,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 } @@ -691,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( @@ -737,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 @@ -771,7 +867,7 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.UPDATE -> { - listener?.onParticipantUpdate(response.update.participantsList) + listener?.onParticipantUpdate(response.update.participantsList, encoded) } LivekitRtc.SignalResponse.MessageCase.TRACK_SUBSCRIBED -> { @@ -849,7 +945,21 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.REQUEST_RESPONSE -> { - // TODO + 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(encoded) } LivekitRtc.SignalResponse.MessageCase.ROOM_MOVED -> { @@ -865,17 +975,26 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.PUBLISH_DATA_TRACK_RESPONSE -> { - // TODO + listener?.onPublishDataTrackResponse(encoded) } LivekitRtc.SignalResponse.MessageCase.UNPUBLISH_DATA_TRACK_RESPONSE -> { - // TODO + listener?.onUnpublishDataTrackResponse(encoded) } LivekitRtc.SignalResponse.MessageCase.DATA_TRACK_SUBSCRIBER_HANDLES -> { - // TODO + listener?.onDataTrackSubscriberHandles(encoded) + } + + 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, -> { @@ -912,6 +1031,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. * @@ -922,6 +1051,7 @@ constructor( LKLog.v(Exception()) { "Closing SignalClient: code = $code, reason = $reason" } isConnected = false isReconnecting = false + failPendingDataBlobRequests() if (::coroutineScope.isInitialized) { coroutineScope.close() } @@ -955,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) @@ -970,8 +1100,22 @@ constructor( fun onRefreshToken(token: String) fun onLocalTrackUnpublished(trackUnpublished: LivekitRtc.TrackUnpublishedResponse) fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed) + 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]. @@ -1024,6 +1168,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 new file mode 100644 index 000000000..488d28fcf --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt @@ -0,0 +1,175 @@ +/* + * 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) +} + +/** + * 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) + 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/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-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/DataTrackManagerFactory.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt new file mode 100644 index 000000000..319cfdf9b --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.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.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. + * + * @suppress + */ +fun interface LocalDataTrackManagerFactory { + fun create( + delegate: LocalDataTrackManagerDelegate, + encryptionProvider: EncryptionProvider?, + ): LocalDataTrackManagerInterface +} + +/** + * Creates UniFFI [io.livekit.uniffi.RemoteDataTrackManager] instances. + * + * @suppress + */ +fun interface RemoteDataTrackManagerFactory { + fun create( + delegate: RemoteDataTrackManagerDelegate, + decryptionProvider: DecryptionProvider?, + ): RemoteDataTrackManagerInterface +} 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..601f8523e --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt @@ -0,0 +1,272 @@ +/* + * 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 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 + +/** + * 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(), + ) + + 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() +} + +/** + * 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) + } + + 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]. + */ + 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/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 new file mode 100644 index 000000000..e463505b5 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt @@ -0,0 +1,166 @@ +/* + * 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.e2ee.DataTrackCryptor +import io.livekit.android.events.BroadcastEventBus +import io.livekit.android.room.RTCEngine +import io.livekit.android.util.LKLog +import io.livekit.uniffi.HandleSignalResponseException +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 [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. + * + * 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 +class IncomingDataTrackManager +@Inject +constructor( + private val engineProvider: Provider, + private val remoteDataTrackManagerFactory: RemoteDataTrackManagerFactory, +) { + private val eventBus = BroadcastEventBus() + + /** + * Publication and unpublication events from the UniFFI remote manager. + */ + internal val events = eventBus.readOnly() + + 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) { + engineProvider.get().sendDataTrackSignalRequest(request) + } + + override fun onTrackPublished(track: FfiRemoteDataTrack) { + val wrapped = RemoteDataTrack(track) + synchronized(lock) { + remoteTracks.add(wrapped) + } + eventBus.tryPostEvent(IncomingDataTrackEvent.TrackPublished(wrapped)) + } + + override fun onTrackUnpublished(sid: String) { + 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() + } + } + + /** + * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing a `JoinResponse` + * 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 { + 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` + * to the UniFFI manager. Pass the websocket bytes as received. + */ + 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. Pass the websocket bytes as received. + */ + 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 reconnect so the SFU re-issues subscriber handles. + */ + fun resendSubscriptionUpdates() { + remoteManager?.resendSubscriptionUpdates() + } + + /** + * Shuts down the underlying UniFFI manager. A subsequent handle call creates a new one. + */ + fun close() { + synchronized(lock) { + (remoteManager as? AutoCloseable)?.close() + remoteManager = null + remoteTracks.clear() + } + } + + private fun ensureManager(): RemoteDataTrackManagerInterface { + synchronized(lock) { + remoteManager?.let { return it } + return remoteDataTrackManagerFactory.create(delegate, cryptor).also { remoteManager = it } + } + } +} 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..bf002c972 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt @@ -0,0 +1,181 @@ +/* + * 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.CheckResult +import io.livekit.android.util.rethrowIfCancellationSignal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.takeWhile +import uniffi.livekit_datatrack.PushFrameErrorReason +import io.livekit.uniffi.LocalDataTrack as FfiLocalDataTrack + +/** + * 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: 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") + * result.onSuccess { track -> + * track.tryPush(DataTrackFrame(payload)) + * track.unpublish() + * } + * ``` + */ +class LocalDataTrack internal constructor( + private val impl: FfiLocalDataTrack, +) : DataTrackFrameSink { + /** + * Whether the track is currently published. Becomes `false` after [unpublish] or if the SFU + * unpublishes it. + */ + override val isPublished: Boolean + get() = impl.isPublished() + + /** + * Metadata for this track. + */ + val info: DataTrackInfo + get() = DataTrackInfo(impl.info()) + + /** + * Pushes a frame to subscribers. + * + * 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 on the exception. + * + * @return A successful [Result] if the frame was enqueued, or a failure containing + * [DataTrackPushFrameException]. + */ + @CheckResult + override fun tryPush(frame: DataTrackFrame): Result { + return try { + impl.tryPush(frame.toFfi()) + Result.success(Unit) + } catch (e: PushFrameErrorReason) { + 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() + 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 fail with + * [DataTrackPushFrameException.TrackUnpublished]. + */ + 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() + } + + /** + * Policy for [send] when the send queue is full. + */ + enum class FrameDropPolicy { + /** Fail the send with [DataTrackPushFrameException.QueueFull]. */ + FAIL, + + /** Silently skip the frame. */ + DROP, + } + + /** + * 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 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 = 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 + } + 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/datatrack/OutgoingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt new file mode 100644 index 000000000..a5c30f5b9 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt @@ -0,0 +1,177 @@ +/* + * 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.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 +import io.livekit.android.util.rethrowIfCancellationSignal +import io.livekit.uniffi.DataTrackOptions +import io.livekit.uniffi.HandleSignalResponseException +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 [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. + * + * @suppress + */ +@Singleton +class OutgoingDataTrackManager +@Inject +constructor( + private val engineProvider: Provider, + private val localDataTrackManagerFactory: LocalDataTrackManagerFactory, +) { + 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) { + engineProvider.get().sendDataTrackSignalRequest(request) + } + + override fun onPacketsAvailable(packets: List) { + engineProvider.get().sendDataTrackPackets(packets) + } + } + + /** + * Publishes a data track with the given name and options. + * + * @return A successful [Result] containing the published track, or a failure containing + * [DataTrackPublishException]. + */ + @CheckResult + suspend fun publishTrack(name: String, options: DataTrackPublishOptions? = null): Result { + val ffiOptions = DataTrackOptions( + name = name, + 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) { + Result.failure(e.toSdk()) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + Result.failure(DataTrackPublishException.Internal(e.message ?: "", e)) + } + } + + /** + * 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" } + } + } + + /** + * Receives a serialized [livekit.LivekitRtc.SignalResponse] containing + * `UnpublishDataTrackResponse`. + * + * UniFFI does not consume this message yet. 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. + */ + 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 serialized `PublishDataTrackResponse` messages for currently published tracks, + * suitable for [livekit.LivekitRtc.SyncState.publishDataTracks]. + */ + suspend fun publishResponsesForSyncState(): List { + return localManager?.publishResponsesForSyncState() ?: emptyList() + } + + /** + * Shuts down the underlying UniFFI manager. A subsequent [publishTrack] creates a new one. + */ + fun close() { + synchronized(lock) { + (localManager as? AutoCloseable)?.close() + localManager = null + } + } + + private fun ensureManager(): LocalDataTrackManagerInterface { + synchronized(lock) { + localManager?.let { return 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-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..b55271504 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt @@ -0,0 +1,108 @@ +/* + * 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.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 + +/** + * A data track published by a remote participant. + * + * Call [subscribe] to start receiving frames. + * + * ``` + * remoteTrack.subscribe().onSuccess { stream -> + * 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. + * @return A successful [Result] containing the [DataTrackStream], or a failure containing + * [DataTrackSubscribeException]. + */ + @CheckResult + suspend fun subscribe( + @IntRange(from = 1) bufferSize: Int = DEFAULT_BUFFER_SIZE, + ): Result { + val options = DataTrackSubscribeOptions(bufferSize = bufferSize.coerceAtLeast(1).toUInt()) + return try { + Result.success(DataTrackStream(impl.subscribeWithOptions(options))) + } catch (e: FfiSubscribeException) { + Result.failure(e.toSdk()) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + Result.failure(DataTrackSubscribeException.Internal(e.message ?: "", e)) + } + } + + companion object { + /** + * Default subscribe-side buffer, in frames. + */ + const val DEFAULT_BUFFER_SIZE: Int = 16 + } +} 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..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 @@ -37,6 +37,12 @@ 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.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 import io.livekit.android.room.rpc.RpcClientManager import io.livekit.android.room.rpc.RpcManager @@ -85,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 @@ -109,6 +118,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 +985,125 @@ 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]. + * + * ``` + * 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 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. + */ + @CheckResult + suspend fun publishDataTrack( + name: String, + options: DataTrackPublishOptions? = null, + ): Result { + if (engine.connectionState == ConnectionState.DISCONNECTED) { + return Result.failure(DataTrackPublishException.Disconnected("Not connected to a room")) + } + 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. + * + * 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 A successful [Result] containing the value returned by [block], or a failure if + * the track cannot be published or [block] throws. + */ + @CheckResult + suspend fun withDataTrack( + name: String, + options: DataTrackPublishOptions? = null, + block: suspend (LocalDataTrack) -> T, + ): Result { + val track = publishDataTrack(name, options).getOrElse { return Result.failure(it) } + try { + return Result.success(block(track)) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + return Result.failure(e) + } 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. @@ -1676,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-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-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/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/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/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/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. 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..6dbe983bf --- /dev/null +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt @@ -0,0 +1,139 @@ +/* + * 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 +import livekit.LivekitModels +import livekit.LivekitRtc +import uniffi.livekit_datatrack.EncryptionProvider + +class MockLocalDataTrackManagerFactory : LocalDataTrackManagerFactory { + /** + * The most recently created manager. + */ + lateinit var manager: MockLocalDataTrackManager + + /** + * 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 } + } +} + +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 { + 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 { + 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) } + } + + var republishTracksCount = 0 + private set + + override fun republishTracks() { + republishTracksCount++ + } + + 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..c933cf2ee --- /dev/null +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.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.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 +import uniffi.livekit_datatrack.DecryptionProvider + +class MockRemoteDataTrackManagerFactory : RemoteDataTrackManagerFactory { + /** + * The most recently created manager. + */ + lateinit var manager: MockRemoteDataTrackManager + + /** + * 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 } + } +} + +class MockRemoteDataTrackManager( + val delegate: RemoteDataTrackManagerDelegate, +) : RemoteDataTrackManagerInterface, AutoCloseable { + val handledJoinResponses = mutableListOf() + val handledParticipantUpdates = mutableListOf() + val handledSubscriberHandles = mutableListOf() + val handledPackets = mutableListOf() + var closed = false + private set + var resendSubscriptionUpdatesCount = 0 + 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() { + resendSubscriptionUpdatesCount++ + } + + /** + * 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/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/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/SignalClientTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt index a10dfe3fa..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 @@ -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() { @@ -125,6 +130,7 @@ class SignalClientTest : BaseTest() { val response = job.await() assertEquals(true, client.isConnected) assertEquals(response, JOIN.join) + assertArrayEquals(JOIN.toByteArray(), client.lastJoinEncoded) } @Test @@ -501,6 +507,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/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 new file mode 100644 index 000000000..92a44eac5 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackManagerMockE2ETest.kt @@ -0,0 +1,561 @@ +/* + * 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.e2ee.E2EEOptions +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 +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.mock.e2ee.NoopKeyProvider +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.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() + + assertEquals(Room.State.CONNECTED, room.state) + val remote = remoteDataTrackManagerFactory.manager + assertTrue(remote.handledJoinResponses.isNotEmpty()) + assertArrayEquals(TestData.JOIN.toByteArray(), remote.handledJoinResponses.first()) + } + + @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) + 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 + 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 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() + 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() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + assertArrayEquals( + TestData.PARTICIPANT_JOIN.toByteArray(), + remoteDataTrackManagerFactory.manager.handledParticipantUpdates.last(), + ) + + 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 }) + } + + @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) + 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") + 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) + } + + @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()) { + 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 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 + .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)]!! +} 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/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) + } + } + } +} 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") diff --git a/protocol b/protocol index 8381f2180..2172178d2 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 8381f2180c45ab926b3ebf19df0608f1dadcac1e +Subproject commit 2172178d20d4c8d14d6873b4e9560cc38cf48c0c