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> = flowDelegate(emptyMap()) + + private var tracks: Map by delegate + + /** + * Adds the track, returning `false` if this exact track is already attached. + */ + fun add(track: RemoteDataTrack): Boolean { + val attached = synchronized(lock) { + if (tracks.values.any { it === track }) { + return@synchronized false + } + tracks = tracks + (track.name to track) + true + } + if (attached) { + onPublished(track) + } + return attached + } + + fun remove(sid: DataTrackSid): RemoteDataTrack? { + // `info.sid` is an FFI call; resolve the instance before taking the lock. + val track = tracks.values.firstOrNull { it.info.sid == sid } ?: return null + synchronized(lock) { + if (tracks.values.none { it === track }) { + return null + } + tracks = tracks - track.name + return track + } + } + + /** + * Removes the track and notifies, even if it was not attached (for example after a full + * reconnect detached it). + */ + fun unpublish(sid: DataTrackSid) { + remove(sid) + onUnpublished(sid) + } + + /** + * Unpublishes every attached data track and notifies for each. + * + * @return The SIDs that were unpublished. + */ + fun unpublishAll(): List { + val previous = synchronized(lock) { + tracks.also { tracks = emptyMap() } + } + val sids = previous.values.map { it.info.sid } + for (sid in sids) { + onUnpublished(sid) + } + return sids + } + + /** + * Drops attached data tracks without notifying. + */ + fun detachAll() { + synchronized(lock) { + tracks = emptyMap() + } + } +} + +/** + * Adds the track, returning `false` if this exact track is already attached. + */ +internal fun RemoteParticipant.addDataTrack(track: RemoteDataTrack): Boolean = + dataTrackCollection.add(track) + +internal fun RemoteParticipant.removeDataTrack(sid: DataTrackSid): RemoteDataTrack? = + dataTrackCollection.remove(sid) + +/** + * Removes the track and emits [io.livekit.android.events.ParticipantEvent.DataTrackUnpublished], + * even if it was not attached (for example after a full reconnect detached it). + */ +internal fun RemoteParticipant.unpublishDataTrack(sid: DataTrackSid) { + dataTrackCollection.unpublish(sid) +} + +/** + * Unpublishes every attached data track and emits an unpublish event for each. + * + * @return The SIDs that were unpublished, for the room to emit matching + * [io.livekit.android.events.RoomEvent]s. + */ +internal fun RemoteParticipant.unpublishDataTracks(): List = + dataTrackCollection.unpublishAll() + +/** + * Drops attached data tracks without notifying. Used when the tracks outlive this participant + * object: a full reconnect recreates participants, but the incoming manager keeps its tracks + * and re-attaches them. + */ +internal fun RemoteParticipant.detachDataTracks() { + dataTrackCollection.detachAll() +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt index d80867af2..08b62048f 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt @@ -23,6 +23,7 @@ import io.livekit.android.dagger.InjectionNames import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.room.SignalClient +import io.livekit.android.room.datatrack.RemoteDataTrack import io.livekit.android.room.track.KIND_AUDIO import io.livekit.android.room.track.KIND_VIDEO import io.livekit.android.room.track.RemoteAudioTrack @@ -31,6 +32,7 @@ import io.livekit.android.room.track.RemoteVideoTrack import io.livekit.android.room.track.Track import io.livekit.android.room.track.TrackException import io.livekit.android.util.CloseableCoroutineScope +import io.livekit.android.util.FlowObservable import io.livekit.android.util.LKLog import io.livekit.android.webrtc.RTCStatsGetter import kotlinx.coroutines.CoroutineDispatcher @@ -94,6 +96,34 @@ class RemoteParticipant( } private val coroutineScope = CloseableCoroutineScope(defaultDispatcher + SupervisorJob()) + internal val dataTrackCollection = RemoteDataTrackCollection( + onPublished = { track -> + eventBus.postEvent(ParticipantEvent.DataTrackPublished(this, track), scope) + }, + onUnpublished = { sid -> + eventBus.postEvent(ParticipantEvent.DataTrackUnpublished(this, sid), scope) + }, + ) + + /** + * Data tracks published by this participant, keyed by track name. + * + * Names are the stable identifier: a track's SID rotates when the publisher republishes + * after a full reconnect (the track object itself survives). + * + * ``` + * val track = participant.dataTracks["telemetry"] + * track?.subscribe()?.onSuccess { stream -> + * stream.flow.collect { frame -> process(frame.payload) } + * } + * ``` + * + * Changes can be observed by using [io.livekit.android.util.flow] + */ + @FlowObservable + @get:FlowObservable + val dataTracks: Map by dataTrackCollection.delegate + /** * Get a track publication with the corresponding sid. */ diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt b/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt index 866e48f15..f943b4892 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt @@ -39,23 +39,25 @@ data class TokenRequestOptions( * Optional deployment to target. Leave empty to target the production deployment. */ val agentDeployment: String? = null, + /** + * Attributes to set on the dispatched agent participant. + */ + val agentAttributes: Map? = null, ) /** * Converts a [TokenRequestOptions] to [TokenSourceRequest], a JSON serializable request body. */ fun TokenRequestOptions.toRequest(): TokenSourceRequest { - val agents = if (agentName != null || agentMetadata != null || agentDeployment != null) { - listOf( - RoomAgentDispatch( - agentName = agentName, - metadata = agentMetadata, - deployment = agentDeployment, - ), - ) - } else { - null - } + val dispatch = RoomAgentDispatch( + agentName = agentName, + metadata = agentMetadata, + deployment = agentDeployment, + attributes = agentAttributes, + ) + // Omit the dispatch entirely when the caller set none of its fields; comparing against a + // default instance keeps this correct as fields are added. + val agents = if (dispatch == RoomAgentDispatch()) null else listOf(dispatch) return TokenSourceRequest( roomName = roomName, participantName = participantName, @@ -114,6 +116,10 @@ data class RoomAgentDispatch( * Optional deployment to target. Leave empty to target the production deployment. */ val deployment: String? = null, + /** + * Attributes to set on the dispatched agent participant. + */ + val attributes: Map? = null, ) @SuppressLint("UnsafeOptInUsageError") diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle index 1629569f0..749b42cea 100644 --- a/livekit-android-test/build.gradle +++ b/livekit-android-test/build.gradle @@ -108,6 +108,7 @@ dokkaHtml { dependencies { implementation(project(":livekit-android-sdk")) + implementation libs.livekit.uniffi implementation libs.coroutines.lib implementation libs.kotlinx.serialization.json api libs.okhttp.lib diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt b/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt index d6dcf9c9d..48fff3c17 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,8 @@ import io.livekit.android.test.mock.TestData import io.livekit.android.test.mock.dagger.DaggerTestLiveKitComponent import io.livekit.android.test.mock.dagger.TestCoroutinesModule import io.livekit.android.test.mock.dagger.TestLiveKitComponent +import io.livekit.android.test.mock.room.datatrack.MockLocalDataTrackManagerFactory +import io.livekit.android.test.mock.room.datatrack.MockRemoteDataTrackManagerFactory import io.livekit.android.util.flow import io.livekit.android.util.toOkioByteString import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -51,6 +53,8 @@ abstract class MockE2ETest : BaseTest() { lateinit var context: Context lateinit var room: Room lateinit var wsFactory: MockWebSocketFactory + lateinit var localDataTrackManagerFactory: MockLocalDataTrackManagerFactory + lateinit var remoteDataTrackManagerFactory: MockRemoteDataTrackManagerFactory @Before fun mocksSetup() { @@ -65,6 +69,8 @@ abstract class MockE2ETest : BaseTest() { enableMetrics = false } wsFactory = component.websocketFactory() + localDataTrackManagerFactory = component.localDataTrackManagerFactory() + remoteDataTrackManagerFactory = component.remoteDataTrackManagerFactory() } @After diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt index c5137cf94..460902906 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt index e3f7f29b2..d31d617cc 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,8 @@ import io.livekit.android.dagger.MemoryModule import io.livekit.android.room.RTCEngine import io.livekit.android.test.mock.MockNetworkCallbackRegistry import io.livekit.android.test.mock.MockWebSocketFactory +import io.livekit.android.test.mock.room.datatrack.MockLocalDataTrackManagerFactory +import io.livekit.android.test.mock.room.datatrack.MockRemoteDataTrackManagerFactory import javax.inject.Singleton @Singleton @@ -48,6 +50,10 @@ interface TestLiveKitComponent : LiveKitComponent { fun networkCallbackRegistry(): MockNetworkCallbackRegistry + fun localDataTrackManagerFactory(): MockLocalDataTrackManagerFactory + + fun remoteDataTrackManagerFactory(): MockRemoteDataTrackManagerFactory + @Component.Factory interface Factory { fun create( diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt index 0ed771c7e..8929c6b73 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,10 +30,14 @@ import io.livekit.android.dagger.CapabilitiesGetter import io.livekit.android.dagger.InjectionNames import io.livekit.android.e2ee.DataPacketCryptorManager import io.livekit.android.e2ee.KeyProvider +import io.livekit.android.room.datatrack.LocalDataTrackManagerFactory +import io.livekit.android.room.datatrack.RemoteDataTrackManagerFactory import io.livekit.android.test.mock.MockAudioDeviceModule import io.livekit.android.test.mock.MockAudioProcessingController import io.livekit.android.test.mock.MockEglBase import io.livekit.android.test.mock.e2ee.ReversingDataPacketCryptorManager +import io.livekit.android.test.mock.room.datatrack.MockLocalDataTrackManagerFactory +import io.livekit.android.test.mock.room.datatrack.MockRemoteDataTrackManagerFactory import io.livekit.android.webrtc.PeerConnectionFactoryManager import io.livekit.android.webrtc.peerconnection.RTCThreadToken import livekit.org.webrtc.EglBase @@ -138,4 +142,28 @@ object TestRTCModule { return ReversingDataPacketCryptorManager() } } + + @Provides + @Singleton + fun mockLocalDataTrackManagerFactory(): MockLocalDataTrackManagerFactory { + return MockLocalDataTrackManagerFactory() + } + + @Provides + @Singleton + fun localDataTrackManagerFactory( + factory: MockLocalDataTrackManagerFactory, + ): LocalDataTrackManagerFactory = factory + + @Provides + @Singleton + fun mockRemoteDataTrackManagerFactory(): MockRemoteDataTrackManagerFactory { + return MockRemoteDataTrackManagerFactory() + } + + @Provides + @Singleton + fun remoteDataTrackManagerFactory( + factory: MockRemoteDataTrackManagerFactory, + ): RemoteDataTrackManagerFactory = factory } diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt new file mode 100644 index 000000000..86c5ca9d7 --- /dev/null +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.test.mock.room.datatrack + +import io.livekit.android.room.datatrack.LocalDataTrackManagerFactory +import io.livekit.uniffi.DataTrackFrame +import io.livekit.uniffi.DataTrackInfo +import io.livekit.uniffi.DataTrackOptions +import io.livekit.uniffi.LocalDataTrack +import io.livekit.uniffi.LocalDataTrackManagerDelegate +import io.livekit.uniffi.LocalDataTrackManagerInterface +import io.livekit.uniffi.NoHandle +import livekit.LivekitModels +import livekit.LivekitRtc +import uniffi.livekit_datatrack.EncryptionProvider +import uniffi.livekit_datatrack.PublishException + +class MockLocalDataTrackManagerFactory : LocalDataTrackManagerFactory { + /** + * The most recently created manager. + */ + lateinit var manager: MockLocalDataTrackManager + + /** + * Encryption provider passed into the last [create] call. + */ + var lastEncryptionProvider: EncryptionProvider? = null + private set + + /** + * When set, [create] throws it instead of returning a manager, standing in for a native + * library that fails to load. + */ + var createError: LinkageError? = null + + /** + * When set, every manager this creates fails [MockLocalDataTrackManager.publishTrack] with it, + * standing in for a publish the native core rejects. + */ + var publishError: PublishException? = null + + override fun create( + delegate: LocalDataTrackManagerDelegate, + encryptionProvider: EncryptionProvider?, + ): LocalDataTrackManagerInterface { + createError?.let { throw it } + lastEncryptionProvider = encryptionProvider + return MockLocalDataTrackManager(delegate).also { + it.publishError = publishError + manager = it + } + } +} + +class MockLocalDataTrackManager( + val delegate: LocalDataTrackManagerDelegate, +) : LocalDataTrackManagerInterface, AutoCloseable { + val publishedTracks = mutableListOf() + val handledPublishResponses = mutableListOf() + val handledRequestResponses = mutableListOf() + var closed = false + private set + + override fun handleSfuPublishResponse(res: ByteArray) { + handledPublishResponses.add(res) + } + + override fun handleSfuRequestResponse(res: ByteArray) { + handledRequestResponses.add(res) + } + + override suspend fun publishResponsesForSyncState(): List { + return publishedTracks.filter { it.isPublished() }.map { track -> + LivekitRtc.PublishDataTrackResponse.newBuilder() + .setInfo( + LivekitModels.DataTrackInfo.newBuilder() + .setSid(track.info().sid) + .setName(track.info().name) + .build(), + ) + .build() + .toByteArray() + } + } + + /** Set to make [publishTrack] fail the way the native manager does. */ + var publishError: PublishException? = null + + override suspend fun publishTrack(options: DataTrackOptions): LocalDataTrack { + publishError?.let { throw it } + val request = LivekitRtc.SignalRequest.newBuilder() + .setPublishDataTrackRequest( + LivekitRtc.PublishDataTrackRequest.newBuilder() + .setName(options.name) + .build(), + ) + .build() + .toByteArray() + delegate.onSignalRequest(request) + return MockFfiLocalDataTrack(name = options.name).also { publishedTracks.add(it) } + } + + var republishTracksCount = 0 + private set + + override fun republishTracks() { + republishTracksCount++ + } + + override fun close() { + closed = true + publishedTracks.forEach { it.unpublish() } + } +} + +/** + * UniFFI [LocalDataTrack] stand-in that does not touch native code. + */ +class MockFfiLocalDataTrack( + name: String, + sid: String = "DT_mock", +) : LocalDataTrack(NoHandle) { + private var published = true + private val trackInfo = DataTrackInfo( + sid = sid, + name = name, + usesE2ee = false, + schema = null, + frameEncoding = null, + ) + val pushedFrames = mutableListOf() + + override fun info(): DataTrackInfo = trackInfo + + override fun isPublished(): Boolean = published + + override fun tryPush(frame: DataTrackFrame) { + pushedFrames.add(frame) + } + + override fun unpublish() { + published = false + } + + override suspend fun waitForUnpublish() {} +} diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt new file mode 100644 index 000000000..1f4f74943 --- /dev/null +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.test.mock.room.datatrack + +import io.livekit.android.room.datatrack.RemoteDataTrackManagerFactory +import io.livekit.uniffi.DataTrackInfo +import io.livekit.uniffi.DataTrackStream +import io.livekit.uniffi.DataTrackSubscribeOptions +import io.livekit.uniffi.NoHandle +import io.livekit.uniffi.RemoteDataTrack +import io.livekit.uniffi.RemoteDataTrackManagerDelegate +import io.livekit.uniffi.RemoteDataTrackManagerInterface +import uniffi.livekit_datatrack.DecryptionProvider + +class MockRemoteDataTrackManagerFactory : RemoteDataTrackManagerFactory { + /** + * The most recently created manager. + */ + lateinit var manager: MockRemoteDataTrackManager + + /** + * Decryption provider passed into the last [create] call. + */ + var lastDecryptionProvider: DecryptionProvider? = null + private set + + /** + * When set, [create] throws it instead of returning a manager, standing in for a native + * library that fails to load. + */ + var createError: LinkageError? = null + + override fun create( + delegate: RemoteDataTrackManagerDelegate, + decryptionProvider: DecryptionProvider?, + ): RemoteDataTrackManagerInterface { + createError?.let { throw it } + lastDecryptionProvider = decryptionProvider + return MockRemoteDataTrackManager(delegate).also { manager = it } + } +} + +class MockRemoteDataTrackManager( + val delegate: RemoteDataTrackManagerDelegate, +) : RemoteDataTrackManagerInterface, AutoCloseable { + val handledJoinResponses = mutableListOf() + val handledParticipantUpdates = mutableListOf() + val handledSubscriberHandles = mutableListOf() + val handledPackets = mutableListOf() + var closed = false + private set + var resendSubscriptionUpdatesCount = 0 + private set + + override fun handlePacketReceived(packet: ByteArray) { + handledPackets.add(packet) + } + + override fun handleSfuJoinResponse(res: ByteArray) { + handledJoinResponses.add(res) + } + + override fun handleSfuParticipantUpdate(res: ByteArray, localParticipantIdentity: String) { + handledParticipantUpdates.add(res) + } + + override fun handleSubscriberHandles(res: ByteArray) { + handledSubscriberHandles.add(res) + } + + override fun resendSubscriptionUpdates() { + resendSubscriptionUpdatesCount++ + } + + /** + * Fires [RemoteDataTrackManagerDelegate.onTrackPublished] as the UniFFI manager would. + */ + fun simulateTrackPublished( + name: String, + publisherIdentity: String, + sid: String = "DT_mock", + ): MockFfiRemoteDataTrack { + val track = MockFfiRemoteDataTrack( + name = name, + publisherIdentity = publisherIdentity, + sid = sid, + ) + delegate.onTrackPublished(track) + return track + } + + /** + * Fires [RemoteDataTrackManagerDelegate.onTrackUnpublished] as the UniFFI manager would. + */ + fun simulateTrackUnpublished(sid: String) { + delegate.onTrackUnpublished(sid) + } + + override fun close() { + closed = true + } +} + +/** + * UniFFI [RemoteDataTrack] stand-in that does not touch native code. + */ +class MockFfiRemoteDataTrack( + name: String, + publisherIdentity: String, + sid: String = "DT_mock", +) : RemoteDataTrack(NoHandle) { + private val trackInfo = DataTrackInfo( + sid = sid, + name = name, + usesE2ee = false, + schema = null, + frameEncoding = null, + ) + private val identity = publisherIdentity + + override fun info(): DataTrackInfo = trackInfo + + override fun isPublished(): Boolean = true + + override fun publisherIdentity(): String = identity + + override suspend fun subscribe(): DataTrackStream { + throw UnsupportedOperationException("subscribe is not supported in tests") + } + + override suspend fun subscribeWithOptions(options: DataTrackSubscribeOptions): DataTrackStream { + throw UnsupportedOperationException("subscribe is not supported in tests") + } + + override suspend fun waitForUnpublish() {} +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/e2ee/DataTrackCryptorTest.kt b/livekit-android-test/src/test/java/io/livekit/android/e2ee/DataTrackCryptorTest.kt new file mode 100644 index 000000000..f4bf24d1c --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/e2ee/DataTrackCryptorTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.e2ee + +import io.livekit.android.test.BaseTest +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import uniffi.livekit_datatrack.DecryptionException +import uniffi.livekit_datatrack.EncryptedPayload +import uniffi.livekit_datatrack.EncryptionException + +class DataTrackCryptorTest : BaseTest() { + + @Test + fun encryptThrowsWhenThereIsNoE2eeManager() { + val cryptor = DataTrackCryptor { null } + try { + cryptor.encrypt(byteArrayOf(1, 2, 3)) + fail("expected EncryptionException.Failed") + } catch (e: EncryptionException.Failed) { + assertTrue(e.message!!.contains("E2EE manager")) + } + } + + @Test + fun decryptThrowsWhenThereIsNoE2eeManager() { + val cryptor = DataTrackCryptor { null } + try { + cryptor.decrypt( + EncryptedPayload( + payload = byteArrayOf(1), + iv = byteArrayOf(2), + keyIndex = 0u, + ), + "sender", + ) + fail("expected DecryptionException.Failed") + } catch (e: DecryptionException.Failed) { + assertTrue(e.message!!.contains("E2EE manager")) + } + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt index 9a71cb630..cde24d5f2 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt @@ -30,6 +30,8 @@ import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.memory.CloseableManager import io.livekit.android.room.datastream.incoming.IncomingDataStreamManagerImpl +import io.livekit.android.room.datatrack.IncomingDataTrackEvent +import io.livekit.android.room.datatrack.IncomingDataTrackManager import io.livekit.android.room.network.NetworkCallbackManagerImpl import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.test.assert.assertIsClassList @@ -91,6 +93,9 @@ class RoomTest { @Mock lateinit var regionUrlProviderFactory: RegionUrlProvider.Factory + @Mock + lateinit var incomingDataTrackManager: IncomingDataTrackManager + lateinit var networkCallbackRegistry: MockNetworkCallbackRegistry var eglBase: EglBase = MockEglBase() @@ -114,6 +119,12 @@ class RoomTest { fun setup() { context = ApplicationProvider.getApplicationContext() networkCallbackRegistry = MockNetworkCallbackRegistry() + whenever(incomingDataTrackManager.events).thenReturn( + object : EventListenable { + override val events: SharedFlow = MutableSharedFlow() + }, + ) + whenever(incomingDataTrackManager.snapshotRemoteTracks()).thenReturn(emptyList()) room = Room( context = context, engine = rtcEngine, @@ -136,6 +147,7 @@ class RoomTest { connectionWarmer = MockConnectionWarmer(), audioRecordPrewarmer = NoAudioRecordPrewarmer(), incomingDataStreamManager = IncomingDataStreamManagerImpl(), + incomingDataTrackManager = incomingDataTrackManager, rpcClientManager = io.livekit.android.room.rpc.RpcClientManager( engine = rtcEngine, outgoingDataStreamManager = Mockito.mock(io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager::class.java), diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt index a10dfe3fa..0e09f1ec5 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt @@ -16,6 +16,9 @@ package io.livekit.android.room +import io.livekit.android.room.datatrack.DataTrackSchemaEncoding +import io.livekit.android.room.datatrack.DataTrackSchemaException +import io.livekit.android.room.datatrack.DataTrackSchemaId import io.livekit.android.stats.NetworkInfo import io.livekit.android.stats.NetworkType import io.livekit.android.test.BaseTest @@ -41,6 +44,7 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.WebSocketListener +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -54,6 +58,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argThat import org.mockito.kotlin.never import org.mockito.kotlin.times +import com.google.protobuf.ByteString as PbByteString @ExperimentalCoroutinesApi class SignalClientTest : BaseTest() { @@ -125,6 +130,7 @@ class SignalClientTest : BaseTest() { val response = job.await() assertEquals(true, client.isConnected) assertEquals(response, JOIN.join) + assertArrayEquals(JOIN.toByteArray(), client.lastJoinEncoded) } @Test @@ -501,6 +507,137 @@ class SignalClientTest : BaseTest() { } } + @Test + fun storeDataBlobSucceeds() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + + val key = DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema).blobKey + val storeJob = async { client.sendStoreDataBlob(key, "{}".toByteArray()) } + yield() + + val sent = LivekitRtc.SignalRequest.parseFrom(wsFactory.ws.sentRequests.last().toPBByteString()) + assertTrue(sent.hasStoreDataBlobRequest()) + val requestId = sent.storeDataBlobRequest.requestId + + client.onMessage( + wsFactory.ws, + LivekitRtc.SignalResponse.newBuilder() + .setStoreDataBlobResponse( + LivekitRtc.StoreDataBlobResponse.newBuilder() + .setRequestId(requestId) + .setKey(key), + ) + .build() + .toOkioByteString(), + ) + + assertTrue(storeJob.await().isSuccess) + } + + @Test + fun sendEncodedRequestForwardsValidBytes() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + val before = wsFactory.ws.sentRequests.size + + val encoded = LivekitRtc.SignalRequest.newBuilder() + .setPing(1234) + .build() + .toByteArray() + client.sendEncodedRequest(encoded) + + assertEquals(before + 1, wsFactory.ws.sentRequests.size) + val sent = LivekitRtc.SignalRequest.parseFrom(wsFactory.ws.sentRequests.last().toPBByteString()) + assertEquals(1234L, sent.ping) + } + + /** + * The native data track managers call this back across the FFI boundary, where a thrown + * parse failure would unwind into Rust. + */ + @Test + fun sendEncodedRequestDropsUnparseableBytes() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + val before = wsFactory.ws.sentRequests.size + + client.sendEncodedRequest(byteArrayOf(-1, -1, -1, -1, -1, -1)) + + assertEquals(before, wsFactory.ws.sentRequests.size) + } + + @Test + fun getDataBlobReturnsContents() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + + val key = DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema).blobKey + val contents = """{"type":"object"}""".toByteArray() + val getJob = async { client.sendGetDataBlob(key, "publisher") } + yield() + + val sent = LivekitRtc.SignalRequest.parseFrom(wsFactory.ws.sentRequests.last().toPBByteString()) + assertTrue(sent.hasGetDataBlobRequest()) + assertEquals("publisher", sent.getDataBlobRequest.participantIdentity) + + client.onMessage( + wsFactory.ws, + LivekitRtc.SignalResponse.newBuilder() + .setGetDataBlobResponse( + LivekitRtc.GetDataBlobResponse.newBuilder() + .setRequestId(sent.getDataBlobRequest.requestId) + .setBlob( + LivekitModels.DataBlob.newBuilder() + .setKey(key) + .setContents(PbByteString.copyFrom(contents)), + ), + ) + .build() + .toOkioByteString(), + ) + + assertArrayEquals(contents, getJob.await().getOrThrow()) + } + + @Test + fun dataBlobRequestFailureCompletesWithRejected() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + + val key = DataTrackSchemaId("missing.v1", DataTrackSchemaEncoding.Protobuf).blobKey + val getJob = async { client.sendGetDataBlob(key, "publisher") } + yield() + + val sent = LivekitRtc.SignalRequest.parseFrom(wsFactory.ws.sentRequests.last().toPBByteString()) + client.onMessage( + wsFactory.ws, + LivekitRtc.SignalResponse.newBuilder() + .setRequestResponse( + LivekitRtc.RequestResponse.newBuilder() + .setRequestId(sent.getDataBlobRequest.requestId) + .setReason(LivekitRtc.RequestResponse.Reason.NOT_FOUND) + .setMessage("not found"), + ) + .build() + .toOkioByteString(), + ) + + val error = getJob.await().exceptionOrNull() + assertTrue(error is DataTrackSchemaException.Rejected) + assertEquals("not found", error?.message) + } + // mock data companion object } diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackFrameSenderTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackFrameSenderTest.kt new file mode 100644 index 000000000..7bffb1cf5 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackFrameSenderTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.BaseTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +private class FakeSendChannel : DataTrackSendChannel { + override var bufferedAmount: Long = 0 + override var isOpen = true + var acceptsSends = true + val sent = mutableListOf() + + override fun send(packet: ByteArray): Boolean { + if (!acceptsSends) { + return false + } + sent.add(packet) + bufferedAmount += packet.size + return true + } + + /** Simulates the transport flushing its buffer (the trigger for a buffered-amount callback). */ + fun drain() { + bufferedAmount = 0 + } +} + +/** + * Pins the outbound drain's semantics, which are deliberately aligned (and deliberately not) + * with the other SDKs: + * + * - **rust-sdks** (`DataChannelSender`): the same design — drop-oldest with a capacity-one frame + * queue, whole-frame atomicity, packets metered on buffered-amount events with an 8 KiB + * low-water mark. These tests mirror its invariants. + * - **client-sdk-js** (`LossyDataChannel` with `bufferFullBehavior: 'wait'`): shares the + * whole-frame atomicity and watermark pacing, but blocks the producer under load instead of + * dropping — its engine awaits sends, so overload backpressures the frame producer. Android's + * producer is a fire-and-forget FFI callback with no backpressure channel, so freshest-wins + * eviction is used instead (as in rust-sdks / Swift). + */ +class DataTrackFrameSenderTest : BaseTest() { + + private lateinit var channel: FakeSendChannel + private lateinit var sender: DataTrackFrameSender + + @Before + fun setUpSender() { + channel = FakeSendChannel() + sender = DataTrackFrameSender() + sender.attach(channel) + } + + @Test + fun sendsImmediatelyWithHeadroom() { + sender.sendOrQueue(frame(1, packets = 3)) + assertEquals(3, channel.sent.size) + } + + /** + * The whole frame goes out even when it is far larger than the buffer headroom: packets are + * metered per drain instead of dumped, so there is no sender-imposed max frame size. + */ + @Test + fun largeFrameStreamsWithinHeadroom() { + val packetSize = 64000 + sender.sendOrQueue(frame(1, packets = 50, packetSize = packetSize)) + var pumps = 0 + while (channel.sent.size < 50 && pumps < 100) { + // Each drain admits exactly one over-watermark packet, so the buffer never holds more + // than one packet beyond the low-water mark. + assertTrue(channel.bufferedAmount <= DataTrackFrameSender.LOW_WATER_MARK + packetSize) + channel.drain() + sender.pump() + pumps++ + } + assertEquals(50, channel.sent.size) + } + + /** + * A newer frame evicts the queued (not yet started) one — freshest wins. + */ + @Test + fun dropsOldestQueuedFrame() { + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + sender.sendOrQueue(frame(1)) + sender.sendOrQueue(frame(2)) + assertTrue(channel.sent.isEmpty()) + + channel.drain() + sender.pump() + assertEquals(listOf(2.toByte()), channel.sent.map { it.first() }) + } + + /** + * An in-flight frame is never abandoned mid-send: its remaining packets go out before a + * newer frame, and packets of two frames never interleave. + */ + @Test + fun inFlightFrameCompletesBeforeNewerFrame() { + sender.sendOrQueue(frame(1, packets = 3, packetSize = 64000)) + assertEquals(1, channel.sent.size) + + sender.sendOrQueue(frame(2, packets = 2, packetSize = 64000)) + while (channel.sent.size < 5) { + channel.drain() + sender.pump() + } + assertEquals( + listOf(1.toByte(), 1.toByte(), 1.toByte(), 2.toByte(), 2.toByte()), + channel.sent.map { it.first() }, + ) + } + + /** + * Attaching a channel drops frames queued for the previous one (stale frames belong to a + * dead transport). + */ + @Test + fun attachClearsQueuedFrames() { + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + sender.sendOrQueue(frame(1)) + + val newChannel = FakeSendChannel() + sender.attach(newChannel) + sender.pump() + assertTrue(newChannel.sent.isEmpty()) + + sender.sendOrQueue(frame(2)) + assertEquals(listOf(2.toByte()), newChannel.sent.map { it.first() }) + } + + /** A rejected send drops the rest of the frame without wedging the pump. */ + @Test + fun rejectedSendDropsFrameOnly() { + channel.acceptsSends = false + sender.sendOrQueue(frame(1, packets = 3)) + assertTrue(channel.sent.isEmpty()) + + channel.acceptsSends = true + sender.sendOrQueue(frame(2)) + assertEquals(listOf(2.toByte()), channel.sent.map { it.first() }) + } + + /** An empty packet batch must not evict a queued frame. */ + @Test + fun emptyBatchIsIgnored() { + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + sender.sendOrQueue(frame(1)) + sender.sendOrQueue(emptyList()) + + channel.drain() + sender.pump() + assertEquals(listOf(1.toByte()), channel.sent.map { it.first() }) + } + + /** Nothing is sent while the channel is closed; opening drains the queue. */ + @Test + fun queuedFrameDrainsOnceOpen() { + channel.isOpen = false + sender.sendOrQueue(frame(1)) + assertTrue(channel.sent.isEmpty()) + + channel.isOpen = true + sender.pump() + assertEquals(listOf(1.toByte()), channel.sent.map { it.first() }) + } + + companion object { + /** One packet per frame, tagged for identification. */ + private fun frame(tag: Byte, packets: Int = 1, packetSize: Int = 100): List = + List(packets) { ByteArray(packetSize) { tag } } + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaMockE2ETest.kt new file mode 100644 index 000000000..4b7a24d14 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaMockE2ETest.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.MockE2ETest +import org.junit.Assert.assertTrue +import org.junit.Test + +class DataTrackSchemaMockE2ETest : MockE2ETest() { + + @Test + fun defineSchemaFailsWhenDisconnected() = runTest { + val result = room.localParticipant.defineSchema( + DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema), + "{}", + ) + assertTrue(result.exceptionOrNull() is DataTrackSchemaException.Disconnected) + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaTest.kt new file mode 100644 index 000000000..c36cefd46 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.BaseTest +import livekit.LivekitModels.DataTrackSchemaEncoding.WellKnownSchemaEncoding +import org.junit.Assert.assertEquals +import org.junit.Test + +class DataTrackSchemaTest : BaseTest() { + + @Test + fun blobKeyCarriesNameAndWellKnownEncoding() { + val schema = DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema) + val key = schema.blobKey + + assertEquals("reading.v1", key.schemaId.name) + assertEquals( + WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA, + key.schemaId.encoding.wellKnown, + ) + } + + @Test + fun blobKeyUsesCustomFieldForCustomEncoding() { + val schema = DataTrackSchemaId("x", DataTrackSchemaEncoding.Custom("myenc")) + assertEquals("myenc", schema.blobKey.schemaId.encoding.custom) + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackStreamTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackStreamTest.kt new file mode 100644 index 000000000..210f62ff9 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackStreamTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.BaseTest +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import io.livekit.uniffi.DataTrackFrame as FfiDataTrackFrame +import io.livekit.uniffi.DataTrackStreamInterface as FfiDataTrackStream + +/** + * A stand-in for the UniFFI stream, so frame delivery can be driven from the test rather than + * from a live subscription. + */ +private class FakeFfiStream : FfiDataTrackStream { + private val frames = Channel(Channel.UNLIMITED) + + override suspend fun next(): FfiDataTrackFrame? = frames.receiveCatching().getOrNull() + + fun offer(payload: Int) { + frames.trySend(FfiDataTrackFrame(payload = byteArrayOf(payload.toByte()), userTimestamp = null)) + } + + /** Ends the stream, the way an unpublish or a cancelled subscription would. */ + fun end() { + frames.close() + } +} + +class DataTrackStreamTest : BaseTest() { + + private fun payloads(frames: List) = frames.map { it.payload.single().toInt() } + + @Test + fun concurrentCollectorsEachReceiveEveryFrame() = runTest { + val fake = FakeFfiStream() + val stream = DataTrackStream(fake, UnconfinedTestDispatcher(testScheduler)) + val first = mutableListOf() + val second = mutableListOf() + + // Unconfined so both collectors are subscribed before any frame is offered. + val firstJob = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { first.add(it) } + } + val secondJob = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { second.add(it) } + } + + fake.offer(1) + fake.offer(2) + fake.offer(3) + fake.end() + advanceUntilIdle() + firstJob.join() + secondJob.join() + + assertEquals(listOf(1, 2, 3), payloads(first)) + assertEquals(listOf(1, 2, 3), payloads(second)) + } + + @Test + fun collectorCompletesWhenStreamEnds() = runTest { + val fake = FakeFfiStream() + val stream = DataTrackStream(fake, UnconfinedTestDispatcher(testScheduler)) + val received = mutableListOf() + + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { received.add(it) } + } + fake.offer(1) + fake.end() + advanceUntilIdle() + job.join() + + assertEquals(listOf(1), payloads(received)) + } + + @Test + fun aLaterCollectorResumesTheDrainAfterAnEarlierOneStopped() = runTest { + val fake = FakeFfiStream() + val stream = DataTrackStream(fake, UnconfinedTestDispatcher(testScheduler)) + val first = mutableListOf() + val second = mutableListOf() + + val firstJob = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { first.add(it) } + } + fake.offer(1) + advanceUntilIdle() + // Drops the subscriber count to zero, which cancels the drain mid-`next()`. + firstJob.cancelAndJoin() + + val secondJob = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { second.add(it) } + } + fake.offer(2) + fake.end() + advanceUntilIdle() + secondJob.join() + + assertEquals(listOf(1), payloads(first)) + // Empty here would mean the cancelled drain had wrongly marked the stream ended. + assertEquals(listOf(2), payloads(second)) + } + + @Test + fun closingWhileCollectingCompletesTheCollector() = runTest { + val fake = FakeFfiStream() + val stream = DataTrackStream(fake, UnconfinedTestDispatcher(testScheduler)) + val received = mutableListOf() + + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { received.add(it) } + } + fake.offer(1) + advanceUntilIdle() + stream.close() + advanceUntilIdle() + + assertEquals(listOf(1), payloads(received)) + assertTrue("collector should have completed after close()", job.isCompleted) + } + + @Test + fun collectingAfterTheStreamEndedCompletesImmediately() = runTest { + val fake = FakeFfiStream() + val stream = DataTrackStream(fake, UnconfinedTestDispatcher(testScheduler)) + + val job = launch(UnconfinedTestDispatcher(testScheduler)) { stream.flow.collect { } } + fake.end() + advanceUntilIdle() + job.join() + + // Would hang if termination depended on a signal the late collector had already missed. + val late = mutableListOf() + stream.flow.collect { late.add(it) } + + assertEquals(emptyList(), payloads(late)) + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt new file mode 100644 index 000000000..465b7162e --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt @@ -0,0 +1,362 @@ +/* + * 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.events.ParticipantEvent +import io.livekit.android.events.RoomEvent +import io.livekit.android.room.RTCEngine +import io.livekit.android.room.ReconnectType +import io.livekit.android.room.Room +import io.livekit.android.room.SignalClient +import io.livekit.android.room.participant.Participant +import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.assert.assertIsClass +import io.livekit.android.test.events.EventCollector +import io.livekit.android.test.mock.MockDataChannel +import io.livekit.android.test.mock.SignalRequestHandler +import io.livekit.android.test.mock.TestData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import livekit.LivekitRtc +import livekit.org.webrtc.DataChannel +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer + +@OptIn(ExperimentalCoroutinesApi::class) +class IncomingDataTrackManagerMockE2ETest : MockE2ETest() { + + @Test + fun subscriberDataTrackChannelForwardsPacketsToIncomingManager() = runTest { + connect() + val channel = openSubscriberDataTrackChannel() + val payload = byteArrayOf(9, 8, 7) + receiveDataTrackPacket(channel, payload) + + val packets = remoteDataTrackManagerFactory.manager.handledPackets + assertEquals(1, packets.size) + assertArrayEquals(payload, packets.single()) + } + + @Test + fun fullReconnectForwardsPacketsOnReplacementSubscriberDataTrackChannel() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + val original = openSubscriberDataTrackChannel() + receiveDataTrackPacket(original, byteArrayOf(1)) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val remote = remoteDataTrackManagerFactory.manager + assertFalse(remote.closed) + + val replacement = openSubscriberDataTrackChannel() + assertNotSame(original, replacement) + receiveDataTrackPacket(replacement, byteArrayOf(2)) + + assertEquals(2, remote.handledPackets.size) + assertArrayEquals(byteArrayOf(1), remote.handledPackets[0]) + assertArrayEquals(byteArrayOf(2), remote.handledPackets[1]) + } + + @Test + fun connectForwardsJoinToInjectedRemoteManager() = runTest { + connect() + + assertEquals(Room.State.CONNECTED, room.state) + val remote = remoteDataTrackManagerFactory.manager + assertTrue(remote.handledJoinResponses.isNotEmpty()) + assertArrayEquals(TestData.JOIN.toByteArray(), remote.handledJoinResponses.first()) + } + + @Test + fun incomingDataTrackAlwaysReceivesDecryptionProvider() = runTest { + connect() + assertNotNull(remoteDataTrackManagerFactory.lastDecryptionProvider) + } + + @Test + fun remoteDataTrackPublishedAttachesToParticipant() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + assertArrayEquals( + TestData.PARTICIPANT_JOIN.toByteArray(), + remoteDataTrackManagerFactory.manager.handledParticipantUpdates.last(), + ) + + val participant = remoteParticipant() + val roomCollector = EventCollector(room.events, coroutineRule.scope) + val participantCollector = EventCollector(participant.events, coroutineRule.scope) + + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_test", + ) + advanceUntilIdle() + + val attached = participant.dataTracks["telemetry"] + assertNotNull(attached) + assertEquals("telemetry", attached!!.name) + assertEquals(DataTrackSid("DT_test"), attached.info.sid) + + val roomEvents = roomCollector.stopCollecting() + val participantEvents = participantCollector.stopCollecting() + + assertEquals(1, roomEvents.size) + assertIsClass(RoomEvent.DataTrackPublished::class.java, roomEvents.first()) + val roomEvent = roomEvents.first() as RoomEvent.DataTrackPublished + assertEquals(participant, roomEvent.participant) + assertEquals(attached, roomEvent.track) + + assertEquals(1, participantEvents.size) + assertIsClass(ParticipantEvent.DataTrackPublished::class.java, participantEvents.first()) + } + + @Test + fun remoteDataTrackPublishedBeforeParticipantIsParkedThenAttached() = runTest { + connect() + + val parkedCollector = EventCollector(room.events, coroutineRule.scope) + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "parked", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_parked", + ) + advanceUntilIdle() + + assertTrue(room.remoteParticipants.isEmpty()) + assertTrue(parkedCollector.stopCollecting().none { it is RoomEvent.DataTrackPublished }) + + val attachedCollector = EventCollector(room.events, coroutineRule.scope) + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + val participant = remoteParticipant() + val attached = participant.dataTracks["parked"] + assertNotNull(attached) + assertEquals("parked", attached!!.name) + + val events = attachedCollector.stopCollecting() + assertTrue(events.any { it is RoomEvent.DataTrackPublished }) + } + + @Test + fun remoteDataTrackUnpublishedRemovesFromParticipant() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + val participant = remoteParticipant() + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_unpub", + ) + advanceUntilIdle() + assertNotNull(participant.dataTracks["telemetry"]) + + val roomCollector = EventCollector(room.events, coroutineRule.scope) + val participantCollector = EventCollector(participant.events, coroutineRule.scope) + + remoteDataTrackManagerFactory.manager.simulateTrackUnpublished("DT_unpub") + advanceUntilIdle() + + assertNull(participant.dataTracks["telemetry"]) + + val roomEvents = roomCollector.stopCollecting() + val participantEvents = participantCollector.stopCollecting() + + assertEquals(1, roomEvents.size) + assertIsClass(RoomEvent.DataTrackUnpublished::class.java, roomEvents.first()) + assertEquals(DataTrackSid("DT_unpub"), (roomEvents.first() as RoomEvent.DataTrackUnpublished).sid) + + assertEquals(1, participantEvents.size) + assertIsClass(ParticipantEvent.DataTrackUnpublished::class.java, participantEvents.first()) + } + + @Test + fun participantDisconnectUnpublishesDataTracks() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_leave", + ) + advanceUntilIdle() + + val roomCollector = EventCollector(room.events, coroutineRule.scope) + simulateMessageFromServer(TestData.PARTICIPANT_DISCONNECT) + advanceUntilIdle() + + val events = roomCollector.stopCollecting() + assertTrue(events.any { it is RoomEvent.DataTrackUnpublished && it.sid == DataTrackSid("DT_leave") }) + assertTrue(events.any { it is RoomEvent.ParticipantDisconnected }) + assertTrue(room.remoteParticipants.isEmpty()) + } + + @Test + fun fullReconnectDetachesDataTracksWithoutUnpublishEvent() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_reconnect", + ) + advanceUntilIdle() + + val roomCollector = EventCollector(room.events, coroutineRule.scope) + room.onFullReconnecting() + advanceUntilIdle() + + val events = roomCollector.stopCollecting() + assertTrue(events.none { it is RoomEvent.DataTrackUnpublished }) + } + + @Test + fun softReconnectResendsDataTrackSubscriptions() = runTest { + room.setReconnectionType(ReconnectType.FORCE_SOFT_RECONNECT) + connect() + + val remote = remoteDataTrackManagerFactory.manager + assertEquals(0, remote.resendSubscriptionUpdatesCount) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + assertEquals(1, remote.resendSubscriptionUpdatesCount) + } + + /** + * Resubscription follows transport connect directly and does not wait on the replacement + * subscriber `_data_track` channel, which the SFU opens on its own schedule. Packets on a + * channel that arrives later are still routed + * ([fullReconnectForwardsPacketsOnReplacementSubscriberDataTrackChannel]). + */ + @Test + fun fullReconnectResendsSubscriptionsOnceReconnected() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + ) + val remote = remoteDataTrackManagerFactory.manager + assertEquals(0, remote.resendSubscriptionUpdatesCount) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + assertEquals(1, remote.resendSubscriptionUpdatesCount) + assertEquals(Room.State.CONNECTED, room.state) + } + + /** + * The join path and the `_data_track` receive path both build the UniFFI manager on demand, + * and the receive path runs on a WebRTC callback thread. Neither may let a native-library + * failure escape, or apps that never touch data tracks lose the connection or the process. + */ + @Test + fun nativeLibraryFailureLeavesTheRoomUsable() = runTest { + remoteDataTrackManagerFactory.createError = UnsatisfiedLinkError("dlopen failed") + + connect() + assertEquals(Room.State.CONNECTED, room.state) + + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + assertNotNull(remoteParticipant()) + + // Received packets are dropped rather than thrown from the callback. + val channel = openSubscriberDataTrackChannel() + receiveDataTrackPacket(channel, byteArrayOf(1)) + advanceUntilIdle() + assertEquals(Room.State.CONNECTED, room.state) + } + + private val publisherOfferHandler: SignalRequestHandler = { request -> + if (request.hasOffer()) { + val answer = with(LivekitRtc.SignalResponse.newBuilder()) { + answer = with(LivekitRtc.SessionDescription.newBuilder()) { + sdp = "remote_answer" + type = "answer" + id = request.offer.id + build() + } + build() + } + wsFactory.receiveMessage(answer) + true + } else { + false + } + } + + private fun openSubscriberDataTrackChannel(): MockDataChannel { + val channel = MockDataChannel(RTCEngine.DATA_TRACK_DATA_CHANNEL_LABEL) + getSubscriberPeerConnection().observer?.onDataChannel(channel) + return channel + } + + private fun receiveDataTrackPacket(channel: MockDataChannel, payload: ByteArray) { + channel.simulateBufferReceived( + DataChannel.Buffer(ByteBuffer.wrap(payload), true), + ) + } + + private fun reconnectWebsocket() { + wsFactory.listener.onOpen(wsFactory.ws, createOpenResponse(wsFactory.request)) + val softReconnectParam = wsFactory.request.url + .queryParameter(SignalClient.CONNECT_QUERY_RECONNECT) + ?.toIntOrNull() + ?: 0 + + if (softReconnectParam == 0) { + simulateMessageFromServer(TestData.JOIN) + } else { + simulateMessageFromServer(TestData.RECONNECT) + } + } + + private fun remoteParticipant() = + room.remoteParticipants[Participant.Identity(TestData.REMOTE_PARTICIPANT.identity)]!! +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/LocalDataTrackSendTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/LocalDataTrackSendTest.kt new file mode 100644 index 000000000..cb1cebac2 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/LocalDataTrackSendTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.BaseTest +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A sink that rejects on demand, so the queue-full policy can be observed without saturating a + * live pipeline (which would make the outcome depend on how fast the SFU drains). + */ +private class RecordingSink( + published: Boolean = true, + private val unpublishAfter: Int? = null, + private val reject: (DataTrackFrame) -> Boolean = { false }, +) : DataTrackFrameSink { + val offered = mutableListOf() + val accepted = mutableListOf() + + override var isPublished: Boolean = published + private set + + override fun tryPush(frame: DataTrackFrame): Result { + offered.add(frame) + if (reject(frame)) { + return Result.failure( + DataTrackPushFrameException.QueueFull("The send queue is full", frame), + ) + } + accepted.add(frame) + if (unpublishAfter != null && accepted.size >= unpublishAfter) { + isPublished = false + } + return Result.success(Unit) + } +} + +class LocalDataTrackSendTest : BaseTest() { + + @Test + fun dropSkipsRejectedFrames() = runTest { + val sink = RecordingSink { it.payload.contentEquals(byteArrayOf(2)) } + + val result = sink.sendFrames(frames(5), LocalDataTrack.FrameDropPolicy.DROP) + + assertTrue(result.isSuccess) + assertEquals(5, sink.offered.size) + assertPayloads(listOf(0, 1, 3, 4), sink.accepted) + } + + @Test + fun failStopsAtRejectedFrame() = runTest { + val sink = RecordingSink { it.payload.contentEquals(byteArrayOf(2)) } + + val result = sink.sendFrames(frames(5), LocalDataTrack.FrameDropPolicy.FAIL) + + val error = result.exceptionOrNull() as DataTrackPushFrameException.QueueFull + assertArrayEquals(byteArrayOf(2), error.frame.payload) + assertPayloads(listOf(0, 1), sink.accepted) + } + + @Test + fun unpublishingEndsSendQuietlyWhenDropping() = runTest { + unpublishingEndsSendQuietly(LocalDataTrack.FrameDropPolicy.DROP) + } + + @Test + fun unpublishingEndsSendQuietlyWhenFailing() = runTest { + unpublishingEndsSendQuietly(LocalDataTrack.FrameDropPolicy.FAIL) + } + + @Test + fun unpublishedTrackSendsNothing() = runTest { + val sink = RecordingSink(published = false) + + val result = sink.sendFrames(frames(3), LocalDataTrack.FrameDropPolicy.FAIL) + + assertTrue(result.isSuccess) + assertTrue(sink.offered.isEmpty()) + } + + private suspend fun unpublishingEndsSendQuietly(policy: LocalDataTrack.FrameDropPolicy) { + val sink = RecordingSink(unpublishAfter = 1) + + val result = sink.sendFrames(frames(5), policy) + + assertTrue(result.isSuccess) + assertEquals(1, sink.accepted.size) + } + + companion object { + private fun frames(count: Int): Flow = flow { + for (index in 0 until count) { + emit(DataTrackFrame(byteArrayOf(index.toByte()))) + } + } + + private fun assertPayloads(expected: List, frames: List) { + assertEquals(expected.size, frames.size) + expected.zip(frames).forEach { (value, frame) -> + assertArrayEquals(byteArrayOf(value.toByte()), frame.payload) + } + } + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt new file mode 100644 index 000000000..ad60248d9 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt @@ -0,0 +1,323 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.e2ee.E2EEOptions +import io.livekit.android.room.RTCEngine +import io.livekit.android.room.ReconnectType +import io.livekit.android.room.SignalClient +import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.mock.MockDataChannel +import io.livekit.android.test.mock.SignalRequestHandler +import io.livekit.android.test.mock.TestData +import io.livekit.android.test.mock.e2ee.NoopKeyProvider +import io.livekit.android.test.util.toPBByteString +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.yield +import livekit.LivekitRtc +import livekit.org.webrtc.DataChannel +import livekit.org.webrtc.PeerConnection +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import uniffi.livekit_datatrack.PublishException + +@OptIn(ExperimentalCoroutinesApi::class) +class OutgoingDataTrackManagerMockE2ETest : MockE2ETest() { + + @Test + fun publishDataTrackUsesInjectedLocalManager() = runTest { + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + assertEquals("telemetry", result.getOrThrow().info.name) + + val local = localDataTrackManagerFactory.manager + assertEquals(1, local.publishedTracks.size) + assertEquals("telemetry", local.publishedTracks.single().info().name) + assertNull(localDataTrackManagerFactory.lastEncryptionProvider) + } + + @Test + fun publishDataTrackFailsWhenNativeLibraryUnavailable() = runTest { + localDataTrackManagerFactory.createError = UnsatisfiedLinkError("dlopen failed") + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DataTrackPublishException.Internal) + } + + /** + * The core rejects a schema whose encoding cannot describe the track's frames (and the other + * schema-metadata rules) before it allocates a handle. This covers the SDK's half: that the + * refusal reaches the caller as a typed failure rather than an opaque one. + */ + @Test + fun publishDataTrackSurfacesInvalidSchemaFromTheCore() = runTest { + localDataTrackManagerFactory.publishError = + PublishException.InvalidSchema("Specified schema and frame encodings are incompatible") + connect() + + val result = room.localParticipant.publishDataTrack( + "telemetry", + DataTrackPublishOptions( + DataTrackFrameEncoding.Cdr, + DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema), + ), + ) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DataTrackPublishException.InvalidSchema) + } + + @Test + fun publishDataTrackPassesEncryptionProviderWhenE2eeEnabled() = runTest { + room.e2eeOptions = E2EEOptions(keyProvider = NoopKeyProvider()) + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + + // Tests use ReversingDataPacketCryptorManager by default. + val encryptionProvider = localDataTrackManagerFactory.lastEncryptionProvider + assertNotNull(encryptionProvider) + val encrypted = encryptionProvider!!.encrypt(byteArrayOf(1, 2, 3)) + assertArrayEquals(byteArrayOf(3, 2, 1), encrypted.payload) + + val decryptionProvider = remoteDataTrackManagerFactory.lastDecryptionProvider + assertNotNull(decryptionProvider) + val decrypted = decryptionProvider!!.decrypt( + encrypted, + room.localParticipant.identity!!.value, + ) + assertArrayEquals(byteArrayOf(1, 2, 3), decrypted) + } + + @Test + fun publishDataTrackWaitsForPublisherChannelOpen() = runTest { + connect() + val channel = publisherDataTrackChannel() + channel.state = DataChannel.State.CONNECTING + + val publish = async { room.localParticipant.publishDataTrack("telemetry") } + yield() + assertTrue(publish.isActive) + + channel.state = DataChannel.State.OPEN + val result = publish.await() + assertTrue(result.isSuccess) + } + + @Test + fun dataTrackPacketsWaitForLowWaterThenSend() = runTest { + connect() + val channel = publisherDataTrackChannel() + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + + room.engine.sendDataTrackPackets(listOf(byteArrayOf(1))) + assertTrue(channel.sentPayloads.isEmpty()) + + channel.bufferedAmount = 0 + advanceUntilIdle() + assertEquals(1, channel.sentPayloads.size) + assertArrayEquals(byteArrayOf(1), channel.sentPayloads.single()) + } + + @Test + fun dataTrackPacketsDropOldestQueuedFrame() = runTest { + connect() + val channel = publisherDataTrackChannel() + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + + room.engine.sendDataTrackPackets(listOf(byteArrayOf(1))) + room.engine.sendDataTrackPackets(listOf(byteArrayOf(2))) + assertTrue(channel.sentPayloads.isEmpty()) + + channel.bufferedAmount = 0 + advanceUntilIdle() + assertEquals(1, channel.sentPayloads.size) + assertArrayEquals(byteArrayOf(2), channel.sentPayloads.single()) + } + + @Test + fun publishDataTrackTimesOutIfPublisherChannelNeverOpens() = runTest { + connect() + publisherDataTrackChannel().state = DataChannel.State.CONNECTING + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DataTrackPublishException.Timeout) + } + + @Test + fun publishDataTrackWaitsForReplacementChannelAcrossFullReconnect() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + publisherDataTrackChannel().state = DataChannel.State.CONNECTING + + val publish = async { room.localParticipant.publishDataTrack("telemetry") } + yield() + assertTrue(publish.isActive) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + yield() + assertTrue(publish.isActive) + + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val result = publish.await() + assertTrue(result.isSuccess) + assertEquals("telemetry", result.getOrThrow().info.name) + } + + @Test + fun publishDataTrackFailsIfDisconnectedWhileWaitingForChannel() = runTest { + connect() + publisherDataTrackChannel().state = DataChannel.State.CONNECTING + + val publish = async { room.localParticipant.publishDataTrack("telemetry") } + yield() + assertTrue(publish.isActive) + + room.disconnect() + advanceUntilIdle() + + val result = publish.await() + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DataTrackPublishException.Disconnected) + } + + @Test + fun fullReconnectSendsDataTrackPacketsOnReplacementChannel() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + val original = publisherDataTrackChannel() + room.engine.sendDataTrackPackets(listOf(byteArrayOf(1))) + assertEquals(1, original.sentPayloads.size) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val replacement = publisherDataTrackChannel() + assertNotSame(original, replacement) + room.engine.sendDataTrackPackets(listOf(byteArrayOf(2))) + advanceUntilIdle() + assertEquals(1, replacement.sentPayloads.size) + assertArrayEquals(byteArrayOf(2), replacement.sentPayloads.single()) + } + + @Test + fun fullReconnectRenegotiatesPublisherForDataTrack() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + + val local = localDataTrackManagerFactory.manager + assertEquals(0, local.republishTracksCount) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + assertEquals(1, local.republishTracksCount) + assertEquals( + PeerConnection.PeerConnectionState.CONNECTED, + getPublisherPeerConnection().connectionState(), + ) + } + + @Test + fun softReconnectIncludesPublishedDataTracksInSyncState() = runTest { + room.setReconnectionType(ReconnectType.FORCE_SOFT_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val syncState = wsFactory.ws.sentRequests + .map { LivekitRtc.SignalRequest.parseFrom(it.toPBByteString()) } + .firstOrNull { it.hasSyncState() } + ?.syncState + assertNotNull(syncState) + assertEquals(1, syncState!!.publishDataTracksCount) + assertEquals("telemetry", syncState.getPublishDataTracks(0).info.name) + assertEquals("DT_mock", syncState.getPublishDataTracks(0).info.sid) + } + + private val publisherOfferHandler: SignalRequestHandler = { request -> + if (request.hasOffer()) { + val answer = with(LivekitRtc.SignalResponse.newBuilder()) { + answer = with(LivekitRtc.SessionDescription.newBuilder()) { + sdp = "remote_answer" + type = "answer" + id = request.offer.id + build() + } + build() + } + wsFactory.receiveMessage(answer) + true + } else { + false + } + } + + private fun publisherDataTrackChannel() = + getPublisherPeerConnection().dataChannels[RTCEngine.DATA_TRACK_DATA_CHANNEL_LABEL] as MockDataChannel + + private fun reconnectWebsocket() { + wsFactory.listener.onOpen(wsFactory.ws, createOpenResponse(wsFactory.request)) + val softReconnectParam = wsFactory.request.url + .queryParameter(SignalClient.CONNECT_QUERY_RECONNECT) + ?.toIntOrNull() + ?: 0 + + if (softReconnectParam == 0) { + simulateMessageFromServer(TestData.JOIN) + } else { + simulateMessageFromServer(TestData.RECONNECT) + } + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt b/livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt index f7683b99d..28015ece2 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit, Inc. + * Copyright 2025-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. @@ -92,6 +92,7 @@ class TokenSourceTest : BaseTest() { participantMetadata = "participant-metadata", agentName = "agent-name", agentMetadata = "agent-metadata", + agentAttributes = mapOf("region" to "us-east"), ) val response = source.fetch(options).getOrThrow() @@ -121,6 +122,10 @@ class TokenSourceTest : BaseTest() { val agent = agents?.get(0)?.jsonObject assertEquals("agent-name", agent?.get("agent_name")?.jsonPrimitive?.content) assertEquals("agent-metadata", agent?.get("metadata")?.jsonPrimitive?.content) + assertEquals( + "us-east", + agent?.get("attributes")?.jsonObject?.get("region")?.jsonPrimitive?.content, + ) } @Test diff --git a/protocol b/protocol index 8381f2180..2172178d2 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 8381f2180c45ab926b3ebf19df0608f1dadcac1e +Subproject commit 2172178d20d4c8d14d6873b4e9560cc38cf48c0c diff --git a/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt b/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt index b218278af..767f1fef1 100644 --- a/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt +++ b/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt @@ -40,6 +40,7 @@ import io.livekit.android.events.collect import io.livekit.android.room.Room import io.livekit.android.room.datastream.StreamTextOptions import io.livekit.android.room.datastream.incoming.TextStreamReceiver +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.RemoteParticipant @@ -65,6 +66,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import livekit.org.webrtc.CameraXHelper +import java.util.Collections @OptIn(ExperimentalCamera2Interop::class) class CallViewModel( @@ -145,6 +147,10 @@ class CallViewModel( private val mutablePermissionAllowed = MutableStateFlow(true) val permissionAllowed = mutablePermissionAllowed.hide() + // Data tracks with a live subscription, so the same track isn't collected twice. + // RemoteDataTrack doesn't override equals, so this compares by identity. + private val subscribedDataTracks = Collections.synchronizedSet(mutableSetOf()) + // RPC tester state. Lives on the ViewModel so it survives dialog dismiss/reopen. private val mutableHandlers = MutableStateFlow>(emptyList()) val handlers: StateFlow> = mutableHandlers @@ -192,6 +198,10 @@ class CallViewModel( room.events.collect { when (it) { is RoomEvent.FailedToConnect -> mutableError.value = it.error + is RoomEvent.DataTrackPublished -> subscribeToDataTrack(it.track) +// // A full reconnect re-attaches the surviving tracks without republishing +// // events for them, so sweep again rather than waiting on DataTrackPublished. +// is RoomEvent.Reconnected -> subscribeToAvailableDataTracks() is RoomEvent.DataReceived -> { // Handling basic data packets. val identity = it.participant?.identity ?: "server" @@ -266,6 +276,9 @@ class CallViewModel( mutableEnhancedNsEnabled.postValue(room.audioProcessorIsEnabled) mutableEnableAudioProcessor.postValue(true) + // Data tracks already published when we joined. + subscribeToAvailableDataTracks() + // Create and publish audio/video tracks val localParticipant = room.localParticipant localParticipant.setMicrophoneEnabled(true) @@ -279,6 +292,46 @@ class CallViewModel( } } + /** + * Subscribes to every data track currently published in the room. + * + * [RoomEvent.DataTrackPublished] only fires while the room is connected, so tracks that were + * already published when we joined — or that were re-attached by a full reconnect — never + * announce themselves and have to be picked up from [RemoteParticipant.dataTracks] instead. + */ + private fun subscribeToAvailableDataTracks() { + room.remoteParticipants.values + .flatMap { participant -> participant.dataTracks.values } + .forEach { track -> subscribeToDataTrack(track) } + } + + /** + * Subscribes to [track] and forwards its frames to [dataReceived], ignoring tracks that are + * already being collected. + */ + private fun subscribeToDataTrack(track: RemoteDataTrack) { + if (!subscribedDataTracks.add(track)) { + return + } + viewModelScope.launch(Dispatchers.Default) { + val stream = track.subscribe() + .getOrElse { e -> + LKLog.e(e) { "Failed to subscribe to data track ${track.name}" } + subscribedDataTracks.remove(track) + return@launch + } + + // Ends on its own once the track is unpublished. + stream.flow.collect { frame -> + val message = frame.payload.toString(Charsets.UTF_8) + mutableDataReceived.emit("${track.publisherIdentity.value}/${track.name}: $message") + } + + // Allow a later republish under the same name to resubscribe. + subscribedDataTracks.remove(track) + } + } + private fun handlePrimarySpeaker(participantsList: List, speakers: List, room: Room?) { var speaker = mutablePrimarySpeaker.value