diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c3caf7b0..f58c24665 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,15 @@ noise = "2.0.0" lifecycleProcess = "2.8.7" agp = "8.7.2" kotlin = "1.9.25" +# Data streams v2 core. Unreleased: build it from rust-sdks with +# `cargo make android-package-local` and it resolves from mavenLocal(). Switch to a published +# version once livekit-uniffi ships this API. +livekit-uniffi = "0.0.1" +# livekit-uniffi's AAR pins jna 5.16.0, whose libjnidispatch.so fails to dlopen on 16 KB +# page-size devices ("program alignment (8192) cannot be smaller than system page size"). +# 5.19.1 loads correctly there, so the SDK overrides the transitive pin. Also used for the +# desktop JNA dispatch library on the unit-test classpath; see gradle/uniffi-native-lib.gradle. +jna = "5.19.1" [libraries] android-jain-sip-ri = { module = "javax.sip:android-jain-sip-ri", version.ref = "androidJainSipRi" } @@ -59,6 +68,8 @@ androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } +livekit-uniffi = { module = "io.livekit:livekit-uniffi-android", version.ref = "livekit-uniffi" } +jna-desktop = { module = "net.java.dev.jna:jna", version.ref = "jna" } okhttp-lib = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" } diff --git a/gradle/uniffi-native-lib.gradle b/gradle/uniffi-native-lib.gradle new file mode 100644 index 000000000..579fc732e --- /dev/null +++ b/gradle/uniffi-native-lib.gradle @@ -0,0 +1,95 @@ +/* + * 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. + */ + +// Points JNA at a *host* build of liblivekit_uniffi for local unit tests. +// +// Data streams are implemented in the Rust core, so any test that exercises them has to load +// the native library. Unit tests here run on the host JVM (Robolectric), but the +// livekit-uniffi AAR only ships Android .so files -- so the host build has to be supplied +// separately. This mirrors how client-sdk-swift's tests link the real xcframework rather than +// faking the core. +// +// Resolution order: +// 1. LIVEKIT_UNIFFI_LIB_DIR environment variable +// 2. livekitUniffiLibDir Gradle property +// 3. auto-discovery in a sibling rust-sdks checkout, which is the layout the LiveKit SDKs +// already assume for local uniffi development +// +// To produce the host library: +// cd ../rust-sdks/livekit-uniffi +// CARGO_PROFILE_RELEASE_STRIP=false cargo build --release --target + +ext.resolveUniffiNativeLibDirs = { + def libNames = ["liblivekit_uniffi.dylib", "liblivekit_uniffi.so"] + def hasLib = { File dir -> dir.isDirectory() && libNames.any { new File(dir, it).isFile() } } + + def explicit = System.getenv("LIVEKIT_UNIFFI_LIB_DIR") ?: project.findProperty("livekitUniffiLibDir") + if (explicit) { + def dir = file(explicit) + if (!hasLib(dir)) { + throw new GradleException( + "LIVEKIT_UNIFFI_LIB_DIR/livekitUniffiLibDir is set to '${dir}' but no " + + "liblivekit_uniffi.{dylib,so} was found there.", + ) + } + return [dir] + } + + // Sibling rust-sdks checkout. `target//release` is where a --target build lands + // (the uniffi Makefile always passes --target); `target/release` covers a plain build. + def targetRoot = new File(rootDir, "../rust-sdks/target") + if (!targetRoot.isDirectory()) { + return [] + } + def candidates = [] + def plain = new File(targetRoot, "release") + if (hasLib(plain)) { + candidates << plain + } + targetRoot.listFiles({ File f -> f.isDirectory() } as FileFilter)?.sort { it.name }?.each { triple -> + def dir = new File(triple, "release") + if (hasLib(dir)) { + candidates << dir + } + } + return candidates +} + +ext.configureUniffiForUnitTests = { Test task -> + // The generated bindings pick their cleaner by API level, and at 34+ use + // android.system.SystemCleaner. Robolectric's implementation of that delegates to + // jdk.internal.ref.CleanerFactory, which the module system hides by default -- so merely + // constructing an FFI object throws IllegalAccessError. Opening the package up lets the + // Android code path run as written, rather than forcing every test that touches the FFI down + // to an older Robolectric SDK. + task.jvmArgs += ["--add-exports", "java.base/jdk.internal.ref=ALL-UNNAMED"] + + def dirs = resolveUniffiNativeLibDirs() + if (dirs.isEmpty()) { + task.doFirst { + logger.warn( + "[livekit] No host build of liblivekit_uniffi found. Tests that exercise data " + + "streams will fail to load the native library.\n" + + " Build it: cd ../rust-sdks/livekit-uniffi && " + + "CARGO_PROFILE_RELEASE_STRIP=false cargo build --release --target \$(rustc -vV | " + + "sed -n 's/^host: //p')\n" + + " Or point at one: -PlivekitUniffiLibDir=/path/to/dir", + ) + } + return + } + task.systemProperty("jna.library.path", dirs.collect { it.absolutePath }.join(File.pathSeparator)) +} diff --git a/livekit-android-sdk/build.gradle b/livekit-android-sdk/build.gradle index dbc69f736..cf7d470bf 100644 --- a/livekit-android-sdk/build.gradle +++ b/livekit-android-sdk/build.gradle @@ -118,6 +118,11 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation libs.coroutines.lib implementation libs.kotlinx.serialization.json + implementation libs.livekit.uniffi + // livekit-uniffi depends on jna 5.16.0, whose native dispatch library cannot be loaded on + // 16 KB page-size devices -- which Android now requires apps targeting API 35+ to support. + // Overriding to a version that loads there; remove once the AAR itself moves up. + implementation "net.java.dev.jna:jna:${libs.versions.jna.get()}@aar" api libs.webrtc api libs.okhttp.lib implementation libs.okhttp.coroutines diff --git a/livekit-android-sdk/src/main/AndroidManifest.xml b/livekit-android-sdk/src/main/AndroidManifest.xml index 800317f7f..83106a2ba 100644 --- a/livekit-android-sdk/src/main/AndroidManifest.xml +++ b/livekit-android-sdk/src/main/AndroidManifest.xml @@ -14,7 +14,16 @@ limitations under the License. --> - + + + + diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt index b7fe11b86..8051d2188 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt @@ -60,7 +60,7 @@ data class ConnectOptions( * for peer-to-peer feature negotiation (RPC v2, etc.). Defaults to the latest * version supported by this SDK build. */ - val clientProtocol: ClientProtocolVersion = ClientProtocolVersion.DATA_STREAM_RPC, + val clientProtocol: ClientProtocolVersion = ClientProtocolVersion.DATA_STREAM_V2, ) { internal var reconnect: Boolean = false internal var participantSid: String? = null diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt index 1919cd2c3..915217ab4 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt @@ -18,6 +18,7 @@ package io.livekit.android import io.livekit.android.e2ee.E2EEOptions import io.livekit.android.room.Room +import io.livekit.android.room.datastream.DataStreamOptions import io.livekit.android.room.network.ReconnectPolicy import io.livekit.android.room.participant.AudioTrackPublishDefaults import io.livekit.android.room.participant.VideoTrackPublishDefaults @@ -51,4 +52,9 @@ data class RoomOptions( * @see [Room.reconnectPolicy] */ val reconnectPolicy: ReconnectPolicy? = null, + + /** + * Room-wide data stream settings. + */ + val dataStreamOptions: DataStreamOptions = DataStreamOptions(), ) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/ClientCapability.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/ClientCapability.kt new file mode 100644 index 000000000..e5f86daa2 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/ClientCapability.kt @@ -0,0 +1,83 @@ +/* + * 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 + +import livekit.LivekitModels + +/** + * An optional feature capability a client advertises at connect time. + * + * Capabilities are independent feature flags, in contrast to [ClientProtocolVersion], which is a + * single monotonic version number. A client may speak a given client protocol yet still lack an + * individual capability, so the two are negotiated separately. + * + * Advertised by this SDK during the join handshake, mirrored by the server onto every + * `ParticipantInfo`, and readable per-peer via [io.livekit.android.room.participant.Participant.capabilities]. + */ +enum class ClientCapability(val value: Int) { + /** + * The client can accept RTP packet trailers passed through by the SFU instead of having + * them stripped. + */ + PACKET_TRAILER(1), + + /** + * The client can decompress a `deflate-raw` compressed data stream payload. + */ + COMPRESSION_DEFLATE_RAW(2), + ; + + fun toProto(): LivekitModels.ClientInfo.Capability { + return when (this) { + PACKET_TRAILER -> LivekitModels.ClientInfo.Capability.CAP_PACKET_TRAILER + COMPRESSION_DEFLATE_RAW -> LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW + } + } + + companion object { + /** + * Converts from the protobuf enum, returning null for values this SDK build does not + * recognize. + * + * Unlike most `fromProto` helpers in this SDK this never throws: capabilities are an + * open, forward-extensible set, so a peer or server advertising a newer capability must + * be ignored rather than crash us. + */ + fun fromProto(capability: LivekitModels.ClientInfo.Capability): ClientCapability? { + return when (capability) { + LivekitModels.ClientInfo.Capability.CAP_PACKET_TRAILER -> PACKET_TRAILER + LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW -> COMPRESSION_DEFLATE_RAW + LivekitModels.ClientInfo.Capability.CAP_UNUSED, + LivekitModels.ClientInfo.Capability.UNRECOGNIZED, + -> null + } + } + } +} + +/** + * The capabilities this SDK advertises to the server and, through it, to peers. + * + * Declared once so that every place which announces capabilities agrees. Data streams v2 + * compression is advertised unconditionally: deflate-raw is compressed and decompressed by the + * Rust data stream core, so support does not vary by device, API level, or room options. + * + * @suppress + */ +internal val ADVERTISED_CLIENT_CAPABILITIES = listOf( + ClientCapability.COMPRESSION_DEFLATE_RAW, +) 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 10aa87821..ce4ffca21 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 @@ -47,6 +47,8 @@ import io.livekit.android.events.RoomEvent import io.livekit.android.events.collect import io.livekit.android.memory.CloseableManager import io.livekit.android.renderer.TextureViewRenderer +import io.livekit.android.room.datastream.DataStreamOptions +import io.livekit.android.room.datastream.DataStreams import io.livekit.android.room.datastream.incoming.IncomingDataStreamManager import io.livekit.android.room.metrics.collectMetrics import io.livekit.android.room.network.NetworkCallbackManagerFactory @@ -150,6 +152,7 @@ constructor( private val connectionWarmer: ConnectionWarmer, private val audioRecordPrewarmer: AudioRecordPrewarmer, private val incomingDataStreamManager: IncomingDataStreamManager, + private val dataStreams: DataStreams, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, private val remoteParticipantFactory: RemoteParticipant.Factory, @@ -182,6 +185,13 @@ constructor( } rpcClientManager.getRemoteClientProtocol = getRemoteClientProtocol rpcServerManager.getRemoteClientProtocol = getRemoteClientProtocol + + // The data stream core decides per-recipient whether a v2 framing is safe, so give it a + // live view of the room. Lambdas rather than a Room reference, which would be a DI cycle. + dataStreams.remoteClientProtocol = getRemoteClientProtocol + dataStreams.remoteIdentities = { remoteParticipants.keys.toList() } + dataStreams.remoteCapabilities = { id -> remoteParticipants[id]?.capabilities ?: emptyList() } + dataStreams.maxPayloadSize = { dataStreamOptions.maxPayloadSize } } enum class State { @@ -316,6 +326,11 @@ constructor( */ var e2eeOptions: E2EEOptions? = null + /** + * Room-wide data stream settings. Must be set up prior to [connect]. + */ + var dataStreamOptions: DataStreamOptions = DataStreamOptions() + /** * Default options to use when creating an audio track. */ @@ -406,6 +421,7 @@ constructor( videoTrackPublishDefaults = videoTrackPublishDefaults, screenShareTrackCaptureDefaults = screenShareTrackCaptureDefaults, screenShareTrackPublishDefaults = screenShareTrackPublishDefaults, + dataStreamOptions = dataStreamOptions, ) /** @@ -647,6 +663,7 @@ constructor( adaptiveStream = options.adaptiveStream dynacast = options.dynacast e2eeOptions = options.e2eeOptions + dataStreamOptions = options.dataStreamOptions } /** @@ -794,6 +811,10 @@ constructor( eventBus.postEvent(RoomEvent.ParticipantDisconnected(this, removedParticipant), coroutineScope) localParticipant.handleParticipantDisconnect(identity) + + // Fail any stream they were midway through sending, so its reader raises instead of waiting + // for chunks that will never arrive. + dataStreams.abortStreamsFrom(identity) } fun getParticipantBySid(sid: String): Participant? { @@ -1341,22 +1362,10 @@ constructor( * @suppress */ override fun onDataStreamPacket(dp: LivekitModels.DataPacket, encryptionType: LivekitModels.Encryption.Type) { - when (dp.valueCase) { - LivekitModels.DataPacket.ValueCase.STREAM_HEADER -> { - incomingDataStreamManager.handleStreamHeader(dp.streamHeader, Participant.Identity(dp.participantIdentity), encryptionType) - } - - LivekitModels.DataPacket.ValueCase.STREAM_CHUNK -> { - incomingDataStreamManager.handleDataChunk(dp.streamChunk, encryptionType) - } - - LivekitModels.DataPacket.ValueCase.STREAM_TRAILER -> { - incomingDataStreamManager.handleStreamTrailer(dp.streamTrailer, encryptionType) - } - - // Ignore other cases. - else -> {} - } + // Forwarded whole rather than destructured: the core re-decodes the packet itself, and v2 + // headers carry fields (inline content, compression) that only it interprets. The packet has + // already been decrypted by the engine, which is what the core expects. + dataStreams.handleIncoming(dp) } /** 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..6090ebc57 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 @@ -242,6 +242,12 @@ constructor( addParam(CONNECT_QUERY_NETWORK_TYPE, networkInfo.getNetworkType().protoName) addParam(CONNECT_QUERY_CLIENT_PROTOCOL, options.clientProtocol.value.toString()) + // Capabilities go out as a comma separated list of protobuf enum names, which is the form + // the server parses on this path. Peers read them back off ParticipantInfo. + if (clientInfo.capabilitiesCount > 0) { + addParam(CONNECT_QUERY_CAPABILITIES, clientInfo.capabilitiesList.joinToString(",") { it.name }) + } + return queryBuilder.toString() } @@ -876,6 +882,14 @@ constructor( // TODO } + LivekitRtc.SignalResponse.MessageCase.STORE_DATA_BLOB_RESPONSE -> { + // TODO + } + + LivekitRtc.SignalResponse.MessageCase.GET_DATA_BLOB_RESPONSE -> { + // TODO + } + LivekitRtc.SignalResponse.MessageCase.MESSAGE_NOT_SET, null, -> { @@ -996,6 +1010,7 @@ constructor( const val CONNECT_QUERY_NETWORK_TYPE = "network" const val CONNECT_QUERY_PARTICIPANT_SID = "sid" const val CONNECT_QUERY_CLIENT_PROTOCOL = "client_protocol" + const val CONNECT_QUERY_CAPABILITIES = "capabilities" const val SD_TYPE_ANSWER = "answer" const val SD_TYPE_OFFER = "offer" @@ -1068,6 +1083,17 @@ enum class ClientProtocolVersion(val value: Int) { * instead of inline packets, lifting the 15 KB payload limit. */ DATA_STREAM_RPC(1), + + /** + * Data streams v2: the client understands single-packet data streams, where a small finite + * payload is carried inline in the stream header rather than as separate chunk and trailer + * packets. + * + * This is a baseline commitment, not an optional feature -- a peer that sees this version may + * send inline streams without further negotiation. Optional v2 features, such as + * `deflate-raw` compression, are negotiated separately via [ClientCapability]. + */ + DATA_STREAM_V2(2), } class ServerInfo( diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreamOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreamOptions.kt new file mode 100644 index 000000000..9a6cc87c5 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreamOptions.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.datastream + +/** + * Room-wide data stream settings. + * + * @see io.livekit.android.RoomOptions.dataStreamOptions + */ +data class DataStreamOptions( + /** + * Maximum size, in bytes, of a reassembled incoming data stream payload. + * + * A stream whose payload would exceed this fails its reader rather than buffering without + * bound, which keeps a misbehaving or malicious sender from growing memory regardless of the + * length its header declared. Null uses the built-in default. + */ + val maxPayloadSize: Long? = null, +) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt new file mode 100644 index 000000000..818503259 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt @@ -0,0 +1,678 @@ +/* + * 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.datastream + +import io.livekit.android.memory.CloseableManager +import io.livekit.android.room.ClientCapability +import io.livekit.android.room.ClientProtocolVersion +import io.livekit.android.room.RTCEngine +import io.livekit.android.room.datastream.incoming.ByteStreamHandler +import io.livekit.android.room.datastream.incoming.ByteStreamReceiver +import io.livekit.android.room.datastream.incoming.TextStreamHandler +import io.livekit.android.room.datastream.incoming.TextStreamReceiver +import io.livekit.android.room.datastream.outgoing.ByteStreamSender +import io.livekit.android.room.datastream.outgoing.DataChunker +import io.livekit.android.room.datastream.outgoing.StreamDestination +import io.livekit.android.room.datastream.outgoing.TextStreamSender +import io.livekit.android.room.participant.Participant +import io.livekit.android.util.LKLog +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import livekit.LivekitModels +import java.io.Closeable +import java.util.Collections +import javax.inject.Inject +import javax.inject.Singleton +import io.livekit.uniffi.ByteStreamInfo as FfiByteStreamInfo +import io.livekit.uniffi.ByteStreamReader as FfiByteStreamReader +import io.livekit.uniffi.ByteStreamWriter as FfiByteStreamWriter +import io.livekit.uniffi.ClientCapability as FfiClientCapability +import io.livekit.uniffi.DataStreamException as FfiDataStreamException +import io.livekit.uniffi.IncomingDataStreamManager as FfiIncomingDataStreamManager +import io.livekit.uniffi.IncomingDataStreamManagerDelegate as FfiIncomingDelegate +import io.livekit.uniffi.OperationType as FfiOperationType +import io.livekit.uniffi.OutgoingDataStreamManager as FfiOutgoingDataStreamManager +import io.livekit.uniffi.OutgoingDataStreamManagerDelegate as FfiOutgoingDelegate +import io.livekit.uniffi.RemoteParticipantRegistryDelegate as FfiRegistryDelegate +import io.livekit.uniffi.StreamByteOptions as FfiStreamByteOptions +import io.livekit.uniffi.StreamTextOptions as FfiStreamTextOptions +import io.livekit.uniffi.TextStreamInfo as FfiTextStreamInfo +import io.livekit.uniffi.TextStreamReader as FfiTextStreamReader +import io.livekit.uniffi.TextStreamWriter as FfiTextStreamWriter + +/** + * Owns the livekit-uniffi data stream managers and the topic to handler registry, and routes + * packets between them and [RTCEngine]. + * + * This is the single place the FFI is touched. Everything above it -- the + * [io.livekit.android.room.datastream.incoming.IncomingDataStreamManager] and + * [io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager] implementations, the + * public sender and receiver types -- deals only in this SDK's own types. + * + * Room-scoped rather than session-scoped: stream handlers are registered before connecting (and by + * internal RPC wiring on every connect), so they and the managers holding them have to survive + * reconnects. Neither FFI manager holds a channel handle -- inbound packets are pushed in via + * [handleIncoming] and outbound ones are pulled out through a delegate -- so there is nothing + * transport-shaped to reopen when the connection changes. + * + * @suppress + */ +@Singleton +class DataStreams +@Inject +internal constructor( + private val engine: RTCEngine, + closeableManager: CloseableManager, +) : Closeable { + + /** + * Room state the send path needs, assigned by [io.livekit.android.room.Room] after + * construction. + * + * Injecting Room here would be a Dagger cycle, so these follow the same + * assign-a-lambda-afterwards pattern Room already uses for the RPC managers' + * `getRemoteClientProtocol`. + */ + internal var remoteIdentities: () -> List = { emptyList() } + internal var remoteClientProtocol: (Participant.Identity) -> Int = { ClientProtocolVersion.DEFAULT.value } + internal var remoteCapabilities: (Participant.Identity) -> List = { emptyList() } + + /** + * Cap on the size of a reassembled incoming payload, from + * [io.livekit.android.RoomOptions.dataStreamOptions]. Read lazily -- see [incomingManager]. + */ + internal var maxPayloadSize: () -> Long? = { null } + + /** + * The dispatcher for everything on the FFI boundary: calls into the core, and the coroutines + * that react to its callbacks. + * + * Deliberately a real dispatcher, and deliberately not the injected one. Two reasons, both of + * which have bitten: + * + * - The core resumes a suspended call from a thread on its own runtime, so the continuation + * has to land on a dispatcher actually backed by threads. On a virtual-time test dispatcher + * it is queued for a scheduler nobody is advancing and the call never completes. + * - The core invokes our delegates synchronously on its runtime threads. An unconfined + * dispatcher resumes waiting coroutines *inline on the calling thread*, so the work those + * coroutines do -- awaiting a publisher connection, waiting out data channel backpressure -- + * would run on, and block, a core runtime thread. Enough of those and the core's runtime + * deadlocks and every stream stops. + * + * Confining all of it here keeps both hazards off callers, including SDK users whose own tests + * may run the SDK on a test dispatcher. + */ + private val ffiDispatcher: CoroutineDispatcher = Dispatchers.IO + + private val coroutineScope = CoroutineScope(SupervisorJob() + ffiDispatcher) + + private val textStreamHandlers = Collections.synchronizedMap(mutableMapOf()) + private val byteStreamHandlers = Collections.synchronizedMap(mutableMapOf()) + + /** Topics we have already warned about, so an unhandled topic logs once rather than per stream. */ + private val warnedTopics = Collections.synchronizedSet(mutableSetOf()) + + /** + * Outbound packets waiting to go on the wire. + * + * The FFI delegate is a plain synchronous callback on a Rust runtime thread: it can neither + * block nor suspend, but sending has to await publisher connection and data channel + * backpressure. Handing off through an unbounded channel drained by a single coroutine keeps + * packets in the order the core emitted them while restoring the backpressure the previous + * implementation had. + */ + private val outboundPackets = Channel(Channel.UNLIMITED) + + private val outgoing: FfiOutgoingDataStreamManager = + FfiOutgoingDataStreamManager(OutgoingDelegate(), RegistryDelegate()) + + private val incomingLock = Any() + private var incoming: FfiIncomingDataStreamManager? = null + + init { + closeableManager.registerClosable(this) + coroutineScope.launch { + for (packet in outboundPackets) { + sendPacket(packet) + } + } + } + + /** + * The incoming manager, built on first use. + * + * Deliberately not built in `init`: its payload cap comes from the room options, which are not + * final until `connect` -- after this class is constructed. The first inbound packet can only + * arrive after connecting, so reading the cap here picks up a value passed to `connect`. + */ + private fun incomingManager(): FfiIncomingDataStreamManager { + synchronized(incomingLock) { + incoming?.let { return it } + val manager = FfiIncomingDataStreamManager( + delegate = IncomingDelegate(), + maxPayloadByteLength = maxPayloadSize()?.toULong(), + ) + incoming = manager + return manager + } + } + + // region Handler registration + + fun registerTextStreamHandler(topic: String, handler: TextStreamHandler) { + synchronized(textStreamHandlers) { + if (textStreamHandlers.containsKey(topic)) { + throw IllegalArgumentException("A text stream handler for topic $topic has already been set.") + } + textStreamHandlers[topic] = handler + } + } + + fun unregisterTextStreamHandler(topic: String) { + synchronized(textStreamHandlers) { + textStreamHandlers.remove(topic) + } + } + + fun registerByteStreamHandler(topic: String, handler: ByteStreamHandler) { + synchronized(byteStreamHandlers) { + if (byteStreamHandlers.containsKey(topic)) { + throw IllegalArgumentException("A byte stream handler for topic $topic has already been set.") + } + byteStreamHandlers[topic] = handler + } + } + + fun unregisterByteStreamHandler(topic: String) { + synchronized(byteStreamHandlers) { + byteStreamHandlers.remove(topic) + } + } + + // endregion + + // region Incoming + + /** + * Feeds a received data stream packet to the core, which re-decodes it itself. + * + * Cheap and non-blocking: the packet is queued and processed on the core's own loop, so this is + * safe to call from a data channel callback. Packets that are not data stream packets, or do + * not decode, are ignored by the core. + */ + fun handleIncoming(packet: LivekitModels.DataPacket) { + incomingManager().handlePacketReceived(packet.toByteArray()) + } + + /** + * Fails every open incoming stream so blocked readers raise instead of hanging. + * + * Handler registrations survive, so streams arriving after a reconnect are still delivered. + * A no-op if no packet has ever been received, since nothing can be open. + */ + fun abortAllStreams() { + synchronized(incomingLock) { incoming }?.abortAllStreams() + } + + /** + * Fails open incoming streams sent by [identity], for when that participant disconnects + * mid-send. Without this their readers would wait forever for chunks that will never arrive. + */ + fun abortStreamsFrom(identity: Participant.Identity) { + synchronized(incomingLock) { incoming }?.abortStreamsFrom(identity.value) + } + + // endregion + + // region Outgoing + + suspend fun streamText(options: StreamTextOptions): TextStreamSender { + val writer = onFfi { outgoing.streamText(options.toFfi()) } + return TextStreamSender( + info = writer.info().toSdk(currentEncryptionType()), + destination = TextWriterDestination(writer), + ) + } + + suspend fun streamBytes(options: StreamBytesOptions): ByteStreamSender { + val writer = onFfi { outgoing.streamBytes(options.toFfi()) } + return ByteStreamSender( + info = writer.info().toSdk(currentEncryptionType()), + destination = ByteWriterDestination(writer), + ) + } + + suspend fun sendText(text: String, options: StreamTextOptions): TextStreamInfo { + return onFfi { outgoing.sendText(text, options.toFfi()) } + .toSdk(currentEncryptionType()) + } + + suspend fun sendBytes(data: ByteArray, options: StreamBytesOptions): ByteStreamInfo { + return onFfi { outgoing.sendBytes(data, options.toFfi()) } + .toSdk(currentEncryptionType()) + } + + /** + * Sends [path] as a byte stream, read from disk by the core rather than buffered in memory. + * + * [options] is expected to already carry the name, MIME type and size resolved from the file: + * the core reads the bytes but does not inspect the file's metadata. + */ + suspend fun sendFile(path: String, options: StreamBytesOptions): ByteStreamInfo { + return onFfi { outgoing.sendFile(path, options.toFfi()) } + .toSdk(currentEncryptionType()) + } + + private suspend fun sendPacket(bytes: ByteArray) { + val packet = try { + LivekitModels.DataPacket.parseFrom(bytes) + } catch (e: Exception) { + LKLog.e(e) { "Unable to decode an outgoing data stream packet; dropping it." } + return + } + + engine.waitForBufferStatusLow(packet.kind) + val result = engine.sendData(packet) + if (result.isFailure) { + // The core acknowledges the send as soon as it hands the packet over, so there is + // nobody left to return this to; the originating send call has already returned. + LKLog.w(result.exceptionOrNull()) { "Failed to send a data stream packet." } + } + } + + // endregion + + /** + * The room's data channel encryption type. + * + * The core reports `NONE` on every stream: end to end encryption across the FFI is not + * implemented, and payload encryption still happens in [RTCEngine] on the whole packet, either + * side of the FFI. Stamping the room's real value keeps [StreamInfo.encryptionType] meaning + * what it did before. + */ + private fun currentEncryptionType(): LivekitModels.Encryption.Type { + return if (engine.e2EEManager?.isDataChannelEncryptionEnabled() == true) { + LivekitModels.Encryption.Type.GCM + } else { + LivekitModels.Encryption.Type.NONE + } + } + + override fun close() { + coroutineScope.cancel() + outboundPackets.close() + // Releases the native handles, and with them the core's reference to our delegates. Those + // delegates are held by a static handle map on the way in, so skipping this would keep this + // object -- and through it the engine -- reachable for the life of the process. + synchronized(incomingLock) { + incoming?.destroy() + incoming = null + } + outgoing.destroy() + } + + // region FFI delegates + + /** + * Receives encoded `DataPacket`s from the core and queues them for the reliable data channel. + * + * Unlike the Swift implementation this holds a strong reference to its owner: the JVM collects + * reference cycles, so the weak back-reference Swift needs to break an ARC cycle would buy + * nothing here. What does matter is [close] running, since the FFI's handle map holds this + * delegate from a static root. + */ + private inner class OutgoingDelegate : FfiOutgoingDelegate { + override fun onPacketsAvailable(packets: List) { + for (packet in packets) { + val result = outboundPackets.trySend(packet) + if (result.isFailure) { + LKLog.w { "Dropping an outgoing data stream packet: the send queue is closed." } + } + } + } + } + + /** + * Receives opened incoming streams from the core and routes them to a handler by topic. + * + * The core surfaces every stream regardless of topic; matching topics to handlers, and + * discarding streams nobody is listening for, is this SDK's job. + */ + private inner class IncomingDelegate : FfiIncomingDelegate { + override fun onTextStreamOpened(reader: FfiTextStreamReader, identity: String) { + val info = reader.info().toSdk(currentEncryptionType()) + val handler = textStreamHandlers[info.topic] + if (handler == null) { + warnMissingHandler("text", info.topic, info.id, identity) + return + } + deliver { + handler.invoke( + TextStreamReceiver(info, pumpText(reader)), + Participant.Identity(identity), + ) + } + } + + override fun onByteStreamOpened(reader: FfiByteStreamReader, identity: String) { + val info = reader.info().toSdk(currentEncryptionType()) + val handler = byteStreamHandlers[info.topic] + if (handler == null) { + warnMissingHandler("byte", info.topic, info.id, identity) + return + } + deliver { + handler.invoke( + ByteStreamReceiver(info, pumpBytes(reader)), + Participant.Identity(identity), + ) + } + } + } + + /** + * Read access to the room's remote participants, used by the core to resolve a broadcast's + * recipients and to decide per-recipient whether an inline or compressed framing is safe. + * + * Read live rather than cached: eligibility has to reflect who is in the room at send time. + */ + private inner class RegistryDelegate : FfiRegistryDelegate { + override fun remoteIdentities(): List { + return this@DataStreams.remoteIdentities().map { it.value } + } + + override fun remoteClientProtocol(identity: String): Int { + return this@DataStreams.remoteClientProtocol(Participant.Identity(identity)) + } + + override fun remoteCapabilities(identity: String): List { + return this@DataStreams.remoteCapabilities(Participant.Identity(identity)) + .map { it.toFfi() } + } + } + + // endregion + + /** + * Runs a stream handler off the FFI callback thread. + * + * Handlers are app code and are not required to return promptly, so running them inline would + * let one of them stall the core's runtime thread and with it every other incoming stream. + */ + private fun deliver(block: () -> Unit) { + coroutineScope.launch { + try { + block() + } catch (e: Exception) { + LKLog.e(e) { "Unhandled exception when invoking stream handler!" } + } + } + } + + private fun warnMissingHandler(kind: String, topic: String, id: String, identity: String) { + if (warnedTopics.add(topic)) { + LKLog.w { + "Received $kind stream for topic \"$topic\", but no handler was found. Ignoring. " + + "(stream $id from $identity)" + } + } + } + + /** + * Drains an FFI reader into the channel the public receivers are built on. + * + * Keeps [io.livekit.android.room.datastream.incoming.BaseStreamReceiver] and its `flow` / + * `readNext` / `readAll` surface exactly as it was: the channel closes normally when the + * stream ends, or with a [StreamException] when it fails. + */ + private fun pumpBytes(reader: FfiByteStreamReader): Channel { + return pump { reader.next() } + } + + /** + * As [pumpBytes], re-encoding each piece to UTF-8 because the public [TextStreamReceiver] + * decodes from a byte channel. + * + * Lossless: the core splits text on character boundaries, so every piece is independently + * valid UTF-8. Round-tripping keeps [TextStreamReceiver]'s public constructor untouched. + */ + private fun pumpText(reader: FfiTextStreamReader): Channel { + return pump { reader.next()?.toByteArray(Charsets.UTF_8) } + } + + private fun pump(next: suspend () -> ByteArray?): Channel { + val channel = Channel(capacity = Channel.UNLIMITED) + coroutineScope.launch { + try { + while (true) { + val chunk = withContext(ffiDispatcher) { next() } ?: break + channel.send(chunk) + } + channel.close() + } catch (e: FfiDataStreamException) { + channel.close(e.toStreamException()) + } catch (e: Exception) { + channel.close(e) + } + } + return channel + } + + // region Writer destinations + + /** + * Bridges a public sender onto an FFI writer. + * + * The [DataChunker] handed in by [io.livekit.android.room.datastream.outgoing.BaseStreamSender] + * is deliberately ignored: chunking (including splitting text on character boundaries) now + * happens in the core, which also needs the whole write to decide on framing. + * + * [isOpen] is a snapshot rather than a query. The interface exposes it as a non-suspending + * property while the FFI's is a suspending call, so it is tracked locally: set false on close, + * and on a failed write, since a write only fails once the stream is finished. + */ + private abstract inner class WriterDestination : StreamDestination { + @Volatile + private var open = true + + override val isOpen: Boolean + get() = open + + protected abstract suspend fun writeToFfi(data: T) + protected abstract suspend fun closeFfi(reason: String?) + + override suspend fun write(data: T, chunker: DataChunker): Result { + return try { + withContext(ffiDispatcher) { writeToFfi(data) } + Result.success(Unit) + } catch (e: FfiDataStreamException) { + open = false + Result.failure(e.toStreamException()) + } + } + + override suspend fun close(reason: String?) { + if (!open) { + return + } + open = false + try { + withContext(ffiDispatcher) { closeFfi(reason) } + } catch (e: FfiDataStreamException) { + throw e.toStreamException() + } + } + } + + private inner class TextWriterDestination( + private val writer: FfiTextStreamWriter, + ) : WriterDestination() { + override suspend fun writeToFfi(data: String) = writer.write(data) + + // closeStream, not close: the latter is the AutoCloseable one uniffi generates for + // releasing the handle. See the rename in livekit-uniffi's uniffi.toml. + override suspend fun closeFfi(reason: String?) { + if (reason == null) writer.closeStream() else writer.closeWithReason(reason) + } + } + + private inner class ByteWriterDestination( + private val writer: FfiByteStreamWriter, + ) : WriterDestination() { + override suspend fun writeToFfi(data: ByteArray) = writer.write(data) + override suspend fun closeFfi(reason: String?) { + if (reason == null) writer.closeStream() else writer.closeWithReason(reason) + } + } + + // endregion + + /** + * Runs a suspending call into the core on [ffiDispatcher], translating its errors. + */ + private suspend fun onFfi(body: suspend () -> T): T { + try { + return withContext(ffiDispatcher) { body() } + } catch (e: FfiDataStreamException) { + throw e.toStreamException() + } + } +} + +// region FFI type conversions + +internal fun FfiTextStreamInfo.toSdk(encryptionType: LivekitModels.Encryption.Type) = TextStreamInfo( + id = id, + topic = topic, + timestampMs = timestampMs, + totalSize = totalLength?.toLong(), + attributes = attributes, + operationType = operationType.toSdk(), + version = version, + replyToStreamId = replyToStreamId, + attachedStreamIds = attachedStreamIds, + generated = generated, + encryptionType = encryptionType, +) + +internal fun FfiByteStreamInfo.toSdk(encryptionType: LivekitModels.Encryption.Type) = ByteStreamInfo( + id = id, + topic = topic, + timestampMs = timestampMs, + totalSize = totalLength?.toLong(), + attributes = attributes, + mimeType = mimeType, + name = name, + encryptionType = encryptionType, +) + +internal fun FfiOperationType.toSdk(): TextStreamInfo.OperationType = when (this) { + FfiOperationType.CREATE -> TextStreamInfo.OperationType.CREATE + FfiOperationType.UPDATE -> TextStreamInfo.OperationType.UPDATE + FfiOperationType.DELETE -> TextStreamInfo.OperationType.DELETE + FfiOperationType.REACTION -> TextStreamInfo.OperationType.REACTION +} + +internal fun TextStreamInfo.OperationType.toFfi(): FfiOperationType = when (this) { + TextStreamInfo.OperationType.CREATE -> FfiOperationType.CREATE + TextStreamInfo.OperationType.UPDATE -> FfiOperationType.UPDATE + TextStreamInfo.OperationType.DELETE -> FfiOperationType.DELETE + TextStreamInfo.OperationType.REACTION -> FfiOperationType.REACTION +} + +internal fun ClientCapability.toFfi(): FfiClientCapability = when (this) { + ClientCapability.PACKET_TRAILER -> FfiClientCapability.PACKET_TRAILER + ClientCapability.COMPRESSION_DEFLATE_RAW -> FfiClientCapability.COMPRESSION_DEFLATE_RAW +} + +/** + * `totalSize` is intentionally not carried over: the core opens an incremental text stream as + * unknown-length, and its options have no field for a declared total. + */ +internal fun StreamTextOptions.toFfi() = FfiStreamTextOptions( + topic = topic, + attributes = attributes, + destinationIdentities = destinationIdentities.map { it.value }, + id = streamId, + operationType = operationType.toFfi(), + version = version, + replyToStreamId = replyToStreamId, + attachedStreamIds = attachedStreamIds, + generated = null, + compress = compress, + senderIdentity = null, +) + +internal fun StreamBytesOptions.toFfi() = FfiStreamByteOptions( + topic = topic, + attributes = attributes, + destinationIdentities = destinationIdentities.map { it.value }, + id = streamId, + mimeType = mimeType, + name = name, + totalLength = totalSize?.toULong(), + compress = compress, + senderIdentity = null, +) + +/** + * Maps a core error onto this SDK's [StreamException] hierarchy, one to one. + * + * Every case the core can report is distinguishable here, either by its own exception type or by + * [StreamException.TerminatedException.Reason]. The size failures are modelled as subclasses of + * [StreamException.LengthExceededException] so that existing code catching that still catches them. + */ +internal fun FfiDataStreamException.toStreamException(): StreamException = when (this) { + is FfiDataStreamException.AbnormalEnd -> StreamException.AbnormalEndException(reason) + is FfiDataStreamException.Utf8 -> StreamException.DecodeFailedException(reason) + is FfiDataStreamException.Decompression -> StreamException.DecodeFailedException("Decompression failed") + is FfiDataStreamException.LengthExceeded -> StreamException.LengthExceededException(message) + is FfiDataStreamException.HeaderTooLarge -> StreamException.HeaderTooLargeException(message) + is FfiDataStreamException.PayloadTooLarge -> StreamException.PayloadTooLargeException(message) + is FfiDataStreamException.Incomplete -> StreamException.IncompleteException() + is FfiDataStreamException.EncryptionTypeMismatch -> StreamException.EncryptionTypeMismatch(message) + is FfiDataStreamException.Internal -> StreamException.InternalException(message) + + // No dedicated type; told apart by their reason. + is FfiDataStreamException.AlreadyClosed -> + StreamException.TerminatedException(message, StreamException.TerminatedException.Reason.ALREADY_CLOSED) + + is FfiDataStreamException.InvalidHeader -> + StreamException.TerminatedException(message, StreamException.TerminatedException.Reason.INVALID_HEADER) + + is FfiDataStreamException.MissedChunk -> + StreamException.TerminatedException(message, StreamException.TerminatedException.Reason.MISSED_CHUNK) + + is FfiDataStreamException.SendFailed -> + StreamException.TerminatedException(message, StreamException.TerminatedException.Reason.SEND_FAILED) + + is FfiDataStreamException.InvalidFileName -> + StreamException.TerminatedException(message, StreamException.TerminatedException.Reason.INVALID_FILE_NAME) + + // A local file read or write failing is not the remote closing on us, so this is terminated + // rather than an abnormal end. + is FfiDataStreamException.Io -> + StreamException.TerminatedException(reason, StreamException.TerminatedException.Reason.IO) +} + +// endregion diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamException.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamException.kt index 1e51135bc..58f533cf2 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamException.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamException.kt @@ -29,13 +29,34 @@ sealed class StreamException(message: String? = null) : Exception(message) { /** * Incoming chunk data could not be decoded. + * + * Covers both invalid UTF-8 in a text stream and a compressed stream that could not be + * decompressed. */ - class DecodeFailedException : StreamException() + class DecodeFailedException(message: String? = null) : StreamException(message) /** * Length exceeded total length specified in stream header. */ - class LengthExceededException : StreamException() + open class LengthExceededException(message: String? = null) : StreamException(message) + + /** + * A stream header was too large to send. + * + * A header travels in a single packet, so its attributes, topic and framing together have to + * fit the packet budget. Raised in place of sending an oversized header. + * + * A subclass of [LengthExceededException] so that code catching that keeps catching every + * size-limit failure. + */ + class HeaderTooLargeException(message: String? = null) : LengthExceededException(message) + + /** + * An incoming stream's payload exceeded the maximum accepted size. + * + * @see io.livekit.android.room.datastream.DataStreamOptions.maxPayloadSize + */ + class PayloadTooLargeException(message: String? = null) : LengthExceededException(message) /** * Length is less than total length specified in stream header. @@ -44,8 +65,38 @@ sealed class StreamException(message: String? = null) : Exception(message) { /** * Stream terminated before completion. + * + * [reason] distinguishes why, for the cases that do not have a dedicated exception. */ - class TerminatedException(message: String? = null) : StreamException(message) + class TerminatedException + @JvmOverloads + constructor( + message: String? = null, + val reason: Reason = Reason.UNKNOWN, + ) : StreamException(message) { + enum class Reason { + /** No specific reason was reported. */ + UNKNOWN, + + /** The stream had already been closed. */ + ALREADY_CLOSED, + + /** An incoming header could not be understood. */ + INVALID_HEADER, + + /** A chunk arrived out of order, leaving a gap the stream cannot recover from. */ + MISSED_CHUNK, + + /** A packet could not be handed to the transport. */ + SEND_FAILED, + + /** A file name was not a plain name, or tried to escape its directory. */ + INVALID_FILE_NAME, + + /** Reading or writing the underlying file failed. */ + IO, + } + } /** * Cannot perform operations on an unknown stream. @@ -66,4 +117,9 @@ sealed class StreamException(message: String? = null) : Exception(message) { * Encryption of the data chunks did not match the declared encryption type. */ class EncryptionTypeMismatch(message: String? = null) : StreamException(message) + + /** + * A stream failed for a reason internal to the SDK, with no more specific cause available. + */ + class InternalException(message: String? = null) : StreamException(message) } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.kt index 0a987eecf..dfc855c8c 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.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. @@ -30,8 +30,22 @@ data class StreamTextOptions( val replyToStreamId: String? = null, /** * The total exact size in bytes when encoded to UTF-8, if known. + * + * Ignored when opening an incremental stream with + * [io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager.streamText], which is + * always announced as unknown-length. */ val totalSize: Long? = null, + /** + * Whether the payload may be compressed, when every recipient supports it. + * + * Only applies to whole-payload sends such as + * [io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager.sendText]; incremental + * streams are never compressed. Compression is additionally skipped unless every recipient + * advertises [io.livekit.android.room.ClientCapability.COMPRESSION_DEFLATE_RAW], and is only + * kept when it actually makes the payload smaller, so leaving this on is safe. + */ + val compress: Boolean = true, ) data class StreamBytesOptions( @@ -51,4 +65,10 @@ data class StreamBytesOptions( * The total exact size in bytes, if known. */ val totalSize: Long? = null, + /** + * Whether the payload may be compressed, when every recipient supports it. + * + * @see StreamTextOptions.compress + */ + val compress: Boolean = true, ) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/incoming/IncomingDataStreamManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/incoming/IncomingDataStreamManager.kt index 337c02acd..b1dc24f90 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/incoming/IncomingDataStreamManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/incoming/IncomingDataStreamManager.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. @@ -16,25 +16,15 @@ package io.livekit.android.room.datastream.incoming -import android.os.SystemClock import androidx.annotation.VisibleForTesting -import io.livekit.android.room.datastream.ByteStreamInfo -import io.livekit.android.room.datastream.StreamException -import io.livekit.android.room.datastream.StreamInfo -import io.livekit.android.room.datastream.TextStreamInfo +import io.livekit.android.room.datastream.DataStreams import io.livekit.android.room.participant.Participant -import io.livekit.android.util.LKLog -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import livekit.LivekitModels import livekit.LivekitModels.DataStream -import java.util.Collections import javax.inject.Inject -// Type-erased stream handler -private typealias AnyStreamHandler = (Channel, Participant.Identity) -> Unit - typealias ByteStreamHandler = (reader: ByteStreamReceiver, fromIdentity: Participant.Identity) -> Unit typealias TextStreamHandler = (reader: TextStreamReceiver, fromIdentity: Participant.Identity) -> Unit @@ -86,265 +76,81 @@ interface IncomingDataStreamManager { } /** + * Adapts [IncomingDataStreamManager] onto [DataStreams], which owns the actual implementation. + * * @suppress */ -class IncomingDataStreamManagerImpl @Inject constructor() : IncomingDataStreamManager { +class IncomingDataStreamManagerImpl @Inject constructor( + private val dataStreams: DataStreams, +) : IncomingDataStreamManager { - /** - * A stream descriptor for any open incoming streams. - */ - private data class Descriptor( - val streamInfo: StreamInfo, - /** - * Measured by SystemClock.elapsedRealtime() - */ - val openTime: Long, - /** - * The channel to pipe any incoming data into. - * - * Calling [Channel.close] will automatically call [closeStream] and remove the stream. - */ - val channel: Channel, - var readLength: Long = 0, - ) - - private val openStreams = Collections.synchronizedMap(mutableMapOf()) - private val textStreamHandlers = Collections.synchronizedMap(mutableMapOf()) - private val byteStreamHandlers = Collections.synchronizedMap(mutableMapOf()) - - /** - * Registers a text stream handler for [topic]. Only one handler can be set for a particular topic at a time. - * - * @throws IllegalArgumentException if a topic is already set. - */ override fun registerTextStreamHandler(topic: String, handler: TextStreamHandler) { - synchronized(textStreamHandlers) { - if (textStreamHandlers.containsKey(topic)) { - throw IllegalArgumentException("A text stream handler for topic $topic has already been set.") - } - - textStreamHandlers[topic] = handler - } + dataStreams.registerTextStreamHandler(topic, handler) } - /** - * Unregisters a previously registered text handler for [topic]. - */ override fun unregisterTextStreamHandler(topic: String) { - synchronized(textStreamHandlers) { - textStreamHandlers.remove(topic) - } + dataStreams.unregisterTextStreamHandler(topic) } - /** - * Registers a byte stream handler for [topic]. Only one handler can be set for a particular topic at a time. - * - * @throws IllegalArgumentException if a topic is already set. - */ override fun registerByteStreamHandler(topic: String, handler: ByteStreamHandler) { - synchronized(byteStreamHandlers) { - if (byteStreamHandlers.containsKey(topic)) { - throw IllegalArgumentException("A byte stream handler for topic $topic has already been set.") - } - - byteStreamHandlers[topic] = handler - } + dataStreams.registerByteStreamHandler(topic, handler) } - /** - * Unregisters a previously registered byte handler for [topic]. - */ override fun unregisterByteStreamHandler(topic: String) { - synchronized(byteStreamHandlers) { - byteStreamHandlers.remove(topic) - } + dataStreams.unregisterByteStreamHandler(topic) } /** * @suppress */ - override fun handleStreamHeader(header: DataStream.Header, fromIdentity: Participant.Identity, encryptionType: LivekitModels.Encryption.Type) { - val info = streamInfoFromHeader(header, encryptionType) ?: return - openStream(info, fromIdentity) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private fun openStream(info: StreamInfo, fromIdentity: Participant.Identity) { - if (openStreams.containsKey(info.id)) { - LKLog.w { "Stream already open for id ${info.id}" } - return - } - - val handler = getHandlerForInfo(info) - val channel = createChannelForStreamReceiver() - - val descriptor = Descriptor( - streamInfo = info, - openTime = SystemClock.elapsedRealtime(), - channel = channel, + override fun handleStreamHeader( + header: DataStream.Header, + fromIdentity: Participant.Identity, + encryptionType: LivekitModels.Encryption.Type, + ) { + dataStreams.handleIncoming( + LivekitModels.DataPacket.newBuilder() + .setParticipantIdentity(fromIdentity.value) + .setStreamHeader(header) + .build(), ) - - openStreams[info.id] = descriptor - channel.invokeOnClose { closeStream(id = info.id) } - LKLog.d { "Opened stream ${info.id}" } - - try { - handler.invoke(channel, fromIdentity) - } catch (e: Exception) { - LKLog.e(e) { "Unhandled exception when invoking stream handler!" } - } } /** * @suppress */ override fun handleDataChunk(chunk: DataStream.Chunk, encryptionType: LivekitModels.Encryption.Type) { - val content = chunk.content ?: return - val descriptor = openStreams[chunk.streamId] ?: return - - if (encryptionType != descriptor.streamInfo.encryptionType) { - descriptor.channel.close( - StreamException.EncryptionTypeMismatch( - "Encryption type mismatch for stream ${chunk.streamId}. Expected ${descriptor.streamInfo.encryptionType}, got $encryptionType", - ), - ) - } - - val totalReadLength = descriptor.readLength + content.size() - - val totalLength = descriptor.streamInfo.totalSize - if (totalLength != null) { - if (totalReadLength > totalLength) { - descriptor.channel.close(StreamException.LengthExceededException()) - return - } - } - descriptor.readLength = totalReadLength - descriptor.channel.trySend(content.toByteArray()) + // No sender identity: chunks are routed by stream id, and the identity recorded against an + // open stream comes from its header. + dataStreams.handleIncoming( + LivekitModels.DataPacket.newBuilder() + .setStreamChunk(chunk) + .build(), + ) } /** * @suppress */ override fun handleStreamTrailer(trailer: DataStream.Trailer, encryptionType: LivekitModels.Encryption.Type) { - val descriptor = openStreams[trailer.streamId] - if (descriptor == null) { - LKLog.w { "Received trailer for unknown stream: ${trailer.streamId}" } - return - } - - if (encryptionType != descriptor.streamInfo.encryptionType) { - descriptor.channel.close( - StreamException.EncryptionTypeMismatch( - "Encryption type mismatch for stream ${trailer.streamId}. Expected ${descriptor.streamInfo.encryptionType}, got $encryptionType", - ), - ) - } - - val totalLength = descriptor.streamInfo.totalSize - if (totalLength != null) { - if (descriptor.readLength != totalLength) { - descriptor.channel.close(StreamException.IncompleteException()) - return - } - } - - val reason = trailer.reason - - if (!reason.isNullOrEmpty()) { - // A non-empty reason string indicates an error - val exception = StreamException.AbnormalEndException(reason) - descriptor.channel.close(exception) - return - } - - // Close successfully. - descriptor.channel.close() - } - - private fun closeStream(id: String) { - synchronized(openStreams) { - val descriptor = openStreams[id] - if (descriptor == null) { - LKLog.d { "Attempted to close stream $id, but no descriptor was found." } - return - } - - descriptor.channel.close() - val openMillis = SystemClock.elapsedRealtime() - descriptor.openTime - LKLog.d { "Closed stream $id, (open for ${openMillis}ms" } - - openStreams.remove(id) - } + dataStreams.handleIncoming( + LivekitModels.DataPacket.newBuilder() + .setStreamTrailer(trailer) + .build(), + ) } /** * @suppress */ override fun clearOpenStreams() { - synchronized(openStreams) { - // Create a copy since closing the channel will also remove from openStreams, - // causing a ConcurrentModificationException - val descriptors = openStreams.values.toList() - for (descriptor in descriptors) { - descriptor.channel.close(StreamException.TerminatedException()) - } - openStreams.clear() - } - } - - private fun getHandlerForInfo(info: StreamInfo): AnyStreamHandler { - return when (info) { - is ByteStreamInfo -> { - val handler = byteStreamHandlers[info.topic] - { channel, identity -> - if (handler == null) { - LKLog.w { "Received byte stream for topic \"${info.topic}\", but no handler was found. Ignoring." } - } else { - handler.invoke(ByteStreamReceiver(info, channel), identity) - } - } - } - - is TextStreamInfo -> { - val handler = textStreamHandlers[info.topic] - - { channel, identity -> - if (handler == null) { - LKLog.w { "Received text stream for topic \"${info.topic}\", but no handler was found. Ignoring." } - } else { - handler.invoke(TextStreamReceiver(info, channel), identity) - } - } - } - } - } - - private fun streamInfoFromHeader(header: DataStream.Header, encryptionType: LivekitModels.Encryption.Type): StreamInfo? { - try { - return when (header.contentHeaderCase) { - DataStream.Header.ContentHeaderCase.TEXT_HEADER -> { - TextStreamInfo(header, header.textHeader, encryptionType) - } - - DataStream.Header.ContentHeaderCase.BYTE_HEADER -> { - ByteStreamInfo(header, header.byteHeader, encryptionType) - } - - DataStream.Header.ContentHeaderCase.CONTENTHEADER_NOT_SET, - null, - -> { - LKLog.i { "received header with non-set content header. streamId: ${header.streamId}, topic: ${header.topic}" } - null - } - } - } catch (e: Exception) { - LKLog.e(e) { "Exception when processing new stream header." } - return null - } + dataStreams.abortAllStreams() } companion object { + /** + * @suppress + */ @VisibleForTesting fun createChannelForStreamReceiver() = Channel( capacity = Int.MAX_VALUE, diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/outgoing/OutgoingDataStreamManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/outgoing/OutgoingDataStreamManager.kt index 08d0e858b..19149899f 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/outgoing/OutgoingDataStreamManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/outgoing/OutgoingDataStreamManager.kt @@ -17,24 +17,15 @@ package io.livekit.android.room.datastream.outgoing import androidx.annotation.CheckResult -import com.google.protobuf.ByteString -import io.livekit.android.room.RTCEngine import io.livekit.android.room.datastream.ByteStreamInfo +import io.livekit.android.room.datastream.DataStreams import io.livekit.android.room.datastream.StreamBytesOptions import io.livekit.android.room.datastream.StreamException -import io.livekit.android.room.datastream.StreamInfo import io.livekit.android.room.datastream.StreamTextOptions import io.livekit.android.room.datastream.TextStreamInfo -import io.livekit.android.room.participant.Participant -import io.livekit.android.util.LKLog -import livekit.LivekitModels -import livekit.LivekitModels.DataPacket -import livekit.LivekitModels.DataStream +import io.livekit.android.util.rethrowIfCancellationSignal import java.io.File import java.io.InputStream -import java.util.Collections -import java.util.Date -import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject interface OutgoingDataStreamManager { @@ -72,6 +63,25 @@ interface OutgoingDataStreamManager { } } + /** + * Send a byte payload through a data stream. + * + * Prefer this over opening a stream with [streamBytes] when the whole payload is already in + * memory: because the size is known up front, it can be sent as a single packet and compressed, + * where recipients support it. + */ + @CheckResult + suspend fun sendBytes(data: ByteArray, options: StreamBytesOptions = StreamBytesOptions()): Result { + return useStreamSender(streamBytes(options)) { + val result = write(data) + if (result.isFailure) { + throw (result.exceptionOrNull() ?: Exception("Unknown error.")) + } + close() + return@useStreamSender info + } + } + /** * Send a file through a data stream. */ @@ -89,219 +99,47 @@ interface OutgoingDataStreamManager { } /** + * Adapts [OutgoingDataStreamManager] onto [DataStreams], which owns the actual implementation. + * + * The whole-payload sends are overridden rather than left to the interface's default + * open-write-close implementations, so that they reach the core's one-shot paths and can be sent as + * a single packet and/or compressed. + * * @suppress */ -class OutgoingDataStreamManagerImpl -@Inject -constructor( - val engine: RTCEngine, +class OutgoingDataStreamManagerImpl @Inject constructor( + private val dataStreams: DataStreams, ) : OutgoingDataStreamManager { - private data class Descriptor( - val info: StreamInfo, - val destinationIdentityStrings: List, - var writtenLength: Long = 0L, - val nextChunkIndex: AtomicLong = AtomicLong(0), - ) - - private val openStreams = Collections.synchronizedMap(mutableMapOf()) - - @CheckResult - private suspend fun openStream( - info: StreamInfo, - destinationIdentities: List = emptyList(), - ): Result { - if (openStreams.containsKey(info.id)) { - throw StreamException.AlreadyOpenedException() - } - - val destinationIdentityStrings = destinationIdentities.map { it.value } - val headerPacket = with(DataPacket.newBuilder()) { - addAllDestinationIdentities(destinationIdentityStrings) - kind = DataPacket.Kind.RELIABLE - streamHeader = with(DataStream.Header.newBuilder()) { - this.streamId = info.id - this.topic = info.topic - this.timestamp = info.timestampMs - this.putAllAttributes(info.attributes) - - info.totalSize?.let { - this.totalLength = it - } - - when (info) { - is ByteStreamInfo -> { - this.mimeType = info.mimeType - this.byteHeader = with(DataStream.ByteHeader.newBuilder()) { - if (info.name != null) { - this.name = info.name - } - build() - } - } - - is TextStreamInfo -> { - textHeader = with(DataStream.TextHeader.newBuilder()) { - this.operationType = info.operationType.toProto() - this.version = info.version - if (info.replyToStreamId != null) { - this.replyToStreamId = info.replyToStreamId - } - this.addAllAttachedStreamIds(info.attachedStreamIds) - this.generated = info.generated - build() - } - } - } - build() - } - build() - } - - val result = engine.sendData(headerPacket) - if (result.isFailure) { - return result - } - - val descriptor = Descriptor(info, destinationIdentityStrings) - openStreams[info.id] = descriptor - - LKLog.d { "Opened send stream ${info.id}" } - return Result.success(Unit) + override suspend fun streamText(options: StreamTextOptions): TextStreamSender { + return dataStreams.streamText(options) } - @CheckResult - private suspend fun sendChunk(streamId: String, dataChunk: ByteArray): Result { - val descriptor = openStreams[streamId] ?: throw StreamException.UnknownStreamException() - val nextChunkIndex = descriptor.nextChunkIndex.getAndIncrement() - - val chunkPacket = with(DataPacket.newBuilder()) { - addAllDestinationIdentities(descriptor.destinationIdentityStrings) - kind = DataPacket.Kind.RELIABLE - streamChunk = with(DataStream.Chunk.newBuilder()) { - this.streamId = streamId - this.content = ByteString.copyFrom(dataChunk) - this.chunkIndex = nextChunkIndex - build() - } - build() - } - - engine.waitForBufferStatusLow(DataPacket.Kind.RELIABLE) - return engine.sendData(chunkPacket) + override suspend fun streamBytes(options: StreamBytesOptions): ByteStreamSender { + return dataStreams.streamBytes(options) } - private suspend fun closeStream(streamId: String, reason: String? = null) { - val descriptor = openStreams[streamId] ?: throw StreamException.UnknownStreamException() - - val trailerPacket = with(DataPacket.newBuilder()) { - addAllDestinationIdentities(descriptor.destinationIdentityStrings) - kind = DataPacket.Kind.RELIABLE - streamTrailer = with(DataStream.Trailer.newBuilder()) { - this.streamId = streamId - if (reason != null) { - this.reason = reason - } - build() - } - build() - } - - engine.waitForBufferStatusLow(DataPacket.Kind.RELIABLE) - val result = engine.sendData(trailerPacket) - - if (result.isFailure) { - // Log close failure only for now. - LKLog.w(result.exceptionOrNull()) { "Error when closing stream!" } - } - - openStreams.remove(streamId) - LKLog.d { "Closed send stream $streamId" } + override suspend fun sendText(text: String, options: StreamTextOptions): Result { + return runCatchingStream { dataStreams.sendText(text, options) } } - override suspend fun streamText(options: StreamTextOptions): TextStreamSender { - val streamInfo = TextStreamInfo( - id = options.streamId, - topic = options.topic, - timestampMs = Date().time, - totalSize = options.totalSize, - attributes = options.attributes, - operationType = options.operationType, - version = options.version, - replyToStreamId = options.replyToStreamId, - attachedStreamIds = options.attachedStreamIds, - generated = false, - encryptionType = if (engine.e2EEManager?.isDataChannelEncryptionEnabled() ?: false) { - LivekitModels.Encryption.Type.GCM - } else { - LivekitModels.Encryption.Type.NONE - }, - ) - - val streamId = options.streamId - val result = openStream(streamInfo, options.destinationIdentities) - - if (result.isFailure) { - throw result.exceptionOrNull() ?: StreamException.TerminatedException("Unknown failure when opening the stream!") - } - - val destination = ManagerStreamDestination(streamId) - return TextStreamSender( - streamInfo, - destination, - ) + override suspend fun sendBytes(data: ByteArray, options: StreamBytesOptions): Result { + return runCatchingStream { dataStreams.sendBytes(data, options) } } - override suspend fun streamBytes(options: StreamBytesOptions): ByteStreamSender { - val streamInfo = ByteStreamInfo( - id = options.streamId, - topic = options.topic, - timestampMs = Date().time, - totalSize = options.totalSize, - attributes = options.attributes, - mimeType = options.mimeType, - name = options.name, - encryptionType = if (engine.e2EEManager?.isDataChannelEncryptionEnabled() ?: false) { - LivekitModels.Encryption.Type.GCM - } else { - LivekitModels.Encryption.Type.NONE - }, - ) - - val streamId = options.streamId - val result = openStream(streamInfo, options.destinationIdentities) - - if (result.isFailure) { - throw result.exceptionOrNull() ?: StreamException.TerminatedException("Unknown failure when opening the stream!") - } - val destination = ManagerStreamDestination(streamId) - return ByteStreamSender( - streamInfo, - destination, - ) + override suspend fun sendFile(file: File, options: StreamBytesOptions): Result { + // Options are passed through untouched: the previous implementation did not infer a name, + // MIME type or size from the file either, and doing so now would change the bytes on the + // wire for existing callers. + return runCatchingStream { dataStreams.sendFile(file.absolutePath, options) } } - private inner class ManagerStreamDestination(val streamId: String) : StreamDestination { - override val isOpen: Boolean - get() = openStreams.contains(streamId) - - override suspend fun write(data: T, chunker: DataChunker): Result { - if (!isOpen) { - return Result.failure(StreamException.TerminatedException("Stream is closed!")) - } - val chunks = chunker.invoke(data, RTCEngine.TARGET_DATA_PACKET_SIZE) - - for (chunk in chunks) { - val result = sendChunk(streamId, chunk) - if (result.isFailure) { - return result - } - } - return Result.success(Unit) - } - - override suspend fun close(reason: String?) { - closeStream(streamId, reason) + private inline fun runCatchingStream(body: () -> T): Result { + return try { + Result.success(body()) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + Result.failure(e) } } } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/Participant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/Participant.kt index c3a6298b9..81ac71543 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/Participant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/Participant.kt @@ -22,6 +22,7 @@ import io.livekit.android.events.BroadcastEventBus import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.events.TrackEvent +import io.livekit.android.room.ClientCapability import io.livekit.android.room.ClientProtocolVersion import io.livekit.android.room.track.LocalTrackPublication import io.livekit.android.room.track.RemoteTrackPublication @@ -277,6 +278,18 @@ open class Participant( var clientProtocol: Int by flowDelegate(ClientProtocolVersion.DEFAULT.value) internal set + /** + * The optional feature capabilities this participant's client advertises. + * + * Mirrored by the server from the participant's `ClientInfo`. Values this SDK build does not + * recognize are omitted, so an empty list means the participant advertised nothing usable. + * Unlike [clientProtocol], which is a single version, capabilities are independent flags. + */ + @FlowObservable + @get:FlowObservable + var capabilities: List by flowDelegate(emptyList()) + internal set + /** * @suppress */ @@ -449,6 +462,7 @@ open class Participant( agentAttributes = AgentAttributes.fromStringMap(info.attributesMap) state = State.fromProto(info.state) clientProtocol = info.clientProtocol + capabilities = info.capabilitiesList.mapNotNull { ClientCapability.fromProto(it) } } override fun equals(other: Any?): Boolean { diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/stats/ClientInfo.kt b/livekit-android-sdk/src/main/java/io/livekit/android/stats/ClientInfo.kt index 19e1b941f..824b2c8a2 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/stats/ClientInfo.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/stats/ClientInfo.kt @@ -18,12 +18,13 @@ package io.livekit.android.stats import android.os.Build import io.livekit.android.BuildConfig +import io.livekit.android.room.ADVERTISED_CLIENT_CAPABILITIES import io.livekit.android.room.ClientProtocolVersion import io.livekit.android.room.SignalClient import livekit.LivekitModels internal fun getClientInfo( - clientProtocol: ClientProtocolVersion = ClientProtocolVersion.DATA_STREAM_RPC, + clientProtocol: ClientProtocolVersion = ClientProtocolVersion.DATA_STREAM_V2, ) = with(LivekitModels.ClientInfo.newBuilder()) { sdk = LivekitModels.ClientInfo.SDK.ANDROID version = BuildConfig.VERSION_NAME @@ -34,5 +35,6 @@ internal fun getClientInfo( val model = Build.MODEL ?: "" deviceModel = ("$vendor $model").trim() this.clientProtocol = clientProtocol.value + addAllCapabilities(ADVERTISED_CLIENT_CAPABILITIES.map { it.toProto() }) build() } diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle index 1629569f0..5fa7954bf 100644 --- a/livekit-android-test/build.gradle +++ b/livekit-android-test/build.gradle @@ -110,6 +110,10 @@ dependencies { implementation(project(":livekit-android-sdk")) implementation libs.coroutines.lib implementation libs.kotlinx.serialization.json + // The SDK keeps livekit-uniffi as `implementation` so the FFI types stay out of its public + // API. Tests here drive the FFI managers directly (capturing delegates, stub registries), so + // they need it on their own classpath. + implementation libs.livekit.uniffi api libs.okhttp.lib api libs.audioswitch implementation libs.androidx.annotation @@ -128,15 +132,40 @@ dependencies { testImplementation libs.junit testImplementation libs.robolectric + // livekit-uniffi depends on jna's *aar*, which only carries Android .so dispatch libs. Host + // JVM test runs need the plain jar, which bundles libjnidispatch for desktop platforms. + testImplementation libs.jna.desktop testImplementation libs.okhttp.mockwebserver testImplementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" kaptTest libs.dagger.compiler androidTestImplementation libs.androidx.test.junit androidTestImplementation libs.espresso + // Instrumented tests drive the FFI directly to prove the native library works on a real + // device; see DataStreamsOnDeviceTest. + androidTestImplementation libs.livekit.uniffi + androidTestImplementation libs.coroutines.lib + androidTestImplementation libs.protobuf.javalite } +apply from: rootProject.file('gradle/uniffi-native-lib.gradle') + tasks.withType(Test).configureEach { + // One JVM per test class. + // + // The data stream core is a native library with process-global state (its async runtime, and + // the callback vtables the bindings register into it), loaded through JNA. Robolectric gives + // test classes their own classloaders, so a second class re-initializing those bindings can + // leave the first class's -- or its own -- callbacks pointing nowhere, and every data stream + // silently stops. It reproduced as whole test classes intermittently timing out depending on + // execution order. + // + // Isolating classes into their own JVMs sidesteps it, at the cost of roughly 90s on this + // module's suite. Worth revisiting if uniffi's Kotlin bindings ever become safe to initialize + // more than once in a process. + forkEvery = 1 systemProperty "robolectric.logging.enabled", true + // Data streams run through the Rust core, so unit tests need a host build of it. + configureUniffiForUnitTests(it) } apply from: rootProject.file('gradle/gradle-mvn-push.gradle') diff --git a/livekit-android-test/src/androidTest/java/io/livekit/android/room/datastream/DataStreamsOnDeviceTest.kt b/livekit-android-test/src/androidTest/java/io/livekit/android/room/datastream/DataStreamsOnDeviceTest.kt new file mode 100644 index 000000000..1e3ecb3ea --- /dev/null +++ b/livekit-android-test/src/androidTest/java/io/livekit/android/room/datastream/DataStreamsOnDeviceTest.kt @@ -0,0 +1,353 @@ +/* + * 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.datastream + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.livekit.android.room.ClientCapability +import io.livekit.android.room.ClientProtocolVersion +import io.livekit.uniffi.buildVersion +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import livekit.LivekitModels +import livekit.LivekitModels.DataPacket +import livekit.LivekitModels.DataStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import io.livekit.uniffi.ByteStreamReader as FfiByteStreamReader +import io.livekit.uniffi.ClientCapability as FfiClientCapability +import io.livekit.uniffi.IncomingDataStreamManager as FfiIncomingDataStreamManager +import io.livekit.uniffi.IncomingDataStreamManagerDelegate as FfiIncomingDelegate +import io.livekit.uniffi.OutgoingDataStreamManager as FfiOutgoingDataStreamManager +import io.livekit.uniffi.OutgoingDataStreamManagerDelegate as FfiOutgoingDelegate +import io.livekit.uniffi.RemoteParticipantRegistryDelegate as FfiRegistryDelegate +import io.livekit.uniffi.StreamTextOptions as FfiStreamTextOptions +import io.livekit.uniffi.TextStreamReader as FfiTextStreamReader + +/** + * Runs the data stream core on a real Android device. + * + * The unit tests exercise the same code but on a host JVM against a host build of the library, + * which leaves a few things unproven that only Android can settle: + * + * - that the `.so` in the AAR loads on Android at all, through JNA, from the packaged jniLibs; + * - that the bindings' Android-specific resource cleaner works. It is selected at API 34 and + * above and uses `android.system.SystemCleaner`, which under Robolectric throws + * IllegalAccessError and needs a JVM flag to work around. This is the only place that path runs + * as written; + * - that the Rust core's async runtime and its threads behave on Android's runtime; + * - that the v2 framings are byte-identical to what the host build produces. + * + * Deliberately no mocking framework: androidTest has no Mockito here, so this drives the FFI + * directly with a capturing delegate, the same seam the Swift SDK's tests use. That also means it + * covers the layer this SDK owns -- the conversions and the error mapping -- without needing an + * engine or a room. + */ +@RunWith(AndroidJUnit4::class) +class DataStreamsOnDeviceTest { + + private companion object { + const val TOPIC = "topic" + const val SENDER = "alice" + const val COMPRESSIBLE = "hello hello compressible world" + val TIMEOUT = 10L to TimeUnit.SECONDS + } + + private class CapturingDelegate : FfiOutgoingDelegate { + val packets = CopyOnWriteArrayList() + override fun onPacketsAvailable(packets: List) { + for (bytes in packets) { + this.packets.add(DataPacket.parseFrom(bytes)) + } + } + } + + /** Reports whatever the test wants the core to believe about the room. */ + private class StubRegistry( + private val protocol: Int, + private val capabilities: List, + private val identities: List = listOf(SENDER), + ) : FfiRegistryDelegate { + override fun remoteClientProtocol(identity: String) = protocol + override fun remoteCapabilities(identity: String) = capabilities.map { it.toFfi() } + override fun remoteIdentities() = identities + } + + private class OpenedStreams : FfiIncomingDelegate { + val text = CopyOnWriteArrayList() + val bytes = CopyOnWriteArrayList() + val latch = CountDownLatch(1) + + override fun onTextStreamOpened(reader: FfiTextStreamReader, identity: String) { + text.add(reader) + latch.countDown() + } + + override fun onByteStreamOpened(reader: FfiByteStreamReader, identity: String) { + bytes.add(reader) + latch.countDown() + } + } + + private fun CapturingDelegate.awaitPackets(count: Int) { + val deadline = System.currentTimeMillis() + TIMEOUT.first * 1000 + while (packets.size < count && System.currentTimeMillis() < deadline) { + Thread.sleep(10) + } + // Settle briefly so "exactly N" assertions mean something. + Thread.sleep(200) + assertTrue( + "expected at least $count packet(s) on device, saw ${packets.size}", + packets.size >= count, + ) + } + + /** + * The library has to load before anything else here can work, and this is the call that forces + * both the JNA registration and the checksum check between the bindings and the `.so`. + */ + @Test + fun nativeLibraryLoadsOnDevice() { + val version = buildVersion() + + assertTrue("livekit-uniffi reported an empty build version", version.isNotEmpty()) + } + + /** + * The cleaner the bindings pick is API dependent, and the branch taken here is the one the host + * tests cannot run. Asserted so a device running an older API is not silently taken as + * covering it. + */ + @Test + fun runsOnAnApiLevelThatUsesTheAndroidCleaner() { + assertTrue( + "this device is API ${Build.VERSION.SDK_INT}; the Android cleaner path needs 34+", + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE, + ) + } + + @Test + fun sendsLegacyFramingToPreV2Recipients() = runBlocking { + val delegate = CapturingDelegate() + val manager = FfiOutgoingDataStreamManager( + delegate, + StubRegistry(ClientProtocolVersion.DEFAULT.value, emptyList()), + ) + + withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + manager.sendText("hello world", FfiStreamTextOptions(topic = TOPIC, attributes = emptyMap())) + } + delegate.awaitPackets(3) + + assertEquals(3, delegate.packets.size) + val header = delegate.packets[0].streamHeader + assertTrue(delegate.packets[0].hasStreamHeader()) + assertEquals(DataStream.CompressionType.NONE, header.compression) + assertFalse(header.hasInlineContent()) + assertEquals("hello world", delegate.packets[1].streamChunk.content.toStringUtf8()) + assertTrue(delegate.packets[2].hasStreamTrailer()) + manager.destroy() + } + + @Test + fun sendsInlineCompressedToV2Recipients() = runBlocking { + val delegate = CapturingDelegate() + val manager = FfiOutgoingDataStreamManager( + delegate, + StubRegistry( + ClientProtocolVersion.DATA_STREAM_V2.value, + listOf(ClientCapability.COMPRESSION_DEFLATE_RAW), + ), + ) + + withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + manager.sendText(COMPRESSIBLE, FfiStreamTextOptions(topic = TOPIC, attributes = emptyMap())) + } + delegate.awaitPackets(1) + + assertEquals("an inline send is a single packet", 1, delegate.packets.size) + val header = delegate.packets[0].streamHeader + assertEquals(DataStream.CompressionType.DEFLATE_RAW, header.compression) + assertTrue(header.hasInlineContent()) + assertFalse( + "inline content should be compressed, not raw text", + header.inlineContent.toStringUtf8() == COMPRESSIBLE, + ) + manager.destroy() + } + + /** Compression is gated on the capability even when the protocol version allows inline. */ + @Test + fun omitsCompressionForRecipientsWithoutTheCapability() = runBlocking { + val delegate = CapturingDelegate() + val manager = FfiOutgoingDataStreamManager( + delegate, + StubRegistry(ClientProtocolVersion.DATA_STREAM_V2.value, emptyList()), + ) + + withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + manager.sendText(COMPRESSIBLE, FfiStreamTextOptions(topic = TOPIC, attributes = emptyMap())) + } + delegate.awaitPackets(1) + + assertEquals(1, delegate.packets.size) + val header = delegate.packets[0].streamHeader + assertEquals(DataStream.CompressionType.NONE, header.compression) + assertEquals(COMPRESSIBLE, header.inlineContent.toStringUtf8()) + manager.destroy() + } + + /** + * Loops the core's own output back into its input on the device. + * + * The strongest single check available without a server: whatever framing the send path chose -- + * here inline and compressed -- the receive path reconstructs the original payload from exactly + * those bytes, with both halves running on Android. + */ + @Test + fun compressedInlineStreamRoundTripsThroughTheCore() = runBlocking { + val payload = buildString { repeat(200) { append("round trip me ") } } + + val sent = CapturingDelegate() + val outgoing = FfiOutgoingDataStreamManager( + sent, + StubRegistry( + ClientProtocolVersion.DATA_STREAM_V2.value, + listOf(ClientCapability.COMPRESSION_DEFLATE_RAW), + ), + ) + withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + outgoing.sendText(payload, FfiStreamTextOptions(topic = TOPIC, attributes = emptyMap())) + } + sent.awaitPackets(1) + assertEquals(DataStream.CompressionType.DEFLATE_RAW, sent.packets[0].streamHeader.compression) + + val opened = OpenedStreams() + val incoming = FfiIncomingDataStreamManager(opened, null) + for (packet in sent.packets) { + // Stamp a sender, which the wire carries but a capturing delegate never sees. + incoming.handlePacketReceived( + packet.toBuilder().setParticipantIdentity(SENDER).build().toByteArray(), + ) + } + + assertTrue( + "the core never surfaced the stream it had just produced", + opened.latch.await(TIMEOUT.first, TIMEOUT.second), + ) + val received = withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + opened.text.first().readAll() + } + + assertEquals(payload, received) + assertEquals(TOPIC, opened.text.first().info().topic) + outgoing.destroy() + incoming.destroy() + } + + /** Multi-byte text has to survive the encode/decode the SDK's reader glue does. */ + @Test + fun multiByteTextRoundTripsOnDevice() = runBlocking { + val text = "héllo → 世界 🎉 café" + + val sent = CapturingDelegate() + val outgoing = FfiOutgoingDataStreamManager( + sent, + StubRegistry(ClientProtocolVersion.DEFAULT.value, emptyList()), + ) + withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + outgoing.sendText(text, FfiStreamTextOptions(topic = TOPIC, attributes = emptyMap())) + } + sent.awaitPackets(3) + + val opened = OpenedStreams() + val incoming = FfiIncomingDataStreamManager(opened, null) + for (packet in sent.packets) { + incoming.handlePacketReceived( + packet.toBuilder().setParticipantIdentity(SENDER).build().toByteArray(), + ) + } + assertTrue(opened.latch.await(TIMEOUT.first, TIMEOUT.second)) + + val received = withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + opened.text.first().readAll() + } + // Round-tripped through the byte channel the public reader is built on, as the SDK does. + val throughByteChannel = received.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8) + + assertEquals(text, throughByteChannel) + outgoing.destroy() + incoming.destroy() + } + + // region This SDK's own translation layer, exercised in an Android runtime + + @Test + fun streamInfoConversionWorksOnDevice() = runBlocking { + val sent = CapturingDelegate() + val outgoing = FfiOutgoingDataStreamManager( + sent, + StubRegistry(ClientProtocolVersion.DEFAULT.value, emptyList()), + ) + + val info = withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + outgoing.sendText( + "hello", + FfiStreamTextOptions(topic = TOPIC, attributes = mapOf("a" to "b"), id = "id-1"), + ) + }.toSdk(LivekitModels.Encryption.Type.GCM) + + assertEquals("id-1", info.id) + assertEquals(TOPIC, info.topic) + assertEquals("b", info.attributes["a"]) + assertEquals(5L, info.totalSize) + assertEquals(LivekitModels.Encryption.Type.GCM, info.encryptionType) + outgoing.destroy() + } + + @Test + fun capabilityAndOperationTypeMappingWorksOnDevice() { + assertEquals( + FfiClientCapability.COMPRESSION_DEFLATE_RAW, + ClientCapability.COMPRESSION_DEFLATE_RAW.toFfi(), + ) + for (value in TextStreamInfo.OperationType.entries) { + assertEquals(value, value.toFfi().toSdk()) + } + } + + @Test + fun errorMappingWorksOnDevice() { + val header = io.livekit.uniffi.DataStreamException.HeaderTooLarge().toStreamException() + assertTrue(header is StreamException.HeaderTooLargeException) + assertTrue("must stay catchable as the older type", header is StreamException.LengthExceededException) + + val terminated = io.livekit.uniffi.DataStreamException.MissedChunk().toStreamException() + assertEquals( + StreamException.TerminatedException.Reason.MISSED_CHUNK, + (terminated as StreamException.TerminatedException).reason, + ) + } + + // endregion +} diff --git a/livekit-android-test/src/main/AndroidManifest.xml b/livekit-android-test/src/main/AndroidManifest.xml index 8bdb7e14b..38ce537be 100644 --- a/livekit-android-test/src/main/AndroidManifest.xml +++ b/livekit-android-test/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ - + + + + diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt b/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt index 866d1cb90..0e458acee 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.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. @@ -56,4 +56,38 @@ abstract class BaseTest { dispatchTimeoutMs = dispatchTimeoutMs, testBody = testBody ) + + /** + * Waits for [condition], pumping the test scheduler while also yielding real time. + * + * Needed wherever work crosses onto a real thread -- notably anything going through the Rust + * data stream core. See io.livekit.android.test.util.awaitCondition. + */ + fun awaitCondition( + timeoutMs: Long = 5_000L, + message: String = "Condition was not met", + condition: () -> Boolean, + ) = io.livekit.android.test.util.awaitCondition( + scheduler = coroutineRule.dispatcher.scheduler, + timeoutMs = timeoutMs, + message = message, + condition = condition, + ) + + /** + * Waits until [snapshot] stops changing, pumping the test scheduler while also yielding real + * time. See io.livekit.android.test.util.awaitStable. + */ + fun awaitStable( + quietMs: Long = 100L, + minWaitMs: Long = 200L, + timeoutMs: Long = 5_000L, + snapshot: () -> Any?, + ) = io.livekit.android.test.util.awaitStable( + scheduler = coroutineRule.dispatcher.scheduler, + quietMs = quietMs, + minWaitMs = minWaitMs, + timeoutMs = timeoutMs, + snapshot = snapshot, + ) } diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockDataChannel.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockDataChannel.kt index 8dfa7e50d..b0a7fd2a4 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockDataChannel.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockDataChannel.kt @@ -17,14 +17,24 @@ package io.livekit.android.test.mock import livekit.org.webrtc.DataChannel +import java.util.concurrent.CopyOnWriteArrayList class MockDataChannel(private val label: String?) : DataChannel(1L) { var observer: Observer? = null - var sentBuffers = mutableListOf() + + /** + * Buffers passed to [send], in order. + * + * Copy-on-write because sends no longer all originate from the test thread: data streams are + * driven by the Rust core, which emits packets from its own runtime threads, while assertions + * iterate this list from the test thread. A synchronized list would not be enough -- it still + * throws ConcurrentModificationException when traversed during a concurrent add. + */ + var sentBuffers: MutableList = CopyOnWriteArrayList() /** Snapshot of the bytes visible at send time, captured via the Buffer's current position/limit. */ - var sentPayloads = mutableListOf() + var sentPayloads: MutableList = CopyOnWriteArrayList() var sendResult = true /** diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/util/AwaitCondition.kt b/livekit-android-test/src/main/java/io/livekit/android/test/util/AwaitCondition.kt new file mode 100644 index 000000000..1e81bc261 --- /dev/null +++ b/livekit-android-test/src/main/java/io/livekit/android/test/util/AwaitCondition.kt @@ -0,0 +1,106 @@ +/* + * 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.util + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler + +/** + * Waiting primitives for work that spans both the test dispatcher and real threads. + * + * Data streams are handled by the Rust core, which resumes calls on threads of its own. That leaves + * tests needing to make progress on two fronts at once, and neither of the usual tools does it: + * + * - advancing the test scheduler ([TestCoroutineScheduler.advanceUntilIdle] and friends) does + * nothing for work sitting on a real thread, and + * - waiting in real time (`withContext(Dispatchers.Default) { delay(..) }`) parks the test body, + * and while it is parked nobody is running the test scheduler -- so a coroutine on the test + * dispatcher waiting to resume from the core never does, and the wait deadlocks. + * + * These helpers interleave the two: pump the scheduler, check, then hand real time to the other + * threads with a blocking sleep on the test thread, and repeat. The sleep is deliberate; `delay` + * here would consume virtual time and never yield the CPU. + */ + +private const val POLL_MS = 2L + +/** + * Pumps [scheduler] and waits, in real time, for [condition] to hold. + * + * @throws AssertionError if [condition] has not become true within [timeoutMs]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun awaitCondition( + scheduler: TestCoroutineScheduler, + timeoutMs: Long = 5_000L, + message: String = "Condition was not met", + condition: () -> Boolean, +) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + scheduler.runCurrent() + if (condition()) { + return + } + Thread.sleep(POLL_MS) + } + scheduler.runCurrent() + if (!condition()) { + throw AssertionError("$message (waited ${timeoutMs}ms)") + } +} + +/** + * Pumps [scheduler] and waits until [snapshot] stops changing for [quietMs]. + * + * For when a test needs everything in flight to finish but has no single condition to wait on -- in + * particular before asserting something was *not* produced, where a fixed wait risks passing only + * because the check ran too early. + * + * [minWaitMs] matters as much as the quiet period: before the work has started, [snapshot] is + * trivially unchanging, and waiting for stability alone would return immediately having observed + * nothing. + * + * @param snapshot typically a count of observed side effects, e.g. packets sent so far. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun awaitStable( + scheduler: TestCoroutineScheduler, + quietMs: Long = 100L, + minWaitMs: Long = 200L, + timeoutMs: Long = 5_000L, + snapshot: () -> Any?, +) { + val start = System.currentTimeMillis() + val deadline = start + timeoutMs + var last: Any? = Unit + var stableSince = start + while (System.currentTimeMillis() < deadline) { + scheduler.runCurrent() + val now = System.currentTimeMillis() + val current = snapshot() + if (current != last) { + last = current + stableSince = now + } + if (now - stableSince >= quietMs && now - start >= minWaitMs) { + return + } + Thread.sleep(POLL_MS) + } + scheduler.runCurrent() +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt b/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt index 8bd9a933f..d6a4fac89 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt @@ -98,7 +98,9 @@ class ProtoConverterTest( ProtoConverterTestCase( LivekitAgentDispatch.RoomAgentDispatch::class.java, RoomAgentDispatch::class.java, - whitelist = listOf("restartPolicy", "deployment"), + // `attributes` arrived with a protocol bump and is not surfaced yet; adding it + // would be an unrelated public API change. + whitelist = listOf("restartPolicy", "deployment", "attributes"), ), ) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/ConnectionParamsTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/ConnectionParamsTest.kt new file mode 100644 index 000000000..78c6b4330 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/ConnectionParamsTest.kt @@ -0,0 +1,104 @@ +/* + * 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 + +import io.livekit.android.ConnectOptions +import io.livekit.android.stats.getClientInfo +import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.mock.TestData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import livekit.LivekitModels +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * What this SDK tells the server, and through it every peer, about the data stream features it + * supports. + * + * Getting this wrong is invisible locally and only shows up as peers never using v2 framings, so + * it is asserted on the actual connect URL rather than on the values feeding it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectionParamsTest : MockE2ETest() { + + @Test + fun advertisesDataStreamV2ClientProtocol() = runTest { + connect() + + val url = wsFactory.request.url.toString() + + assertTrue( + "connect URL should advertise client_protocol=2, was: $url", + url.contains("client_protocol=${ClientProtocolVersion.DATA_STREAM_V2.value}"), + ) + } + + @Test + fun advertisesCompressionCapability() = runTest { + connect() + + val url = wsFactory.request.url.toString() + val capabilities = wsFactory.request.url.queryParameter("capabilities") + + assertTrue( + "connect URL should carry a capabilities param, was: $url", + capabilities != null, + ) + // The server parses these as protobuf enum names, so the exact spelling is wire contract. + assertTrue( + "capabilities should advertise deflate-raw support, was: $capabilities", + capabilities!!.split(",").contains("CAP_COMPRESSION_DEFLATE_RAW"), + ) + } + + /** + * Compression is done by the Rust core rather than a platform codec, so there is no device or + * API level on which we support v2 but cannot decompress. + */ + @Test + fun clientInfoCarriesTheSameCapabilities() { + val clientInfo = getClientInfo(ClientProtocolVersion.DATA_STREAM_V2) + + assertEquals(ClientProtocolVersion.DATA_STREAM_V2.value, clientInfo.clientProtocol) + assertEquals( + listOf(LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW), + clientInfo.capabilitiesList, + ) + } + + @Test + fun clientProtocolIsOverridableByConnectOptions() = runTest { + val job = coroutineRule.scope.launch { + room.connect( + url = TestData.EXAMPLE_URL, + token = "token", + options = ConnectOptions(clientProtocol = ClientProtocolVersion.DATA_STREAM_RPC), + ) + } + prepareSignal() + job.join() + + val url = wsFactory.request.url.toString() + + assertTrue( + "explicit clientProtocol should be honored, was: $url", + url.contains("client_protocol=${ClientProtocolVersion.DATA_STREAM_RPC.value}"), + ) + } +} 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..5a2bc6005 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 @@ -29,6 +29,7 @@ import io.livekit.android.events.EventListenable import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.memory.CloseableManager +import io.livekit.android.room.datastream.DataStreams import io.livekit.android.room.datastream.incoming.IncomingDataStreamManagerImpl import io.livekit.android.room.network.NetworkCallbackManagerImpl import io.livekit.android.room.participant.LocalParticipant @@ -109,11 +110,16 @@ class RoomTest { } lateinit var room: Room + lateinit var dataStreams: DataStreams @Before fun setup() { context = ApplicationProvider.getApplicationContext() networkCallbackRegistry = MockNetworkCallbackRegistry() + dataStreams = DataStreams( + engine = rtcEngine, + closeableManager = CloseableManager(), + ) room = Room( context = context, engine = rtcEngine, @@ -135,7 +141,8 @@ class RoomTest { regionUrlProviderFactory = regionUrlProviderFactory, connectionWarmer = MockConnectionWarmer(), audioRecordPrewarmer = NoAudioRecordPrewarmer(), - incomingDataStreamManager = IncomingDataStreamManagerImpl(), + incomingDataStreamManager = IncomingDataStreamManagerImpl(dataStreams), + dataStreams = dataStreams, 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/datastream/DataStreamsConversionTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt new file mode 100644 index 000000000..8b15b4244 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt @@ -0,0 +1,287 @@ +/* + * 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.datastream + +import io.livekit.android.room.ClientCapability +import io.livekit.android.room.participant.Participant +import livekit.LivekitModels +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import io.livekit.uniffi.ByteStreamInfo as FfiByteStreamInfo +import io.livekit.uniffi.ClientCapability as FfiClientCapability +import io.livekit.uniffi.DataStreamException as FfiDataStreamException +import io.livekit.uniffi.EncryptionType as FfiEncryptionType +import io.livekit.uniffi.OperationType as FfiOperationType +import io.livekit.uniffi.TextStreamInfo as FfiTextStreamInfo + +/** + * The pure translation layer between the core's types and this SDK's. + * + * Worth testing directly because most of it is only reachable through a live stream otherwise, and + * because the error mapping is lossy by design: the public [StreamException] hierarchy predates the + * core and several new failure modes fold onto one existing case. A wrong fold turns a diagnosable + * failure into a misleading one, and nothing else would catch it. + */ +class DataStreamsConversionTest { + + private val encryption = LivekitModels.Encryption.Type.GCM + + // region Info records + + @Test + fun textStreamInfoConversion() { + val info = FfiTextStreamInfo( + id = "id", + topic = "topic", + timestampMs = 1_700_000_000_000, + totalLength = 42UL, + attributes = mapOf("a" to "b"), + mimeType = "text/plain", + operationType = FfiOperationType.UPDATE, + version = 3, + replyToStreamId = "reply-to", + attachedStreamIds = listOf("att"), + generated = true, + encryptionType = FfiEncryptionType.NONE, + ).toSdk(encryption) + + assertEquals("id", info.id) + assertEquals("topic", info.topic) + assertEquals(1_700_000_000_000, info.timestampMs) + assertEquals(42L, info.totalSize) + assertEquals(mapOf("a" to "b"), info.attributes) + assertEquals(TextStreamInfo.OperationType.UPDATE, info.operationType) + assertEquals(3, info.version) + assertEquals("reply-to", info.replyToStreamId) + assertEquals(listOf("att"), info.attachedStreamIds) + assertTrue(info.generated) + // The core reports NONE on every stream; the room's real value is stamped on instead. + assertEquals(encryption, info.encryptionType) + } + + @Test + fun byteStreamInfoConversion() { + val info = FfiByteStreamInfo( + id = "id", + topic = "topic", + timestampMs = 5, + totalLength = null, + attributes = emptyMap(), + mimeType = "image/png", + name = "pic.png", + encryptionType = FfiEncryptionType.NONE, + ).toSdk(encryption) + + assertEquals("pic.png", info.name) + assertEquals("image/png", info.mimeType) + assertNull("an unknown-length stream must not report a size", info.totalSize) + assertEquals(encryption, info.encryptionType) + } + + // endregion + + // region Enums + + @Test + fun operationTypeRoundTrips() { + for (value in TextStreamInfo.OperationType.entries) { + assertEquals(value, value.toFfi().toSdk()) + } + } + + @Test + fun clientCapabilityMapsToFfi() { + assertEquals(FfiClientCapability.PACKET_TRAILER, ClientCapability.PACKET_TRAILER.toFfi()) + assertEquals( + FfiClientCapability.COMPRESSION_DEFLATE_RAW, + ClientCapability.COMPRESSION_DEFLATE_RAW.toFfi(), + ) + } + + // endregion + + // region Options + + @Test + fun textOptionsConversion() { + val ffi = StreamTextOptions( + topic = "topic", + attributes = mapOf("k" to "v"), + streamId = "sid", + destinationIdentities = listOf(Participant.Identity("alice"), Participant.Identity("bob")), + operationType = TextStreamInfo.OperationType.REACTION, + version = 2, + attachedStreamIds = listOf("a"), + replyToStreamId = "r", + compress = false, + ).toFfi() + + assertEquals("topic", ffi.topic) + assertEquals(mapOf("k" to "v"), ffi.attributes) + assertEquals("sid", ffi.id) + assertEquals(listOf("alice", "bob"), ffi.destinationIdentities) + assertEquals(FfiOperationType.REACTION, ffi.operationType) + assertEquals(2, ffi.version) + assertEquals(listOf("a"), ffi.attachedStreamIds) + assertEquals("r", ffi.replyToStreamId) + assertEquals(false, ffi.compress) + } + + @Test + fun byteOptionsConversion() { + val ffi = StreamBytesOptions( + topic = "topic", + streamId = "sid", + destinationIdentities = listOf(Participant.Identity("alice")), + mimeType = "application/pdf", + name = "doc.pdf", + totalSize = 99, + ).toFfi() + + assertEquals("application/pdf", ffi.mimeType) + assertEquals("doc.pdf", ffi.name) + assertEquals(99UL, ffi.totalLength) + assertEquals(listOf("alice"), ffi.destinationIdentities) + assertEquals(true, ffi.compress) + } + + /** + * An incremental text stream is opened as unknown-length, so a declared total has nowhere to go. + * Asserted so the drop stays deliberate rather than becoming a silent surprise. + */ + @Test + fun textOptionsTotalSizeIsNotCarriedOver() { + val options = StreamTextOptions(topic = "topic", totalSize = 1234) + + // StreamTextOptions has no total length on the FFI side at all; nothing to assert but that + // conversion succeeds and the caller's value is not smuggled elsewhere. + val ffi = options.toFfi() + assertEquals("topic", ffi.topic) + assertEquals(1234L, options.totalSize) + } + + // endregion + + // region Error mapping + + @Test + fun abnormalEndCarriesItsMessage() { + val mapped = FfiDataStreamException.AbnormalEnd("sender gave up").toStreamException() + + assertTrue(mapped is StreamException.AbnormalEndException) + assertTrue( + "the core's reason should survive, was: ${mapped.message}", + mapped.message?.contains("sender gave up") == true, + ) + } + + @Test + fun decodeFailuresKeepTheirDetail() { + val utf8 = FfiDataStreamException.Utf8("invalid byte").toStreamException() + assertTrue(utf8 is StreamException.DecodeFailedException) + assertTrue( + "the core's detail should survive, was: ${utf8.message}", + utf8.message?.contains("invalid byte") == true, + ) + + assertTrue( + FfiDataStreamException.Decompression().toStreamException() + is StreamException.DecodeFailedException, + ) + } + + /** + * The size failures each get their own type, but remain [StreamException.LengthExceededException] + * subclasses so that code catching that keeps catching all of them. + */ + @Test + fun sizeFailuresAreDistinctYetStillLengthExceeded() { + val lengthExceeded = FfiDataStreamException.LengthExceeded().toStreamException() + val headerTooLarge = FfiDataStreamException.HeaderTooLarge().toStreamException() + val payloadTooLarge = FfiDataStreamException.PayloadTooLarge().toStreamException() + + assertTrue(headerTooLarge is StreamException.HeaderTooLargeException) + assertTrue(payloadTooLarge is StreamException.PayloadTooLargeException) + + // All three catchable as the pre-existing type. + for (error in listOf(lengthExceeded, headerTooLarge, payloadTooLarge)) { + assertTrue( + "${error::class.simpleName} should be a LengthExceededException", + error is StreamException.LengthExceededException, + ) + } + // ...but the plain case is not one of the new subtypes. + assertFalse(lengthExceeded is StreamException.HeaderTooLargeException) + assertFalse(lengthExceeded is StreamException.PayloadTooLargeException) + } + + @Test + fun incompleteAndEncryptionMismatchAndInternal() { + assertTrue(FfiDataStreamException.Incomplete().toStreamException() is StreamException.IncompleteException) + assertTrue( + FfiDataStreamException.EncryptionTypeMismatch().toStreamException() + is StreamException.EncryptionTypeMismatch, + ) + assertTrue(FfiDataStreamException.Internal().toStreamException() is StreamException.InternalException) + } + + /** + * The cases without a dedicated type stay tellable apart by their reason, which is what makes + * the mapping one to one rather than lossy. + */ + @Test + fun terminatedCasesAreDisambiguatedByReason() { + val expected = mapOf( + FfiDataStreamException.AlreadyClosed() to StreamException.TerminatedException.Reason.ALREADY_CLOSED, + FfiDataStreamException.InvalidHeader() to StreamException.TerminatedException.Reason.INVALID_HEADER, + FfiDataStreamException.MissedChunk() to StreamException.TerminatedException.Reason.MISSED_CHUNK, + FfiDataStreamException.SendFailed() to StreamException.TerminatedException.Reason.SEND_FAILED, + FfiDataStreamException.InvalidFileName() to StreamException.TerminatedException.Reason.INVALID_FILE_NAME, + FfiDataStreamException.Io("disk went away") to StreamException.TerminatedException.Reason.IO, + ) + + for ((ffi, reason) in expected) { + val mapped = ffi.toStreamException() + assertTrue( + "${ffi::class.simpleName} should map to TerminatedException, was ${mapped::class.simpleName}", + mapped is StreamException.TerminatedException, + ) + assertEquals(reason, (mapped as StreamException.TerminatedException).reason) + } + + // Every reason but UNKNOWN is actually produced by some core failure. + val produced = expected.values.toSet() + val unmapped = StreamException.TerminatedException.Reason.entries + .filterNot { it == StreamException.TerminatedException.Reason.UNKNOWN } + .filterNot { it in produced } + assertTrue("reasons never produced by the mapping: $unmapped", unmapped.isEmpty()) + } + + /** The default keeps the pre-existing single-argument construction working. */ + @Test + fun terminatedReasonDefaultsToUnknown() { + assertEquals( + StreamException.TerminatedException.Reason.UNKNOWN, + StreamException.TerminatedException("boom").reason, + ) + } + + // endregion +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2ReceiveTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2ReceiveTest.kt new file mode 100644 index 000000000..725add814 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2ReceiveTest.kt @@ -0,0 +1,434 @@ +/* + * 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.datastream + +import com.google.protobuf.ByteString +import io.livekit.android.memory.CloseableManager +import io.livekit.android.room.RTCEngine +import io.livekit.android.room.datastream.incoming.ByteStreamReceiver +import io.livekit.android.room.datastream.incoming.TextStreamReceiver +import io.livekit.android.room.participant.Participant +import io.livekit.android.test.BaseTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import livekit.LivekitModels.DataPacket +import livekit.LivekitModels.DataStream +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.stub +import org.robolectric.RobolectricTestRunner +import java.util.concurrent.CopyOnWriteArrayList +import java.util.zip.Deflater + +/** + * The v2 receive path through [DataStreams]. + * + * The core does the reassembly and decompression, so what is actually under test here is our glue: + * that we hand it whole packets, route the streams it opens to the right topic handler, and surface + * their content and failures through the SDK's own reader types. + * + * The legacy header/chunk/trailer cases are already covered at the Room level by + * RoomIncomingDataStreamMockE2ETest; these are the v2 framings and the wiring around them. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class DataStreamsV2ReceiveTest : BaseTest() { + + @Mock + lateinit var engine: RTCEngine + + private lateinit var dataStreams: DataStreams + + private val textStreams = CopyOnWriteArrayList>() + private val byteStreams = CopyOnWriteArrayList>() + + private companion object { + const val TOPIC = "topic" + const val SENDER = "alice" + const val STREAM_ID = "stream-1" + } + + @Before + fun setup() { + engine.stub { on { e2EEManager } doReturn null } + dataStreams = DataStreams(engine = engine, closeableManager = CloseableManager()) + dataStreams.registerTextStreamHandler(TOPIC) { reader, identity -> textStreams.add(reader to identity) } + dataStreams.registerByteStreamHandler(TOPIC) { reader, identity -> byteStreams.add(reader to identity) } + } + + @After + fun tearDown() { + dataStreams.close() + } + + // region Packet building + + private fun deflateRaw(input: ByteArray): ByteArray { + // nowrap = true gives raw DEFLATE, with no zlib header, which is the wire format. + val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true) + deflater.setInput(input) + deflater.finish() + val out = ByteArray(input.size * 2 + 64) + val written = deflater.deflate(out) + deflater.end() + return out.copyOf(written) + } + + private fun header( + text: Boolean, + inlineContent: ByteArray? = null, + compression: DataStream.CompressionType = DataStream.CompressionType.NONE, + totalLength: Long? = null, + attributes: Map = emptyMap(), + topic: String = TOPIC, + streamId: String = STREAM_ID, + sender: String = SENDER, + ): DataPacket = DataPacket.newBuilder() + .setParticipantIdentity(sender) + .setStreamHeader( + DataStream.Header.newBuilder() + .setStreamId(streamId) + .setTopic(topic) + .setTimestamp(0) + .putAllAttributes(attributes) + .setCompression(compression) + .apply { + if (inlineContent != null) setInlineContent(ByteString.copyFrom(inlineContent)) + if (totalLength != null) setTotalLength(totalLength) + if (text) { + mimeType = "text/plain" + textHeader = DataStream.TextHeader.newBuilder() + .setOperationType(DataStream.OperationType.CREATE) + .build() + } else { + mimeType = "application/octet-stream" + byteHeader = DataStream.ByteHeader.newBuilder().setName("blob").build() + } + } + .build(), + ) + .build() + + private fun chunk(content: ByteArray, index: Long = 0, streamId: String = STREAM_ID): DataPacket = + DataPacket.newBuilder() + .setParticipantIdentity(SENDER) + .setStreamChunk( + DataStream.Chunk.newBuilder() + .setStreamId(streamId) + .setChunkIndex(index) + .setContent(ByteString.copyFrom(content)) + .build(), + ) + .build() + + private fun trailer(reason: String = "", streamId: String = STREAM_ID): DataPacket = + DataPacket.newBuilder() + .setParticipantIdentity(SENDER) + .setStreamTrailer( + DataStream.Trailer.newBuilder().setStreamId(streamId).setReason(reason).build(), + ) + .build() + + private fun awaitTextStream(): TextStreamReceiver { + awaitCondition(message = "No text stream was opened") { textStreams.isNotEmpty() } + return textStreams.first().first + } + + private fun awaitByteStream(): ByteStreamReceiver { + awaitCondition(message = "No byte stream was opened") { byteStreams.isNotEmpty() } + return byteStreams.first().first + } + + // endregion + + // region Inline (single packet) streams + + @Test + fun inlineUncompressedText() = runTest { + val text = "hello world" + dataStreams.handleIncoming( + header(text = true, inlineContent = text.toByteArray(), totalLength = text.length.toLong()), + ) + + // Deliberately no chunk or trailer: an inline stream is complete on its own. + assertEquals(text, awaitTextStream().readAll().joinToString("")) + } + + @Test + fun inlineCompressedText() = runTest { + val text = "hello hello compressible world" + dataStreams.handleIncoming( + header( + text = true, + inlineContent = deflateRaw(text.toByteArray()), + compression = DataStream.CompressionType.DEFLATE_RAW, + totalLength = text.toByteArray().size.toLong(), + ), + ) + + assertEquals(text, awaitTextStream().readAll().joinToString("")) + } + + @Test + fun inlineUncompressedBytes() = runTest { + val payload = byteArrayOf(1, 2, 3) + dataStreams.handleIncoming( + header(text = false, inlineContent = payload, totalLength = 3), + ) + + val received = awaitByteStream().readAll().reduce { a, b -> a + b } + assertEquals(payload.toList(), received.toList()) + } + + @Test + fun inlineCompressedBytes() = runTest { + val payload = ByteArray(500) { (it % 7).toByte() } + dataStreams.handleIncoming( + header( + text = false, + inlineContent = deflateRaw(payload), + compression = DataStream.CompressionType.DEFLATE_RAW, + totalLength = payload.size.toLong(), + ), + ) + + val received = awaitByteStream().readAll().reduce { a, b -> a + b } + assertEquals(payload.toList(), received.toList()) + } + + // endregion + + // region Chunked compressed streams + + /** + * A compressed stream is one deflate stream spread across chunks, so the receiver has to feed + * them through a single decompressor in order rather than decompressing each chunk. + */ + @Test + fun chunkedCompressedTextSplitAcrossChunks() = runTest { + val text = buildString { repeat(2_000) { append("compress me please ") } } + val compressed = deflateRaw(text.toByteArray()) + assertTrue("test needs the compressed form to span chunks", compressed.size > 1) + val split = compressed.size / 2 + + dataStreams.handleIncoming( + header( + text = true, + compression = DataStream.CompressionType.DEFLATE_RAW, + totalLength = text.toByteArray().size.toLong(), + ), + ) + dataStreams.handleIncoming(chunk(compressed.copyOfRange(0, split), index = 0)) + dataStreams.handleIncoming(chunk(compressed.copyOfRange(split, compressed.size), index = 1)) + dataStreams.handleIncoming(trailer()) + + assertEquals(text, awaitTextStream().readAll().joinToString("")) + } + + /** + * Text crosses the FFI as decoded strings but the SDK's reader is built on a byte channel, so + * the glue re-encodes. Multi-byte characters would break if a chunk were ever split mid + * character. + */ + @Test + fun multiByteTextRoundTrips() = runTest { + val text = "héllo → 世界 🎉 café" + dataStreams.handleIncoming( + header( + text = true, + inlineContent = text.toByteArray(), + totalLength = text.toByteArray().size.toLong(), + ), + ) + + assertEquals(text, awaitTextStream().readAll().joinToString("")) + } + + // endregion + + // region Routing and lifecycle + + @Test + fun streamOnUnhandledTopicIsIgnored() = runTest { + dataStreams.handleIncoming( + header(text = true, inlineContent = "hi".toByteArray(), topic = "other-topic"), + ) + // Then a stream we do handle, as a barrier proving the first was processed and dropped. + dataStreams.handleIncoming( + header(text = true, inlineContent = "hi".toByteArray(), streamId = "stream-2"), + ) + + awaitCondition { textStreams.isNotEmpty() } + awaitStable { textStreams.size } + assertEquals(1, textStreams.size) + assertEquals("stream-2", textStreams.first().first.info.id) + } + + @Test + fun senderIdentityIsSurfacedToTheHandler() = runTest { + dataStreams.handleIncoming(header(text = true, inlineContent = "hi".toByteArray())) + + awaitCondition { textStreams.isNotEmpty() } + assertEquals(Participant.Identity(SENDER), textStreams.first().second) + } + + @Test + fun headerAttributesReachStreamInfo() = runTest { + dataStreams.handleIncoming( + header( + text = true, + inlineContent = "hi".toByteArray(), + attributes = mapOf("foo" to "bar"), + ), + ) + + val info = awaitTextStream().info + assertEquals(STREAM_ID, info.id) + assertEquals(TOPIC, info.topic) + assertEquals("bar", info.attributes["foo"]) + } + + @Test + fun unregisteringAHandlerStopsDelivery() = runTest { + dataStreams.unregisterTextStreamHandler(TOPIC) + + dataStreams.handleIncoming(header(text = true, inlineContent = "hi".toByteArray())) + // Byte stream on the same topic still has a handler, and acts as the barrier. + dataStreams.handleIncoming( + header(text = false, inlineContent = byteArrayOf(1), streamId = "stream-b"), + ) + + awaitCondition { byteStreams.isNotEmpty() } + awaitStable { textStreams.size } + assertTrue(textStreams.isEmpty()) + } + + /** A sender that disconnects mid-stream must fail its readers rather than leave them waiting. */ + @Test + fun abortStreamsFromFailsThatSendersOpenStreams() = runTest { + dataStreams.handleIncoming(header(text = true, totalLength = 100)) + dataStreams.handleIncoming(chunk("partial".toByteArray())) + val reader = awaitTextStream() + + dataStreams.abortStreamsFrom(Participant.Identity(SENDER)) + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue("expected a StreamException, got $error", error is StreamException) + } + + @Test + fun abortAllStreamsFailsOpenStreams() = runTest { + dataStreams.handleIncoming(header(text = true, totalLength = 100)) + dataStreams.handleIncoming(chunk("partial".toByteArray())) + val reader = awaitTextStream() + + dataStreams.abortAllStreams() + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue("expected a StreamException, got $error", error is StreamException) + } + + /** Handler registrations outlive an abort, so streams after a reconnect are still delivered. */ + @Test + fun handlersSurviveAbortAllStreams() = runTest { + dataStreams.abortAllStreams() + + dataStreams.handleIncoming(header(text = true, inlineContent = "after".toByteArray())) + + assertEquals("after", awaitTextStream().readAll().joinToString("")) + } + + // endregion + + // region Failures + + @Test + fun trailerWithReasonSurfacesAbnormalEnd() = runTest { + dataStreams.handleIncoming(header(text = true)) + val reader = awaitTextStream() + dataStreams.handleIncoming(chunk("partial".toByteArray())) + dataStreams.handleIncoming(trailer(reason = "sender gave up")) + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue( + "expected AbnormalEndException, got $error", + error is StreamException.AbnormalEndException, + ) + } + + @Test + fun shortStreamSurfacesIncomplete() = runTest { + dataStreams.handleIncoming(header(text = true, totalLength = 100)) + val reader = awaitTextStream() + dataStreams.handleIncoming(chunk("tiny".toByteArray())) + dataStreams.handleIncoming(trailer()) + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue("expected IncompleteException, got $error", error is StreamException.IncompleteException) + } + + @Test + fun overlongStreamSurfacesLengthExceeded() = runTest { + dataStreams.handleIncoming(header(text = true, totalLength = 3)) + val reader = awaitTextStream() + dataStreams.handleIncoming(chunk("far too much content".toByteArray())) + dataStreams.handleIncoming(trailer()) + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue( + "expected LengthExceededException, got $error", + error is StreamException.LengthExceededException, + ) + } + + /** + * The payload cap is read when the first packet arrives, not at construction, so that a value + * passed to connect() is picked up. + */ + @Test + fun maxPayloadSizeIsEnforced() = runTest { + dataStreams.maxPayloadSize = { 16 } + + dataStreams.handleIncoming(header(text = true)) + val reader = awaitTextStream() + dataStreams.handleIncoming(chunk(ByteArray(1_000) { 'a'.code.toByte() })) + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue("expected a StreamException, got $error", error is StreamException) + } + + @Test + fun nonDataStreamPacketsAreIgnored() = runTest { + // A user packet has no stream fields at all; the core should drop it without complaint. + dataStreams.handleIncoming( + DataPacket.newBuilder() + .setParticipantIdentity(SENDER) + .setUser(livekit.LivekitModels.UserPacket.newBuilder().setPayload(ByteString.copyFromUtf8("x"))) + .build(), + ) + dataStreams.handleIncoming(header(text = true, inlineContent = "ok".toByteArray())) + + assertEquals("ok", awaitTextStream().readAll().joinToString("")) + } + + // endregion +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2SendTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2SendTest.kt new file mode 100644 index 000000000..4aded9f69 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2SendTest.kt @@ -0,0 +1,504 @@ +/* + * 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.datastream + +import io.livekit.android.memory.CloseableManager +import io.livekit.android.room.ClientCapability +import io.livekit.android.room.ClientProtocolVersion +import io.livekit.android.room.RTCEngine +import io.livekit.android.room.participant.Participant +import io.livekit.android.test.BaseTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import livekit.LivekitModels +import livekit.LivekitModels.DataPacket +import livekit.LivekitModels.DataStream +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.stub +import org.robolectric.RobolectricTestRunner +import java.util.concurrent.CopyOnWriteArrayList + +/** + * The v2 send path, end to end through [DataStreams] and out to a stubbed engine. + * + * These are the interop-critical cases: whether a payload goes out as one inline packet or as + * header/chunks/trailer, and whether it is compressed, is decided by the core from what we tell it + * about each recipient. That makes this as much a test of our registry wiring and options mapping + * as of the framing itself -- if we report capabilities wrongly, the core silently picks a framing + * a peer cannot read, and nothing here fails locally. + * + * Cases follow the matrix in rust-sdks/DATA_STREAMS_SPEC.md ("Minimum required test cases"). + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class DataStreamsV2SendTest : BaseTest() { + + @Mock + lateinit var engine: RTCEngine + + private lateinit var dataStreams: DataStreams + private val sentPackets = CopyOnWriteArrayList() + + /** identity -> (clientProtocol, capabilities) */ + private var remotes: Map>> = emptyMap() + + private companion object { + const val TOPIC = "topic" + + val PRE_V2 = mapOf( + "alice" to (ClientProtocolVersion.DEFAULT.value to emptyList()), + "bob" to (ClientProtocolVersion.DEFAULT.value to emptyList()), + "jim" to (ClientProtocolVersion.DATA_STREAM_RPC.value to emptyList()), + ) + + val ALL_V2 = mapOf( + "alice" to (ClientProtocolVersion.DATA_STREAM_V2.value to listOf(ClientCapability.COMPRESSION_DEFLATE_RAW)), + "bob" to (ClientProtocolVersion.DATA_STREAM_V2.value to listOf(ClientCapability.COMPRESSION_DEFLATE_RAW)), + "noCompression" to (ClientProtocolVersion.DATA_STREAM_V2.value to emptyList()), + ) + + val MIXED = PRE_V2 + ALL_V2 + + /** Compresses well, and short enough that the inline header still fits the MTU budget. */ + const val COMPRESSIBLE = "hello hello compressible world" + } + + @Before + fun setup() { + engine.stub { + onBlocking { sendData(any()) } doAnswer { invocation -> + sentPackets.add(invocation.arguments[0] as DataPacket) + Result.success(Unit) + } + onBlocking { waitForBufferStatusLow(any()) } doReturn Unit + on { e2EEManager } doReturn null + } + dataStreams = DataStreams(engine = engine, closeableManager = CloseableManager()) + dataStreams.remoteIdentities = { remotes.keys.map { Participant.Identity(it) } } + dataStreams.remoteClientProtocol = { id -> remotes[id.value]?.first ?: ClientProtocolVersion.DEFAULT.value } + dataStreams.remoteCapabilities = { id -> remotes[id.value]?.second ?: emptyList() } + } + + @After + fun tearDown() { + dataStreams.close() + } + + // region Helpers + + private fun awaitPackets(count: Int): List { + awaitCondition(message = "Expected $count packet(s), saw ${sentPackets.size}") { + sentPackets.size >= count + } + // Give any extra packets a chance to show up, so "exactly N" assertions are meaningful. + awaitStable { sentPackets.size } + return sentPackets.toList() + } + + private fun destinations(vararg identities: String) = identities.map { Participant.Identity(it) } + + private val DataPacket.header: DataStream.Header get() = streamHeader + + // endregion + + // region A room where every recipient predates v2 + + @Test + fun preV2RoomSendsLegacyThreePackets() = runTest { + remotes = PRE_V2 + + dataStreams.sendText("hello world", StreamTextOptions(topic = TOPIC)) + + val packets = awaitPackets(3) + assertEquals(3, packets.size) + assertTrue(packets[0].hasStreamHeader()) + assertTrue(packets[0].header.hasTextHeader()) + assertTrue(packets[1].hasStreamChunk()) + assertTrue(packets[2].hasStreamTrailer()) + + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertFalse("a pre-v2 recipient must never be sent inline content", packets[0].header.hasInlineContent()) + assertEquals("hello world", packets[1].streamChunk.content.toStringUtf8()) + } + + @Test + fun preV2RoomSendsBytesUncompressed() = runTest { + remotes = PRE_V2 + + dataStreams.sendBytes(byteArrayOf(0, 1, 2, 3), StreamBytesOptions(topic = TOPIC)) + + val packets = awaitPackets(3) + assertEquals(3, packets.size) + assertTrue(packets[0].header.hasByteHeader()) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertFalse(packets[0].header.hasInlineContent()) + assertArrayEqualsBytes(byteArrayOf(0, 1, 2, 3), packets[1].streamChunk.content.toByteArray()) + } + + // endregion + + // region A room where every recipient speaks v2 + + @Test + fun v2RoomSendsCompressibleTextAsOneCompressedPacket() = runTest { + remotes = ALL_V2 + + dataStreams.sendText( + COMPRESSIBLE, + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + + val packets = awaitPackets(1) + assertEquals("inline sends are a single packet", 1, packets.size) + assertEquals(DataStream.CompressionType.DEFLATE_RAW, packets[0].header.compression) + assertTrue(packets[0].header.hasInlineContent()) + assertFalse( + "inline content should be compressed, not the raw text", + packets[0].header.inlineContent.toStringUtf8() == COMPRESSIBLE, + ) + } + + @Test + fun v2RoomSendsIncompressibleTextInlineButRaw() = runTest { + remotes = ALL_V2 + + // Too short for deflate to win; the core keeps the raw bytes rather than growing them. + dataStreams.sendText( + "short", + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertEquals("short", packets[0].header.inlineContent.toStringUtf8()) + } + + /** + * The two v2 features are gated independently: inline on the protocol version, compression on + * the capability. A recipient that advertises v2 but no codec still gets the single packet. + */ + @Test + fun recipientWithoutCompressionCapabilityStillGetsInline() = runTest { + remotes = ALL_V2 + + dataStreams.sendText( + COMPRESSIBLE, + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("noCompression")), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertEquals(COMPRESSIBLE, packets[0].header.inlineContent.toStringUtf8()) + } + + @Test + fun compressOptOutStillSendsInline() = runTest { + remotes = ALL_V2 + + dataStreams.sendText( + COMPRESSIBLE, + StreamTextOptions( + topic = TOPIC, + destinationIdentities = destinations("alice", "bob"), + compress = false, + ), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertEquals(COMPRESSIBLE, packets[0].header.inlineContent.toStringUtf8()) + } + + @Test + fun v2RoomSendsShortBytesInline() = runTest { + remotes = ALL_V2 + + dataStreams.sendBytes( + byteArrayOf(0, 1, 2, 3), + StreamBytesOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertTrue(packets[0].header.hasByteHeader()) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertArrayEqualsBytes(byteArrayOf(0, 1, 2, 3), packets[0].header.inlineContent.toByteArray()) + } + + /** + * A payload too big to fit a header packet falls back to chunks, but stays compressed. + */ + @Test + fun largePayloadFallsBackToCompressedChunks() = runTest { + remotes = ALL_V2 + // Only somewhat compressible: repetitive text would deflate small enough to still fit + // inline, which is a different case (and covered above). Seeded, so the size is stable. + val random = java.util.Random(1234) + val alphabet = ('a'..'z') + ('A'..'Z') + ('0'..'9') + val payload = buildString { + repeat(50) { + append("hello world") + repeat(1_000) { append(alphabet[random.nextInt(alphabet.size)]) } + } + } + + dataStreams.sendText( + payload, + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + + val packets = awaitPackets(3) + assertTrue("expected a chunked send, got ${packets.size} packet(s)", packets.size > 1) + assertEquals(DataStream.CompressionType.DEFLATE_RAW, packets[0].header.compression) + assertFalse(packets[0].header.hasInlineContent()) + assertTrue(packets.last().hasStreamTrailer()) + + val compressedSize = packets.filter { it.hasStreamChunk() }.sumOf { it.streamChunk.content.size() } + assertTrue( + "compressed chunks ($compressedSize) should be smaller than the payload (${payload.length})", + compressedSize < payload.length, + ) + } + + // endregion + + // region Incremental writers + + /** + * Incremental writers are never inlined or compressed: the payload is not known up front, and + * the core cannot flush a deflate stream mid-write. + */ + @Test + fun streamTextIsNeverInlineOrCompressed() = runTest { + remotes = ALL_V2 + + val sender = dataStreams.streamText( + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + awaitPackets(1) + assertEquals(DataStream.CompressionType.NONE, sentPackets[0].header.compression) + assertFalse(sentPackets[0].header.hasInlineContent()) + + assertTrue(sender.write(COMPRESSIBLE).isSuccess) + val afterWrite = awaitPackets(2) + assertTrue(afterWrite[1].hasStreamChunk()) + assertEquals(COMPRESSIBLE, afterWrite[1].streamChunk.content.toStringUtf8()) + + sender.close() + val afterClose = awaitPackets(3) + assertTrue(afterClose[2].hasStreamTrailer()) + assertTrue(afterClose[2].streamTrailer.reason.isEmpty()) + } + + @Test + fun streamBytesIsNeverInlineOrCompressed() = runTest { + remotes = ALL_V2 + + val sender = dataStreams.streamBytes( + StreamBytesOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + assertTrue(sender.write(byteArrayOf(0, 1, 2, 3)).isSuccess) + sender.close() + + val packets = awaitPackets(3) + assertEquals(3, packets.size) + assertTrue(packets[0].header.hasByteHeader()) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertArrayEqualsBytes(byteArrayOf(0, 1, 2, 3), packets[1].streamChunk.content.toByteArray()) + } + + /** A non-null close reason travels in the trailer, which the receiver reads as an error. */ + @Test + fun closeWithReasonSetsTrailerReason() = runTest { + remotes = ALL_V2 + + val sender = dataStreams.streamText(StreamTextOptions(topic = TOPIC)) + sender.close(reason = "because") + + val packets = awaitPackets(2) + val trailer = packets.first { it.hasStreamTrailer() }.streamTrailer + assertEquals("because", trailer.reason) + } + + // endregion + + // region Mixed rooms + + /** + * Eligibility is unanimous across recipients. One pre-v2 participant in a broadcast is enough + * to drop the whole send back to a framing everyone understands. + */ + @Test + fun broadcastToMixedRoomFallsBackToLegacy() = runTest { + remotes = MIXED + + dataStreams.sendText(COMPRESSIBLE, StreamTextOptions(topic = TOPIC)) + + val packets = awaitPackets(3) + assertEquals(3, packets.size) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertFalse(packets[0].header.hasInlineContent()) + assertEquals(COMPRESSIBLE, packets[1].streamChunk.content.toStringUtf8()) + } + + /** Narrowing the same send to capable recipients re-enables both v2 features. */ + @Test + fun targetedSendToCapableSubsetOfMixedRoomUsesV2() = runTest { + remotes = MIXED + + dataStreams.sendText( + COMPRESSIBLE, + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertEquals(DataStream.CompressionType.DEFLATE_RAW, packets[0].header.compression) + } + + @Test + fun targetedSendIncludingAnIncapableRecipientDropsCompression() = runTest { + remotes = MIXED + + dataStreams.sendText( + COMPRESSIBLE, + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob", "noCompression")), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertEquals(COMPRESSIBLE, packets[0].header.inlineContent.toStringUtf8()) + } + + /** + * An empty room has nobody who could fail to understand v2, so the fast path applies. + */ + @Test + fun emptyRoomIsEligibleForV2() = runTest { + remotes = emptyMap() + + dataStreams.sendText(COMPRESSIBLE, StreamTextOptions(topic = TOPIC)) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertTrue(packets[0].header.hasInlineContent()) + } + + // endregion + + // region Options mapping + + @Test + fun textOptionsReachTheWire() = runTest { + remotes = PRE_V2 + + dataStreams.sendText( + "hi", + StreamTextOptions( + topic = TOPIC, + attributes = mapOf("foo" to "bar"), + streamId = "explicit-id", + destinationIdentities = destinations("alice"), + operationType = TextStreamInfo.OperationType.UPDATE, + version = 7, + replyToStreamId = "earlier", + attachedStreamIds = listOf("att-1"), + ), + ) + + val header = awaitPackets(3)[0].header + assertEquals("explicit-id", header.streamId) + assertEquals(TOPIC, header.topic) + assertEquals("bar", header.attributesMap["foo"]) + assertEquals(listOf("alice"), sentPackets[0].destinationIdentitiesList) + assertEquals(DataStream.OperationType.UPDATE, header.textHeader.operationType) + assertEquals(7, header.textHeader.version) + assertEquals("earlier", header.textHeader.replyToStreamId) + assertEquals(listOf("att-1"), header.textHeader.attachedStreamIdsList) + } + + @Test + fun byteOptionsReachTheWire() = runTest { + remotes = PRE_V2 + + dataStreams.sendBytes( + byteArrayOf(1, 2, 3), + StreamBytesOptions( + topic = TOPIC, + attributes = mapOf("k" to "v"), + streamId = "bytes-id", + mimeType = "image/png", + name = "pic.png", + totalSize = 3, + ), + ) + + val header = awaitPackets(3)[0].header + assertEquals("bytes-id", header.streamId) + assertEquals("image/png", header.mimeType) + assertEquals("pic.png", header.byteHeader.name) + assertEquals("v", header.attributesMap["k"]) + assertEquals(3L, header.totalLength) + } + + /** Everything the core emits is reliable; data streams must not race down the lossy channel. */ + @Test + fun packetsAreSentReliably() = runTest { + remotes = PRE_V2 + + dataStreams.sendText("hi", StreamTextOptions(topic = TOPIC)) + + awaitPackets(3).forEach { + assertEquals(DataPacket.Kind.RELIABLE, it.kind) + } + } + + @Test + fun returnedInfoDescribesTheStream() = runTest { + remotes = PRE_V2 + + val info = dataStreams.sendText( + "hello", + StreamTextOptions(topic = TOPIC, streamId = "id-1", attributes = mapOf("a" to "b")), + ) + + assertEquals("id-1", info.id) + assertEquals(TOPIC, info.topic) + assertEquals("b", info.attributes["a"]) + assertEquals(5L, info.totalSize) + assertEquals(LivekitModels.Encryption.Type.NONE, info.encryptionType) + } + + // endregion + + private fun assertArrayEqualsBytes(expected: ByteArray, actual: ByteArray) { + assertEquals(expected.toList(), actual.toList()) + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.kt index 8f88f898d..ff46f36f5 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.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. @@ -62,7 +62,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(0, ByteArray(1) { 1 }).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertEquals(1, collectedData.size) assertEquals(1, collectedData[0][0].toInt()) } @@ -102,7 +102,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(0, "hello".toByteArray()).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertEquals(1, collectedData.size) assertEquals("hello", collectedData[0]) } @@ -148,7 +148,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(2, "!".toByteArray()).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertEquals(3, collectedData.size) assertEquals("hello", collectedData[0]) assertEquals("world", collectedData[1]) @@ -190,7 +190,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(2, "!".toByteArray()).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertEquals(3, collectedData.size) assertEquals("hello", collectedData[0]) assertEquals("world", collectedData[1]) @@ -232,7 +232,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { } subDataChannel.observer?.onMessage(abnormalEnd.wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertTrue(threwOnce) } @@ -269,7 +269,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(0, ByteArray(2) { 1 }).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertTrue(threwOnce) } @@ -306,7 +306,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(0, ByteArray(1) { 1 }).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertTrue(threwOnce) } diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomOutgoingDataStreamMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomOutgoingDataStreamMockE2ETest.kt index 0e6e39229..2b4a1cf33 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomOutgoingDataStreamMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomOutgoingDataStreamMockE2ETest.kt @@ -72,9 +72,13 @@ class RoomOutgoingDataStreamMockE2ETest : MockE2ETest() { sender.close() assertFalse(sender.isOpen) + // Packets reach the wire asynchronously now: the core hands them to us on its own thread + // and we queue them for the reliable channel, so a completed write does not mean sent. + awaitCondition(message = "Expected header, chunk and trailer to be sent") { + pubDataChannel.sentBuffers.size >= 3 + } val buffers = pubDataChannel.sentBuffers - println(buffers) assertEquals(3, buffers.size) val headerPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffers[0].data)) @@ -127,9 +131,13 @@ class RoomOutgoingDataStreamMockE2ETest : MockE2ETest() { assertFalse(sender.isOpen) + // Packets reach the wire asynchronously now: the core hands them to us on its own thread + // and we queue them for the reliable channel, so a completed write does not mean sent. + awaitCondition(message = "Expected header, chunk and trailer to be sent") { + pubDataChannel.sentBuffers.size >= 3 + } val buffers = pubDataChannel.sentBuffers - println(buffers) assertEquals(3, buffers.size) val headerPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffers[0].data)) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/UniffiNativeLibraryTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/UniffiNativeLibraryTest.kt new file mode 100644 index 000000000..b1f0b3d3d --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/UniffiNativeLibraryTest.kt @@ -0,0 +1,47 @@ +/* + * 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.datastream + +import io.livekit.android.test.BaseTest +import io.livekit.uniffi.buildVersion +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the one piece of infrastructure every other data stream test depends on: that the + * livekit-uniffi native library can be found and loaded from a host JVM test run. + * + * When this fails, every data stream test fails with an inscrutable + * [UnsatisfiedLinkError]/`NoClassDefFoundError` from deep inside JNA. Failing here first, with a + * pointer to the fix, saves that debugging. See gradle/uniffi-native-lib.gradle. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class UniffiNativeLibraryTest : BaseTest() { + @Test + fun loadsNativeLibrary() { + // buildVersion() is the cheapest possible FFI call: no arguments, no objects, and it + // forces the UniffiLib class-init that does Native.register() plus the checksum contract + // check between these Kotlin bindings and the .dylib/.so they were generated from. + val version = buildVersion() + + assertTrue( + "livekit-uniffi reported an empty build version", + version.isNotEmpty(), + ) + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/participant/ParticipantTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/participant/ParticipantTest.kt index d2eb15568..0aa4769ce 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/participant/ParticipantTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/participant/ParticipantTest.kt @@ -17,6 +17,7 @@ package io.livekit.android.room.participant import io.livekit.android.events.ParticipantEvent +import io.livekit.android.room.ClientCapability import io.livekit.android.room.track.TrackPublication import io.livekit.android.room.types.AgentInput import io.livekit.android.room.types.AgentOutput @@ -281,6 +282,59 @@ class ParticipantTest { assertTrue(abs(lastSpokeAt!! - timestamp) < 1000) } + @Test + fun capabilitiesDefaultToEmpty() = runTest { + participant.updateFromInfo(INFO) + + assertEquals(emptyList(), participant.capabilities) + } + + @Test + fun capabilitiesFromInfo() = runTest { + participant.updateFromInfo( + INFO.toBuilder() + .addCapabilities(LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW) + .addCapabilities(LivekitModels.ClientInfo.Capability.CAP_PACKET_TRAILER) + .build(), + ) + + assertEquals( + listOf(ClientCapability.COMPRESSION_DEFLATE_RAW, ClientCapability.PACKET_TRAILER), + participant.capabilities, + ) + } + + /** + * Capabilities are an open set, so a peer running a newer SDK than us must be tolerated + * rather than throwing (which is what most of this SDK's other `fromProto` helpers do). + */ + @Test + fun capabilitiesDropUnknownValues() = runTest { + participant.updateFromInfo( + INFO.toBuilder() + .addCapabilities(LivekitModels.ClientInfo.Capability.CAP_UNUSED) + .addCapabilitiesValue(9999) + .addCapabilities(LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW) + .build(), + ) + + assertEquals(listOf(ClientCapability.COMPRESSION_DEFLATE_RAW), participant.capabilities) + } + + @Test + fun capabilitiesClearedByLaterUpdate() = runTest { + participant.updateFromInfo( + INFO.toBuilder() + .addCapabilities(LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW) + .build(), + ) + assertEquals(1, participant.capabilities.size) + + participant.updateFromInfo(INFO) + + assertEquals(emptyList(), participant.capabilities) + } + companion object { val INFO = LivekitModels.ParticipantInfo.newBuilder() .setSid("sid") diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt index 7df4841e7..640147e61 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt @@ -88,7 +88,14 @@ class RpcV2MockE2ETest : MockE2ETest() { * Returns the request attributes and the assembled UTF-8 payload, or null if no such * stream is present. */ - private fun collectOutgoingV2Stream(topic: String): Pair, String>? { + private suspend fun collectOutgoingV2Stream(topic: String): Pair, String>? { + // Sending a stream now crosses into the Rust core and back on its own threads, so the + // packets are not on the mock channel the instant the calling coroutine yields. Waiting for + // them, then letting the test dispatcher run, also gets the sender past its send and into + // whatever it does next -- for an RPC caller, waiting for the ack the test is about to feed + // it. Without that the ack arrives before anyone is listening. + awaitStable { pubDataChannel.sentBuffers.size } + coroutineRule.dispatcher.scheduler.runCurrent() val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } val header = packets.firstOrNull { it.hasStreamHeader() && it.streamHeader.topic == topic } ?: return null @@ -107,6 +114,57 @@ class RpcV2MockE2ETest : MockE2ETest() { /** * Simulate an inbound v2 RPC request stream landing on the subscriber data channel. */ + /** + * Waits for in-flight data stream work to finish. + * + * Replaces advancing the test scheduler: data streams are now handled by the Rust core on its + * own threads, which no amount of virtual time will drive. Waiting for the sent-packet count to + * go quiet keeps the tests that assert a packet was *not* produced honest, which a fixed sleep + * would not. + */ + /** + * Waits for the caller's outgoing RPC request stream header to reach the mock channel. + * + * Publishing a request now goes through the Rust core on its own threads, so the header is not + * on the channel the moment the calling coroutine yields. + */ + private fun awaitRequestStreamHeader(): DataPacket { + fun headers() = pubDataChannel.sentBuffers.map { parsePacket(it) } + .filter { it.hasStreamHeader() && it.streamHeader.topic == RPC_REQUEST_DATA_STREAM_TOPIC } + + awaitCondition(message = "No outgoing RPC request stream was published") { + headers().isNotEmpty() + } + return headers().first() + } + + /** + * Waits for [job] to finish. + * + * Data streams cross into the Rust core and back on its own threads, so an RPC no longer + * settles just by advancing the test scheduler. Waiting on the job itself keeps this + * deterministic -- waiting a fixed period, or for output to go quiet, races the core's + * start-up on a cold run. + */ + private fun awaitJob(job: kotlinx.coroutines.Deferred<*>) { + awaitCondition(message = "RPC did not complete") { job.isCompleted } + // A completed job can still have siblings finishing behind it -- closing the request + // stream, emitting a disconnect event -- whose continuations are posted back to the test + // dispatcher from the core's threads. Keep pumping briefly so they run here rather than + // showing up as unfinished coroutines at tear-down. + awaitStable(quietMs = 50, minWaitMs = 100) { pubDataChannel.sentBuffers.size } + coroutineRule.dispatcher.scheduler.advanceUntilIdle() + } + + /** + * Waits for a packet matching [predicate] to reach the mock publisher channel. + */ + private fun awaitPacket(message: String, predicate: (DataPacket) -> Boolean) { + awaitCondition(message = message) { + pubDataChannel.sentBuffers.map { parsePacket(it) }.any(predicate) + } + } + private fun simulateIncomingRequestStream( requestId: String, method: String, @@ -249,7 +307,7 @@ class RpcV2MockE2ETest : MockE2ETest() { val requestId = attrs[RpcRequestAttrs.REQUEST_ID]!! subDataChannel.simulateBufferReceived(createAck(requestId)) simulateIncomingResponseStream(requestId, "bye") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals("bye", rpcJob.await()) } @@ -277,7 +335,7 @@ class RpcV2MockE2ETest : MockE2ETest() { subDataChannel.simulateBufferReceived(createAck(requestId)) simulateIncomingResponseStream(requestId, largePayload) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals(largePayload, rpcJob.await()) } @@ -289,7 +347,7 @@ class RpcV2MockE2ETest : MockE2ETest() { room.localParticipant.registerRpcMethod("hello") { "pong" } simulateIncomingRequestStream("req-1", "hello", "ping") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No ack for req-1") { it.hasRpcAck() && it.rpcAck.requestId == "req-1" } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } @@ -317,7 +375,7 @@ class RpcV2MockE2ETest : MockE2ETest() { val largeResponse = "X".repeat(20_000) room.localParticipant.registerRpcMethod("echo") { largeResponse } simulateIncomingRequestStream("req-large", "echo", "ping") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No ack for req-large") { it.hasRpcAck() && it.rpcAck.requestId == "req-large" } val outgoing = collectOutgoingV2Stream(RPC_RESPONSE_DATA_STREAM_TOPIC) assertNotNull("expected a v2 response stream for a 20k response", outgoing) @@ -336,7 +394,9 @@ class RpcV2MockE2ETest : MockE2ETest() { simulateRemoteJoinAsV2() simulateIncomingRequestStream("req-x", "unknown-method", "") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No error response for req-x") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-x" && it.rpcResponse.hasError() + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } // Ack first @@ -362,7 +422,9 @@ class RpcV2MockE2ETest : MockE2ETest() { throw RuntimeException("oops") } simulateIncomingRequestStream("req-app-err", "boom", "") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No error response for req-app-err") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-app-err" && it.rpcResponse.hasError() + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } val errorResponse = packets.firstOrNull { @@ -383,7 +445,9 @@ class RpcV2MockE2ETest : MockE2ETest() { val custom = RpcError(101, "custom error") room.localParticipant.registerRpcMethod("err") { throw custom } simulateIncomingRequestStream("req-custom", "err", "") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No error response for req-custom") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-custom" && it.rpcResponse.hasError() + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } val errorResponse = packets.firstOrNull { @@ -447,7 +511,7 @@ class RpcV2MockE2ETest : MockE2ETest() { val requestId = outgoing.first[RpcRequestAttrs.REQUEST_ID]!! subDataChannel.simulateBufferReceived(createAck(requestId)) subDataChannel.simulateBufferReceived(createV1Response(requestId, error = customError)) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals(customError, rpcJob.await()) } @@ -473,7 +537,7 @@ class RpcV2MockE2ETest : MockE2ETest() { coroutineRule.dispatcher.scheduler.runCurrent() simulateMessageFromServer(TestData.PARTICIPANT_DISCONNECT) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals( RpcError.BuiltinRpcError.RECIPIENT_DISCONNECTED.create(), @@ -498,17 +562,13 @@ class RpcV2MockE2ETest : MockE2ETest() { payload = "p1", ) } - coroutineRule.dispatcher.scheduler.runCurrent() - - val firstHeader = pubDataChannel.sentBuffers - .map { parsePacket(it) } - .first { it.hasStreamHeader() && it.streamHeader.topic == RPC_REQUEST_DATA_STREAM_TOPIC } + val firstHeader = awaitRequestStreamHeader() val requestId1 = firstHeader.streamHeader.attributesMap[RpcRequestAttrs.REQUEST_ID]!! // No advanceTimeBy / no scheduler tick between publish and reply. subDataChannel.simulateBufferReceived(createAck(requestId1)) simulateIncomingResponseStream(requestId1, "r1", streamId = "resp-1") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob1) assertEquals("r1", rpcJob1.await()) // A second back-to-back call works — proves no orphaned pending entries. @@ -520,15 +580,12 @@ class RpcV2MockE2ETest : MockE2ETest() { payload = "p2", ) } - coroutineRule.dispatcher.scheduler.runCurrent() - val secondHeader = pubDataChannel.sentBuffers - .map { parsePacket(it) } - .first { it.hasStreamHeader() && it.streamHeader.topic == RPC_REQUEST_DATA_STREAM_TOPIC } + val secondHeader = awaitRequestStreamHeader() val requestId2 = secondHeader.streamHeader.attributesMap[RpcRequestAttrs.REQUEST_ID]!! assertTrue("second call must get a fresh request id", requestId2 != requestId1) subDataChannel.simulateBufferReceived(createAck(requestId2)) simulateIncomingResponseStream(requestId2, "r2", streamId = "resp-2") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob2) assertEquals("r2", rpcJob2.await()) } @@ -566,7 +623,7 @@ class RpcV2MockE2ETest : MockE2ETest() { payload = "p", ) } - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals("during", rpcJob.await()) } @@ -605,7 +662,9 @@ class RpcV2MockE2ETest : MockE2ETest() { // Now deliver a late ack + response. Must not throw or double-resolve. subDataChannel.simulateBufferReceived(createAck(requestId)) simulateIncomingResponseStream(requestId, "too-late", streamId = "resp-late") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + // Already completed by the timeout; let the late delivery be processed, then re-check. + awaitJob(rpcJob) + awaitStable { pubDataChannel.sentBuffers.size } // The original completion stays at CONNECTION_TIMEOUT; the rpcJob doesn't change. assertEquals(RpcError.BuiltinRpcError.CONNECTION_TIMEOUT.create(), rpcJob.await()) @@ -656,7 +715,12 @@ class RpcV2MockE2ETest : MockE2ETest() { ) } } - coroutineRule.dispatcher.scheduler.runCurrent() + awaitCondition(message = "Expected five outgoing RPC request streams") { + pubDataChannel.sentBuffers.map { parsePacket(it) }.count { + it.hasStreamHeader() && it.streamHeader.topic == RPC_REQUEST_DATA_STREAM_TOPIC + } == 5 + } + awaitStable { pubDataChannel.sentBuffers.size } // Five separate v2 request streams should have been produced. val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } @@ -685,6 +749,9 @@ class RpcV2MockE2ETest : MockE2ETest() { subDataChannel.simulateBufferReceived(createAck(requestId)) simulateIncomingResponseStream(requestId, responsePayload) } + awaitCondition(message = "Not all concurrent RPCs completed") { + deferred.all { it.isCompleted } + } coroutineRule.dispatcher.scheduler.advanceUntilIdle() // Each call resolves with its own response, not cross-talked. @@ -717,7 +784,7 @@ class RpcV2MockE2ETest : MockE2ETest() { val requestId = rpcRequest!!.rpcRequest.id subDataChannel.simulateBufferReceived(createAck(requestId)) subDataChannel.simulateBufferReceived(createV1Response(requestId, payload = "bye")) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals("bye", rpcJob.await()) } @@ -742,7 +809,9 @@ class RpcV2MockE2ETest : MockE2ETest() { build() } subDataChannel.simulateBufferReceived(v1Request.toDataChannelBuffer()) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No response for req-v1") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-v1" + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } // Ack @@ -811,7 +880,9 @@ class RpcV2MockE2ETest : MockE2ETest() { throw RuntimeException("oops") } subDataChannel.simulateBufferReceived(v1RequestPacket("req-v1-app", "boom", "")) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No error response for req-v1-app") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-v1-app" && it.rpcResponse.hasError() + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } val errorResponse = packets.firstOrNull { @@ -836,7 +907,9 @@ class RpcV2MockE2ETest : MockE2ETest() { val custom = RpcError(101, "custom v1 err") room.localParticipant.registerRpcMethod("err") { throw custom } subDataChannel.simulateBufferReceived(v1RequestPacket("req-v1-cust", "err", "")) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No error response for req-v1-cust") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-v1-cust" && it.rpcResponse.hasError() + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } val errorResponse = packets.firstOrNull { @@ -905,7 +978,7 @@ class RpcV2MockE2ETest : MockE2ETest() { val rpcRequest = packets.first { it.hasRpcRequest() }.rpcRequest subDataChannel.simulateBufferReceived(createAck(rpcRequest.id)) subDataChannel.simulateBufferReceived(createV1Response(rpcRequest.id, error = customError)) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals(customError, rpcJob.await()) } @@ -932,7 +1005,7 @@ class RpcV2MockE2ETest : MockE2ETest() { coroutineRule.dispatcher.scheduler.runCurrent() simulateMessageFromServer(TestData.PARTICIPANT_DISCONNECT) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals( RpcError.BuiltinRpcError.RECIPIENT_DISCONNECTED.create(), diff --git a/protocol b/protocol index 8381f2180..28e604c04 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 8381f2180c45ab926b3ebf19df0608f1dadcac1e +Subproject commit 28e604c046c6aec29757cabed341b86458cc40f9 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 1f6f9bceb..05a004e80 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 @@ -38,6 +38,7 @@ import io.livekit.android.e2ee.E2EEOptions import io.livekit.android.events.RoomEvent import io.livekit.android.events.collect import io.livekit.android.room.Room +import io.livekit.android.room.datastream.StreamBytesOptions import io.livekit.android.room.datastream.StreamTextOptions import io.livekit.android.room.datastream.incoming.TextStreamReceiver import io.livekit.android.room.participant.LocalParticipant @@ -155,6 +156,12 @@ class CallViewModel( private val mutableHandlers = MutableStateFlow>(emptyList()) val handlers: StateFlow> = mutableHandlers + // Data stream tester state. On the ViewModel for the same reason, and because the handlers are + // registered against the room rather than the dialog -- they should keep collecting while the + // panel is closed. + private val mutableStreamSubscriptions = MutableStateFlow>(emptyList()) + val streamSubscriptions: StateFlow> = mutableStreamSubscriptions + /** * The token source used to fetch connection details for the room. * @@ -382,6 +389,12 @@ class CallViewModel( } mutableHandlers.value = emptyList() + // Same for data stream subscriptions. + mutableStreamSubscriptions.value.forEach { subscription -> + runCatching { unsubscribeFromStream(subscription.topic, subscription.kind) } + } + mutableStreamSubscriptions.value = emptyList() + // Make sure to release any resources associated with LiveKit room.disconnect() room.release() @@ -431,6 +444,95 @@ class CallViewModel( } } + // region Data stream tester + + /** + * Registers a handler for [topic] and starts collecting what arrives on it. + * + * Text and byte handlers are separate registries in the SDK, so the same topic can carry one of + * each; subscriptions are keyed on the pair. + * + * @return a failure if the topic is already taken. That is not exceptional -- `lk.chat` is + * registered by this ViewModel and `lk.rpc_request`/`lk.rpc_response` by the Room -- so it is + * returned for the caller to show rather than thrown. + */ + fun subscribeToStream(topic: String, kind: StreamKind): Result { + if (topic.isBlank()) { + return Result.failure(IllegalArgumentException("Enter a topic")) + } + if (mutableStreamSubscriptions.value.any { it.topic == topic && it.kind == kind }) { + return Result.failure(IllegalArgumentException("Already subscribed to $topic")) + } + + val state = StreamSubscriptionState(topic = topic, kind = kind) + return runCatching { + when (kind) { + StreamKind.TEXT -> room.registerTextStreamHandler(topic) { receiver, identity -> + viewModelScope.launch { + val (size, preview) = runCatching { receiver.readAll().joinToString("") } + .fold( + onSuccess = { it.toByteArray().size to truncateChars(it) }, + onFailure = { 0 to "" }, + ) + state.record(identity, size, preview) + } + } + + StreamKind.BYTES -> room.registerByteStreamHandler(topic) { receiver, identity -> + viewModelScope.launch { + val (size, preview) = runCatching { + receiver.readAll().fold(ByteArray(0)) { acc, chunk -> acc + chunk } + }.fold( + onSuccess = { it.size to bytesPreview(it) }, + onFailure = { 0 to "" }, + ) + state.record(identity, size, preview) + } + } + } + mutableStreamSubscriptions.value = mutableStreamSubscriptions.value + state + } + } + + fun unsubscribeFromStream(topic: String, kind: StreamKind) { + when (kind) { + StreamKind.TEXT -> room.unregisterTextStreamHandler(topic) + StreamKind.BYTES -> room.unregisterByteStreamHandler(topic) + } + mutableStreamSubscriptions.value = mutableStreamSubscriptions.value + .filterNot { it.topic == topic && it.kind == kind } + } + + /** + * Sends [content] as a data stream, returning the new stream's id. + * + * A null [destination] broadcasts. Which framing this actually produces on the wire -- one + * inline packet, or a compressed multi-packet stream, or plain chunks -- is decided by the core + * from what every recipient advertises, so it is worth varying the destination when testing. + */ + suspend fun sendDataStream( + kind: StreamKind, + topic: String, + destination: Participant.Identity?, + content: String, + ): Result { + val destinations = listOfNotNull(destination) + return when (kind) { + StreamKind.TEXT -> room.localParticipant + .sendText(content, StreamTextOptions(topic = topic, destinationIdentities = destinations)) + .map { it.id } + + StreamKind.BYTES -> room.localParticipant + .sendBytes( + content.toByteArray(), + StreamBytesOptions(topic = topic, destinationIdentities = destinations), + ) + .map { it.id } + } + } + + // endregion + fun registerRpcHandler(method: String, initialResponse: String) { if (method.isBlank()) return val state = RpcHandlerState( @@ -576,3 +678,59 @@ sealed class RpcRequestResult { data class Success(val response: String) : RpcRequestResult() data class Error(val code: Int?, val message: String) : RpcRequestResult() } + +/** Whether a stream carries text or raw bytes. Applies to both sending and subscribing. */ +enum class StreamKind(val label: String) { + TEXT("text"), + BYTES("bytes"), +} + +data class ReceivedStreamRecord( + /** 1-based arrival number within its subscription. */ + val n: Long, + val sender: Participant.Identity, + val receivedAtMs: Long, + /** Size of the whole payload, not of the truncated preview. */ + val size: Int, + val preview: String, +) + +class StreamSubscriptionState( + val topic: String, + val kind: StreamKind, +) { + val received = MutableStateFlow>(emptyList()) + val count = MutableStateFlow(0L) + + /** Newest first, capped so a chatty topic cannot grow without bound. */ + fun record(sender: Participant.Identity, size: Int, preview: String) { + val n = count.value + 1 + count.value = n + received.value = ( + listOf( + ReceivedStreamRecord( + n = n, + sender = sender, + receivedAtMs = System.currentTimeMillis(), + size = size, + preview = preview, + ), + ) + received.value + ).take(MAX_RECEIVED_PER_TOPIC) + } +} + +private const val MAX_RECEIVED_PER_TOPIC = 100 +private const val PREVIEW_CHARS = 256 +private const val PREVIEW_BYTES = 64 + +private fun truncateChars(s: String): String = + if (s.length <= PREVIEW_CHARS) s else s.take(PREVIEW_CHARS) + "..." + +private fun bytesPreview(data: ByteArray): String { + val shown = data.take(PREVIEW_BYTES) + val ellipsis = if (data.size > PREVIEW_BYTES) "..." else "" + val hex = shown.joinToString(" ") { "%02x".format(it) } + val utf8 = shown.toByteArray().toString(Charsets.UTF_8) + return "hex: $hex$ellipsis\nutf8: $utf8$ellipsis" +} diff --git a/sample-app-common/src/main/res/drawable/baseline_stream_24.xml b/sample-app-common/src/main/res/drawable/baseline_stream_24.xml new file mode 100644 index 000000000..a012ea4f6 --- /dev/null +++ b/sample-app-common/src/main/res/drawable/baseline_stream_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/sample-app/src/debug/AndroidManifest.xml b/sample-app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..fd5e252ea --- /dev/null +++ b/sample-app/src/debug/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt b/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt index 98ee95708..cbd2e1461 100644 --- a/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt +++ b/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt @@ -35,6 +35,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.xwray.groupie.GroupieAdapter import io.livekit.android.sample.common.R import io.livekit.android.sample.databinding.CallActivityBinding +import io.livekit.android.sample.dialog.DataStreamsDialogFragment import io.livekit.android.sample.dialog.RpcTestDialogFragment import io.livekit.android.sample.dialog.showAudioProcessorSwitchDialog import io.livekit.android.sample.dialog.showDebugMenuDialog @@ -224,6 +225,10 @@ class CallActivity : AppCompatActivity() { binding.rpcTest.setOnClickListener { RpcTestDialogFragment().show(supportFragmentManager, "rpc_test") } + + binding.dataStreams.setOnClickListener { + DataStreamsDialogFragment().show(supportFragmentManager, "data_streams") + } } override fun onResume() { diff --git a/sample-app/src/main/java/io/livekit/android/sample/dialog/DataStreamsDialogFragment.kt b/sample-app/src/main/java/io/livekit/android/sample/dialog/DataStreamsDialogFragment.kt new file mode 100644 index 000000000..19415858f --- /dev/null +++ b/sample-app/src/main/java/io/livekit/android/sample/dialog/DataStreamsDialogFragment.kt @@ -0,0 +1,298 @@ +/* + * 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.sample.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.xwray.groupie.GroupieAdapter +import com.xwray.groupie.viewbinding.BindableItem +import com.xwray.groupie.viewbinding.GroupieViewHolder +import io.livekit.android.room.participant.Participant +import io.livekit.android.room.participant.RemoteParticipant +import io.livekit.android.sample.CallViewModel +import io.livekit.android.sample.ReceivedStreamRecord +import io.livekit.android.sample.StreamKind +import io.livekit.android.sample.StreamSubscriptionState +import io.livekit.android.sample.databinding.DialogDataStreamsBinding +import io.livekit.android.sample.databinding.ItemDataStreamReceivedBinding +import io.livekit.android.sample.databinding.ItemDataStreamSubscriptionBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.random.Random + +private const val HELLO_CONTENT = "hello world" +private const val TWENTY_K_SIZE = 20_000 +private const val ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +/** + * 20k of random characters. + * + * Deliberately random rather than a repeated character: random data does not compress, so a v2 + * send of this has to fall back to a compressed multi-packet stream, where a repetitive payload of + * the same size would shrink under the packet budget and go out as a single inline packet. + */ +private fun twentyKRandom(): String { + val random = Random.Default + return buildString(TWENTY_K_SIZE) { + repeat(TWENTY_K_SIZE) { append(ALPHABET[random.nextInt(ALPHABET.length)]) } + } +} + +/** + * A panel for exercising data streams by hand: send text or bytes on a topic to one participant or + * everyone, and subscribe to topics to watch what arrives. + */ +class DataStreamsDialogFragment : DialogFragment() { + + private var _binding: DialogDataStreamsBinding? = null + private val binding get() = _binding!! + + private val viewModel: CallViewModel by activityViewModels() + + /** Parallel to the destination spinner's entries; null at index 0 means broadcast. */ + private val destinations = mutableListOf(null) + private lateinit var destinationAdapter: ArrayAdapter + private val subscriptionsAdapter = GroupieAdapter() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = DialogDataStreamsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.closeButton.setOnClickListener { dismiss() } + + destinationAdapter = ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_item, + mutableListOf(BROADCAST_LABEL), + ) + destinationAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.destinationSpinner.adapter = destinationAdapter + + binding.presetHello.setOnClickListener { binding.contentEdit.setText(HELLO_CONTENT) } + binding.preset20kRandom.setOnClickListener { binding.contentEdit.setText(twentyKRandom()) } + + binding.sendButton.setOnClickListener { send() } + binding.subscribeButton.setOnClickListener { subscribe() } + + binding.subscriptionsList.layoutManager = LinearLayoutManager(requireContext()) + binding.subscriptionsList.adapter = subscriptionsAdapter + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.participants + .map { list -> list.filterIsInstance() } + .collect { remotes -> updateDestinations(remotes) } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.streamSubscriptions.collect { subscriptions -> + subscriptionsAdapter.update( + subscriptions.map { DataStreamSubscriptionItem(it, viewModel) }, + ) + } + } + } + } + + /** Keeps the spinner in step with the room, preserving the selection where it still exists. */ + private fun updateDestinations(remotes: List) { + val previous = destinations.getOrNull(binding.destinationSpinner.selectedItemPosition) + + destinations.clear() + destinations.add(null) + destinations.addAll(remotes.mapNotNull { it.identity }) + + destinationAdapter.clear() + destinationAdapter.add(BROADCAST_LABEL) + destinationAdapter.addAll(destinations.drop(1).map { it?.value ?: "(unknown)" }) + + // Falls back to broadcast if the participant we were targeting has left. + val restored = destinations.indexOf(previous).takeIf { it >= 0 } ?: 0 + binding.destinationSpinner.setSelection(restored) + } + + private fun sendKind(): StreamKind = + if (binding.sendKindBytes.isChecked) StreamKind.BYTES else StreamKind.TEXT + + private fun send() { + val topic = binding.sendTopicEdit.text.toString().trim() + if (topic.isEmpty()) { + Toast.makeText(requireContext(), "Enter a topic", Toast.LENGTH_SHORT).show() + return + } + val destination = destinations.getOrNull(binding.destinationSpinner.selectedItemPosition) + val content = binding.contentEdit.text.toString() + val kind = sendKind() + + binding.sendButton.isEnabled = false + binding.sendResultText.visibility = View.GONE + binding.sendSpinner.visibility = View.VISIBLE + + viewLifecycleOwner.lifecycleScope.launch { + val result = viewModel.sendDataStream(kind, topic, destination, content) + val b = _binding ?: return@launch + b.sendSpinner.visibility = View.GONE + b.sendButton.isEnabled = true + b.sendResultText.visibility = View.VISIBLE + b.sendResultText.text = result.fold( + onSuccess = { "OK: stream ${it.take(8)} (${content.toByteArray().size}B ${kind.label})" }, + onFailure = { "Error: ${it.message ?: it::class.java.simpleName}" }, + ) + } + } + + private fun subscribe() { + val topic = binding.subscribeTopicEdit.text.toString().trim() + val kind = if (binding.subscribeKindBytes.isChecked) StreamKind.BYTES else StreamKind.TEXT + + viewModel.subscribeToStream(topic, kind) + .onSuccess { binding.subscribeTopicEdit.text.clear() } + .onFailure { + // Expected for a topic something else already owns, such as `lk.chat`. + Toast.makeText( + requireContext(), + it.message ?: "Could not subscribe to $topic", + Toast.LENGTH_LONG, + ).show() + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onDestroyView() { + binding.subscriptionsList.adapter = null + super.onDestroyView() + _binding = null + } + + private companion object { + const val BROADCAST_LABEL = "Everyone (broadcast)" + } +} + +private val receivedTimeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + +/** One subscribed topic, with its own scrolling list of what has arrived on it. */ +class DataStreamSubscriptionItem( + private val state: StreamSubscriptionState, + private val viewModel: CallViewModel, +) : BindableItem() { + + private var scope: CoroutineScope? = null + + override fun initializeViewBinding(view: View): ItemDataStreamSubscriptionBinding = + ItemDataStreamSubscriptionBinding.bind(view) + + override fun getLayout(): Int = io.livekit.android.sample.R.layout.item_data_stream_subscription + + override fun bind(viewBinding: ItemDataStreamSubscriptionBinding, position: Int) { + viewBinding.topicName.text = "${state.topic} [${state.kind.label}]" + + viewBinding.unsubscribeButton.setOnClickListener { + viewModel.unsubscribeFromStream(state.topic, state.kind) + } + + val receivedAdapter = GroupieAdapter() + viewBinding.receivedList.layoutManager = LinearLayoutManager(viewBinding.root.context) + viewBinding.receivedList.adapter = receivedAdapter + + scope?.cancel() + val newScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + scope = newScope + newScope.launch { + state.received.collect { records -> + viewBinding.receivedCount.text = "Received (${state.count.value})" + viewBinding.emptyLabel.visibility = if (records.isEmpty()) View.VISIBLE else View.GONE + viewBinding.receivedList.visibility = if (records.isEmpty()) View.GONE else View.VISIBLE + receivedAdapter.update(records.map { DataStreamReceivedItem(it) }) + } + } + } + + override fun unbind(viewHolder: GroupieViewHolder) { + scope?.cancel() + scope = null + viewHolder.binding.receivedList.adapter = null + super.unbind(viewHolder) + } + + override fun isSameAs(other: com.xwray.groupie.Item<*>): Boolean = + other is DataStreamSubscriptionItem && + other.state.topic == state.topic && + other.state.kind == state.kind + + override fun hasSameContentAs(other: com.xwray.groupie.Item<*>): Boolean = + other is DataStreamSubscriptionItem && other.state === state +} + +/** One received stream: where it came from, how big it was, and a preview of the payload. */ +class DataStreamReceivedItem(private val record: ReceivedStreamRecord) : + BindableItem() { + + override fun initializeViewBinding(view: View): ItemDataStreamReceivedBinding = + ItemDataStreamReceivedBinding.bind(view) + + override fun getLayout(): Int = io.livekit.android.sample.R.layout.item_data_stream_received + + override fun bind(viewBinding: ItemDataStreamReceivedBinding, position: Int) { + val time = receivedTimeFormat.format(Date(record.receivedAtMs)) + viewBinding.meta.text = + "#${record.n} | ${record.sender.value} | ${formatSize(record.size)} | $time" + viewBinding.preview.text = record.preview + } + + override fun isSameAs(other: com.xwray.groupie.Item<*>): Boolean = + other is DataStreamReceivedItem && + other.record.n == record.n && + other.record.sender == record.sender + + private fun formatSize(bytes: Int): String = + if (bytes < 1024) "${bytes}B" else "%.1fKB".format(bytes / 1024f) +} diff --git a/sample-app/src/main/res/layout/call_activity.xml b/sample-app/src/main/res/layout/call_activity.xml index a76c2088b..ca7910c46 100644 --- a/sample-app/src/main/res/layout/call_activity.xml +++ b/sample-app/src/main/res/layout/call_activity.xml @@ -149,5 +149,15 @@ android:padding="@dimen/control_padding" android:src="@drawable/baseline_swap_horiz_24" app:tint="@android:color/white" /> + + diff --git a/sample-app/src/main/res/layout/dialog_data_streams.xml b/sample-app/src/main/res/layout/dialog_data_streams.xml new file mode 100644 index 000000000..4e62e9276 --- /dev/null +++ b/sample-app/src/main/res/layout/dialog_data_streams.xml @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +