diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6c3caf7b0..53e36be1f 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.9"
[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/detekt-baseline-release.xml b/livekit-android-sdk/detekt-baseline-release.xml
index 54ebf273d..6bd3cb94e 100644
--- a/livekit-android-sdk/detekt-baseline-release.xml
+++ b/livekit-android-sdk/detekt-baseline-release.xml
@@ -27,7 +27,7 @@
CyclomaticComplexMethod:Room.kt$Room$@Throws(Exception::class) suspend fun connect(url: String, token: String, options: ConnectOptions = ConnectOptions())
CyclomaticComplexMethod:RoomEvent.kt$fun LivekitModels.DisconnectReason?.convert(): DisconnectReason
CyclomaticComplexMethod:SignalClient.kt$SignalClient$override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?)
- CyclomaticComplexMethod:SignalClient.kt$SignalClient$private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse)
+ CyclomaticComplexMethod:SignalClient.kt$SignalClient$private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse, encoded: ByteArray)
EmptyFunctionBlock:RTCEngine.kt$RTCEngine${ }
HasPlatformType:DataChannelManager.kt$DataChannelManager$@get:FlowObservable var state by flowDelegate(dataChannel.state()) private set
IgnoredReturnValue:RpcServerManager.kt$RpcServerManager$publishRpcAck(callerIdentity, requestId)
@@ -38,11 +38,12 @@
LargeClass:SignalClient.kt$SignalClient : WebSocketListener
LongMethod:RTCEngine.kt$RTCEngine$@Synchronized @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) fun reconnect()
LongMethod:Room.kt$Room$@Throws(Exception::class) suspend fun connect(url: String, token: String, options: ConnectOptions = ConnectOptions())
+ LongMethod:SignalClient.kt$SignalClient$private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse, encoded: ByteArray)
LongParameterList:AudioBufferCallbackDispatcher.kt$AudioBufferCallback$(buffer: ByteBuffer, audioFormat: Int, channelCount: Int, sampleRate: Int, bytesRead: Int, captureTimeNs: Long)
LongParameterList:KeyProvider.kt$BaseKeyProvider$( ratchetSalt: String = defaultRatchetSalt, uncryptedMagicBytes: String = defaultMagicBytes, ratchetWindowSize: Int = defaultRatchetWindowSize, override var enableSharedKey: Boolean = true, failureTolerance: Int = defaultFailureTolerance, keyRingSize: Int = defaultKeyRingSize, discardFrameWhenCryptorNotReady: Boolean = defaultDiscardFrameWhenCryptorNotReady, keyDerivationAlgorithm: FrameCryptorKeyDerivationAlgorithm = defaultKeyDerivationAlgorithm, )
LongParameterList:LiveKitOverrides.kt$AudioOptions$( /** * Override the default output [AudioType]. * * This affects the audio routing and how the audio is handled. Default is [AudioType.CallAudioType]. * * Note: if [audioHandler] is also passed, the values from [audioOutputType] will not be reflected in it, * and must be set yourself. */ val audioOutputType: AudioType? = null, /** * Override the default [AudioHandler]. * * Default is [AudioSwitchHandler]. * * Use [NoAudioHandler] to turn off automatic audio handling or * [AudioFocusHandler] to get simple audio focus handling. */ val audioHandler: AudioHandler? = null, /** * Override the default [AudioDeviceModule]. * * If a non-null value is passed, the library does not * take ownership of the object and will not release it upon [Room.release]. * It is the responsibility of the owner to call [AudioDeviceModule.release] when finished * with it to prevent memory leaks. */ val audioDeviceModule: AudioDeviceModule? = null, /** * Called after default setup to allow for customizations on the [JavaAudioDeviceModule]. * * Not used if [audioDeviceModule] is provided. * * Note: We require setting the [JavaAudioDeviceModule.Builder.setSamplesReadyCallback] to provide * support for [LocalAudioTrack.addSink]. If you wish to grab the audio samples * from the local microphone track, use [LocalAudioTrack.addSink] instead of setting your own * callback. */ val javaAudioDeviceModuleCustomizer: ((builder: JavaAudioDeviceModule.Builder) -> Unit)? = null, /** * On Android 11+, the audio mode will reset itself from [AudioManager.MODE_IN_COMMUNICATION] if * there is no audio playback or capture for 6 seconds (for example when joining a room with * no speakers and the local mic is muted.) This mode reset will cause unexpected * behavior when trying to change the volume, causing it to not properly change the volume. * * We use a workaround by playing a silent audio track to keep the communication mode from * resetting. * * Setting this flag to true will disable the workaround. * * This flag is a no-op when the audio mode is set to anything other than * [AudioManager.MODE_IN_COMMUNICATION]. */ val disableCommunicationModeWorkaround: Boolean = false, /** * Options for processing the mic and incoming audio. */ val audioProcessorOptions: AudioProcessorOptions? = null, /** * Devices may take some time initializing the audio stack for recording. * Prewarming allows starting up the underlying audio recording prior to publish, letting * the audio device be ready immediately when the track is fully published. * * If set to true, disables audio recording prewarming (and the related * [LocalAudioTrack.prewarm] function), and audio resources are only used while the * track is connected and published. Defaults to false. */ val disableAudioPrewarming: Boolean = false, )
LongParameterList:LocalAudioTrack.kt$LocalAudioTrack$( @Assisted name: String, @Assisted mediaTrack: livekit.org.webrtc.AudioTrack, @Assisted options: LocalAudioTrackOptions, private val audioProcessingController: AudioProcessingController, @Named(InjectionNames.DISPATCHER_DEFAULT) private val dispatcher: CoroutineDispatcher, @Named(InjectionNames.LOCAL_AUDIO_RECORD_SAMPLES_DISPATCHER) private val audioRecordSamplesDispatcher: AudioRecordSamplesDispatcher, @Named(InjectionNames.LOCAL_AUDIO_BUFFER_CALLBACK_DISPATCHER) private val audioBufferCallbackDispatcher: AudioBufferCallbackDispatcher, private val audioRecordPrewarmer: AudioRecordPrewarmer, rtcThreadToken: RTCThreadToken, )
- LongParameterList:LocalParticipant.kt$LocalParticipant$( @Assisted internal var dynacast: Boolean, internal val engine: RTCEngine, private val peerConnectionFactory: PeerConnectionFactory, private val context: Context, private val eglBase: EglBase, private val screencastVideoTrackFactory: LocalScreencastVideoTrack.Factory, private val videoTrackFactory: LocalVideoTrack.Factory, private val audioTrackFactory: LocalAudioTrack.Factory, private val defaultsManager: DefaultsManager, @Named(InjectionNames.DISPATCHER_DEFAULT) coroutineDispatcher: CoroutineDispatcher, @Named(InjectionNames.SENDER) private val capabilitiesGetter: CapabilitiesGetter, private val outgoingDataStreamManager: OutgoingDataStreamManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, )
+ LongParameterList:LocalParticipant.kt$LocalParticipant$( @Assisted internal var dynacast: Boolean, internal val engine: RTCEngine, private val peerConnectionFactory: PeerConnectionFactory, private val context: Context, private val eglBase: EglBase, private val screencastVideoTrackFactory: LocalScreencastVideoTrack.Factory, private val videoTrackFactory: LocalVideoTrack.Factory, private val audioTrackFactory: LocalAudioTrack.Factory, private val defaultsManager: DefaultsManager, @Named(InjectionNames.DISPATCHER_DEFAULT) coroutineDispatcher: CoroutineDispatcher, @Named(InjectionNames.SENDER) private val capabilitiesGetter: CapabilitiesGetter, private val outgoingDataStreamManager: OutgoingDataStreamManager, private val outgoingDataTrackManager: OutgoingDataTrackManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, )
LongParameterList:LocalScreencastVideoTrack.kt$LocalScreencastVideoTrack$( @Assisted capturer: VideoCapturer, @Assisted source: VideoSource, @Assisted name: String, @Assisted options: LocalVideoTrackOptions, @Assisted rtcTrack: livekit.org.webrtc.VideoTrack, @Assisted mediaProjectionCallback: MediaProjectionCallback, peerConnectionFactory: PeerConnectionFactory, context: Context, eglBase: EglBase, defaultsManager: DefaultsManager, videoTrackFactory: LocalVideoTrack.Factory, rtcThreadToken: RTCThreadToken, )
LongParameterList:LocalScreencastVideoTrack.kt$LocalScreencastVideoTrack.Companion$( mediaProjectionPermissionResultData: Intent, peerConnectionFactory: PeerConnectionFactory, context: Context, name: String, options: LocalVideoTrackOptions, rootEglBase: EglBase, screencastVideoTrackFactory: Factory, videoProcessor: VideoProcessor?, onStop: (Track) -> Unit, )
LongParameterList:LocalScreencastVideoTrack.kt$LocalScreencastVideoTrack.Factory$( capturer: VideoCapturer, source: VideoSource, name: String, options: LocalVideoTrackOptions, rtcTrack: livekit.org.webrtc.VideoTrack, mediaProjectionCallback: MediaProjectionCallback, )
@@ -52,13 +53,14 @@
LongParameterList:LocalVideoTrack.kt$LocalVideoTrack.Factory$( capturer: VideoCapturer, source: VideoSource, name: String, options: LocalVideoTrackOptions, rtcTrack: livekit.org.webrtc.VideoTrack, dispatchObserver: CaptureDispatchObserver?, )
LongParameterList:MixerAudioBufferCallback.kt$MixerAudioBufferCallback$(originalBuffer: ByteBuffer, audioFormat: Int, channelCount: Int, sampleRate: Int, bytesRead: Int, captureTimeNs: Long)
LongParameterList:PeerConnectionTransport.kt$PeerConnectionTransport$( @Assisted config: RTCConfiguration, @Assisted pcObserver: PeerConnection.Observer, @Assisted private val listener: Listener?, @Named(InjectionNames.DISPATCHER_IO) private val ioDispatcher: CoroutineDispatcher, connectionFactory: PeerConnectionFactory, private val sdpFactory: SdpFactory, private val rtcThreadToken: RTCThreadToken, )
+ LongParameterList:RTCEngine.kt$RTCEngine$( val client: SignalClient, private val pctFactory: PeerConnectionTransport.Factory, @Named(InjectionNames.DISPATCHER_IO) private val ioDispatcher: CoroutineDispatcher, private val rtcThreadToken: RTCThreadToken, private val dataPacketCryptorFactory: DataPacketCryptorManager.Factory, private val outgoingDataTrackManager: OutgoingDataTrackManager, private val incomingDataTrackManager: IncomingDataTrackManager, )
LongParameterList:RTCMetricsManager.kt$( label: MetricLabel, strings: MutableList<String>, samples: List<MetricSample>, identity: Participant.Identity? = null, trackSid: String? = null, rid: String? = null, )
LongParameterList:RTCModule.kt$RTCModule$( @Named(InjectionNames.OVERRIDE_AUDIO_DEVICE_MODULE) audioDeviceModuleOverride: AudioDeviceModule?, @Named(InjectionNames.OVERRIDE_JAVA_AUDIO_DEVICE_MODULE_CUSTOMIZER) moduleCustomizer: ((builder: JavaAudioDeviceModule.Builder) -> Unit)?, audioOutputAttributes: AudioAttributes, appContext: Context, closeableManager: CloseableManager, communicationWorkaround: CommunicationWorkaround, @Named(InjectionNames.LOCAL_AUDIO_RECORD_SAMPLES_DISPATCHER) audioRecordSamplesDispatcher: AudioRecordSamplesDispatcher, @Named(InjectionNames.LOCAL_AUDIO_BUFFER_CALLBACK_DISPATCHER) audioBufferCallbackDispatcher: AudioBufferCallbackDispatcher, )
LongParameterList:RTCModule.kt$RTCModule$( @Suppress("UNUSED_PARAMETER") @Named(InjectionNames.LIB_WEBRTC_INITIALIZATION) webrtcInitialization: LibWebrtcInitialization, audioDeviceModule: AudioDeviceModule, videoEncoderFactory: VideoEncoderFactory, videoDecoderFactory: VideoDecoderFactory, @Named(InjectionNames.OVERRIDE_PEER_CONNECTION_FACTORY_OPTIONS) peerConnectionFactoryOptions: PeerConnectionFactory.Options?, memoryManager: CloseableManager, audioProcessingFactory: AudioProcessingFactory, )
LongParameterList:RTCStatsExt.kt$( trackIdentifier: String, ssrcs: Set<Long?>, codecIds: Set<String?>, localCandidateId: String?, remoteCandidateId: String?, statsMap: Map<String, RTCStats>, )
LongParameterList:RemoteParticipant.kt$RemoteParticipant$( mediaTrack: MediaStreamTrack, sid: String, statsGetter: RTCStatsGetter, receiver: RtpReceiver, autoManageVideo: Boolean = false, triesLeft: Int = 20, )
LongParameterList:RemoteParticipant.kt$RemoteParticipant$( sid: Sid, identity: Identity? = null, internal val signalClient: SignalClient, private val ioDispatcher: CoroutineDispatcher, defaultDispatcher: CoroutineDispatcher, private val audioTrackFactory: RemoteAudioTrack.Factory, private val videoTrackFactory: RemoteVideoTrack.Factory, )
- LongParameterList:Room.kt$Room$( @Assisted private val context: Context, internal val engine: RTCEngine, private val eglBase: EglBase, localParticipantFactory: LocalParticipant.Factory, private val defaultsManager: DefaultsManager, @Named(InjectionNames.DISPATCHER_DEFAULT) private val defaultDispatcher: CoroutineDispatcher, @Named(InjectionNames.DISPATCHER_IO) private val ioDispatcher: CoroutineDispatcher, /** * The [AudioHandler] for setting up the audio as need. * * By default, this is an instance of [AudioSwitchHandler]. * * This can be substituted for your own custom implementation through * [LiveKitOverrides.audioOptions] when creating the room with [LiveKit.create]. * * @see [audioSwitchHandler] * @see [AudioSwitchHandler] */ val audioHandler: AudioHandler, private val closeableManager: CloseableManager, private val e2EEManagerFactory: E2EEManager.Factory, private val communicationWorkaround: CommunicationWorkaround, val audioProcessingController: AudioProcessingController, /** * A holder for objects that are used internally within LiveKit. */ val lkObjects: LKObjects, networkCallbackManagerFactory: NetworkCallbackManagerFactory, private val audioDeviceModule: AudioDeviceModule, private val regionUrlProviderFactory: RegionUrlProvider.Factory, private val connectionWarmer: ConnectionWarmer, private val audioRecordPrewarmer: AudioRecordPrewarmer, private val incomingDataStreamManager: IncomingDataStreamManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, private val remoteParticipantFactory: RemoteParticipant.Factory, )
+ LongParameterList:Room.kt$Room$( @Assisted private val context: Context, internal val engine: RTCEngine, private val eglBase: EglBase, localParticipantFactory: LocalParticipant.Factory, private val defaultsManager: DefaultsManager, @Named(InjectionNames.DISPATCHER_DEFAULT) private val defaultDispatcher: CoroutineDispatcher, @Named(InjectionNames.DISPATCHER_IO) private val ioDispatcher: CoroutineDispatcher, /** * The [AudioHandler] for setting up the audio as need. * * By default, this is an instance of [AudioSwitchHandler]. * * This can be substituted for your own custom implementation through * [LiveKitOverrides.audioOptions] when creating the room with [LiveKit.create]. * * @see [audioSwitchHandler] * @see [AudioSwitchHandler] */ val audioHandler: AudioHandler, private val closeableManager: CloseableManager, private val e2EEManagerFactory: E2EEManager.Factory, private val communicationWorkaround: CommunicationWorkaround, val audioProcessingController: AudioProcessingController, /** * A holder for objects that are used internally within LiveKit. */ val lkObjects: LKObjects, networkCallbackManagerFactory: NetworkCallbackManagerFactory, private val audioDeviceModule: AudioDeviceModule, private val regionUrlProviderFactory: RegionUrlProvider.Factory, 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, )
MapGetWithNotNullAssertionOperator:LocalParticipant.kt$LocalParticipant$sourcePubLocks[source]!!
NestedBlockDepth:ByteStreamSender.kt$@CheckResult suspend fun ByteStreamSender.write(source: Source): Result<Unit>
NestedBlockDepth:LocalParticipant.kt$LocalParticipant$@Throws(TrackException.PublishException::class) private suspend fun publishTrackImpl( track: Track, options: TrackPublishOptions, requestConfig: AddTrackRequest.Builder.() -> Unit, encodings: List<RtpParameters.Encoding> = emptyList(), publishListener: PublishListener? = null, ): LocalTrackPublication?
@@ -71,7 +73,7 @@
NestedBlockDepth:RTCEngine.kt$RTCEngine$private fun makeRTCConfig( serverResponse: Either<JoinResponse, ReconnectResponse>, connectOptions: ConnectOptions, ): RTCConfiguration
NestedBlockDepth:Room.kt$Room$override suspend fun onPostReconnect(isFullReconnect: Boolean)
NestedBlockDepth:SignalClient.kt$SignalClient$override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?)
- NestedBlockDepth:SignalClient.kt$SignalClient$private fun handleSignalResponse(ws: WebSocket, response: LivekitRtc.SignalResponse)
+ NestedBlockDepth:SignalClient.kt$SignalClient$private fun handleSignalResponse(ws: WebSocket, response: LivekitRtc.SignalResponse, encoded: ByteArray)
SwallowedException:FlowExt.kt$e: CancellationException
SwallowedException:LocalVideoTrack.kt$LocalVideoTrack$e: Exception
SwallowedException:TextureViewRenderer.kt$TextureViewRenderer$e: NotFoundException
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..b49a85098 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,41 @@ 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.
+ *
+ * Fires for every track, including those already published when this participant was
+ * first seen and those reattached after a full reconnect.
+ *
+ * Collect frames in a separate coroutine so this event collector is not blocked.
+ *
+ * ```
+ * room.events.collect { event ->
+ * if (event is RoomEvent.DataTrackPublished) {
+ * scope.launch {
+ * 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..6efe936cf 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.DataTrackPublishException
+import io.livekit.android.room.datatrack.DataTrackPublisherChannel
+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
@@ -119,6 +123,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 +180,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 +198,8 @@ internal constructor(
private var reliableDataChannelSub: DataChannel? = null
private var lossyDataChannel: DataChannel? = null
private var lossyDataChannelSub: DataChannel? = null
+ private var dataTrackDataChannelSub: DataChannel? = null
+ private val dataTrackPublisherChannel = DataTrackPublisherChannel(rtcThreadToken)
private var reliableDataChannelManager: DataChannelManager? = null
private var reliableBufferedAmountJob: Job? = null
private var reliableDataChannelSubManager: DataChannelManager? = null
@@ -259,7 +268,20 @@ internal constructor(
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 +289,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()
@@ -324,6 +348,7 @@ internal constructor(
when (dataChannel.label()) {
RELIABLE_DATA_CHANNEL_LABEL -> reliableDataChannelSub = dataChannel
LOSSY_DATA_CHANNEL_LABEL -> lossyDataChannelSub = dataChannel
+ DATA_TRACK_DATA_CHANNEL_LABEL -> dataTrackDataChannelSub = dataChannel
else -> return@onDataChannel
}
dataChannel.registerObserver(DataChannelObserver(dataChannel))
@@ -377,6 +402,9 @@ internal constructor(
dataChannel.registerObserver(lossyDataChannelManager)
}
}
+
+ ensureActive()
+ createPublisherDataTrackChannel()
}
}
}
@@ -460,9 +488,12 @@ internal constructor(
connectOptions = null
lastRoomOptions = null
participantSid = null
+ localParticipantIdentity = null
regionUrlProvider = null
abortPendingPublishTracks()
closeResources(reason)
+ outgoingDataTrackManager.close()
+ incomingDataTrackManager.close()
connectionState = ConnectionState.DISCONNECTED
synchronized(reliableStateLock) {
@@ -497,6 +528,8 @@ internal constructor(
lossyDataChannelSubManager?.dispose()
lossyDataChannelSubManager = null
lossyDataChannelSub = null
+ dataTrackPublisherChannel.detach()
+ dataTrackDataChannelSub = null
isSubscriberPrimary = false
}
}
@@ -690,6 +723,10 @@ internal constructor(
// Is connected, notify and return.
regionUrlProvider?.clearAttemptedRegions()
client.onPCConnected()
+ if (isFullReconnect) {
+ outgoingDataTrackManager.republishTracks()
+ }
+ incomingDataTrackManager.resendSubscriptionUpdates()
listener?.onPostReconnect(isFullReconnect)
return@launch
}
@@ -899,6 +936,64 @@ 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].
+ *
+ * [DataTrackPublisherChannel] re-reads its manager each pass rather than capturing it, so a
+ * publish in flight when a full reconnect swaps the transport waits for the replacement
+ * channel instead of failing against a disposed one. A real [close] fails it as a disconnect.
+ */
+ @Throws(exceptionClasses = [DataTrackPublishException::class])
+ internal suspend fun ensureDataTrackPublisherConnected() {
+ // Always mark publish intent so a full reconnect's joinImpl renegotiates even if this
+ // wait started against a torn-down publisher transport.
+ if (isSubscriberPrimary) {
+ val publisherTransport = publisher
+ val iceChecking = publisherTransport?.iceConnectionState() ==
+ PeerConnection.IceConnectionState.CHECKING
+ if (publisherTransport?.isConnected() != true && !iceChecking) {
+ negotiatePublisher()
+ }
+ }
+
+ val opened = dataTrackPublisherChannel.awaitOpen(MAX_ICE_CONNECT_TIMEOUT_MS.toLong()) { isClosed }
+ when (opened) {
+ true -> return
+ false -> throw DataTrackPublishException.Disconnected(
+ "Lost the connection while establishing the publisher data track channel",
+ )
+ null -> throw DataTrackPublishException.Timeout(
+ "Timed out establishing the publisher data track channel",
+ )
+ }
+ }
+
+ /**
+ * Creates the publisher `_data_track` channel and hands it to [dataTrackPublisherChannel].
+ */
+ private suspend fun createPublisherDataTrackChannel() {
+ val dataTrackInit = DataChannel.Init()
+ dataTrackInit.ordered = false
+ dataTrackInit.maxRetransmits = 0
+ publisher?.withPeerConnection {
+ createDataChannel(
+ DATA_TRACK_DATA_CHANNEL_LABEL,
+ dataTrackInit,
+ ).also { dataChannel ->
+ val dataChannelManager = DataChannelManager(
+ dataChannel,
+ DataChannelObserver(dataChannel),
+ rtcThreadToken,
+ )
+ dataChannel.registerObserver(dataChannelManager)
+ dataTrackPublisherChannel.attach(dataChannelManager, coroutineScope)
+ }
+ }
+ }
+
private fun dataChannelManagerForKind(kind: LivekitModels.DataPacket.Kind): DataChannelManager? =
when (kind) {
LivekitModels.DataPacket.Kind.RELIABLE -> reliableDataChannelManager
@@ -1021,6 +1116,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 +1129,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 +1152,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 +1297,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,6 +1392,40 @@ 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) {
+ negotiatePublisher()
+ }
+ client.sendEncodedRequest(requestBytes)
+ }
+
+ /**
+ * Queues serialized data-track packets on the dedicated `_data_track` data channel.
+ */
+ internal fun sendDataTrackPackets(packets: List) {
+ dataTrackPublisherChannel.sendPackets(packets)
+ }
+
// --------------------------------- DataChannel.Observer ------------------------------------//
fun onBufferedAmountChange(dataChannel: DataChannel, previousAmount: Long) {
@@ -1294,6 +1438,10 @@ internal constructor(
if (buffer == null) {
return
}
+ if (dataChannel.label() == DATA_TRACK_DATA_CHANNEL_LABEL) {
+ incomingDataTrackManager.handlePacketReceived(ByteString.copyFrom(buffer.data).toByteArray())
+ return
+ }
var dp = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffer.data))
if (dp.sequence > 0 && dp.participantSid.isNotEmpty()) {
@@ -1396,7 +1544,7 @@ internal constructor(
}
}
- fun sendSyncState(
+ suspend fun sendSyncState(
subscription: LivekitRtc.UpdateSubscription,
publishedTracks: List,
) {
@@ -1429,6 +1577,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 +1596,7 @@ internal constructor(
}
setSubscription(subscription)
addAllPublishTracks(publishedTracks)
+ addAllPublishDataTracks(publishDataTracks)
addAllDataChannels(dataChannelInfos)
addAllDatachannelReceiveStates(dataChannelReceiveStates)
build()
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt
index 9d5c71bf3..d4164a72a 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
@@ -59,7 +63,11 @@ import io.livekit.android.room.participant.ParticipantListener
import io.livekit.android.room.participant.RemoteParticipant
import io.livekit.android.room.participant.RpcHandler
import io.livekit.android.room.participant.VideoTrackPublishDefaults
+import io.livekit.android.room.participant.addDataTrack
+import io.livekit.android.room.participant.detachDataTracks
import io.livekit.android.room.participant.publishTracksInfo
+import io.livekit.android.room.participant.unpublishDataTrack
+import io.livekit.android.room.participant.unpublishDataTracks
import io.livekit.android.room.provisions.LKObjects
import io.livekit.android.room.rpc.RPC_REQUEST_DATA_STREAM_TOPIC
import io.livekit.android.room.rpc.RPC_RESPONSE_DATA_STREAM_TOPIC
@@ -150,6 +158,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 +360,9 @@ constructor(
*/
var reconnectPolicy: ReconnectPolicy by engine::reconnectPolicy
+ /**
+ * The local participant.
+ */
val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = false).apply {
internalListener = this@Room
}
@@ -483,6 +495,7 @@ constructor(
// Setup local participant.
localParticipant.reinitialize(options)
setupLocalParticipantEventHandling()
+ setupIncomingDataTrackEventHandling()
if (roomOptions.e2eeOptions != null) {
e2eeManager = e2EEManagerFactory.create(roomOptions.e2eeOptions.keyProvider).apply {
@@ -789,14 +802,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 +906,14 @@ constructor(
}
}
+ is ParticipantEvent.DataTrackPublished -> eventBus.postEvent(
+ RoomEvent.DataTrackPublished(
+ room = this@Room,
+ participant = it.participant,
+ track = it.track,
+ ),
+ )
+
is ParticipantEvent.TrackStreamStateChanged -> eventBus.postEvent(
RoomEvent.TrackStreamStateChanged(
this@Room,
@@ -1045,7 +1105,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 +1519,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 +1531,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..68e3a3ab5 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
@@ -17,10 +17,12 @@
package io.livekit.android.room
import androidx.annotation.VisibleForTesting
+import com.google.protobuf.InvalidProtocolBufferException
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 +30,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 +65,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 +120,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 +138,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 +277,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 +331,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 +660,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 +740,22 @@ constructor(
}
}
+ /**
+ * Sends a previously encoded [LivekitRtc.SignalRequest] (e.g. from UniFFI data track manager).
+ *
+ * Undecodable bytes are dropped rather than thrown: this runs on a callback from the native
+ * data track managers, so an exception here would unwind through the FFI boundary.
+ */
+ internal fun sendEncodedRequest(requestBytes: ByteArray) {
+ val request = try {
+ LivekitRtc.SignalRequest.parseFrom(requestBytes)
+ } catch (e: InvalidProtocolBufferException) {
+ LKLog.e(e) { "Discarding an encoded signal request that could not be parsed." }
+ return
+ }
+ sendRequest(request)
+ }
+
private fun sendRequestImpl(request: LivekitRtc.SignalRequest) {
LKLog.v { "sending request: $request" }
if (!isConnected || currentWs == null) {
@@ -665,7 +770,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 +796,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 +843,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 +877,7 @@ constructor(
}
LivekitRtc.SignalResponse.MessageCase.UPDATE -> {
- listener?.onParticipantUpdate(response.update.participantsList)
+ listener?.onParticipantUpdate(response.update.participantsList, encoded)
}
LivekitRtc.SignalResponse.MessageCase.TRACK_SUBSCRIBED -> {
@@ -849,7 +955,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 +985,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 +1041,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 +1061,7 @@ constructor(
LKLog.v(Exception()) { "Closing SignalClient: code = $code, reason = $reason" }
isConnected = false
isReconnecting = false
+ failPendingDataBlobRequests()
if (::coroutineScope.isInitialized) {
coroutineScope.close()
}
@@ -955,7 +1095,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 +1110,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 +1178,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..8fd32dfe2
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrame.kt
@@ -0,0 +1,80 @@
+/*
+ * 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 kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+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 [durationSinceTimestamp] 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, 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 durationSinceTimestamp: Duration?
+ get() {
+ val timestamp = userTimestamp ?: return null
+ val elapsed = System.currentTimeMillis() - timestamp
+ return elapsed.takeIf { it >= 0 }?.milliseconds
+ }
+
+ 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..a823b36d4
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrameSender.kt
@@ -0,0 +1,152 @@
+/*
+ * 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).
+ *
+ * Takes ownership of [packets] and of the arrays inside it: they are queued and handed to the
+ * channel as-is rather than copied, so a caller must not retain or mutate them afterwards.
+ * The packets arrive freshly lifted from the native manager, which keeps no reference to them.
+ */
+ 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
+ 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..13b97ab46
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublishOptions.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ *
+ * @param frameFormat Describes the track's frames. Leaving this unset publishes an untyped track.
+ */
+data class DataTrackPublishOptions(
+ val frameFormat: DataTrackFrameFormat? = null,
+) {
+ /**
+ * Declares the frame format inline.
+ *
+ * @param frameEncoding Encoding of the track's frames.
+ * @param schema Schema describing the track's frames.
+ */
+ constructor(
+ frameEncoding: DataTrackFrameEncoding,
+ schema: DataTrackSchemaId? = null,
+ ) : this(DataTrackFrameFormat(frameEncoding, schema))
+}
+
+/**
+ * Describes the frames on a data track.
+ *
+ * A schema always describes frames in a specific encoding, so [frameEncoding] is required
+ * alongside a [schema]. The declared metadata is surfaced to subscribers via [DataTrackInfo].
+ *
+ * Whether a schema's encoding can actually describe frames in [frameEncoding] is checked when the
+ * track is published, surfacing as [DataTrackPublishException.InvalidSchema].
+ *
+ * @param frameEncoding Encoding of the track's frames.
+ * @param schema Schema describing the track's frames.
+ */
+data class DataTrackFrameFormat(
+ val frameEncoding: DataTrackFrameEncoding,
+ val schema: DataTrackSchemaId? = null,
+)
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublisherChannel.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublisherChannel.kt
new file mode 100644
index 000000000..13373d392
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublisherChannel.kt
@@ -0,0 +1,125 @@
+/*
+ * 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.flow
+import io.livekit.android.webrtc.DataChannelManager
+import io.livekit.android.webrtc.peerconnection.RTCThreadToken
+import io.livekit.android.webrtc.peerconnection.executeOnRTCThread
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+import livekit.org.webrtc.DataChannel
+
+/**
+ * Owns the publisher `_data_track` transport: the frame sender that drains into it, the pump that
+ * follows its buffered-amount and state changes, and the readiness wait a publish blocks on.
+ *
+ * The transport is swapped, not recreated, across a full reconnect — [attach] hands over a
+ * replacement [DataChannelManager] for the same session while the frame sender lives on, and
+ * [awaitOpen] re-reads the current manager each pass so an in-flight publish waits for that
+ * replacement instead of failing against a disposed one.
+ *
+ * @suppress
+ */
+internal class DataTrackPublisherChannel(
+ private val rtcThreadToken: RTCThreadToken,
+) {
+ private val frameSender = DataTrackFrameSender()
+ private var channelManager: DataChannelManager? = null
+ private var pumpJob: Job? = null
+
+ /**
+ * Adopts [channelManager] as the transport: same frame sender, new SCTP association. A full
+ * reconnect's replacement arrives unopened; [awaitOpen] callers keep waiting until it hits
+ * [DataChannel.State.OPEN].
+ */
+ fun attach(channelManager: DataChannelManager, scope: CoroutineScope) {
+ this.channelManager = channelManager
+ // Frames queued for the old channel belong to the torn-down transport.
+ frameSender.attach(DataChannelManagerSendChannel(channelManager))
+ pumpJob?.cancel()
+ pumpJob = scope.launch {
+ launch {
+ channelManager::bufferedAmount.flow.collect {
+ pump()
+ }
+ }
+ launch {
+ channelManager::state.flow.collect {
+ pump()
+ }
+ }
+ }
+ }
+
+ /**
+ * Tears down the current transport. A later [attach] revives the sender against the
+ * replacement channel.
+ */
+ fun detach() {
+ pumpJob?.cancel()
+ pumpJob = null
+ frameSender.attach(null)
+ channelManager?.dispose()
+ channelManager = null
+ }
+
+ /**
+ * Queues serialized data-track packets.
+ *
+ * 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].
+ */
+ fun sendPackets(packets: List) {
+ executeOnRTCThread(rtcThreadToken) {
+ frameSender.sendOrQueue(packets)
+ }
+ }
+
+ /**
+ * Waits until the channel is open.
+ *
+ * @param sessionClosed whether the room session itself has ended, which ends the wait instead
+ * of letting it run out the timeout.
+ * @return `true` once open, `false` if the session closed first, `null` on timeout.
+ */
+ suspend fun awaitOpen(timeoutMs: Long, sessionClosed: () -> Boolean): Boolean? =
+ withTimeoutOrNull(timeoutMs) {
+ while (!sessionClosed()) {
+ val manager = channelManager
+ if (manager?.disposed == false && manager.state == DataChannel.State.OPEN) {
+ return@withTimeoutOrNull true
+ }
+ delay(POLL_INTERVAL_MS)
+ }
+ false
+ }
+
+ private fun pump() {
+ executeOnRTCThread(rtcThreadToken) {
+ frameSender.pump()
+ }
+ }
+
+ private companion object {
+ const val POLL_INTERVAL_MS = 50L
+ }
+}
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..2060ff92a
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackStream.kt
@@ -0,0 +1,117 @@
+/*
+ * 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.CloseableCoroutineScope
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.takeWhile
+import io.livekit.uniffi.DataTrackStreamInterface 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.
+ *
+ * Close the stream once you are done with it: the subscription lasts as long as the stream does,
+ * so an unclosed stream leaves the SFU forwarding frames for it.
+ *
+ * ```
+ * remoteTrack.subscribe().onSuccess { stream ->
+ * stream.use { it.flow.collect { frame -> process(frame.payload) } }
+ * }
+ * ```
+ */
+class DataTrackStream internal constructor(
+ private val impl: FfiDataTrackStream,
+ dispatcher: CoroutineDispatcher = Dispatchers.Default,
+) : AutoCloseable {
+
+ private val coroutineScope = CloseableCoroutineScope(dispatcher + SupervisorJob())
+
+ /**
+ * Set once no further frames will arrive, whether because the underlying stream was exhausted
+ * or because [close] was called. Collectors watch this so they complete instead of waiting
+ * for a frame that will never come, including those that arrive afterwards.
+ */
+ private val ended = MutableStateFlow(false)
+
+ /**
+ * 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) }
+ }
+
+ /**
+ * Drains the underlying stream while anyone is collecting [flow], so every collector sees
+ * every frame.
+ *
+ * Emission suspends until every collector has taken the frame, so a slow one holds up the
+ * drain rather than being skipped. While it is held up, frames accumulate in the buffer the
+ * subscription was created with, and once that fills the oldest are dropped for all
+ * collectors at once — see [RemoteDataTrack.subscribe]'s `bufferSize`.
+ */
+ private val sharedFrames: SharedFlow = flow {
+ while (true) {
+ val frame = next() ?: break
+ emit(frame)
+ }
+ ended.value = true
+ }.shareIn(coroutineScope, SharingStarted.WhileSubscribed(), replay = 0)
+
+ /**
+ * A [Flow] of incoming frames. Completes normally when the stream ends.
+ *
+ * Concurrent collectors each receive every frame that arrives while they are collecting;
+ * frames are not replayed to a collector that starts late.
+ */
+ val flow: Flow = merge(
+ sharedFrames,
+ ended.filter { it }.map { null },
+ ).takeWhile { it != null }.filterNotNull()
+
+ /**
+ * Ends this stream and releases it.
+ *
+ * The data track's subscription is dropped once every [DataTrackStream] subscribed
+ * to it has been closed, so other subscribers are unaffected. Leaving a stream
+ * unclosed keeps its subscription alive until the stream is garbage collected.
+ *
+ * Any in-progress collection of [flow] completes.
+ */
+ override fun close() {
+ // Before cancelling the drain: cancelling it cannot complete the collectors, since it is
+ // this flag rather than the drain finishing that ends them.
+ ended.value = true
+ coroutineScope.close()
+ (impl as? AutoCloseable)?.close()
+ }
+}
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..bbffcc9a8
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt
@@ -0,0 +1,201 @@
+/*
+ * 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.android.util.rethrowIfCancellationSignal
+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 var nativeUnavailable = false
+ private val remoteTracks = mutableListOf()
+ private val cryptor = DataTrackCryptor { engineProvider.get().e2EEManager }
+
+ /**
+ * Handles events from the UniFFI remote data track manager.
+ */
+ 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))
+ }
+ }
+ }
+
+ /**
+ * Returns a snapshot of the 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) {
+ val manager = ensureManager() ?: return
+ try {
+ manager.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) {
+ val manager = ensureManager() ?: return
+ try {
+ manager.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) {
+ val manager = ensureManager() ?: return
+ try {
+ manager.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.
+ *
+ * Called on a WebRTC callback thread, so nothing may escape: a throw here takes down the
+ * process rather than surfacing anywhere the app can handle it.
+ */
+ fun handlePacketReceived(packet: ByteArray) {
+ val manager = ensureManager() ?: return
+ try {
+ manager.handlePacketReceived(packet)
+ } catch (e: Exception) {
+ e.rethrowIfCancellationSignal()
+ LKLog.w(e) { "Failed to handle a data track 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()
+ }
+ }
+
+ /**
+ * The UniFFI manager, or `null` if its native library could not be loaded.
+ *
+ * Loading can fail on a device the packaged APK has no ABI for, among other reasons. Data
+ * tracks are then unavailable — but this runs on every connect and on the WebRTC receive
+ * path, so a failure must not fail [io.livekit.android.room.Room.connect] or crash the
+ * process for apps that never publish or subscribe to one. The failure is latched so the
+ * load is not retried per call, and every entry point above degrades to a no-op.
+ */
+ private fun ensureManager(): RemoteDataTrackManagerInterface? {
+ synchronized(lock) {
+ remoteManager?.let { return it }
+ if (nativeUnavailable) {
+ return null
+ }
+ return try {
+ remoteDataTrackManagerFactory.create(delegate, cryptor).also { remoteManager = it }
+ } catch (e: LinkageError) {
+ nativeUnavailable = true
+ LKLog.e(e) { "Data tracks are unavailable: the native library failed to load." }
+ null
+ }
+ }
+ }
+}
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..272e2cdfc
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt
@@ -0,0 +1,185 @@
+/*
+ * 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] or [send].
+ *
+ * The publication stays live until [unpublish] is called, the SFU unpublishes
+ * the track, or the room disconnects. Dropping the last reference eventually
+ * unpublishes the track, but only once it is garbage collected — call
+ * [unpublish] to end the publication at a predictable point, or
+ * [io.livekit.android.room.participant.LocalParticipant.withDataTrack] to scope
+ * one to a block.
+ *
+ * ```
+ * 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..b7d21ae6a
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt
@@ -0,0 +1,200 @@
+/*
+ * 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.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 var nativeUnavailable = false
+ private val cryptor = DataTrackCryptor { engineProvider.get().e2EEManager }
+
+ /**
+ * Handles events from the UniFFI local data track manager.
+ */
+ 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?.frameFormat?.schema?.toFfi(),
+ frameEncoding = options?.frameFormat?.frameEncoding?.toFfi(),
+ )
+ try {
+ engineProvider.get().ensureDataTrackPublisherConnected()
+ } catch (e: DataTrackPublishException) {
+ return Result.failure(e)
+ } catch (e: Exception) {
+ e.rethrowIfCancellationSignal()
+ return Result.failure(
+ DataTrackPublishException.Disconnected(
+ e.message ?: "Lost the connection while establishing the publisher data track channel",
+ e,
+ ),
+ )
+ }
+ val manager = ensureManager()
+ ?: return Result.failure(
+ DataTrackPublishException.Internal(
+ "Data tracks are unavailable: the native library failed to load",
+ ),
+ )
+ return try {
+ Result.success(LocalDataTrack(manager.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
+ }
+ }
+
+ /**
+ * The UniFFI manager, or `null` if its native library could not be loaded — see
+ * [IncomingDataTrackManager]. Reached only from [publishTrack], so the failure surfaces to
+ * the caller as a failed [Result] rather than degrading silently.
+ */
+ private fun ensureManager(): LocalDataTrackManagerInterface? {
+ synchronized(lock) {
+ localManager?.let { return it }
+ if (nativeUnavailable) {
+ return null
+ }
+ // 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 try {
+ localDataTrackManagerFactory.create(delegate, encryptionProvider)
+ .also { localManager = it }
+ } catch (e: LinkageError) {
+ nativeUnavailable = true
+ LKLog.e(e) { "Data tracks are unavailable: the native library failed to load." }
+ null
+ }
+ }
+ }
+}
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..f9083c8bf 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,8 @@ 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.CodingErrorAction
import java.util.Collections
import javax.inject.Named
import kotlin.math.max
@@ -109,6 +117,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 +984,127 @@ internal constructor(
eventBus.postEvent(ParticipantEvent.LocalTrackUnpublished(this, publication), scope)
}
+ /**
+ * Publishes a data track, allowing this participant to send frames to subscribers.
+ *
+ * The publication stays live until [LocalDataTrack.unpublish] is called, the SFU unpublishes
+ * the track, or the room disconnects. Dropping the last reference to the returned track
+ * eventually unpublishes it, but only once it is garbage collected — call
+ * [LocalDataTrack.unpublish] to end the publication at a predictable point, or use
+ * [withDataTrack] to scope it to a block.
+ *
+ * ```
+ * 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(DataTrackFrameEncoding.Json, schema),
+ * )
+ * ```
+ *
+ * @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 +1806,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/RemoteDataTrackCollection.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteDataTrackCollection.kt
new file mode 100644
index 000000000..e1a46cc35
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteDataTrackCollection.kt
@@ -0,0 +1,140 @@
+/*
+ * 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.participant
+
+import io.livekit.android.room.datatrack.DataTrackSid
+import io.livekit.android.room.datatrack.RemoteDataTrack
+import io.livekit.android.util.MutableStateFlowDelegate
+import io.livekit.android.util.flowDelegate
+
+/**
+ * Bookkeeping for the data tracks attached to a [RemoteParticipant].
+ *
+ * Owns the delegate backing [RemoteParticipant.dataTracks], so the participant can expose the
+ * observable property without also owning the mutation logic.
+ */
+internal class RemoteDataTrackCollection(
+ private val onPublished: (RemoteDataTrack) -> Unit,
+ private val onUnpublished: (DataTrackSid) -> Unit,
+) {
+ private val lock = Any()
+
+ /**
+ * Backing delegate for [RemoteParticipant.dataTracks].
+ */
+ val delegate: MutableStateFlowDelegate