From 8e7a63e633d5bac01698e17e7a02904497bff65c Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 01/47] feat(token): add agentAttributes to TokenRequestOptions This commit adds the agentAttributes field to TokenRequestOptions. The field is a map of strings. The token server sets these attributes on the agent participant that it dispatches. The RoomAgentDispatch request body gets the matching attributes field. The toRequest function now compares the dispatch object with a default instance. If the caller sets no dispatch field, the request omits the agents list. Before this change, the function checked each field by hand. The new check stays correct when new fields are added. This change is not related to data tracks. It can move to a separate pull request. --- .../io/livekit/android/token/TokenSource.kt | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt b/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt index 866e48f1..f943b489 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/token/TokenSource.kt @@ -39,23 +39,25 @@ data class TokenRequestOptions( * Optional deployment to target. Leave empty to target the production deployment. */ val agentDeployment: String? = null, + /** + * Attributes to set on the dispatched agent participant. + */ + val agentAttributes: Map? = null, ) /** * Converts a [TokenRequestOptions] to [TokenSourceRequest], a JSON serializable request body. */ fun TokenRequestOptions.toRequest(): TokenSourceRequest { - val agents = if (agentName != null || agentMetadata != null || agentDeployment != null) { - listOf( - RoomAgentDispatch( - agentName = agentName, - metadata = agentMetadata, - deployment = agentDeployment, - ), - ) - } else { - null - } + val dispatch = RoomAgentDispatch( + agentName = agentName, + metadata = agentMetadata, + deployment = agentDeployment, + attributes = agentAttributes, + ) + // Omit the dispatch entirely when the caller set none of its fields; comparing against a + // default instance keeps this correct as fields are added. + val agents = if (dispatch == RoomAgentDispatch()) null else listOf(dispatch) return TokenSourceRequest( roomName = roomName, participantName = participantName, @@ -114,6 +116,10 @@ data class RoomAgentDispatch( * Optional deployment to target. Leave empty to target the production deployment. */ val deployment: String? = null, + /** + * Attributes to set on the dispatched agent participant. + */ + val attributes: Map? = null, ) @SuppressLint("UnsafeOptInUsageError") From fdfc41c2813b73f059332ded6cb4e93792ed3fa8 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 02/47] test(token): assert agent attributes reach the request body This commit extends the TokenSource test. The test sets an agent attribute in the options. The test then reads the JSON request body. The test checks that the attributes object contains the value. The commit also updates the copyright year of the test file. --- .../test/java/io/livekit/android/token/TokenSourceTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt b/livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt index f7683b99..28015ece 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/token/TokenSourceTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit, Inc. + * Copyright 2025-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -92,6 +92,7 @@ class TokenSourceTest : BaseTest() { participantMetadata = "participant-metadata", agentName = "agent-name", agentMetadata = "agent-metadata", + agentAttributes = mapOf("region" to "us-east"), ) val response = source.fetch(options).getOrThrow() @@ -121,6 +122,10 @@ class TokenSourceTest : BaseTest() { val agent = agents?.get(0)?.jsonObject assertEquals("agent-name", agent?.get("agent_name")?.jsonPrimitive?.content) assertEquals("agent-metadata", agent?.get("metadata")?.jsonPrimitive?.content) + assertEquals( + "us-east", + agent?.get("attributes")?.jsonObject?.get("region")?.jsonPrimitive?.content, + ) } @Test From 5663e9ceab64a2413566f1e20f98a227b166bd7c Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 03/47] chore(protocol): bump protocol submodule to 2172178d This commit moves the protocol submodule from 8381f218 to 2172178d. The new protocol version adds the messages that data tracks need: - DataTrackSchemaId and DataTrackSchemaEncoding - DataBlobKey and DataBlob - StoreDataBlobRequest and StoreDataBlobResponse - GetDataBlobRequest and GetDataBlobResponse - The publish_data_tracks field on SyncState The new version also contains many changes to the agent, SIP, egress, and ingress protos. The Android SDK does not use those changes. Review only that the generated code compiles. This commit contains no other change. --- protocol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protocol b/protocol index 8381f218..2172178d 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 8381f2180c45ab926b3ebf19df0608f1dadcac1e +Subproject commit 2172178d20d4c8d14d6873b4e9560cc38cf48c0c From 619d25683137c16a80b02bc09b77c07bb05bf2ff Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 04/47] IMPORTANT(build): depend on livekit-uniffi-android 0.1.9 This commit adds the livekit-uniffi-android library. The library contains the Rust data track core and its Kotlin bindings. The commit makes these changes: - The version catalog gets the livekit-uniffi entry. - The SDK module and the test module depend on the library. - The SDK manifest gets a tools:overrideLibrary entry for io.livekit.uniffi. The manifest entry is the important part. The library declares minSdk 24. The SDK declares minSdk 21. Without the override, the manifest merger rejects the build. With the override, the SDK ships a library that is not tested on API 21 to 23. On those API levels the native library can fail to load. A later commit adds code that catches that failure and disables data tracks. Decide if this trade-off is acceptable before the release. --- gradle/libs.versions.toml | 3 +++ livekit-android-sdk/build.gradle | 1 + livekit-android-sdk/src/main/AndroidManifest.xml | 7 ++++++- livekit-android-test/build.gradle | 1 + 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c3caf7b..53e36be1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,8 +32,11 @@ noise = "2.0.0" lifecycleProcess = "2.8.7" agp = "8.7.2" kotlin = "1.9.25" +livekit-uniffi = "0.1.9" [libraries] +livekit-uniffi = { module = "io.livekit:livekit-uniffi-android", version.ref = "livekit-uniffi" } + android-jain-sip-ri = { module = "javax.sip:android-jain-sip-ri", version.ref = "androidJainSipRi" } androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" } diff --git a/livekit-android-sdk/build.gradle b/livekit-android-sdk/build.gradle index dbc69f73..b86ece0a 100644 --- a/livekit-android-sdk/build.gradle +++ b/livekit-android-sdk/build.gradle @@ -118,6 +118,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation libs.coroutines.lib implementation libs.kotlinx.serialization.json + implementation libs.livekit.uniffi api libs.webrtc api libs.okhttp.lib implementation libs.okhttp.coroutines diff --git a/livekit-android-sdk/src/main/AndroidManifest.xml b/livekit-android-sdk/src/main/AndroidManifest.xml index 800317f7..59d51eb4 100644 --- a/livekit-android-sdk/src/main/AndroidManifest.xml +++ b/livekit-android-sdk/src/main/AndroidManifest.xml @@ -14,7 +14,11 @@ limitations under the License. --> - + + + + @@ -31,3 +35,4 @@ android:stopWithTask="true" /> + diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle index 1629569f..749b42ce 100644 --- a/livekit-android-test/build.gradle +++ b/livekit-android-test/build.gradle @@ -108,6 +108,7 @@ dokkaHtml { dependencies { implementation(project(":livekit-android-sdk")) + implementation libs.livekit.uniffi implementation libs.coroutines.lib implementation libs.kotlinx.serialization.json api libs.okhttp.lib From 1c2ab183dd329237ddd967f80ddc73abcbacca9a Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 05/47] feat(datatrack): add DataTrackSid and DataTrackFrame This commit adds two value types. Both types have no dependency on other SDK code. DataTrackSid wraps the identifier that the server gives to a data track. The identifier is not stable across a full reconnect of the publisher. The documentation tells callers to use the track name as the stable key. DataTrackFrame holds the payload of one frame and an optional user timestamp. The SDK does not read the timestamp. The now() function creates a frame with the current time in milliseconds. The durationSinceTimestamp property reads the timestamp as a Unix time in milliseconds. The class has converters to and from the UniFFI frame type. The class also has equals and hashCode based on the payload content. --- .../android/room/datatrack/DataTrackFrame.kt | 80 +++++++++++++++++++ .../android/room/datatrack/DataTrackSid.kt | 31 +++++++ 2 files changed, 111 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrame.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSid.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrame.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrame.kt new file mode 100644 index 00000000..8fd32dfe --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrame.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import io.livekit.uniffi.DataTrackFrame as FfiDataTrackFrame + +/** + * A single unit of application data sent or received over a data track. + * + * @param payload The application payload carried by this frame. + * @param userTimestamp Optional sender-provided timestamp, opaque to the SDK and carried + * end-to-end unmodified. Publisher and subscriber agree on what it means, so a sensor's clock + * works as well as wall time. [now] and [durationSinceTimestamp] are the exception — they + * read it as milliseconds since the Unix epoch. + */ +class DataTrackFrame( + val payload: ByteArray, + val userTimestamp: Long? = null, +) { + /** + * How long ago the frame was stamped, or `null` if it carries no timestamp or the timestamp + * lies in the future. + * + * Assumes [userTimestamp] is a Unix timestamp in milliseconds, as set by [now]. + */ + val durationSinceTimestamp: Duration? + get() { + val timestamp = userTimestamp ?: return null + val elapsed = System.currentTimeMillis() - timestamp + return elapsed.takeIf { it >= 0 }?.milliseconds + } + + internal constructor(ffi: FfiDataTrackFrame) : this( + payload = ffi.payload, + userTimestamp = ffi.userTimestamp?.toLong(), + ) + + internal fun toFfi(): FfiDataTrackFrame = FfiDataTrackFrame( + payload = payload, + userTimestamp = userTimestamp?.toULong(), + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DataTrackFrame) return false + return payload.contentEquals(other.payload) && userTimestamp == other.userTimestamp + } + + override fun hashCode(): Int { + var result = payload.contentHashCode() + result = 31 * result + (userTimestamp?.hashCode() ?: 0) + return result + } + + companion object { + /** + * Creates a frame stamped with the current time, in milliseconds since the Unix epoch. + */ + @JvmStatic + fun now(payload: ByteArray): DataTrackFrame { + return DataTrackFrame(payload, System.currentTimeMillis()) + } + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSid.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSid.kt new file mode 100644 index 00000000..8440fd2b --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSid.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import kotlinx.serialization.Serializable + +/** + * A server-assigned data track identifier. + * + * SIDs are not stable across a publisher's full reconnect: the track object survives and its SID + * is rewritten in place. Prefer [RemoteDataTrack.name] when keying a map of remote tracks. + */ +@Serializable +@JvmInline +value class DataTrackSid(val value: String) { + override fun toString(): String = value +} From 70388411c06651c6c98c8d0bc6146383662b37c1 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 06/47] feat(datatrack): add schema and frame encoding types This commit adds three public types in DataTrackSchema.kt. DataTrackSchemaId names a schema and gives the encoding of the schema definition. The type has converters to the UniFFI type and to the protobuf type. The blobKey property builds the DataBlobKey that stores the schema definition on the server. DataTrackSchemaEncoding lists the well-known encodings of a schema definition. DataTrackFrameEncoding lists the well-known encodings of the frames on a track. Both are sealed classes with a Custom case. The fromIdentifier function maps a string to a case. A well-known identifier always maps to the well-known case. A custom encoding cannot use a well-known identifier. The commit is larger than the target size. All content is a mechanical mapping between the SDK types, the UniFFI types, and the protobuf types. Check the identifier strings against rust-sdks and the Swift SDK. The strings must be the same in all SDKs. --- .../android/room/datatrack/DataTrackSchema.kt | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt new file mode 100644 index 00000000..601f8523 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackSchema.kt @@ -0,0 +1,272 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import livekit.LivekitModels +import livekit.LivekitModels.DataTrackSchemaEncoding.WellKnownSchemaEncoding +import io.livekit.uniffi.DataTrackSchemaId as FfiSchemaId +import uniffi.livekit_datatrack.DataTrackFrameEncoding as FfiFrameEncoding +import uniffi.livekit_datatrack.DataTrackSchemaEncoding as FfiSchemaEncoding + +/** + * Identifies the schema describing a data track's frames. + * + * @param name Schema name, unique within the room. + * @param encoding Encoding of the schema definition itself. + */ +data class DataTrackSchemaId( + val name: String, + val encoding: DataTrackSchemaEncoding, +) { + internal constructor(ffi: FfiSchemaId) : this( + name = ffi.name, + encoding = DataTrackSchemaEncoding.fromFfi(ffi.encoding), + ) + + internal fun toFfi(): FfiSchemaId = FfiSchemaId( + name = name, + encoding = encoding.toFfi(), + ) + + internal fun toProto(): LivekitModels.DataTrackSchemaId = + LivekitModels.DataTrackSchemaId.newBuilder() + .setName(name) + .setEncoding(encoding.toProto()) + .build() + + /** + * As a data blob key, for storing and reading back the schema's definition. + */ + internal val blobKey: LivekitModels.DataBlobKey + get() = LivekitModels.DataBlobKey.newBuilder() + .setSchemaId(toProto()) + .build() +} + +/** + * Encoding of a data track schema definition. + * + * Identifiers naming a well-known encoding always map to that case, so a custom encoding cannot + * shadow one. + */ +sealed class DataTrackSchemaEncoding { + /** + * Stable string form. Identifiers naming a well-known encoding always map to that case. + */ + abstract val identifier: String + + /** Protocol Buffers schema (`.proto`), describing `protobuf`-encoded frames. */ + data object Protobuf : DataTrackSchemaEncoding() { + override val identifier: String = "protobuf" + } + + /** FlatBuffers schema (`.fbs`), describing `flatbuffer`-encoded frames. */ + data object Flatbuffer : DataTrackSchemaEncoding() { + override val identifier: String = "flatbuffer" + } + + /** ROS 1 message definition, describing `ros1`-encoded frames. */ + data object Ros1Msg : DataTrackSchemaEncoding() { + override val identifier: String = "ros1msg" + } + + /** ROS 2 message definition, describing `cdr`-encoded frames. */ + data object Ros2Msg : DataTrackSchemaEncoding() { + override val identifier: String = "ros2msg" + } + + /** ROS 2 IDL definition, describing `cdr`-encoded frames. */ + data object Ros2Idl : DataTrackSchemaEncoding() { + override val identifier: String = "ros2idl" + } + + /** OMG IDL definition, describing `cdr`-encoded frames. */ + data object OmgIdl : DataTrackSchemaEncoding() { + override val identifier: String = "omgidl" + } + + /** JSON Schema, describing `json`-encoded frames. */ + data object JsonSchema : DataTrackSchemaEncoding() { + override val identifier: String = "jsonschema" + } + + /** Another well-known encoding not known to this client version. */ + data object Other : DataTrackSchemaEncoding() { + override val identifier: String = "other" + } + + /** + * An application-specific encoding identified by [identifier]. + */ + data class Custom(override val identifier: String) : DataTrackSchemaEncoding() + + internal fun toFfi(): FfiSchemaEncoding = when (this) { + Protobuf -> FfiSchemaEncoding.Protobuf + Flatbuffer -> FfiSchemaEncoding.Flatbuffer + Ros1Msg -> FfiSchemaEncoding.Ros1Msg + Ros2Msg -> FfiSchemaEncoding.Ros2Msg + Ros2Idl -> FfiSchemaEncoding.Ros2Idl + OmgIdl -> FfiSchemaEncoding.OmgIdl + JsonSchema -> FfiSchemaEncoding.JsonSchema + Other -> FfiSchemaEncoding.Other + is Custom -> FfiSchemaEncoding.Custom(identifier) + } + + internal fun toProto(): LivekitModels.DataTrackSchemaEncoding { + val builder = LivekitModels.DataTrackSchemaEncoding.newBuilder() + when (this) { + Protobuf -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF + Flatbuffer -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER + Ros1Msg -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG + Ros2Msg -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG + Ros2Idl -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL + OmgIdl -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL + JsonSchema -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA + Other -> builder.wellKnown = WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED + is Custom -> builder.custom = identifier + } + return builder.build() + } + + companion object { + /** + * Creates an encoding from its [identifier]; unrecognized identifiers become [Custom]. + */ + fun fromIdentifier(identifier: String): DataTrackSchemaEncoding = when (identifier) { + "protobuf" -> Protobuf + "flatbuffer" -> Flatbuffer + "ros1msg" -> Ros1Msg + "ros2msg" -> Ros2Msg + "ros2idl" -> Ros2Idl + "omgidl" -> OmgIdl + "jsonschema" -> JsonSchema + "other" -> Other + else -> Custom(identifier) + } + + internal fun fromFfi(ffi: FfiSchemaEncoding): DataTrackSchemaEncoding = when (ffi) { + FfiSchemaEncoding.Protobuf -> Protobuf + FfiSchemaEncoding.Flatbuffer -> Flatbuffer + FfiSchemaEncoding.Ros1Msg -> Ros1Msg + FfiSchemaEncoding.Ros2Msg -> Ros2Msg + FfiSchemaEncoding.Ros2Idl -> Ros2Idl + FfiSchemaEncoding.OmgIdl -> OmgIdl + FfiSchemaEncoding.JsonSchema -> JsonSchema + FfiSchemaEncoding.Other -> Other + is FfiSchemaEncoding.Custom -> Custom(ffi.v1) + } + } +} + +/** + * Encoding of the frames sent over a data track. + * + * Identifiers naming a well-known encoding always map to that case, so a custom encoding cannot + * shadow one. + */ +sealed class DataTrackFrameEncoding { + /** + * Stable string form. Identifiers naming a well-known encoding always map to that case. + */ + abstract val identifier: String + + /** ROS 1. */ + data object Ros1 : DataTrackFrameEncoding() { + override val identifier: String = "ros1" + } + + /** CDR (ROS 2 / OMG IDL). */ + data object Cdr : DataTrackFrameEncoding() { + override val identifier: String = "cdr" + } + + /** Protocol Buffers. */ + data object Protobuf : DataTrackFrameEncoding() { + override val identifier: String = "protobuf" + } + + /** FlatBuffers. */ + data object Flatbuffer : DataTrackFrameEncoding() { + override val identifier: String = "flatbuffer" + } + + /** CBOR, self-describing. */ + data object Cbor : DataTrackFrameEncoding() { + override val identifier: String = "cbor" + } + + /** MessagePack, self-describing. */ + data object Msgpack : DataTrackFrameEncoding() { + override val identifier: String = "msgpack" + } + + /** JSON, self-describing. */ + data object Json : DataTrackFrameEncoding() { + override val identifier: String = "json" + } + + /** Another well-known encoding not known to this client version. */ + data object Other : DataTrackFrameEncoding() { + override val identifier: String = "other" + } + + /** + * An application-specific encoding identified by [identifier]. + */ + data class Custom(override val identifier: String) : DataTrackFrameEncoding() + + internal fun toFfi(): FfiFrameEncoding = when (this) { + Ros1 -> FfiFrameEncoding.Ros1 + Cdr -> FfiFrameEncoding.Cdr + Protobuf -> FfiFrameEncoding.Protobuf + Flatbuffer -> FfiFrameEncoding.Flatbuffer + Cbor -> FfiFrameEncoding.Cbor + Msgpack -> FfiFrameEncoding.Msgpack + Json -> FfiFrameEncoding.Json + Other -> FfiFrameEncoding.Other + is Custom -> FfiFrameEncoding.Custom(identifier) + } + + companion object { + /** + * Creates an encoding from its [identifier]; unrecognized identifiers become [Custom]. + */ + fun fromIdentifier(identifier: String): DataTrackFrameEncoding = when (identifier) { + "ros1" -> Ros1 + "cdr" -> Cdr + "protobuf" -> Protobuf + "flatbuffer" -> Flatbuffer + "cbor" -> Cbor + "msgpack" -> Msgpack + "json" -> Json + "other" -> Other + else -> Custom(identifier) + } + + internal fun fromFfi(ffi: FfiFrameEncoding): DataTrackFrameEncoding = when (ffi) { + FfiFrameEncoding.Ros1 -> Ros1 + FfiFrameEncoding.Cdr -> Cdr + FfiFrameEncoding.Protobuf -> Protobuf + FfiFrameEncoding.Flatbuffer -> Flatbuffer + FfiFrameEncoding.Cbor -> Cbor + FfiFrameEncoding.Msgpack -> Msgpack + FfiFrameEncoding.Json -> Json + FfiFrameEncoding.Other -> Other + is FfiFrameEncoding.Custom -> Custom(ffi.v1) + } + } +} From 0e0d9d9b13f53c5d99ad20f034ef01d8d94b8da9 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 07/47] test(datatrack): cover schema blob key encoding This commit adds DataTrackSchemaTest. One test checks that the blob key of a well-known schema encoding carries the name and the protobuf enum value. One test checks that a custom encoding uses the custom string field of the protobuf message. --- .../room/datatrack/DataTrackSchemaTest.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaTest.kt diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaTest.kt new file mode 100644 index 00000000..c36cefd4 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.BaseTest +import livekit.LivekitModels.DataTrackSchemaEncoding.WellKnownSchemaEncoding +import org.junit.Assert.assertEquals +import org.junit.Test + +class DataTrackSchemaTest : BaseTest() { + + @Test + fun blobKeyCarriesNameAndWellKnownEncoding() { + val schema = DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema) + val key = schema.blobKey + + assertEquals("reading.v1", key.schemaId.name) + assertEquals( + WellKnownSchemaEncoding.WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA, + key.schemaId.encoding.wellKnown, + ) + } + + @Test + fun blobKeyUsesCustomFieldForCustomEncoding() { + val schema = DataTrackSchemaId("x", DataTrackSchemaEncoding.Custom("myenc")) + assertEquals("myenc", schema.blobKey.schemaId.encoding.custom) + } +} From a6434619a1d213be2691c97aade73b78467fc9b7 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 08/47] feat(datatrack): add DataTrackInfo and DataTrackPublishOptions This commit adds two public types. DataTrackInfo describes a published data track. It carries the SID, the name, the E2EE flag, the optional schema, and the optional frame encoding. The internal constructor converts the UniFFI type. DataTrackPublishOptions holds the options for a publish call. The frameFormat field is optional. DataTrackFrameFormat groups a frame encoding with an optional schema. The type makes the frame encoding mandatory when a schema is given. A schema always describes frames in one encoding. The secondary constructor of DataTrackPublishOptions accepts the encoding and the schema directly. --- .../android/room/datatrack/DataTrackInfo.kt | 45 ++++++++++++++++ .../room/datatrack/DataTrackPublishOptions.kt | 54 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackInfo.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublishOptions.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackInfo.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackInfo.kt new file mode 100644 index 00000000..cd165ff4 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackInfo.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.uniffi.DataTrackInfo as FfiDataTrackInfo + +/** + * Metadata describing a published data track. + * + * @param sid Server-assigned unique identifier for the track. Not stable across a publisher's + * full reconnect; see [DataTrackSid]. + * @param name Name chosen by the publisher; unique per participant. + * @param usesE2ee Whether the track's frames are end-to-end encrypted. + * @param schema Schema describing the track's frames, if the publisher declared one. + * @param frameEncoding Encoding of the track's frames, if the publisher declared one. + */ +data class DataTrackInfo( + val sid: DataTrackSid, + val name: String, + val usesE2ee: Boolean, + val schema: DataTrackSchemaId?, + val frameEncoding: DataTrackFrameEncoding?, +) { + internal constructor(ffi: FfiDataTrackInfo) : this( + sid = DataTrackSid(ffi.sid), + name = ffi.name, + usesE2ee = ffi.usesE2ee, + schema = ffi.schema?.let { DataTrackSchemaId(it) }, + frameEncoding = ffi.frameEncoding?.let { DataTrackFrameEncoding.fromFfi(it) }, + ) +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublishOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublishOptions.kt new file mode 100644 index 00000000..13b97ab4 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublishOptions.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +/** + * Options for publishing a data track. + * + * @param frameFormat Describes the track's frames. Leaving this unset publishes an untyped track. + */ +data class DataTrackPublishOptions( + val frameFormat: DataTrackFrameFormat? = null, +) { + /** + * Declares the frame format inline. + * + * @param frameEncoding Encoding of the track's frames. + * @param schema Schema describing the track's frames. + */ + constructor( + frameEncoding: DataTrackFrameEncoding, + schema: DataTrackSchemaId? = null, + ) : this(DataTrackFrameFormat(frameEncoding, schema)) +} + +/** + * Describes the frames on a data track. + * + * A schema always describes frames in a specific encoding, so [frameEncoding] is required + * alongside a [schema]. The declared metadata is surfaced to subscribers via [DataTrackInfo]. + * + * Whether a schema's encoding can actually describe frames in [frameEncoding] is checked when the + * track is published, surfacing as [DataTrackPublishException.InvalidSchema]. + * + * @param frameEncoding Encoding of the track's frames. + * @param schema Schema describing the track's frames. + */ +data class DataTrackFrameFormat( + val frameEncoding: DataTrackFrameEncoding, + val schema: DataTrackSchemaId? = null, +) From be921d47bacafa5965eb4290c5d4a5f9ca96e415 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 09/47] feat(datatrack): add publish, push, subscribe, and schema exception types This commit adds four sealed exception hierarchies in DataTrackException.kt: - DataTrackPublishException for publishDataTrack failures - DataTrackPushFrameException for tryPush failures - DataTrackSubscribeException for subscribe failures - DataTrackSchemaException for defineSchema and getSchema failures The QueueFull case of DataTrackPushFrameException carries the rejected frame. The caller can retry the same frame. The commit also adds three internal toSdk functions. They map the UniFFI error types to the SDK types one to one. The mapping is mechanical. --- .../room/datatrack/DataTrackException.kt | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt new file mode 100644 index 00000000..488d28fc --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackException.kt @@ -0,0 +1,175 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import uniffi.livekit_datatrack.DataTrackSubscribeException as FfiSubscribeException +import uniffi.livekit_datatrack.PublishException as FfiPublishException +import uniffi.livekit_datatrack.PushFrameErrorReason as FfiPushFrameErrorReason + +/** + * An error raised while publishing a [LocalDataTrack]. + */ +sealed class DataTrackPublishException(message: String, cause: Throwable? = null) : Exception(message, cause) { + /** + * The participant is not permitted to publish data tracks. + */ + class NotAllowed(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * A data track with the same name is already published by this participant. + */ + class DuplicateName(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * The requested track name is invalid. + */ + class InvalidName(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * The SFU did not respond to the publish request in time. + */ + class Timeout(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * The maximum number of data tracks for this participant has been reached. + */ + class LimitReached(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * The connection was lost before the publish completed. + */ + class Disconnected(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * The track's schema metadata is invalid. + */ + class InvalidSchema(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) + + /** + * An unexpected internal error occurred. + */ + class Internal(message: String, cause: Throwable? = null) : DataTrackPublishException(message, cause) +} + +/** + * The reason a frame could not be pushed via [LocalDataTrack.tryPush]. + */ +sealed class DataTrackPushFrameException(message: String, cause: Throwable? = null) : Exception(message, cause) { + /** + * The track has been unpublished, by either the local participant or the SFU. + */ + class TrackUnpublished(message: String, cause: Throwable? = null) : DataTrackPushFrameException(message, cause) + + /** + * The send queue is full; the frame was not enqueued. + * + * The rejected [frame] — the same instance that was pushed, not a copy — comes back so it can + * be retried or re-queued. Mainly for [LocalDataTrack.send], where frames come from a + * [kotlinx.coroutines.flow.Flow] and the caller holds no reference of its own. + */ + class QueueFull( + message: String, + val frame: DataTrackFrame, + cause: Throwable? = null, + ) : DataTrackPushFrameException(message, cause) + + /** + * An unexpected internal error occurred. + */ + class Internal(message: String, cause: Throwable? = null) : DataTrackPushFrameException(message, cause) +} + +/** + * An error raised while subscribing to a [RemoteDataTrack]. + */ +sealed class DataTrackSubscribeException(message: String, cause: Throwable? = null) : Exception(message, cause) { + /** + * The track was unpublished before the subscription completed. + */ + class Unpublished(message: String, cause: Throwable? = null) : DataTrackSubscribeException(message, cause) + + /** + * The SFU did not respond to the subscribe request in time. + */ + class Timeout(message: String, cause: Throwable? = null) : DataTrackSubscribeException(message, cause) + + /** + * The connection was lost before the subscription completed. + */ + class Disconnected(message: String, cause: Throwable? = null) : DataTrackSubscribeException(message, cause) + + /** + * An unexpected internal error occurred. + */ + class Internal(message: String, cause: Throwable? = null) : DataTrackSubscribeException(message, cause) +} + +/** + * An error raised while storing or resolving a data track schema via + * [io.livekit.android.room.participant.LocalParticipant.defineSchema] / + * [io.livekit.android.room.participant.LocalParticipant.getSchema]. + */ +sealed class DataTrackSchemaException(message: String, cause: Throwable? = null) : Exception(message, cause) { + /** + * The connection was lost before the request completed, or the participant is not connected. + */ + class Disconnected(message: String, cause: Throwable? = null) : DataTrackSchemaException(message, cause) + + /** + * The SFU rejected the request (for example the schema was never defined). + */ + class Rejected(message: String, cause: Throwable? = null) : DataTrackSchemaException(message, cause) + + /** + * The stored definition is not valid UTF-8. + */ + class InvalidDefinition(message: String, cause: Throwable? = null) : DataTrackSchemaException(message, cause) + + /** + * The SFU did not respond in time. + */ + class Timeout(message: String, cause: Throwable? = null) : DataTrackSchemaException(message, cause) + + /** + * An unexpected internal error occurred. + */ + class Internal(message: String, cause: Throwable? = null) : DataTrackSchemaException(message, cause) +} + +@Suppress("CyclomaticComplexMethod") // Mechanical 1:1 mapping of UniFFI publish error cases. +internal fun FfiPublishException.toSdk(): DataTrackPublishException = when (this) { + is FfiPublishException.NotAllowed -> DataTrackPublishException.NotAllowed(message ?: "", this) + is FfiPublishException.DuplicateName -> DataTrackPublishException.DuplicateName(message ?: "", this) + is FfiPublishException.InvalidName -> DataTrackPublishException.InvalidName(message ?: "", this) + is FfiPublishException.Timeout -> DataTrackPublishException.Timeout(message ?: "", this) + is FfiPublishException.LimitReached -> DataTrackPublishException.LimitReached(message ?: "", this) + is FfiPublishException.Disconnected -> DataTrackPublishException.Disconnected(message ?: "", this) + is FfiPublishException.InvalidSchema -> DataTrackPublishException.InvalidSchema(message ?: "", this) + is FfiPublishException.Internal -> DataTrackPublishException.Internal(message ?: "", this) +} + +internal fun FfiPushFrameErrorReason.toSdk(frame: DataTrackFrame): DataTrackPushFrameException = when (this) { + is FfiPushFrameErrorReason.TrackUnpublished -> DataTrackPushFrameException.TrackUnpublished(message ?: "", this) + is FfiPushFrameErrorReason.QueueFull -> DataTrackPushFrameException.QueueFull(message ?: "", frame, this) +} + +internal fun FfiSubscribeException.toSdk(): DataTrackSubscribeException = when (this) { + is FfiSubscribeException.Unpublished -> DataTrackSubscribeException.Unpublished(message ?: "", this) + is FfiSubscribeException.Timeout -> DataTrackSubscribeException.Timeout(message ?: "", this) + is FfiSubscribeException.Disconnected -> DataTrackSubscribeException.Disconnected(message ?: "", this) + is FfiSubscribeException.Internal -> DataTrackSubscribeException.Internal(message ?: "", this) +} From 05f804140705dec44cbc626e5096ec9c3ad18195 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 10/47] feat(datatrack): add factory seam for the UniFFI data track managers This commit adds two functional interfaces: LocalDataTrackManagerFactory and RemoteDataTrackManagerFactory. Each interface creates one UniFFI manager from a delegate and an optional cryptor. RTCModule provides both factories. The production factories create the real UniFFI LocalDataTrackManager and RemoteDataTrackManager. The interfaces let the tests replace the native managers with fakes. The native library is not loaded in unit tests. --- .../io/livekit/android/dagger/RTCModule.kt | 18 +++++++ .../room/datatrack/DataTrackManagerFactory.kt | 48 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt index e8d78fa4..62235631 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt @@ -36,6 +36,8 @@ import io.livekit.android.audio.NoAudioRecordPrewarmer import io.livekit.android.e2ee.DataPacketCryptorManager import io.livekit.android.e2ee.DataPacketCryptorManagerImpl import io.livekit.android.memory.CloseableManager +import io.livekit.android.room.datatrack.LocalDataTrackManagerFactory +import io.livekit.android.room.datatrack.RemoteDataTrackManagerFactory import io.livekit.android.util.LKLog import io.livekit.android.util.LoggingLevel import io.livekit.android.webrtc.CustomAudioProcessingFactory @@ -46,6 +48,8 @@ import io.livekit.android.webrtc.peerconnection.RTCThreadToken import io.livekit.android.webrtc.peerconnection.RTCThreadTokenImpl import io.livekit.android.webrtc.peerconnection.executeBlockingOnRTCThread import io.livekit.android.webrtc.peerconnection.executeOnRTCThread +import io.livekit.uniffi.LocalDataTrackManager +import io.livekit.uniffi.RemoteDataTrackManager import livekit.org.webrtc.AudioProcessingFactory import livekit.org.webrtc.EglBase import livekit.org.webrtc.Logging @@ -384,6 +388,20 @@ internal object RTCModule { return DataPacketCryptorManagerImpl.Factory } + @Provides + fun localDataTrackManagerFactory(): LocalDataTrackManagerFactory { + return LocalDataTrackManagerFactory { delegate, encryptionProvider -> + LocalDataTrackManager(delegate, encryptionProvider) + } + } + + @Provides + fun remoteDataTrackManagerFactory(): RemoteDataTrackManagerFactory { + return RemoteDataTrackManagerFactory { delegate, decryptionProvider -> + RemoteDataTrackManager(delegate, decryptionProvider) + } + } + @Provides @Singleton fun peerConnectionFactory( diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt new file mode 100644 index 00000000..319cfdf9 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackManagerFactory.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.uniffi.LocalDataTrackManagerDelegate +import io.livekit.uniffi.LocalDataTrackManagerInterface +import io.livekit.uniffi.RemoteDataTrackManagerDelegate +import io.livekit.uniffi.RemoteDataTrackManagerInterface +import uniffi.livekit_datatrack.DecryptionProvider +import uniffi.livekit_datatrack.EncryptionProvider + +/** + * Creates UniFFI [io.livekit.uniffi.LocalDataTrackManager] instances. + * + * @suppress + */ +fun interface LocalDataTrackManagerFactory { + fun create( + delegate: LocalDataTrackManagerDelegate, + encryptionProvider: EncryptionProvider?, + ): LocalDataTrackManagerInterface +} + +/** + * Creates UniFFI [io.livekit.uniffi.RemoteDataTrackManager] instances. + * + * @suppress + */ +fun interface RemoteDataTrackManagerFactory { + fun create( + delegate: RemoteDataTrackManagerDelegate, + decryptionProvider: DecryptionProvider?, + ): RemoteDataTrackManagerInterface +} From b08210dfaba2ef71ca88d3c37ac713c70d418ffa Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 11/47] test(datatrack): add mock UniFFI local and remote data track managers This commit adds test doubles for the UniFFI managers. The doubles do not load native code. MockLocalDataTrackManagerFactory records the encryption provider of the last create call. The factory can throw a LinkageError on create. The factory can make every publish fail with a given PublishException. MockLocalDataTrackManager records the SFU responses it receives. It sends a PublishDataTrackRequest through the delegate on publish. It counts republishTracks calls. MockFfiLocalDataTrack records the pushed frames. MockRemoteDataTrackManagerFactory records the decryption provider of the last create call. The factory can throw a LinkageError on create. MockRemoteDataTrackManager records the join responses, participant updates, subscriber handles, and packets it receives. It counts resendSubscriptionUpdates calls. The simulateTrackPublished and simulateTrackUnpublished functions fire the delegate callbacks. MockFfiRemoteDataTrack does not support subscribe. This commit is larger than the target size. All content is test scaffolding. --- .../datatrack/MockLocalDataTrackManager.kt | 160 ++++++++++++++++++ .../datatrack/MockRemoteDataTrackManager.kt | 150 ++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt create mode 100644 livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt new file mode 100644 index 00000000..86c5ca9d --- /dev/null +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockLocalDataTrackManager.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.test.mock.room.datatrack + +import io.livekit.android.room.datatrack.LocalDataTrackManagerFactory +import io.livekit.uniffi.DataTrackFrame +import io.livekit.uniffi.DataTrackInfo +import io.livekit.uniffi.DataTrackOptions +import io.livekit.uniffi.LocalDataTrack +import io.livekit.uniffi.LocalDataTrackManagerDelegate +import io.livekit.uniffi.LocalDataTrackManagerInterface +import io.livekit.uniffi.NoHandle +import livekit.LivekitModels +import livekit.LivekitRtc +import uniffi.livekit_datatrack.EncryptionProvider +import uniffi.livekit_datatrack.PublishException + +class MockLocalDataTrackManagerFactory : LocalDataTrackManagerFactory { + /** + * The most recently created manager. + */ + lateinit var manager: MockLocalDataTrackManager + + /** + * Encryption provider passed into the last [create] call. + */ + var lastEncryptionProvider: EncryptionProvider? = null + private set + + /** + * When set, [create] throws it instead of returning a manager, standing in for a native + * library that fails to load. + */ + var createError: LinkageError? = null + + /** + * When set, every manager this creates fails [MockLocalDataTrackManager.publishTrack] with it, + * standing in for a publish the native core rejects. + */ + var publishError: PublishException? = null + + override fun create( + delegate: LocalDataTrackManagerDelegate, + encryptionProvider: EncryptionProvider?, + ): LocalDataTrackManagerInterface { + createError?.let { throw it } + lastEncryptionProvider = encryptionProvider + return MockLocalDataTrackManager(delegate).also { + it.publishError = publishError + manager = it + } + } +} + +class MockLocalDataTrackManager( + val delegate: LocalDataTrackManagerDelegate, +) : LocalDataTrackManagerInterface, AutoCloseable { + val publishedTracks = mutableListOf() + val handledPublishResponses = mutableListOf() + val handledRequestResponses = mutableListOf() + var closed = false + private set + + override fun handleSfuPublishResponse(res: ByteArray) { + handledPublishResponses.add(res) + } + + override fun handleSfuRequestResponse(res: ByteArray) { + handledRequestResponses.add(res) + } + + override suspend fun publishResponsesForSyncState(): List { + return publishedTracks.filter { it.isPublished() }.map { track -> + LivekitRtc.PublishDataTrackResponse.newBuilder() + .setInfo( + LivekitModels.DataTrackInfo.newBuilder() + .setSid(track.info().sid) + .setName(track.info().name) + .build(), + ) + .build() + .toByteArray() + } + } + + /** Set to make [publishTrack] fail the way the native manager does. */ + var publishError: PublishException? = null + + override suspend fun publishTrack(options: DataTrackOptions): LocalDataTrack { + publishError?.let { throw it } + val request = LivekitRtc.SignalRequest.newBuilder() + .setPublishDataTrackRequest( + LivekitRtc.PublishDataTrackRequest.newBuilder() + .setName(options.name) + .build(), + ) + .build() + .toByteArray() + delegate.onSignalRequest(request) + return MockFfiLocalDataTrack(name = options.name).also { publishedTracks.add(it) } + } + + var republishTracksCount = 0 + private set + + override fun republishTracks() { + republishTracksCount++ + } + + override fun close() { + closed = true + publishedTracks.forEach { it.unpublish() } + } +} + +/** + * UniFFI [LocalDataTrack] stand-in that does not touch native code. + */ +class MockFfiLocalDataTrack( + name: String, + sid: String = "DT_mock", +) : LocalDataTrack(NoHandle) { + private var published = true + private val trackInfo = DataTrackInfo( + sid = sid, + name = name, + usesE2ee = false, + schema = null, + frameEncoding = null, + ) + val pushedFrames = mutableListOf() + + override fun info(): DataTrackInfo = trackInfo + + override fun isPublished(): Boolean = published + + override fun tryPush(frame: DataTrackFrame) { + pushedFrames.add(frame) + } + + override fun unpublish() { + published = false + } + + override suspend fun waitForUnpublish() {} +} diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt new file mode 100644 index 00000000..1f4f7494 --- /dev/null +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/room/datatrack/MockRemoteDataTrackManager.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.test.mock.room.datatrack + +import io.livekit.android.room.datatrack.RemoteDataTrackManagerFactory +import io.livekit.uniffi.DataTrackInfo +import io.livekit.uniffi.DataTrackStream +import io.livekit.uniffi.DataTrackSubscribeOptions +import io.livekit.uniffi.NoHandle +import io.livekit.uniffi.RemoteDataTrack +import io.livekit.uniffi.RemoteDataTrackManagerDelegate +import io.livekit.uniffi.RemoteDataTrackManagerInterface +import uniffi.livekit_datatrack.DecryptionProvider + +class MockRemoteDataTrackManagerFactory : RemoteDataTrackManagerFactory { + /** + * The most recently created manager. + */ + lateinit var manager: MockRemoteDataTrackManager + + /** + * Decryption provider passed into the last [create] call. + */ + var lastDecryptionProvider: DecryptionProvider? = null + private set + + /** + * When set, [create] throws it instead of returning a manager, standing in for a native + * library that fails to load. + */ + var createError: LinkageError? = null + + override fun create( + delegate: RemoteDataTrackManagerDelegate, + decryptionProvider: DecryptionProvider?, + ): RemoteDataTrackManagerInterface { + createError?.let { throw it } + lastDecryptionProvider = decryptionProvider + return MockRemoteDataTrackManager(delegate).also { manager = it } + } +} + +class MockRemoteDataTrackManager( + val delegate: RemoteDataTrackManagerDelegate, +) : RemoteDataTrackManagerInterface, AutoCloseable { + val handledJoinResponses = mutableListOf() + val handledParticipantUpdates = mutableListOf() + val handledSubscriberHandles = mutableListOf() + val handledPackets = mutableListOf() + var closed = false + private set + var resendSubscriptionUpdatesCount = 0 + private set + + override fun handlePacketReceived(packet: ByteArray) { + handledPackets.add(packet) + } + + override fun handleSfuJoinResponse(res: ByteArray) { + handledJoinResponses.add(res) + } + + override fun handleSfuParticipantUpdate(res: ByteArray, localParticipantIdentity: String) { + handledParticipantUpdates.add(res) + } + + override fun handleSubscriberHandles(res: ByteArray) { + handledSubscriberHandles.add(res) + } + + override fun resendSubscriptionUpdates() { + resendSubscriptionUpdatesCount++ + } + + /** + * Fires [RemoteDataTrackManagerDelegate.onTrackPublished] as the UniFFI manager would. + */ + fun simulateTrackPublished( + name: String, + publisherIdentity: String, + sid: String = "DT_mock", + ): MockFfiRemoteDataTrack { + val track = MockFfiRemoteDataTrack( + name = name, + publisherIdentity = publisherIdentity, + sid = sid, + ) + delegate.onTrackPublished(track) + return track + } + + /** + * Fires [RemoteDataTrackManagerDelegate.onTrackUnpublished] as the UniFFI manager would. + */ + fun simulateTrackUnpublished(sid: String) { + delegate.onTrackUnpublished(sid) + } + + override fun close() { + closed = true + } +} + +/** + * UniFFI [RemoteDataTrack] stand-in that does not touch native code. + */ +class MockFfiRemoteDataTrack( + name: String, + publisherIdentity: String, + sid: String = "DT_mock", +) : RemoteDataTrack(NoHandle) { + private val trackInfo = DataTrackInfo( + sid = sid, + name = name, + usesE2ee = false, + schema = null, + frameEncoding = null, + ) + private val identity = publisherIdentity + + override fun info(): DataTrackInfo = trackInfo + + override fun isPublished(): Boolean = true + + override fun publisherIdentity(): String = identity + + override suspend fun subscribe(): DataTrackStream { + throw UnsupportedOperationException("subscribe is not supported in tests") + } + + override suspend fun subscribeWithOptions(options: DataTrackSubscribeOptions): DataTrackStream { + throw UnsupportedOperationException("subscribe is not supported in tests") + } + + override suspend fun waitForUnpublish() {} +} From 8624b3b3d7473e5a46b843e06a4a7e127dba98e4 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 12/47] test(datatrack): bind the mock factories in the test component This commit wires the mock factories into the test Dagger graph. TestRTCModule provides the mock factories as singletons. It also binds them to the production factory interfaces. TestLiveKitComponent exposes both mock factories. MockE2ETest stores both factories in fields, so tests can inspect the managers that the room creates. The commit also updates the copyright year of the changed files. --- .../io/livekit/android/test/MockE2ETest.kt | 8 ++++- .../test/mock/dagger/TestLiveKitComponent.kt | 8 ++++- .../android/test/mock/dagger/TestRTCModule.kt | 30 ++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt b/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt index d6dcf9c9..48fff3c1 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/MockE2ETest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,8 @@ import io.livekit.android.test.mock.TestData import io.livekit.android.test.mock.dagger.DaggerTestLiveKitComponent import io.livekit.android.test.mock.dagger.TestCoroutinesModule import io.livekit.android.test.mock.dagger.TestLiveKitComponent +import io.livekit.android.test.mock.room.datatrack.MockLocalDataTrackManagerFactory +import io.livekit.android.test.mock.room.datatrack.MockRemoteDataTrackManagerFactory import io.livekit.android.util.flow import io.livekit.android.util.toOkioByteString import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -51,6 +53,8 @@ abstract class MockE2ETest : BaseTest() { lateinit var context: Context lateinit var room: Room lateinit var wsFactory: MockWebSocketFactory + lateinit var localDataTrackManagerFactory: MockLocalDataTrackManagerFactory + lateinit var remoteDataTrackManagerFactory: MockRemoteDataTrackManagerFactory @Before fun mocksSetup() { @@ -65,6 +69,8 @@ abstract class MockE2ETest : BaseTest() { enableMetrics = false } wsFactory = component.websocketFactory() + localDataTrackManagerFactory = component.localDataTrackManagerFactory() + remoteDataTrackManagerFactory = component.remoteDataTrackManagerFactory() } @After diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt index e3f7f29b..d31d617c 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestLiveKitComponent.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,8 @@ import io.livekit.android.dagger.MemoryModule import io.livekit.android.room.RTCEngine import io.livekit.android.test.mock.MockNetworkCallbackRegistry import io.livekit.android.test.mock.MockWebSocketFactory +import io.livekit.android.test.mock.room.datatrack.MockLocalDataTrackManagerFactory +import io.livekit.android.test.mock.room.datatrack.MockRemoteDataTrackManagerFactory import javax.inject.Singleton @Singleton @@ -48,6 +50,10 @@ interface TestLiveKitComponent : LiveKitComponent { fun networkCallbackRegistry(): MockNetworkCallbackRegistry + fun localDataTrackManagerFactory(): MockLocalDataTrackManagerFactory + + fun remoteDataTrackManagerFactory(): MockRemoteDataTrackManagerFactory + @Component.Factory interface Factory { fun create( diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt index 0ed771c7..8929c6b7 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,10 +30,14 @@ import io.livekit.android.dagger.CapabilitiesGetter import io.livekit.android.dagger.InjectionNames import io.livekit.android.e2ee.DataPacketCryptorManager import io.livekit.android.e2ee.KeyProvider +import io.livekit.android.room.datatrack.LocalDataTrackManagerFactory +import io.livekit.android.room.datatrack.RemoteDataTrackManagerFactory import io.livekit.android.test.mock.MockAudioDeviceModule import io.livekit.android.test.mock.MockAudioProcessingController import io.livekit.android.test.mock.MockEglBase import io.livekit.android.test.mock.e2ee.ReversingDataPacketCryptorManager +import io.livekit.android.test.mock.room.datatrack.MockLocalDataTrackManagerFactory +import io.livekit.android.test.mock.room.datatrack.MockRemoteDataTrackManagerFactory import io.livekit.android.webrtc.PeerConnectionFactoryManager import io.livekit.android.webrtc.peerconnection.RTCThreadToken import livekit.org.webrtc.EglBase @@ -138,4 +142,28 @@ object TestRTCModule { return ReversingDataPacketCryptorManager() } } + + @Provides + @Singleton + fun mockLocalDataTrackManagerFactory(): MockLocalDataTrackManagerFactory { + return MockLocalDataTrackManagerFactory() + } + + @Provides + @Singleton + fun localDataTrackManagerFactory( + factory: MockLocalDataTrackManagerFactory, + ): LocalDataTrackManagerFactory = factory + + @Provides + @Singleton + fun mockRemoteDataTrackManagerFactory(): MockRemoteDataTrackManagerFactory { + return MockRemoteDataTrackManagerFactory() + } + + @Provides + @Singleton + fun remoteDataTrackManagerFactory( + factory: MockRemoteDataTrackManagerFactory, + ): RemoteDataTrackManagerFactory = factory } From 5963caafedce8192bf2f0409c0f94164b3fa0e15 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 13/47] IMPORTANT(datatrack): add DataTrackFrameSender drop-oldest outbound drain This commit adds DataTrackFrameSender. The class sends the packets of data track frames to a channel. It is pure Kotlin and does not use WebRTC classes. DataTrackSendChannel is the interface to the channel. DataChannelManagerSendChannel implements it on top of a DataChannelManager. The interface makes the sender testable without a peer connection. The sender holds at most one queued frame. A new frame replaces the queued frame. The sender logs the replaced frame. The sender sends all packets of the current frame before it starts the next frame. Packets of two frames never mix. The sender sends packets only while the buffered amount of the channel is at or below LOW_WATER_MARK. The mark is 8 KiB. It is the same value as in rust-sdks. The owner calls pump() on each buffered-amount and state change to continue the drain. The design is the same as in rust-sdks and the Swift SDK. It is different from client-sdk-js. The JS SDK blocks the producer when the buffer is full. The Android producer is a callback from the native code. It cannot block. The sender drops the oldest queued frame instead. Review the drop policy with care. The policy decides which frames the subscribers do not receive under load. --- .../room/datatrack/DataTrackFrameSender.kt | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrameSender.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrameSender.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrameSender.kt new file mode 100644 index 00000000..a823b36d --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackFrameSender.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.util.LKLog +import io.livekit.android.webrtc.DataChannelManager +import livekit.org.webrtc.DataChannel +import java.nio.ByteBuffer + +/** + * The slice of the RTC data channel the outbound drain drives — a seam so the drain logic is + * unit-testable ([livekit.org.webrtc.DataChannel] can't be constructed without a live peer + * connection). + * + * @suppress + */ +internal interface DataTrackSendChannel { + val bufferedAmount: Long + val isOpen: Boolean + fun send(packet: ByteArray): Boolean +} + +/** + * [DataTrackSendChannel] backed by the publisher `_data_track` [DataChannelManager]. + * + * [bufferedAmount] is read live from the native channel so the pump can meter after each send; + * [DataChannelManager.bufferedAmount] only updates on the buffered-amount callback. + * + * @suppress + */ +internal class DataChannelManagerSendChannel( + private val manager: DataChannelManager, +) : DataTrackSendChannel { + override val bufferedAmount: Long + get() = manager.dataChannel.bufferedAmount() + + override val isOpen: Boolean + get() = manager.state == DataChannel.State.OPEN + + override fun send(packet: ByteArray): Boolean { + val buffer = DataChannel.Buffer(ByteBuffer.wrap(packet), true) + return manager.dataChannel.send(buffer) + } +} + +/** + * Drop-oldest outbound drain for data-track frames. + * + * Packets are metered into the channel on buffered-amount events instead of dumped, keeping the + * SCTP buffer near [LOW_WATER_MARK] (so a frame of any size streams out safely) and bounding send + * latency: at most one frame waits while another drains, and a newer frame evicts the waiting + * one. Frames are handled whole — a partial frame is never left on the wire. + * + * Not thread-safe: the owner confines all calls to the RTC thread. + * + * @suppress + */ +internal class DataTrackFrameSender { + companion object { + /** + * Resume sending when the channel buffer drains to this level; parity with + * `DATA_TRACK_BUFFERED_AMOUNT_LOW_THRESHOLD` in rust-sdks. + */ + const val LOW_WATER_MARK: Long = 8 * 1024 + } + + private var channel: DataTrackSendChannel? = null + + /** Freshest queued frame (capacity one — a newer frame evicts it). */ + private var pendingFrame: List? = null + + /** Packets of the frame currently draining, in FIFO order. */ + private val inFlight = ArrayDeque() + + private var pumping = false + + /** + * Attaches the channel this sender drains into, dropping frames queued for the previous one + * (they belong to a torn-down transport). + */ + fun attach(channel: DataTrackSendChannel?) { + this.channel = channel + pendingFrame = null + inFlight.clear() + } + + /** + * Queues a frame's packets for sending, evicting a previously queued frame (drop-oldest). + * + * Takes ownership of [packets] and of the arrays inside it: they are queued and handed to the + * channel as-is rather than copied, so a caller must not retain or mutate them afterwards. + * The packets arrive freshly lifted from the native manager, which keeps no reference to them. + */ + fun sendOrQueue(packets: List) { + if (packets.isEmpty()) { + return + } + val evicted = pendingFrame + if (evicted != null) { + LKLog.d { "Evicted queued data track frame (${evicted.size} packets) in favor of a newer one" } + } + pendingFrame = packets + pump() + } + + /** + * Feeds packets to the channel while it has headroom, promoting the queued frame when the + * in-flight one is fully handed off. + */ + fun pump() { + if (pumping) { + return + } + pumping = true + try { + val channel = channel ?: return + if (!channel.isOpen) { + return + } + while (channel.bufferedAmount <= LOW_WATER_MARK) { + if (inFlight.isEmpty()) { + val next = pendingFrame ?: return + pendingFrame = null + inFlight.addAll(next) + } + val packet = inFlight.firstOrNull() ?: return + if (!channel.send(packet)) { + LKLog.d { "Data track channel rejected packet; dropping the rest of the frame" } + inFlight.clear() + return + } + inFlight.removeFirst() + } + } finally { + pumping = false + } + } +} From b7f155f578d13720574c4e94f85a42a65d6614c0 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 14/47] test(datatrack): pin DataTrackFrameSender drain semantics This commit adds DataTrackFrameSenderTest. FakeSendChannel is a channel that records sent packets and simulates the buffer. The tests check these rules: - The sender sends immediately when the channel has headroom. - A large frame streams out over many drains without a size limit. - A newer frame replaces the queued frame. - An in-flight frame completes before a newer frame starts. - attach() drops the frames queued for the old channel. - A rejected send drops the rest of the frame and does not block the pump. - An empty packet list does not replace the queued frame. - A closed channel holds the frame until the channel opens. --- .../datatrack/DataTrackFrameSenderTest.kt | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackFrameSenderTest.kt diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackFrameSenderTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackFrameSenderTest.kt new file mode 100644 index 00000000..7bffb1cf --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackFrameSenderTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.BaseTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +private class FakeSendChannel : DataTrackSendChannel { + override var bufferedAmount: Long = 0 + override var isOpen = true + var acceptsSends = true + val sent = mutableListOf() + + override fun send(packet: ByteArray): Boolean { + if (!acceptsSends) { + return false + } + sent.add(packet) + bufferedAmount += packet.size + return true + } + + /** Simulates the transport flushing its buffer (the trigger for a buffered-amount callback). */ + fun drain() { + bufferedAmount = 0 + } +} + +/** + * Pins the outbound drain's semantics, which are deliberately aligned (and deliberately not) + * with the other SDKs: + * + * - **rust-sdks** (`DataChannelSender`): the same design — drop-oldest with a capacity-one frame + * queue, whole-frame atomicity, packets metered on buffered-amount events with an 8 KiB + * low-water mark. These tests mirror its invariants. + * - **client-sdk-js** (`LossyDataChannel` with `bufferFullBehavior: 'wait'`): shares the + * whole-frame atomicity and watermark pacing, but blocks the producer under load instead of + * dropping — its engine awaits sends, so overload backpressures the frame producer. Android's + * producer is a fire-and-forget FFI callback with no backpressure channel, so freshest-wins + * eviction is used instead (as in rust-sdks / Swift). + */ +class DataTrackFrameSenderTest : BaseTest() { + + private lateinit var channel: FakeSendChannel + private lateinit var sender: DataTrackFrameSender + + @Before + fun setUpSender() { + channel = FakeSendChannel() + sender = DataTrackFrameSender() + sender.attach(channel) + } + + @Test + fun sendsImmediatelyWithHeadroom() { + sender.sendOrQueue(frame(1, packets = 3)) + assertEquals(3, channel.sent.size) + } + + /** + * The whole frame goes out even when it is far larger than the buffer headroom: packets are + * metered per drain instead of dumped, so there is no sender-imposed max frame size. + */ + @Test + fun largeFrameStreamsWithinHeadroom() { + val packetSize = 64000 + sender.sendOrQueue(frame(1, packets = 50, packetSize = packetSize)) + var pumps = 0 + while (channel.sent.size < 50 && pumps < 100) { + // Each drain admits exactly one over-watermark packet, so the buffer never holds more + // than one packet beyond the low-water mark. + assertTrue(channel.bufferedAmount <= DataTrackFrameSender.LOW_WATER_MARK + packetSize) + channel.drain() + sender.pump() + pumps++ + } + assertEquals(50, channel.sent.size) + } + + /** + * A newer frame evicts the queued (not yet started) one — freshest wins. + */ + @Test + fun dropsOldestQueuedFrame() { + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + sender.sendOrQueue(frame(1)) + sender.sendOrQueue(frame(2)) + assertTrue(channel.sent.isEmpty()) + + channel.drain() + sender.pump() + assertEquals(listOf(2.toByte()), channel.sent.map { it.first() }) + } + + /** + * An in-flight frame is never abandoned mid-send: its remaining packets go out before a + * newer frame, and packets of two frames never interleave. + */ + @Test + fun inFlightFrameCompletesBeforeNewerFrame() { + sender.sendOrQueue(frame(1, packets = 3, packetSize = 64000)) + assertEquals(1, channel.sent.size) + + sender.sendOrQueue(frame(2, packets = 2, packetSize = 64000)) + while (channel.sent.size < 5) { + channel.drain() + sender.pump() + } + assertEquals( + listOf(1.toByte(), 1.toByte(), 1.toByte(), 2.toByte(), 2.toByte()), + channel.sent.map { it.first() }, + ) + } + + /** + * Attaching a channel drops frames queued for the previous one (stale frames belong to a + * dead transport). + */ + @Test + fun attachClearsQueuedFrames() { + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + sender.sendOrQueue(frame(1)) + + val newChannel = FakeSendChannel() + sender.attach(newChannel) + sender.pump() + assertTrue(newChannel.sent.isEmpty()) + + sender.sendOrQueue(frame(2)) + assertEquals(listOf(2.toByte()), newChannel.sent.map { it.first() }) + } + + /** A rejected send drops the rest of the frame without wedging the pump. */ + @Test + fun rejectedSendDropsFrameOnly() { + channel.acceptsSends = false + sender.sendOrQueue(frame(1, packets = 3)) + assertTrue(channel.sent.isEmpty()) + + channel.acceptsSends = true + sender.sendOrQueue(frame(2)) + assertEquals(listOf(2.toByte()), channel.sent.map { it.first() }) + } + + /** An empty packet batch must not evict a queued frame. */ + @Test + fun emptyBatchIsIgnored() { + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + sender.sendOrQueue(frame(1)) + sender.sendOrQueue(emptyList()) + + channel.drain() + sender.pump() + assertEquals(listOf(1.toByte()), channel.sent.map { it.first() }) + } + + /** Nothing is sent while the channel is closed; opening drains the queue. */ + @Test + fun queuedFrameDrainsOnceOpen() { + channel.isOpen = false + sender.sendOrQueue(frame(1)) + assertTrue(channel.sent.isEmpty()) + + channel.isOpen = true + sender.pump() + assertEquals(listOf(1.toByte()), channel.sent.map { it.first() }) + } + + companion object { + /** One packet per frame, tagged for identification. */ + private fun frame(tag: Byte, packets: Int = 1, packetSize: Int = 100): List = + List(packets) { ByteArray(packetSize) { tag } } + } +} From 82dc9d97d3f8df7441ca6169c20f46c77d6cdfbc Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 15/47] IMPORTANT(datatrack): open a publisher _data_track channel and drain frames into it This commit adds DataTrackPublisherChannel and connects it to RTCEngine. DataTrackPublisherChannel owns the publisher-side _data_track transport. It holds one DataTrackFrameSender for the life of the session. attach() gives the channel a new DataChannelManager and starts a pump job. The pump job watches the buffered amount and the state of the channel. detach() stops the pump, drops queued frames, and disposes the manager. sendPackets() queues packets on the RTC thread. awaitOpen() polls until the current manager is open, the session is closed, or the timeout expires. RTCEngine gets these changes: - The DATA_TRACK_DATA_CHANNEL_LABEL constant names the channel "_data_track". - configure() creates the channel on the publisher peer connection after the reliable and lossy channels. The channel is unordered and has zero retransmits. - closeResources() detaches the transport. - sendDataTrackPackets() forwards packets to the transport. A full reconnect replaces the DataChannelManager but keeps the frame sender. A publish that waits in awaitOpen() sees the replacement channel. Review the channel options and the reconnect behavior with care. --- .../java/io/livekit/android/room/RTCEngine.kt | 45 +++++++ .../datatrack/DataTrackPublisherChannel.kt | 125 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublisherChannel.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 19d3464f..38451fd4 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -29,6 +29,7 @@ import io.livekit.android.e2ee.E2EEManager import io.livekit.android.e2ee.EncryptedPacket import io.livekit.android.events.DisconnectReason import io.livekit.android.events.convert +import io.livekit.android.room.datatrack.DataTrackPublisherChannel import io.livekit.android.room.network.DefaultReconnectPolicy import io.livekit.android.room.network.ReconnectContext import io.livekit.android.room.network.ReconnectPolicy @@ -191,6 +192,7 @@ internal constructor( private var reliableDataChannelSub: DataChannel? = null private var lossyDataChannel: DataChannel? = null private var lossyDataChannelSub: DataChannel? = null + private val dataTrackPublisherChannel = DataTrackPublisherChannel(rtcThreadToken) private var reliableDataChannelManager: DataChannelManager? = null private var reliableBufferedAmountJob: Job? = null private var reliableDataChannelSubManager: DataChannelManager? = null @@ -377,6 +379,9 @@ internal constructor( dataChannel.registerObserver(lossyDataChannelManager) } } + + ensureActive() + createPublisherDataTrackChannel() } } } @@ -497,6 +502,7 @@ internal constructor( lossyDataChannelSubManager?.dispose() lossyDataChannelSubManager = null lossyDataChannelSub = null + dataTrackPublisherChannel.detach() isSubscriberPrimary = false } } @@ -899,6 +905,29 @@ internal constructor( ) } + /** + * Creates the publisher `_data_track` channel and hands it to [dataTrackPublisherChannel]. + */ + private suspend fun createPublisherDataTrackChannel() { + val dataTrackInit = DataChannel.Init() + dataTrackInit.ordered = false + dataTrackInit.maxRetransmits = 0 + publisher?.withPeerConnection { + createDataChannel( + DATA_TRACK_DATA_CHANNEL_LABEL, + dataTrackInit, + ).also { dataChannel -> + val dataChannelManager = DataChannelManager( + dataChannel, + DataChannelObserver(dataChannel), + rtcThreadToken, + ) + dataChannel.registerObserver(dataChannelManager) + dataTrackPublisherChannel.attach(dataChannelManager, coroutineScope) + } + } + } + private fun dataChannelManagerForKind(kind: LivekitModels.DataPacket.Kind): DataChannelManager? = when (kind) { LivekitModels.DataPacket.Kind.RELIABLE -> reliableDataChannelManager @@ -1056,6 +1085,15 @@ internal constructor( */ @VisibleForTesting const val LOSSY_DATA_CHANNEL_LABEL = "_lossy" + + /** + * Dedicated data channel for LiveKit data-track packets. + * + * @suppress + */ + @VisibleForTesting + const val DATA_TRACK_DATA_CHANNEL_LABEL = "_data_track" + internal const val TARGET_DATA_PACKET_SIZE = 15 * 1024 // 15 KB /** @@ -1282,6 +1320,13 @@ internal constructor( listener?.onLocalTrackUnpublished(trackUnpublished) } + /** + * Queues serialized data-track packets on the dedicated `_data_track` data channel. + */ + internal fun sendDataTrackPackets(packets: List) { + dataTrackPublisherChannel.sendPackets(packets) + } + // --------------------------------- DataChannel.Observer ------------------------------------// fun onBufferedAmountChange(dataChannel: DataChannel, previousAmount: Long) { diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublisherChannel.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublisherChannel.kt new file mode 100644 index 00000000..13373d39 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackPublisherChannel.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.util.flow +import io.livekit.android.webrtc.DataChannelManager +import io.livekit.android.webrtc.peerconnection.RTCThreadToken +import io.livekit.android.webrtc.peerconnection.executeOnRTCThread +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import livekit.org.webrtc.DataChannel + +/** + * Owns the publisher `_data_track` transport: the frame sender that drains into it, the pump that + * follows its buffered-amount and state changes, and the readiness wait a publish blocks on. + * + * The transport is swapped, not recreated, across a full reconnect — [attach] hands over a + * replacement [DataChannelManager] for the same session while the frame sender lives on, and + * [awaitOpen] re-reads the current manager each pass so an in-flight publish waits for that + * replacement instead of failing against a disposed one. + * + * @suppress + */ +internal class DataTrackPublisherChannel( + private val rtcThreadToken: RTCThreadToken, +) { + private val frameSender = DataTrackFrameSender() + private var channelManager: DataChannelManager? = null + private var pumpJob: Job? = null + + /** + * Adopts [channelManager] as the transport: same frame sender, new SCTP association. A full + * reconnect's replacement arrives unopened; [awaitOpen] callers keep waiting until it hits + * [DataChannel.State.OPEN]. + */ + fun attach(channelManager: DataChannelManager, scope: CoroutineScope) { + this.channelManager = channelManager + // Frames queued for the old channel belong to the torn-down transport. + frameSender.attach(DataChannelManagerSendChannel(channelManager)) + pumpJob?.cancel() + pumpJob = scope.launch { + launch { + channelManager::bufferedAmount.flow.collect { + pump() + } + } + launch { + channelManager::state.flow.collect { + pump() + } + } + } + } + + /** + * Tears down the current transport. A later [attach] revives the sender against the + * replacement channel. + */ + fun detach() { + pumpJob?.cancel() + pumpJob = null + frameSender.attach(null) + channelManager?.dispose() + channelManager = null + } + + /** + * Queues serialized data-track packets. + * + * Packets belonging to one application frame are metered as a unit (drop-oldest, one frame in + * flight) once the channel is [DataChannel.State.OPEN] and buffered amount is at or below + * [DataTrackFrameSender.LOW_WATER_MARK]. + */ + fun sendPackets(packets: List) { + executeOnRTCThread(rtcThreadToken) { + frameSender.sendOrQueue(packets) + } + } + + /** + * Waits until the channel is open. + * + * @param sessionClosed whether the room session itself has ended, which ends the wait instead + * of letting it run out the timeout. + * @return `true` once open, `false` if the session closed first, `null` on timeout. + */ + suspend fun awaitOpen(timeoutMs: Long, sessionClosed: () -> Boolean): Boolean? = + withTimeoutOrNull(timeoutMs) { + while (!sessionClosed()) { + val manager = channelManager + if (manager?.disposed == false && manager.state == DataChannel.State.OPEN) { + return@withTimeoutOrNull true + } + delay(POLL_INTERVAL_MS) + } + false + } + + private fun pump() { + executeOnRTCThread(rtcThreadToken) { + frameSender.pump() + } + } + + private companion object { + const val POLL_INTERVAL_MS = 50L + } +} From ac3063f65edd6953b938f2a86e17bce7c1e79669 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:39 +0200 Subject: [PATCH 16/47] IMPORTANT(datatrack): wait for the publisher channel before publishing This commit adds ensureDataTrackPublisherConnected() to RTCEngine. A publish must not start before the _data_track channel is open. The frame sender queues at most one frame while the channel is not open. The function first checks the publisher peer connection. In subscriber-primary mode the publisher connection is created on demand. If the publisher is not connected and ICE is not checking, the function calls negotiatePublisher(). This also sets hasPublished. The function then waits for the channel with awaitOpen(). It uses MAX_ICE_CONNECT_TIMEOUT_MS as the timeout. If the session closes during the wait, the function throws DataTrackPublishException.Disconnected. If the timeout expires, the function throws DataTrackPublishException.Timeout. Review the negotiation condition with care. It starts a publisher negotiation as a side effect of a publish call. --- .../java/io/livekit/android/room/RTCEngine.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 38451fd4..4599bfcd 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -29,6 +29,7 @@ import io.livekit.android.e2ee.E2EEManager import io.livekit.android.e2ee.EncryptedPacket import io.livekit.android.events.DisconnectReason import io.livekit.android.events.convert +import io.livekit.android.room.datatrack.DataTrackPublishException import io.livekit.android.room.datatrack.DataTrackPublisherChannel import io.livekit.android.room.network.DefaultReconnectPolicy import io.livekit.android.room.network.ReconnectContext @@ -905,6 +906,41 @@ internal constructor( ) } + /** + * Negotiates the publisher if needed and waits until the `_data_track` channel is open. + * + * Data-track publish must not proceed until then: [sendDataTrackPackets] queues at most one + * frame while the channel is not [DataChannel.State.OPEN]. + * + * [DataTrackPublisherChannel] re-reads its manager each pass rather than capturing it, so a + * publish in flight when a full reconnect swaps the transport waits for the replacement + * channel instead of failing against a disposed one. A real [close] fails it as a disconnect. + */ + @Throws(exceptionClasses = [DataTrackPublishException::class]) + internal suspend fun ensureDataTrackPublisherConnected() { + // Always mark publish intent so a full reconnect's joinImpl renegotiates even if this + // wait started against a torn-down publisher transport. + if (isSubscriberPrimary) { + val publisherTransport = publisher + val iceChecking = publisherTransport?.iceConnectionState() == + PeerConnection.IceConnectionState.CHECKING + if (publisherTransport?.isConnected() != true && !iceChecking) { + negotiatePublisher() + } + } + + val opened = dataTrackPublisherChannel.awaitOpen(MAX_ICE_CONNECT_TIMEOUT_MS.toLong()) { isClosed } + when (opened) { + true -> return + false -> throw DataTrackPublishException.Disconnected( + "Lost the connection while establishing the publisher data track channel", + ) + null -> throw DataTrackPublishException.Timeout( + "Timed out establishing the publisher data track channel", + ) + } + } + /** * Creates the publisher `_data_track` channel and hands it to [dataTrackPublisherChannel]. */ From 1928876f58fbb0369c88a60cb40a68b3c270b47b Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 17/47] refactor(signal): keep the encoded websocket bytes alongside decoded responses This commit changes SignalClient so the raw websocket bytes travel with each decoded response. The UniFFI data track managers parse the signal messages themselves. If the SDK decoded and re-encoded a message, the protobuf library would drop fields that the SDK protocol version does not know. The raw bytes avoid that problem. The commit makes these changes: - The private IncomingSignal class replaces the Pair in the response flow. It holds the websocket, the response, and the bytes. - handleSignalResponse() and handleSignalResponseImpl() get an encoded parameter. - onMessage() passes the received bytes through. - lastJoinEncoded keeps the bytes of the last successful Join response. - Listener.onParticipantUpdate() gets an encoded parameter. RTCEngine updates its override. It does not use the bytes yet. The commit does not change the behavior of the existing message handling. --- .../java/io/livekit/android/room/RTCEngine.kt | 2 +- .../io/livekit/android/room/SignalClient.kt | 39 ++++++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 4599bfcd..14429adc 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -1266,7 +1266,7 @@ internal constructor( listener?.onLocalTrackSubscribed(trackSubscribed) } - override fun onParticipantUpdate(updates: List) { + override fun onParticipantUpdate(updates: List, encoded: ByteArray) { listener?.onUpdateParticipants(updates) } 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 3f243270..e7c4f191 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 @@ -112,7 +112,15 @@ constructor( /** * @see [onReadyForResponses] */ - private val responseFlow = MutableSharedFlow>(Int.MAX_VALUE) + private val responseFlow = MutableSharedFlow(Int.MAX_VALUE) + + /** + * Wire bytes of the Join [LivekitRtc.SignalResponse] from the last successful [join]. + * UniFFI parses these itself so re-encoding the decoded join cannot drop newer fields. + */ + @Volatile + internal var lastJoinEncoded: ByteArray? = null + private set private val responseFlowJobLock = Object() private var responseFlowJob: Job? = null @@ -258,9 +266,9 @@ constructor( synchronized(responseFlowJobLock) { if (responseFlowJob == null) { responseFlowJob = coroutineScope.launch { - responseFlow.collect { (ws, response) -> + responseFlow.collect { incoming -> responseFlow.resetReplayCache() - handleSignalResponseImpl(ws, response) + handleSignalResponseImpl(incoming.ws, incoming.response, incoming.encoded) } } } @@ -312,7 +320,7 @@ constructor( .mergeFrom(byteArray) val response = signalResponseBuilder.build() - handleSignalResponse(webSocket, response) + handleSignalResponse(webSocket, response, byteArray) } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { @@ -665,7 +673,7 @@ constructor( } } - private fun handleSignalResponse(ws: WebSocket, response: LivekitRtc.SignalResponse) { + private fun handleSignalResponse(ws: WebSocket, response: LivekitRtc.SignalResponse, encoded: ByteArray) { if (ws != currentWs) { return } @@ -691,11 +699,12 @@ constructor( edition = ServerInfo.Edition.fromProto(response.join.serverInfo.edition), version = serverVersion ) + lastJoinEncoded = encoded joinContinuation?.resumeWith(Result.success(ConnectResult.Join(response.join))) joinContinuation = null } else if (response.hasLeave()) { // Some reconnects may immediately send leave back without a join response first. - handleSignalResponseImpl(ws, response) + handleSignalResponseImpl(ws, response, encoded) val cont = joinContinuation joinContinuation = null cont?.resumeWithException( @@ -737,10 +746,10 @@ constructor( return } } - responseFlow.tryEmit(ws to response) + responseFlow.tryEmit(IncomingSignal(ws, response, encoded)) } - private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse) { + private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse, encoded: ByteArray) { if (ws != currentWs) { LKLog.v { "received message from old websocket, discarding." } return @@ -771,7 +780,7 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.UPDATE -> { - listener?.onParticipantUpdate(response.update.participantsList) + listener?.onParticipantUpdate(response.update.participantsList, encoded) } LivekitRtc.SignalResponse.MessageCase.TRACK_SUBSCRIBED -> { @@ -955,7 +964,7 @@ constructor( fun onServerOffer(sessionDescription: SessionDescription, offerId: Int) fun onTrickle(candidate: IceCandidate, target: LivekitRtc.SignalTarget) fun onLocalTrackPublished(response: LivekitRtc.TrackPublishedResponse) - fun onParticipantUpdate(updates: List) + fun onParticipantUpdate(updates: List, encoded: ByteArray) fun onSpeakersChanged(speakers: List) fun onClose(reason: String, code: Int) fun onRemoteMuteChanged(trackSid: String, muted: Boolean) @@ -972,6 +981,16 @@ constructor( fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed) } + /** + * A signal message together with the websocket bytes it arrived as. + * Data-track managers parse the encoded form themselves. + */ + private class IncomingSignal( + val ws: WebSocket, + val response: LivekitRtc.SignalResponse, + val encoded: ByteArray, + ) + /** * Result of waiting for the initial signal response after opening the WebSocket. * Join always yields [Join]; reconnect yields [Reconnect] or [OtherResponse]. From 45e93ad7a23e448a77ad5e4a8d8151eddf3e6d2b Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 18/47] feat(signal): dispatch data track SFU responses to the listener This commit replaces four TODO comments in SignalClient with listener calls. The Listener interface gets four methods with empty default bodies: - onPublishDataTrackResponse - onUnpublishDataTrackResponse - onRequestResponse - onDataTrackSubscriberHandles Each method receives the encoded bytes of the full SignalResponse. The UniFFI managers decode the bytes. The REQUEST_RESPONSE case forwards every request response. The managers ignore the responses that are not about data tracks. RTCEngine does not override the methods yet. --- .../java/io/livekit/android/room/SignalClient.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 e7c4f191..76674e5c 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 @@ -858,7 +858,8 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.REQUEST_RESPONSE -> { - // TODO + // Pass the full SignalResponse — UniFFI deserializes and filters data-track related ones. + listener?.onRequestResponse(encoded) } LivekitRtc.SignalResponse.MessageCase.ROOM_MOVED -> { @@ -874,15 +875,15 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.PUBLISH_DATA_TRACK_RESPONSE -> { - // TODO + listener?.onPublishDataTrackResponse(encoded) } LivekitRtc.SignalResponse.MessageCase.UNPUBLISH_DATA_TRACK_RESPONSE -> { - // TODO + listener?.onUnpublishDataTrackResponse(encoded) } LivekitRtc.SignalResponse.MessageCase.DATA_TRACK_SUBSCRIBER_HANDLES -> { - // TODO + listener?.onDataTrackSubscriberHandles(encoded) } LivekitRtc.SignalResponse.MessageCase.MESSAGE_NOT_SET, @@ -979,6 +980,10 @@ constructor( fun onRefreshToken(token: String) fun onLocalTrackUnpublished(trackUnpublished: LivekitRtc.TrackUnpublishedResponse) fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed) + fun onPublishDataTrackResponse(encoded: ByteArray) {} + fun onUnpublishDataTrackResponse(encoded: ByteArray) {} + fun onRequestResponse(encoded: ByteArray) {} + fun onDataTrackSubscriberHandles(encoded: ByteArray) {} } /** From 1ed9875efe1441e4e05c4df3e9556c1e6661255d Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 19/47] feat(signal): forward UniFFI-built signal requests This commit adds the path for signal requests that the native managers build. SignalClient.sendEncodedRequest() parses the bytes as a SignalRequest and sends it through the normal queue. If the bytes do not parse, the function logs an error and returns. It does not throw. The native managers call this function from a callback. An exception here would unwind through the FFI boundary. RTCEngine.sendDataTrackSignalRequest() forwards the bytes to the client. If nothing is published yet, it calls negotiatePublisher() first. Data track signaling needs the publisher peer connection and the _data_track channel. --- .../java/io/livekit/android/room/RTCEngine.kt | 11 +++++++++++ .../io/livekit/android/room/SignalClient.kt | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 14429adc..b823a847 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -1356,6 +1356,17 @@ internal constructor( listener?.onLocalTrackUnpublished(trackUnpublished) } + /** + * Forwards an encoded [LivekitRtc.SignalRequest] produced by a UniFFI data track manager. + */ + internal fun sendDataTrackSignalRequest(requestBytes: ByteArray) { + // Data-track publish / subscribe signaling requires the publisher PC / `_data_track` DC. + if (!hasPublished) { + negotiatePublisher() + } + client.sendEncodedRequest(requestBytes) + } + /** * Queues serialized data-track packets on the dedicated `_data_track` data channel. */ 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 76674e5c..cdc74500 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt @@ -17,6 +17,7 @@ package io.livekit.android.room import androidx.annotation.VisibleForTesting +import com.google.protobuf.InvalidProtocolBufferException import com.vdurmont.semver4j.Semver import io.livekit.android.ConnectOptions import io.livekit.android.RoomOptions @@ -659,6 +660,22 @@ constructor( } } + /** + * Sends a previously encoded [LivekitRtc.SignalRequest] (e.g. from UniFFI data track manager). + * + * Undecodable bytes are dropped rather than thrown: this runs on a callback from the native + * data track managers, so an exception here would unwind through the FFI boundary. + */ + internal fun sendEncodedRequest(requestBytes: ByteArray) { + val request = try { + LivekitRtc.SignalRequest.parseFrom(requestBytes) + } catch (e: InvalidProtocolBufferException) { + LKLog.e(e) { "Discarding an encoded signal request that could not be parsed." } + return + } + sendRequest(request) + } + private fun sendRequestImpl(request: LivekitRtc.SignalRequest) { LKLog.v { "sending request: $request" } if (!isConnected || currentWs == null) { From 4c26a5ade3a0144cbb7e5653610edac623de8aa0 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 20/47] test(signal): cover encoded request forwarding and retained join bytes This commit extends SignalClientTest. The existing join test now checks that lastJoinEncoded equals the bytes of the Join response. Two new tests cover sendEncodedRequest(). One test sends valid bytes and checks that the request reaches the websocket. One test sends invalid bytes and checks that nothing is sent. --- .../livekit/android/room/SignalClientTest.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt index a10dfe3f..8885709e 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt @@ -41,6 +41,7 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.WebSocketListener +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -125,6 +126,7 @@ class SignalClientTest : BaseTest() { val response = job.await() assertEquals(true, client.isConnected) assertEquals(response, JOIN.join) + assertArrayEquals(JOIN.toByteArray(), client.lastJoinEncoded) } @Test @@ -501,6 +503,42 @@ class SignalClientTest : BaseTest() { } } + @Test + fun sendEncodedRequestForwardsValidBytes() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + val before = wsFactory.ws.sentRequests.size + + val encoded = LivekitRtc.SignalRequest.newBuilder() + .setPing(1234) + .build() + .toByteArray() + client.sendEncodedRequest(encoded) + + assertEquals(before + 1, wsFactory.ws.sentRequests.size) + val sent = LivekitRtc.SignalRequest.parseFrom(wsFactory.ws.sentRequests.last().toPBByteString()) + assertEquals(1234L, sent.ping) + } + + /** + * The native data track managers call this back across the FFI boundary, where a thrown + * parse failure would unwind into Rust. + */ + @Test + fun sendEncodedRequestDropsUnparseableBytes() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + val before = wsFactory.ws.sentRequests.size + + client.sendEncodedRequest(byteArrayOf(-1, -1, -1, -1, -1, -1)) + + assertEquals(before, wsFactory.ws.sentRequests.size) + } + // mock data companion object } From ac9a7fe3b2680b29fe2219433060df73c01b12c9 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 21/47] IMPORTANT(datatrack): add LocalDataTrack wrapper This commit adds the public LocalDataTrack class. The class wraps the UniFFI LocalDataTrack. The commit does not include the Flow-based send function. A later commit adds it. The class exposes isPublished, info, tryPush(), unpublish(), and waitForUnpublish(). tryPush() returns a Result. It maps a UniFFI PushFrameErrorReason to DataTrackPushFrameException. The class implements the internal DataTrackFrameSink interface. The interface is the seam that a later commit uses to test the send policy. Review the second catch block in tryPush() with care. The UniFFI bindings cannot decode the PushFrameErrorReason type, because a different UniFFI component defines it. The bindings report an internal error instead. The catch block infers the real cause. If the track is still published, the cause is a full queue. If the track is not published, the cause is the unpublish. This is a workaround for a bindings limitation. --- .../android/room/datatrack/LocalDataTrack.kt | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt new file mode 100644 index 00000000..31fc5c17 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import androidx.annotation.CheckResult +import io.livekit.android.util.rethrowIfCancellationSignal +import uniffi.livekit_datatrack.PushFrameErrorReason +import io.livekit.uniffi.LocalDataTrack as FfiLocalDataTrack + +/** + * A data track published by the local participant. Obtain one from + * [io.livekit.android.room.participant.LocalParticipant.publishDataTrack], + * then push frames with [tryPush] or [send]. + * + * The publication stays live until [unpublish] is called, the SFU unpublishes + * the track, or the room disconnects. Dropping the last reference eventually + * unpublishes the track, but only once it is garbage collected — call + * [unpublish] to end the publication at a predictable point, or + * [io.livekit.android.room.participant.LocalParticipant.withDataTrack] to scope + * one to a block. + * + * ``` + * val result = room.localParticipant.publishDataTrack("telemetry") + * result.onSuccess { track -> + * track.tryPush(DataTrackFrame(payload)) + * track.unpublish() + * } + * ``` + */ +class LocalDataTrack internal constructor( + private val impl: FfiLocalDataTrack, +) : DataTrackFrameSink { + /** + * Whether the track is currently published. Becomes `false` after [unpublish] or if the SFU + * unpublishes it. + */ + override val isPublished: Boolean + get() = impl.isPublished() + + /** + * Metadata for this track. + */ + val info: DataTrackInfo + get() = DataTrackInfo(impl.info()) + + /** + * Pushes a frame to subscribers. + * + * Non-blocking. Fails with [DataTrackPushFrameException.TrackUnpublished] if the track was + * unpublished by the local participant or the SFU, or if the room is no longer connected; + * [DataTrackPushFrameException.QueueFull] if frames are being pushed faster than they can + * be sent, which hands the rejected frame back on the exception. + * + * @return A successful [Result] if the frame was enqueued, or a failure containing + * [DataTrackPushFrameException]. + */ + @CheckResult + override fun tryPush(frame: DataTrackFrame): Result { + return try { + impl.tryPush(frame.toFfi()) + Result.success(Unit) + } catch (e: PushFrameErrorReason) { + Result.failure(e.toSdk(frame)) + } catch (e: Exception) { + // The bindings can't decode the reason a push was rejected — the error type is + // defined in a different UniFFI component — and report an internal error instead. + // The call did fail, and only two things cause that, so recover the one that + // applies rather than leaking an FFI-internal error through the public API. + e.rethrowIfCancellationSignal() + Result.failure( + if (isPublished) { + DataTrackPushFrameException.QueueFull("The send queue is full", frame, e) + } else { + DataTrackPushFrameException.TrackUnpublished("The track is no longer published", e) + }, + ) + } + } + + /** + * Unpublishes the track. Subsequent [tryPush] calls fail with + * [DataTrackPushFrameException.TrackUnpublished]. + */ + fun unpublish() { + impl.unpublish() + } + + /** + * Waits until the track is unpublished, by either the local participant or the SFU. + * + * Use this to trigger follow-up work once the track is no longer published. Returns + * immediately if it is already unpublished. + */ + suspend fun waitForUnpublish() { + impl.waitForUnpublish() + } +} + +/** + * The slice of a publication the sequence send drives — a seam so the queue-full policy is + * unit-testable, since saturating a live pipeline to observe it is inherently timing-dependent. + * + * @suppress + */ +internal interface DataTrackFrameSink { + val isPublished: Boolean + fun tryPush(frame: DataTrackFrame): Result +} From 8cb39b09814c035ca53628332faa8797ca19347e Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 22/47] IMPORTANT(datatrack): add OutgoingDataTrackManager bridging the UniFFI local manager This commit adds OutgoingDataTrackManager. The class owns the UniFFI LocalDataTrackManager and connects it to RTCEngine. The class is a Dagger singleton. It gets the engine through a Provider to avoid a dependency cycle. The delegate receives two callbacks from the native manager. onSignalRequest forwards the request bytes to the engine. onPacketsAvailable forwards the packets to the engine. publishTrack() first calls ensureDataTrackPublisherConnected() on the engine. It then creates the native manager on demand and publishes the track. Every failure returns a typed DataTrackPublishException in a Result. The handle functions forward SFU responses to the native manager. The unpublish response is not consumed. The native manager does not use it. republishTracks() and publishResponsesForSyncState() support reconnect. close() disposes the native manager. ensureManager() creates the native manager once. If the native library fails to load, the class remembers the failure. It does not retry. Every later publish fails with DataTrackPublishException.Internal. This commit does not contain E2EE code. The factory receives a null encryption provider. A later commit adds encryption. --- .../datatrack/OutgoingDataTrackManager.kt | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt new file mode 100644 index 00000000..ee7197dd --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt @@ -0,0 +1,190 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import androidx.annotation.CheckResult +import io.livekit.android.room.RTCEngine +import io.livekit.android.util.LKLog +import io.livekit.android.util.rethrowIfCancellationSignal +import io.livekit.uniffi.DataTrackOptions +import io.livekit.uniffi.HandleSignalResponseException +import io.livekit.uniffi.LocalDataTrackManagerDelegate +import io.livekit.uniffi.LocalDataTrackManagerInterface +import uniffi.livekit_datatrack.PublishException +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * Owns the UniFFI [io.livekit.uniffi.LocalDataTrackManager] and bridges its transport callbacks + * into [RTCEngine]. + * + * Signal requests / SFU responses and data-track packets are forwarded through the engine so the + * Rust manager stays decoupled from WebRTC and WebSocket details. + * + * @suppress + */ +@Singleton +class OutgoingDataTrackManager +@Inject +constructor( + private val engineProvider: Provider, + private val localDataTrackManagerFactory: LocalDataTrackManagerFactory, +) { + private val lock = Any() + private var localManager: LocalDataTrackManagerInterface? = null + private var nativeUnavailable = false + + /** + * Handles events from the UniFFI local data track manager. + */ + private val delegate = object : LocalDataTrackManagerDelegate { + override fun onSignalRequest(request: ByteArray) { + engineProvider.get().sendDataTrackSignalRequest(request) + } + + override fun onPacketsAvailable(packets: List) { + engineProvider.get().sendDataTrackPackets(packets) + } + } + + /** + * Publishes a data track with the given name and options. + * + * @return A successful [Result] containing the published track, or a failure containing + * [DataTrackPublishException]. + */ + @CheckResult + suspend fun publishTrack(name: String, options: DataTrackPublishOptions? = null): Result { + val ffiOptions = DataTrackOptions( + name = name, + schema = options?.frameFormat?.schema?.toFfi(), + frameEncoding = options?.frameFormat?.frameEncoding?.toFfi(), + ) + try { + engineProvider.get().ensureDataTrackPublisherConnected() + } catch (e: DataTrackPublishException) { + return Result.failure(e) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + return Result.failure( + DataTrackPublishException.Disconnected( + e.message ?: "Lost the connection while establishing the publisher data track channel", + e, + ), + ) + } + val manager = ensureManager() + ?: return Result.failure( + DataTrackPublishException.Internal( + "Data tracks are unavailable: the native library failed to load", + ), + ) + return try { + Result.success(LocalDataTrack(manager.publishTrack(ffiOptions))) + } catch (e: PublishException) { + Result.failure(e.toSdk()) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + Result.failure(DataTrackPublishException.Internal(e.message ?: "", e)) + } + } + + /** + * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing + * `PublishDataTrackResponse` to the UniFFI manager. + */ + fun handleSfuPublishResponse(responseBytes: ByteArray) { + val manager = localManager ?: return + try { + manager.handleSfuPublishResponse(responseBytes) + } catch (e: HandleSignalResponseException) { + LKLog.w(e) { "Failed to handle PublishDataTrackResponse" } + } + } + + /** + * Receives a serialized [livekit.LivekitRtc.SignalResponse] containing + * `UnpublishDataTrackResponse`. + * + * UniFFI does not consume this message yet. Local unpublish is applied by + * [LocalDataTrack.unpublish] before the SFU acks. + */ + fun handleSfuUnpublishResponse(responseBytes: ByteArray) { + // UniFFI does not consume UnpublishDataTrackResponse. + } + + /** + * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing `RequestResponse` + * to the UniFFI manager. Non-data-track request responses are ignored by the manager. + */ + fun handleSfuRequestResponse(responseBytes: ByteArray) { + val manager = localManager ?: return + try { + manager.handleSfuRequestResponse(responseBytes) + } catch (e: HandleSignalResponseException) { + LKLog.w(e) { "Failed to handle RequestResponse for data tracks" } + } + } + + /** + * Republish all tracks after a full reconnect so the SFU recognizes existing publications. + */ + fun republishTracks() { + localManager?.republishTracks() + } + + /** + * Returns serialized `PublishDataTrackResponse` messages for currently published tracks, + * suitable for [livekit.LivekitRtc.SyncState.publishDataTracks]. + */ + suspend fun publishResponsesForSyncState(): List { + return localManager?.publishResponsesForSyncState() ?: emptyList() + } + + /** + * Shuts down the underlying UniFFI manager. A subsequent [publishTrack] creates a new one. + */ + fun close() { + synchronized(lock) { + (localManager as? AutoCloseable)?.close() + localManager = null + } + } + + /** + * The UniFFI manager, or `null` if its native library could not be loaded — see + * [IncomingDataTrackManager]. Reached only from [publishTrack], so the failure surfaces to + * the caller as a failed [Result] rather than degrading silently. + */ + private fun ensureManager(): LocalDataTrackManagerInterface? { + synchronized(lock) { + localManager?.let { return it } + if (nativeUnavailable) { + return null + } + return try { + localDataTrackManagerFactory.create(delegate, null) + .also { localManager = it } + } catch (e: LinkageError) { + nativeUnavailable = true + LKLog.e(e) { "Data tracks are unavailable: the native library failed to load." } + null + } + } + } +} From 68d126586438871cd628a16eb86c379d1b446773 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 23/47] feat(datatrack): wire OutgoingDataTrackManager into RTCEngine This commit injects OutgoingDataTrackManager into RTCEngine. The engine closes the manager in close(). The engine overrides three listener methods. onPublishDataTrackResponse, onUnpublishDataTrackResponse, and onRequestResponse forward the encoded bytes to the manager. --- .../java/io/livekit/android/room/RTCEngine.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index b823a847..6d9467fe 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -31,6 +31,7 @@ import io.livekit.android.events.DisconnectReason import io.livekit.android.events.convert import io.livekit.android.room.datatrack.DataTrackPublishException import io.livekit.android.room.datatrack.DataTrackPublisherChannel +import io.livekit.android.room.datatrack.OutgoingDataTrackManager import io.livekit.android.room.network.DefaultReconnectPolicy import io.livekit.android.room.network.ReconnectContext import io.livekit.android.room.network.ReconnectPolicy @@ -121,6 +122,7 @@ internal constructor( private val ioDispatcher: CoroutineDispatcher, private val rtcThreadToken: RTCThreadToken, private val dataPacketCryptorFactory: DataPacketCryptorManager.Factory, + private val outgoingDataTrackManager: OutgoingDataTrackManager, ) : SignalClient.Listener { internal var listener: Listener? = null @@ -469,6 +471,7 @@ internal constructor( regionUrlProvider = null abortPendingPublishTracks() closeResources(reason) + outgoingDataTrackManager.close() connectionState = ConnectionState.DISCONNECTED synchronized(reliableStateLock) { @@ -1356,6 +1359,18 @@ internal constructor( listener?.onLocalTrackUnpublished(trackUnpublished) } + override fun onPublishDataTrackResponse(encoded: ByteArray) { + outgoingDataTrackManager.handleSfuPublishResponse(encoded) + } + + override fun onUnpublishDataTrackResponse(encoded: ByteArray) { + outgoingDataTrackManager.handleSfuUnpublishResponse(encoded) + } + + override fun onRequestResponse(encoded: ByteArray) { + outgoingDataTrackManager.handleSfuRequestResponse(encoded) + } + /** * Forwards an encoded [LivekitRtc.SignalRequest] produced by a UniFFI data track manager. */ From e0415ba5f6962e3a9577f9d386cd6870bea70783 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 24/47] IMPORTANT(datatrack): add LocalParticipant.publishDataTrack and withDataTrack This commit adds the public API for publishing a data track. publishDataTrack() checks that the engine is connected. It then delegates to OutgoingDataTrackManager. The function returns a Result with the LocalDataTrack or a DataTrackPublishException. withDataTrack() publishes a track for the duration of a block. It unpublishes the track when the block returns, throws, or is cancelled. LocalParticipant gets the manager through its constructor. Review the API shape and the documentation with care. The documentation says that a dropped track is unpublished only when the garbage collector runs. Callers must call unpublish() for a predictable end of the publication. --- .../room/participant/LocalParticipant.kt | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt index a1fe8861..745e2420 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt @@ -37,6 +37,10 @@ import io.livekit.android.room.RTCEngine import io.livekit.android.room.Room import io.livekit.android.room.TrackBitrateInfo import io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager +import io.livekit.android.room.datatrack.DataTrackPublishException +import io.livekit.android.room.datatrack.DataTrackPublishOptions +import io.livekit.android.room.datatrack.LocalDataTrack +import io.livekit.android.room.datatrack.OutgoingDataTrackManager import io.livekit.android.room.isSVCCodec import io.livekit.android.room.rpc.RpcClientManager import io.livekit.android.room.rpc.RpcManager @@ -109,6 +113,7 @@ internal constructor( @Named(InjectionNames.SENDER) private val capabilitiesGetter: CapabilitiesGetter, private val outgoingDataStreamManager: OutgoingDataStreamManager, + private val outgoingDataTrackManager: OutgoingDataTrackManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, ) : Participant(Sid(""), null, coroutineDispatcher), @@ -975,6 +980,77 @@ internal constructor( eventBus.postEvent(ParticipantEvent.LocalTrackUnpublished(this, publication), scope) } + /** + * Publishes a data track, allowing this participant to send frames to subscribers. + * + * The publication stays live until [LocalDataTrack.unpublish] is called, the SFU unpublishes + * the track, or the room disconnects. Dropping the last reference to the returned track + * eventually unpublishes it, but only once it is garbage collected — call + * [LocalDataTrack.unpublish] to end the publication at a predictable point, or use + * [withDataTrack] to scope it to a block. + * + * ``` + * val result = room.localParticipant.publishDataTrack("telemetry") + * result.onSuccess { track -> + * track.tryPush(DataTrackFrame(payload)) + * track.unpublish() + * } + * ``` + * + * @param name Track name visible to other participants. Must be unique per publisher. + * @param options Optional encoding and schema metadata, surfaced to subscribers via + * [io.livekit.android.room.datatrack.DataTrackInfo]. + * @return A successful [Result] containing the published [LocalDataTrack], or a failure + * containing [DataTrackPublishException]. + * + * When self-hosting the LiveKit SFU, a [DataTrackPublishException.Timeout] may indicate a + * release that predates data track support. + */ + @CheckResult + suspend fun publishDataTrack( + name: String, + options: DataTrackPublishOptions? = null, + ): Result { + if (engine.connectionState == ConnectionState.DISCONNECTED) { + return Result.failure(DataTrackPublishException.Disconnected("Not connected to a room")) + } + return outgoingDataTrackManager.publishTrack(name, options) + } + + /** + * Publishes a data track for the duration of [block], then unpublishes it automatically. + * + * The track is unpublished when [block] returns, throws, or the calling coroutine is cancelled. + * + * ``` + * room.localParticipant.withDataTrack("telemetry") { track -> + * track.tryPush(DataTrackFrame(payload)) + * } + * ``` + * + * @param name Track name visible to other participants. Must be unique per publisher. + * @param options Optional encoding and schema metadata; see [publishDataTrack]. + * @param block Receives the published track; the track is unpublished when it returns or throws. + * @return A successful [Result] containing the value returned by [block], or a failure if + * the track cannot be published or [block] throws. + */ + @CheckResult + suspend fun withDataTrack( + name: String, + options: DataTrackPublishOptions? = null, + block: suspend (LocalDataTrack) -> T, + ): Result { + val track = publishDataTrack(name, options).getOrElse { return Result.failure(it) } + try { + return Result.success(block(track)) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + return Result.failure(e) + } finally { + track.unpublish() + } + } + /** * Publish a new data payload to the room. Data will be forwarded to each participant in the room. * Each payload must not exceed 65535 bytes (64KB - 1) in size. From 84214e3ac94e8542ac9f9ccee514066fffddff32 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 25/47] test(datatrack): cover publishing through the mock local manager This commit adds OutgoingDataTrackManagerMockE2ETest with eight tests. The tests check these behaviors: - publishDataTrack() uses the injected manager and passes no encryption provider. - A native library failure returns DataTrackPublishException.Internal. - An InvalidSchema error from the core reaches the caller as a typed failure. - publishDataTrack() waits until the _data_track channel is open. - publishDataTrack() fails with Timeout if the channel never opens. - publishDataTrack() fails with Disconnected if the room disconnects during the wait. - Packets wait for the low-water mark and then send. - A newer frame replaces the queued frame. The publisherDataTrackChannel() helper finds the mock channel on the publisher peer connection. --- .../OutgoingDataTrackManagerMockE2ETest.kt | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt new file mode 100644 index 00000000..0c03c04b --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.room.RTCEngine +import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.mock.MockDataChannel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.yield +import livekit.org.webrtc.DataChannel +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import uniffi.livekit_datatrack.PublishException + +@OptIn(ExperimentalCoroutinesApi::class) +class OutgoingDataTrackManagerMockE2ETest : MockE2ETest() { + + @Test + fun publishDataTrackUsesInjectedLocalManager() = runTest { + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + assertEquals("telemetry", result.getOrThrow().info.name) + + val local = localDataTrackManagerFactory.manager + assertEquals(1, local.publishedTracks.size) + assertEquals("telemetry", local.publishedTracks.single().info().name) + assertNull(localDataTrackManagerFactory.lastEncryptionProvider) + } + + @Test + fun publishDataTrackFailsWhenNativeLibraryUnavailable() = runTest { + localDataTrackManagerFactory.createError = UnsatisfiedLinkError("dlopen failed") + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DataTrackPublishException.Internal) + } + + /** + * The core rejects a schema whose encoding cannot describe the track's frames (and the other + * schema-metadata rules) before it allocates a handle. This covers the SDK's half: that the + * refusal reaches the caller as a typed failure rather than an opaque one. + */ + @Test + fun publishDataTrackSurfacesInvalidSchemaFromTheCore() = runTest { + localDataTrackManagerFactory.publishError = + PublishException.InvalidSchema("Specified schema and frame encodings are incompatible") + connect() + + val result = room.localParticipant.publishDataTrack( + "telemetry", + DataTrackPublishOptions( + DataTrackFrameEncoding.Cdr, + DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema), + ), + ) + + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DataTrackPublishException.InvalidSchema) + } + + @Test + fun publishDataTrackWaitsForPublisherChannelOpen() = runTest { + connect() + val channel = publisherDataTrackChannel() + channel.state = DataChannel.State.CONNECTING + + val publish = async { room.localParticipant.publishDataTrack("telemetry") } + yield() + assertTrue(publish.isActive) + + channel.state = DataChannel.State.OPEN + val result = publish.await() + assertTrue(result.isSuccess) + } + + @Test + fun dataTrackPacketsWaitForLowWaterThenSend() = runTest { + connect() + val channel = publisherDataTrackChannel() + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + + room.engine.sendDataTrackPackets(listOf(byteArrayOf(1))) + assertTrue(channel.sentPayloads.isEmpty()) + + channel.bufferedAmount = 0 + advanceUntilIdle() + assertEquals(1, channel.sentPayloads.size) + assertArrayEquals(byteArrayOf(1), channel.sentPayloads.single()) + } + + @Test + fun dataTrackPacketsDropOldestQueuedFrame() = runTest { + connect() + val channel = publisherDataTrackChannel() + channel.bufferedAmount = DataTrackFrameSender.LOW_WATER_MARK + 1 + + room.engine.sendDataTrackPackets(listOf(byteArrayOf(1))) + room.engine.sendDataTrackPackets(listOf(byteArrayOf(2))) + assertTrue(channel.sentPayloads.isEmpty()) + + channel.bufferedAmount = 0 + advanceUntilIdle() + assertEquals(1, channel.sentPayloads.size) + assertArrayEquals(byteArrayOf(2), channel.sentPayloads.single()) + } + + @Test + fun publishDataTrackTimesOutIfPublisherChannelNeverOpens() = runTest { + connect() + publisherDataTrackChannel().state = DataChannel.State.CONNECTING + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DataTrackPublishException.Timeout) + } + + @Test + fun publishDataTrackFailsIfDisconnectedWhileWaitingForChannel() = runTest { + connect() + publisherDataTrackChannel().state = DataChannel.State.CONNECTING + + val publish = async { room.localParticipant.publishDataTrack("telemetry") } + yield() + assertTrue(publish.isActive) + + room.disconnect() + advanceUntilIdle() + + val result = publish.await() + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is DataTrackPublishException.Disconnected) + } + + private fun publisherDataTrackChannel() = + getPublisherPeerConnection().dataChannels[RTCEngine.DATA_TRACK_DATA_CHANNEL_LABEL] as MockDataChannel +} From f29d0625a118e03bc9874abd8db2c195231c3e79 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 26/47] feat(datatrack): add LocalDataTrack.send for streaming frames from a Flow This commit adds the send() function to LocalDataTrack. The function pushes every frame from a Flow until the Flow ends or the track is unpublished. The FrameDropPolicy enum selects the behavior when the send queue is full. DROP skips the frame. FAIL ends the send with DataTrackPushFrameException.QueueFull. The sendFrames() and sendOne() extension functions on DataTrackFrameSink contain the logic. An unpublish during the send ends the send with success. The documentation describes this outcome. --- .../android/room/datatrack/LocalDataTrack.kt | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt index 31fc5c17..272e2cdf 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/LocalDataTrack.kt @@ -18,6 +18,8 @@ package io.livekit.android.room.datatrack import androidx.annotation.CheckResult import io.livekit.android.util.rethrowIfCancellationSignal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.takeWhile import uniffi.livekit_datatrack.PushFrameErrorReason import io.livekit.uniffi.LocalDataTrack as FfiLocalDataTrack @@ -108,6 +110,31 @@ class LocalDataTrack internal constructor( suspend fun waitForUnpublish() { impl.waitForUnpublish() } + + /** + * Policy for [send] when the send queue is full. + */ + enum class FrameDropPolicy { + /** Fail the send with [DataTrackPushFrameException.QueueFull]. */ + FAIL, + + /** Silently skip the frame. */ + DROP, + } + + /** + * Sends frames from [frames] until it ends or the track is unpublished. + * + * @param onQueueFull How to handle a full send queue. Defaults to [FrameDropPolicy.DROP]. + * @return A successful [Result] if every frame was sent or dropped per [onQueueFull], or if + * the track is unpublished mid-send. A failure containing [DataTrackPushFrameException] if + * [onQueueFull] is [FrameDropPolicy.FAIL] and the queue is full. + */ + @CheckResult + suspend fun send( + frames: Flow, + onQueueFull: FrameDropPolicy = FrameDropPolicy.DROP, + ): Result = sendFrames(frames, onQueueFull) } /** @@ -120,3 +147,39 @@ internal interface DataTrackFrameSink { val isPublished: Boolean fun tryPush(frame: DataTrackFrame): Result } + +internal suspend fun DataTrackFrameSink.sendFrames( + source: Flow, + onQueueFull: LocalDataTrack.FrameDropPolicy, +): Result { + var outcome: Result? = null + source.takeWhile { isPublished && outcome == null }.collect { frame -> + outcome = sendOne(frame, onQueueFull) + } + return outcome ?: Result.success(Unit) +} + +/** + * @return `null` to keep sending, or a [Result] that ends the send — success if the track was + * unpublished, failure otherwise. + */ +private fun DataTrackFrameSink.sendOne( + frame: DataTrackFrame, + onQueueFull: LocalDataTrack.FrameDropPolicy, +): Result? { + if (!isPublished) return Result.success(Unit) + val error = tryPush(frame).exceptionOrNull() ?: return null + // The track can be unpublished between the check above and the push; end the send as + // documented rather than surfacing an error. + return when (error) { + is DataTrackPushFrameException.TrackUnpublished -> Result.success(Unit) + is DataTrackPushFrameException.QueueFull -> + if (onQueueFull == LocalDataTrack.FrameDropPolicy.FAIL) { + Result.failure(error) + } else { + null + } + is DataTrackPushFrameException -> Result.failure(error) + else -> Result.failure(DataTrackPushFrameException.Internal(error.message ?: "", error)) + } +} From 69435bf2bb8d6a4a3bf064c7ead568b249e571da Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 27/47] test(datatrack): cover send drop and fail policies This commit adds LocalDataTrackSendTest. RecordingSink is a DataTrackFrameSink that rejects frames on demand. It can also unpublish itself after a number of frames. The tests check these behaviors: - DROP skips the rejected frame and sends the rest. - FAIL stops at the rejected frame and returns it in the exception. - An unpublish during the send ends the send with success under both policies. - An unpublished track sends nothing. --- .../room/datatrack/LocalDataTrackSendTest.kt | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/LocalDataTrackSendTest.kt diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/LocalDataTrackSendTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/LocalDataTrackSendTest.kt new file mode 100644 index 00000000..cb1cebac --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/LocalDataTrackSendTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.BaseTest +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A sink that rejects on demand, so the queue-full policy can be observed without saturating a + * live pipeline (which would make the outcome depend on how fast the SFU drains). + */ +private class RecordingSink( + published: Boolean = true, + private val unpublishAfter: Int? = null, + private val reject: (DataTrackFrame) -> Boolean = { false }, +) : DataTrackFrameSink { + val offered = mutableListOf() + val accepted = mutableListOf() + + override var isPublished: Boolean = published + private set + + override fun tryPush(frame: DataTrackFrame): Result { + offered.add(frame) + if (reject(frame)) { + return Result.failure( + DataTrackPushFrameException.QueueFull("The send queue is full", frame), + ) + } + accepted.add(frame) + if (unpublishAfter != null && accepted.size >= unpublishAfter) { + isPublished = false + } + return Result.success(Unit) + } +} + +class LocalDataTrackSendTest : BaseTest() { + + @Test + fun dropSkipsRejectedFrames() = runTest { + val sink = RecordingSink { it.payload.contentEquals(byteArrayOf(2)) } + + val result = sink.sendFrames(frames(5), LocalDataTrack.FrameDropPolicy.DROP) + + assertTrue(result.isSuccess) + assertEquals(5, sink.offered.size) + assertPayloads(listOf(0, 1, 3, 4), sink.accepted) + } + + @Test + fun failStopsAtRejectedFrame() = runTest { + val sink = RecordingSink { it.payload.contentEquals(byteArrayOf(2)) } + + val result = sink.sendFrames(frames(5), LocalDataTrack.FrameDropPolicy.FAIL) + + val error = result.exceptionOrNull() as DataTrackPushFrameException.QueueFull + assertArrayEquals(byteArrayOf(2), error.frame.payload) + assertPayloads(listOf(0, 1), sink.accepted) + } + + @Test + fun unpublishingEndsSendQuietlyWhenDropping() = runTest { + unpublishingEndsSendQuietly(LocalDataTrack.FrameDropPolicy.DROP) + } + + @Test + fun unpublishingEndsSendQuietlyWhenFailing() = runTest { + unpublishingEndsSendQuietly(LocalDataTrack.FrameDropPolicy.FAIL) + } + + @Test + fun unpublishedTrackSendsNothing() = runTest { + val sink = RecordingSink(published = false) + + val result = sink.sendFrames(frames(3), LocalDataTrack.FrameDropPolicy.FAIL) + + assertTrue(result.isSuccess) + assertTrue(sink.offered.isEmpty()) + } + + private suspend fun unpublishingEndsSendQuietly(policy: LocalDataTrack.FrameDropPolicy) { + val sink = RecordingSink(unpublishAfter = 1) + + val result = sink.sendFrames(frames(5), policy) + + assertTrue(result.isSuccess) + assertEquals(1, sink.accepted.size) + } + + companion object { + private fun frames(count: Int): Flow = flow { + for (index in 0 until count) { + emit(DataTrackFrame(byteArrayOf(index.toByte()))) + } + } + + private fun assertPayloads(expected: List, frames: List) { + assertEquals(expected.size, frames.size) + expected.zip(frames).forEach { (value, frame) -> + assertArrayEquals(byteArrayOf(value.toByte()), frame.payload) + } + } + } +} From b727dfb023c1effa4dc0392a710f376089f30669 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 28/47] IMPORTANT(signal): add id-correlated StoreDataBlob and GetDataBlob requests This commit adds request and response correlation for data blobs to SignalClient. sendStoreDataBlob() stores a blob under a key. sendGetDataBlob() reads a blob that another participant stored. Both functions return a Result with a DataTrackSchemaException on failure. sendIdCorrelatedRequest() contains the shared logic. It takes a request id from an AtomicInteger. It registers a CompletableDeferred in a map. It sends the request and waits for the response with a deadline of 5 seconds. A timeout returns DataTrackSchemaException.Timeout. The response handling has three parts: - STORE_DATA_BLOB_RESPONSE completes the deferred with an empty array. - GET_DATA_BLOB_RESPONSE completes the deferred with the blob contents. - REQUEST_RESPONSE with a reason other than OK or QUEUED fails the deferred with DataTrackSchemaException.Rejected. close() fails every pending request with DataTrackSchemaException.Disconnected. Review the failure reasons and the timeout with care. The values are not configurable. --- .../io/livekit/android/room/SignalClient.kt | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) 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 cdc74500..68e3a3ab 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 @@ -22,6 +22,7 @@ import com.vdurmont.semver4j.Semver import io.livekit.android.ConnectOptions import io.livekit.android.RoomOptions import io.livekit.android.dagger.InjectionNames +import io.livekit.android.room.datatrack.DataTrackSchemaException import io.livekit.android.room.participant.ParticipantTrackPermission import io.livekit.android.room.track.Track import io.livekit.android.stats.NetworkInfo @@ -29,11 +30,14 @@ import io.livekit.android.stats.getClientInfo import io.livekit.android.util.CloseableCoroutineScope import io.livekit.android.util.Either import io.livekit.android.util.LKLog +import io.livekit.android.util.TimeoutException +import io.livekit.android.util.rethrowIfCancellationSignal import io.livekit.android.util.toHttpUrl import io.livekit.android.util.toWebsocketUrl import io.livekit.android.util.withDeadline import io.livekit.android.webrtc.toProtoSessionDescription import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -61,11 +65,14 @@ import okhttp3.WebSocketListener import okio.ByteString import okio.ByteString.Companion.toByteString import java.util.Date +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton import kotlin.coroutines.resumeWithException import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds /** * SignalClient to LiveKit WS servers @@ -131,6 +138,9 @@ constructor( private var pingIntervalDurationMillis: Long = 0 private var rtt: Long = 0 + private val nextDataBlobRequestId = AtomicInteger(0) + private val dataBlobCompleters = ConcurrentHashMap>() + var connectionState: ConnectionState = ConnectionState.DISCONNECTED /** @@ -650,6 +660,76 @@ constructor( sendRequest(request) } + /** + * Stores a blob on the server under [key], replacing nothing — a key can only be written once. + */ + internal suspend fun sendStoreDataBlob(key: LivekitModels.DataBlobKey, contents: ByteArray): Result { + return sendIdCorrelatedRequest { requestId -> + LivekitRtc.SignalRequest.newBuilder() + .setStoreDataBlobRequest( + LivekitRtc.StoreDataBlobRequest.newBuilder() + .setRequestId(requestId) + .setBlob( + LivekitModels.DataBlob.newBuilder() + .setKey(key) + .setContents(com.google.protobuf.ByteString.copyFrom(contents)), + ), + ) + .build() + }.map { } + } + + /** + * Reads back a blob [participantIdentity] stored under [key]. + */ + internal suspend fun sendGetDataBlob( + key: LivekitModels.DataBlobKey, + participantIdentity: String, + ): Result { + return sendIdCorrelatedRequest { requestId -> + LivekitRtc.SignalRequest.newBuilder() + .setGetDataBlobRequest( + LivekitRtc.GetDataBlobRequest.newBuilder() + .setRequestId(requestId) + .setParticipantIdentity(participantIdentity) + .setKey(key), + ) + .build() + } + } + + /** + * Sends a request the SFU answers by echoing its id, and waits for that answer. + */ + private suspend fun sendIdCorrelatedRequest( + build: (Int) -> LivekitRtc.SignalRequest, + ): Result { + if (!isConnected) { + return Result.failure(DataTrackSchemaException.Disconnected("Not connected to a room")) + } + val requestId = nextDataBlobRequestId.incrementAndGet() + val deferred = CompletableDeferred() + dataBlobCompleters[requestId] = deferred + try { + sendRequest(build(requestId)) + return withDeadline(DATA_BLOB_REQUEST_TIMEOUT) { + Result.success(deferred.await()) + } + } catch (e: TimeoutException) { + return Result.failure( + DataTrackSchemaException.Timeout("Timed out waiting for data blob response", e), + ) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + return Result.failure( + e as? DataTrackSchemaException + ?: DataTrackSchemaException.Internal(e.message ?: "", e), + ) + } finally { + dataBlobCompleters.remove(requestId) + } + } + private fun sendRequest(request: LivekitRtc.SignalRequest) { val skipQueue = skipQueueTypes.contains(request.messageCase) @@ -875,6 +955,19 @@ constructor( } LivekitRtc.SignalResponse.MessageCase.REQUEST_RESPONSE -> { + val requestResponse = response.requestResponse + val reason = requestResponse.reason + val isFailure = reason != LivekitRtc.RequestResponse.Reason.OK && + reason != LivekitRtc.RequestResponse.Reason.QUEUED + if (isFailure) { + val completer = dataBlobCompleters.remove(requestResponse.requestId) + if (completer != null) { + val message = requestResponse.message.ifEmpty { + "Request rejected (reason ${reason.number})" + } + completer.completeExceptionally(DataTrackSchemaException.Rejected(message)) + } + } // Pass the full SignalResponse — UniFFI deserializes and filters data-track related ones. listener?.onRequestResponse(encoded) } @@ -903,6 +996,15 @@ constructor( listener?.onDataTrackSubscriberHandles(encoded) } + LivekitRtc.SignalResponse.MessageCase.STORE_DATA_BLOB_RESPONSE -> { + dataBlobCompleters.remove(response.storeDataBlobResponse.requestId) + ?.complete(ByteArray(0)) + } + + LivekitRtc.SignalResponse.MessageCase.GET_DATA_BLOB_RESPONSE -> { + dataBlobCompleters.remove(response.getDataBlobResponse.requestId) + ?.complete(response.getDataBlobResponse.blob.contents.toByteArray()) + } LivekitRtc.SignalResponse.MessageCase.MESSAGE_NOT_SET, null, -> { @@ -939,6 +1041,16 @@ constructor( pongJob = null } + private fun failPendingDataBlobRequests() { + val pending = dataBlobCompleters.values.toList() + dataBlobCompleters.clear() + pending.forEach { completer -> + completer.completeExceptionally( + DataTrackSchemaException.Disconnected("Not connected to a room"), + ) + } + } + /** * Closes out any existing websocket connection, and cleans up used resources. * @@ -949,6 +1061,7 @@ constructor( LKLog.v(Exception()) { "Closing SignalClient: code = $code, reason = $reason" } isConnected = false isReconnecting = false + failPendingDataBlobRequests() if (::coroutineScope.isInitialized) { coroutineScope.close() } @@ -1065,6 +1178,7 @@ constructor( // iceServer("stun:stun4.l.google.com:19302"), ) private const val SIGNAL_CONNECT_TIMEOUT = 10000 + private val DATA_BLOB_REQUEST_TIMEOUT = 5.seconds const val CLOSE_REASON_NORMAL_CLOSURE = 1000 const val CLOSE_REASON_PING_TIMEOUT = 3000 const val CLOSE_REASON_WEBSOCKET_FAILURE = 3500 From 7b66194c24499d3c0d98391d10086b6d89485c55 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 29/47] test(signal): cover blob store, get, and rejection This commit adds three tests to SignalClientTest. One test stores a blob and checks that the response completes the request. One test reads a blob and checks that the contents come back. One test sends a RequestResponse with reason NOT_FOUND and checks that the request fails with DataTrackSchemaException.Rejected and the server message. --- .../livekit/android/room/SignalClientTest.kt | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt index 8885709e..0e09f1ec 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/SignalClientTest.kt @@ -16,6 +16,9 @@ package io.livekit.android.room +import io.livekit.android.room.datatrack.DataTrackSchemaEncoding +import io.livekit.android.room.datatrack.DataTrackSchemaException +import io.livekit.android.room.datatrack.DataTrackSchemaId import io.livekit.android.stats.NetworkInfo import io.livekit.android.stats.NetworkType import io.livekit.android.test.BaseTest @@ -55,6 +58,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.argThat import org.mockito.kotlin.never import org.mockito.kotlin.times +import com.google.protobuf.ByteString as PbByteString @ExperimentalCoroutinesApi class SignalClientTest : BaseTest() { @@ -503,6 +507,36 @@ class SignalClientTest : BaseTest() { } } + @Test + fun storeDataBlobSucceeds() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + + val key = DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema).blobKey + val storeJob = async { client.sendStoreDataBlob(key, "{}".toByteArray()) } + yield() + + val sent = LivekitRtc.SignalRequest.parseFrom(wsFactory.ws.sentRequests.last().toPBByteString()) + assertTrue(sent.hasStoreDataBlobRequest()) + val requestId = sent.storeDataBlobRequest.requestId + + client.onMessage( + wsFactory.ws, + LivekitRtc.SignalResponse.newBuilder() + .setStoreDataBlobResponse( + LivekitRtc.StoreDataBlobResponse.newBuilder() + .setRequestId(requestId) + .setKey(key), + ) + .build() + .toOkioByteString(), + ) + + assertTrue(storeJob.await().isSuccess) + } + @Test fun sendEncodedRequestForwardsValidBytes() = runTest { val job = async { client.join(EXAMPLE_URL, "") } @@ -539,6 +573,71 @@ class SignalClientTest : BaseTest() { assertEquals(before, wsFactory.ws.sentRequests.size) } + @Test + fun getDataBlobReturnsContents() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + + val key = DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema).blobKey + val contents = """{"type":"object"}""".toByteArray() + val getJob = async { client.sendGetDataBlob(key, "publisher") } + yield() + + val sent = LivekitRtc.SignalRequest.parseFrom(wsFactory.ws.sentRequests.last().toPBByteString()) + assertTrue(sent.hasGetDataBlobRequest()) + assertEquals("publisher", sent.getDataBlobRequest.participantIdentity) + + client.onMessage( + wsFactory.ws, + LivekitRtc.SignalResponse.newBuilder() + .setGetDataBlobResponse( + LivekitRtc.GetDataBlobResponse.newBuilder() + .setRequestId(sent.getDataBlobRequest.requestId) + .setBlob( + LivekitModels.DataBlob.newBuilder() + .setKey(key) + .setContents(PbByteString.copyFrom(contents)), + ), + ) + .build() + .toOkioByteString(), + ) + + assertArrayEquals(contents, getJob.await().getOrThrow()) + } + + @Test + fun dataBlobRequestFailureCompletesWithRejected() = runTest { + val job = async { client.join(EXAMPLE_URL, "") } + connectWebsocketAndJoin() + job.await() + client.onReadyForResponses() + + val key = DataTrackSchemaId("missing.v1", DataTrackSchemaEncoding.Protobuf).blobKey + val getJob = async { client.sendGetDataBlob(key, "publisher") } + yield() + + val sent = LivekitRtc.SignalRequest.parseFrom(wsFactory.ws.sentRequests.last().toPBByteString()) + client.onMessage( + wsFactory.ws, + LivekitRtc.SignalResponse.newBuilder() + .setRequestResponse( + LivekitRtc.RequestResponse.newBuilder() + .setRequestId(sent.getDataBlobRequest.requestId) + .setReason(LivekitRtc.RequestResponse.Reason.NOT_FOUND) + .setMessage("not found"), + ) + .build() + .toOkioByteString(), + ) + + val error = getJob.await().exceptionOrNull() + assertTrue(error is DataTrackSchemaException.Rejected) + assertEquals("not found", error?.message) + } + // mock data companion object } From 6c99f2e101d73a1816d9ec00b04d2d0fdea5401a Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 30/47] feat(datatrack): add LocalParticipant.defineSchema and getSchema This commit adds the public schema API to LocalParticipant. defineSchema() stores a schema definition on the server under its DataTrackSchemaId. getSchema() reads the definition that another participant stored. Both functions check that the engine is connected. Both return a Result with a DataTrackSchemaException on failure. getSchema() decodes the stored bytes as strict UTF-8. Invalid bytes return DataTrackSchemaException.InvalidDefinition. The SDK does not parse or validate the definition against its encoding. --- .../room/participant/LocalParticipant.kt | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt index 745e2420..f9083c8b 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt @@ -39,6 +39,8 @@ import io.livekit.android.room.TrackBitrateInfo import io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager import io.livekit.android.room.datatrack.DataTrackPublishException import io.livekit.android.room.datatrack.DataTrackPublishOptions +import io.livekit.android.room.datatrack.DataTrackSchemaException +import io.livekit.android.room.datatrack.DataTrackSchemaId import io.livekit.android.room.datatrack.LocalDataTrack import io.livekit.android.room.datatrack.OutgoingDataTrackManager import io.livekit.android.room.isSVCCodec @@ -89,6 +91,8 @@ import livekit.org.webrtc.RtpTransceiver.RtpTransceiverInit import livekit.org.webrtc.SurfaceTextureHelper import livekit.org.webrtc.VideoCapturer import livekit.org.webrtc.VideoProcessor +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction import java.util.Collections import javax.inject.Named import kotlin.math.max @@ -1017,6 +1021,56 @@ internal constructor( return outgoingDataTrackManager.publishTrack(name, options) } + /** + * Stores the definition of a data track schema, making it available to subscribers. + * + * Define a schema before publishing any data track that references it, so subscribers can + * resolve it by ID via [getSchema]. Treat a definition as write-once — whether redefining an + * existing one is rejected is up to the server. + * + * ``` + * val schema = DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema) + * room.localParticipant.defineSchema(schema, definition) + * room.localParticipant.publishDataTrack( + * "reading", + * DataTrackPublishOptions(DataTrackFrameEncoding.Json, schema), + * ) + * ``` + * + * @param id Identifies the schema; the same ID goes into [DataTrackPublishOptions]. + * @param definition The definition, stored as-is. It is neither parsed nor validated against + * its [DataTrackSchemaId.encoding], so it's up to the caller to keep it well-formed. + * @return A successful [Result] if the schema was stored, or a failure containing + * [DataTrackSchemaException]. + */ + @CheckResult + suspend fun defineSchema(id: DataTrackSchemaId, definition: String): Result { + if (engine.connectionState == ConnectionState.DISCONNECTED) { + return Result.failure(DataTrackSchemaException.Disconnected("Not connected to a room")) + } + return engine.client.sendStoreDataBlob(id.blobKey, definition.toByteArray(Charsets.UTF_8)) + } + + /** + * Retrieves the definition a participant [defineSchema]'d for a schema its data tracks + * reference. + * + * @param id Identifies the schema, as carried by [io.livekit.android.room.datatrack.DataTrackInfo.schema]. + * @param publishedBy Identity of the participant that defined it. + * @return A successful [Result] containing the definition, or a failure containing + * [DataTrackSchemaException]. + */ + @CheckResult + suspend fun getSchema(id: DataTrackSchemaId, publishedBy: Identity): Result { + if (engine.connectionState == ConnectionState.DISCONNECTED) { + return Result.failure(DataTrackSchemaException.Disconnected("Not connected to a room")) + } + val bytes = engine.client.sendGetDataBlob(id.blobKey, publishedBy.value) + .getOrElse { return Result.failure(it) } + return decodeUtf8(bytes)?.let { Result.success(it) } + ?: Result.failure(DataTrackSchemaException.InvalidDefinition("Schema definition is not valid UTF-8")) + } + /** * Publishes a data track for the duration of [block], then unpublishes it automatically. * @@ -1752,6 +1806,17 @@ internal fun VideoTrackPublishOptions.hasBackupCodec(): Boolean { private val backupCodecs = listOf(VideoCodec.VP8.codecName, VideoCodec.H264.codecName) private fun isBackupCodec(codecName: String) = backupCodecs.contains(codecName) +private fun decodeUtf8(bytes: ByteArray): String? { + val decoder = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + return try { + decoder.decode(ByteBuffer.wrap(bytes)).toString() + } catch (_: CharacterCodingException) { + null + } +} + /** * A handler that processes an RPC request and returns a string * that will be sent back to the requester. The payload must From a42d5903d6f7ae4fa928f1c08a1e8dd54f99d5a7 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 31/47] test(datatrack): cover defineSchema when disconnected This commit adds DataTrackSchemaMockE2ETest. The test calls defineSchema() before the room connects. The test checks that the result is DataTrackSchemaException.Disconnected. --- .../datatrack/DataTrackSchemaMockE2ETest.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaMockE2ETest.kt diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaMockE2ETest.kt new file mode 100644 index 00000000..4b7a24d1 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackSchemaMockE2ETest.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.MockE2ETest +import org.junit.Assert.assertTrue +import org.junit.Test + +class DataTrackSchemaMockE2ETest : MockE2ETest() { + + @Test + fun defineSchemaFailsWhenDisconnected() = runTest { + val result = room.localParticipant.defineSchema( + DataTrackSchemaId("reading.v1", DataTrackSchemaEncoding.JsonSchema), + "{}", + ) + assertTrue(result.exceptionOrNull() is DataTrackSchemaException.Disconnected) + } +} From 0a747264ba8cde9f5fc8c2bfc42089e27d452adf Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:40 +0200 Subject: [PATCH 32/47] IMPORTANT(datatrack): add DataTrackStream with a shared multi-collector flow This commit adds the public DataTrackStream class. The class wraps a UniFFI DataTrackStreamInterface. A subscription creates one stream. next() returns the next frame or null when the stream ends. flow is a Flow of frames. The flow completes when the stream ends or when close() is called. The class drains the native stream in one coroutine. The sharedFrames flow uses shareIn with WhileSubscribed. Every collector receives every frame that arrives while it collects. A late collector does not receive old frames. A slow collector delays the drain for all collectors. The ended flag marks the end of the stream. The public flow merges the shared frames with the ended flag. A collector that starts after the end completes at once. close() sets the flag before it cancels the drain. If it cancelled the drain first, the collectors would not complete. This is the final form of the fix for frames that did not reach all collectors. Review the shareIn and the ended flag logic with care. --- .../android/room/datatrack/DataTrackStream.kt | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackStream.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackStream.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackStream.kt new file mode 100644 index 00000000..2060ff92 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/DataTrackStream.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.util.CloseableCoroutineScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.takeWhile +import io.livekit.uniffi.DataTrackStreamInterface as FfiDataTrackStream + +/** + * A stream of frames received from a subscribed [RemoteDataTrack]. + * + * Collect [flow] or call [next] repeatedly. The stream ends when the track is unpublished or the + * subscription is cancelled. + * + * Close the stream once you are done with it: the subscription lasts as long as the stream does, + * so an unclosed stream leaves the SFU forwarding frames for it. + * + * ``` + * remoteTrack.subscribe().onSuccess { stream -> + * stream.use { it.flow.collect { frame -> process(frame.payload) } } + * } + * ``` + */ +class DataTrackStream internal constructor( + private val impl: FfiDataTrackStream, + dispatcher: CoroutineDispatcher = Dispatchers.Default, +) : AutoCloseable { + + private val coroutineScope = CloseableCoroutineScope(dispatcher + SupervisorJob()) + + /** + * Set once no further frames will arrive, whether because the underlying stream was exhausted + * or because [close] was called. Collectors watch this so they complete instead of waiting + * for a frame that will never come, including those that arrive afterwards. + */ + private val ended = MutableStateFlow(false) + + /** + * Returns the next frame, or `null` once the stream ends (the track is unpublished or the + * subscription is cancelled). + */ + suspend fun next(): DataTrackFrame? { + return impl.next()?.let { DataTrackFrame(it) } + } + + /** + * Drains the underlying stream while anyone is collecting [flow], so every collector sees + * every frame. + * + * Emission suspends until every collector has taken the frame, so a slow one holds up the + * drain rather than being skipped. While it is held up, frames accumulate in the buffer the + * subscription was created with, and once that fills the oldest are dropped for all + * collectors at once — see [RemoteDataTrack.subscribe]'s `bufferSize`. + */ + private val sharedFrames: SharedFlow = flow { + while (true) { + val frame = next() ?: break + emit(frame) + } + ended.value = true + }.shareIn(coroutineScope, SharingStarted.WhileSubscribed(), replay = 0) + + /** + * A [Flow] of incoming frames. Completes normally when the stream ends. + * + * Concurrent collectors each receive every frame that arrives while they are collecting; + * frames are not replayed to a collector that starts late. + */ + val flow: Flow = merge( + sharedFrames, + ended.filter { it }.map { null }, + ).takeWhile { it != null }.filterNotNull() + + /** + * Ends this stream and releases it. + * + * The data track's subscription is dropped once every [DataTrackStream] subscribed + * to it has been closed, so other subscribers are unaffected. Leaving a stream + * unclosed keeps its subscription alive until the stream is garbage collected. + * + * Any in-progress collection of [flow] completes. + */ + override fun close() { + // Before cancelling the drain: cancelling it cannot complete the collectors, since it is + // this flag rather than the drain finishing that ends them. + ended.value = true + coroutineScope.close() + (impl as? AutoCloseable)?.close() + } +} From c655a79846bd91834abdccbccf2b8f87db7d4f7f Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 33/47] test(datatrack): cover DataTrackStream collector semantics This commit adds DataTrackStreamTest. FakeFfiStream is a stream that the test feeds from a Channel. The tests check these behaviors: - Two concurrent collectors each receive every frame. - A collector completes when the stream ends. - A later collector resumes the drain after an earlier collector stopped. - close() completes an active collector. - A collector that starts after the end completes at once. --- .../room/datatrack/DataTrackStreamTest.kt | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackStreamTest.kt diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackStreamTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackStreamTest.kt new file mode 100644 index 00000000..210f62ff --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/DataTrackStreamTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.test.BaseTest +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import io.livekit.uniffi.DataTrackFrame as FfiDataTrackFrame +import io.livekit.uniffi.DataTrackStreamInterface as FfiDataTrackStream + +/** + * A stand-in for the UniFFI stream, so frame delivery can be driven from the test rather than + * from a live subscription. + */ +private class FakeFfiStream : FfiDataTrackStream { + private val frames = Channel(Channel.UNLIMITED) + + override suspend fun next(): FfiDataTrackFrame? = frames.receiveCatching().getOrNull() + + fun offer(payload: Int) { + frames.trySend(FfiDataTrackFrame(payload = byteArrayOf(payload.toByte()), userTimestamp = null)) + } + + /** Ends the stream, the way an unpublish or a cancelled subscription would. */ + fun end() { + frames.close() + } +} + +class DataTrackStreamTest : BaseTest() { + + private fun payloads(frames: List) = frames.map { it.payload.single().toInt() } + + @Test + fun concurrentCollectorsEachReceiveEveryFrame() = runTest { + val fake = FakeFfiStream() + val stream = DataTrackStream(fake, UnconfinedTestDispatcher(testScheduler)) + val first = mutableListOf() + val second = mutableListOf() + + // Unconfined so both collectors are subscribed before any frame is offered. + val firstJob = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { first.add(it) } + } + val secondJob = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { second.add(it) } + } + + fake.offer(1) + fake.offer(2) + fake.offer(3) + fake.end() + advanceUntilIdle() + firstJob.join() + secondJob.join() + + assertEquals(listOf(1, 2, 3), payloads(first)) + assertEquals(listOf(1, 2, 3), payloads(second)) + } + + @Test + fun collectorCompletesWhenStreamEnds() = runTest { + val fake = FakeFfiStream() + val stream = DataTrackStream(fake, UnconfinedTestDispatcher(testScheduler)) + val received = mutableListOf() + + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { received.add(it) } + } + fake.offer(1) + fake.end() + advanceUntilIdle() + job.join() + + assertEquals(listOf(1), payloads(received)) + } + + @Test + fun aLaterCollectorResumesTheDrainAfterAnEarlierOneStopped() = runTest { + val fake = FakeFfiStream() + val stream = DataTrackStream(fake, UnconfinedTestDispatcher(testScheduler)) + val first = mutableListOf() + val second = mutableListOf() + + val firstJob = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { first.add(it) } + } + fake.offer(1) + advanceUntilIdle() + // Drops the subscriber count to zero, which cancels the drain mid-`next()`. + firstJob.cancelAndJoin() + + val secondJob = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { second.add(it) } + } + fake.offer(2) + fake.end() + advanceUntilIdle() + secondJob.join() + + assertEquals(listOf(1), payloads(first)) + // Empty here would mean the cancelled drain had wrongly marked the stream ended. + assertEquals(listOf(2), payloads(second)) + } + + @Test + fun closingWhileCollectingCompletesTheCollector() = runTest { + val fake = FakeFfiStream() + val stream = DataTrackStream(fake, UnconfinedTestDispatcher(testScheduler)) + val received = mutableListOf() + + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + stream.flow.collect { received.add(it) } + } + fake.offer(1) + advanceUntilIdle() + stream.close() + advanceUntilIdle() + + assertEquals(listOf(1), payloads(received)) + assertTrue("collector should have completed after close()", job.isCompleted) + } + + @Test + fun collectingAfterTheStreamEndedCompletesImmediately() = runTest { + val fake = FakeFfiStream() + val stream = DataTrackStream(fake, UnconfinedTestDispatcher(testScheduler)) + + val job = launch(UnconfinedTestDispatcher(testScheduler)) { stream.flow.collect { } } + fake.end() + advanceUntilIdle() + job.join() + + // Would hang if termination depended on a signal the late collector had already missed. + val late = mutableListOf() + stream.flow.collect { late.add(it) } + + assertEquals(emptyList(), payloads(late)) + } +} From d0ed4e87b86a3c7924d6690a83465ee17d7de5b6 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 34/47] feat(datatrack): add RemoteDataTrack wrapper This commit adds the public RemoteDataTrack class. The class wraps the UniFFI RemoteDataTrack. The class exposes publisherIdentity, name, isPublished, info, and waitForUnpublish(). The name is the stable key across reconnects. The SID is not stable. subscribe() takes a buffer size in frames and returns a Result with a DataTrackStream. Values below 1 are raised to 1. The default is 16 frames. A UniFFI DataTrackSubscribeException maps to the SDK exception type. More than one subscribe call on the same track is allowed. All streams share one pipeline. --- .../android/room/datatrack/RemoteDataTrack.kt | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt new file mode 100644 index 00000000..b5527150 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/RemoteDataTrack.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import androidx.annotation.CheckResult +import androidx.annotation.IntRange +import io.livekit.android.room.participant.Participant +import io.livekit.android.util.rethrowIfCancellationSignal +import io.livekit.uniffi.DataTrackSubscribeOptions +import io.livekit.uniffi.RemoteDataTrack as FfiRemoteDataTrack +import uniffi.livekit_datatrack.DataTrackSubscribeException as FfiSubscribeException + +/** + * A data track published by a remote participant. + * + * Call [subscribe] to start receiving frames. + * + * ``` + * remoteTrack.subscribe().onSuccess { stream -> + * stream.flow.collect { frame -> process(frame.payload) } + * } + * ``` + */ +class RemoteDataTrack internal constructor( + private val impl: FfiRemoteDataTrack, +) { + /** + * Identity of the participant publishing this track. + */ + val publisherIdentity: Participant.Identity = Participant.Identity(impl.publisherIdentity()) + + /** + * Name chosen by the publisher; unique per participant. + * + * This is a stable identifier across reconnects, unlike [DataTrackInfo.sid]. + */ + val name: String = impl.info().name + + /** + * Whether the track is currently published by the remote participant. + */ + val isPublished: Boolean + get() = impl.isPublished() + + /** + * Metadata for this track. + */ + val info: DataTrackInfo + get() = DataTrackInfo(impl.info()) + + /** + * Waits until the track is unpublished, by either the publisher or the SFU. + * + * Use this to trigger follow-up work once the track is no longer published. Returns + * immediately if it is already unpublished. + */ + suspend fun waitForUnpublish() { + impl.waitForUnpublish() + } + + /** + * Subscribes to the track and returns a [DataTrackStream] of incoming frames. + * + * Subscribing more than once is allowed: the streams share one pipeline, each receives every + * frame from the moment it subscribes (nothing is replayed), and later calls don't change + * the buffer size. + * + * @param bufferSize Maximum number of received frames buffered internally before the oldest + * is dropped. Values below 1 are clamped to 1. + * @return A successful [Result] containing the [DataTrackStream], or a failure containing + * [DataTrackSubscribeException]. + */ + @CheckResult + suspend fun subscribe( + @IntRange(from = 1) bufferSize: Int = DEFAULT_BUFFER_SIZE, + ): Result { + val options = DataTrackSubscribeOptions(bufferSize = bufferSize.coerceAtLeast(1).toUInt()) + return try { + Result.success(DataTrackStream(impl.subscribeWithOptions(options))) + } catch (e: FfiSubscribeException) { + Result.failure(e.toSdk()) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + Result.failure(DataTrackSubscribeException.Internal(e.message ?: "", e)) + } + } + + companion object { + /** + * Default subscribe-side buffer, in frames. + */ + const val DEFAULT_BUFFER_SIZE: Int = 16 + } +} From cd5361bac346fd75130ecdcbbfe649d027389684 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 35/47] IMPORTANT(datatrack): add IncomingDataTrackManager bridging the UniFFI remote manager This commit adds IncomingDataTrackManager and IncomingDataTrackEvent. The manager owns the UniFFI RemoteDataTrackManager and connects it to RTCEngine. It is a Dagger singleton. IncomingDataTrackEvent has two cases. TrackPublished carries a new RemoteDataTrack. TrackUnpublished carries the SID and the track. The manager posts the events on an internal event bus. The delegate receives callbacks from the native manager. onSignalRequest forwards the bytes to the engine. onTrackPublished wraps the track, stores it in a list, and posts an event. onTrackUnpublished removes every track with that SID and posts an event for each one. The manager keeps every known track in a list. snapshotRemoteTracks() returns a copy. The room uses the snapshot to attach tracks whose publisher joined after the track was published. The handle functions forward join responses, participant updates, subscriber handles, and packets to the native manager. handlePacketReceived() runs on a WebRTC thread. It catches every exception. An exception on that thread would stop the process. ensureManager() creates the native manager once. If the native library fails to load, the class remembers the failure. Every entry point then does nothing. The room stays usable for apps that do not use data tracks. This commit does not contain E2EE code. The factory receives a null decryption provider. A later commit adds decryption. --- .../room/datatrack/IncomingDataTrackEvent.kt | 38 ++++ .../datatrack/IncomingDataTrackManager.kt | 199 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackEvent.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackEvent.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackEvent.kt new file mode 100644 index 00000000..e85efb4b --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackEvent.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +/** + * Events emitted by [IncomingDataTrackManager] when the UniFFI remote manager reports + * publication changes. + * + * @suppress + */ +internal sealed class IncomingDataTrackEvent { + /** + * A remote data track is available to subscribe. The publisher may not be in the room yet. + */ + class TrackPublished(val track: RemoteDataTrack) : IncomingDataTrackEvent() + + /** + * A remote data track with [sid] is no longer published. + */ + class TrackUnpublished( + val sid: DataTrackSid, + val track: RemoteDataTrack, + ) : IncomingDataTrackEvent() +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt new file mode 100644 index 00000000..6ba00b0e --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.events.BroadcastEventBus +import io.livekit.android.room.RTCEngine +import io.livekit.android.util.LKLog +import io.livekit.android.util.rethrowIfCancellationSignal +import io.livekit.uniffi.HandleSignalResponseException +import io.livekit.uniffi.RemoteDataTrackManagerDelegate +import io.livekit.uniffi.RemoteDataTrackManagerInterface +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import io.livekit.uniffi.RemoteDataTrack as FfiRemoteDataTrack + +/** + * Owns the UniFFI [io.livekit.uniffi.RemoteDataTrackManager] and bridges its transport callbacks + * into [RTCEngine]. + * + * SFU participant / subscriber-handle responses and `_data_track` channel packets are forwarded + * into the Rust manager; subscription signal requests are sent back out through the engine. + * + * Publication events are emitted on [events]. The publisher may not be in the room yet; callers + * should park the track until [io.livekit.android.room.participant.RemoteParticipant] exists. + * + * @suppress + */ +@Singleton +class IncomingDataTrackManager +@Inject +constructor( + private val engineProvider: Provider, + private val remoteDataTrackManagerFactory: RemoteDataTrackManagerFactory, +) { + private val eventBus = BroadcastEventBus() + + /** + * Publication and unpublication events from the UniFFI remote manager. + */ + internal val events = eventBus.readOnly() + + private val lock = Any() + private var remoteManager: RemoteDataTrackManagerInterface? = null + private var nativeUnavailable = false + private val remoteTracks = mutableListOf() + + /** + * Handles events from the UniFFI remote data track manager. + */ + private val delegate = object : RemoteDataTrackManagerDelegate { + override fun onSignalRequest(request: ByteArray) { + engineProvider.get().sendDataTrackSignalRequest(request) + } + + override fun onTrackPublished(track: FfiRemoteDataTrack) { + val wrapped = RemoteDataTrack(track) + synchronized(lock) { + remoteTracks.add(wrapped) + } + eventBus.tryPostEvent(IncomingDataTrackEvent.TrackPublished(wrapped)) + } + + override fun onTrackUnpublished(sid: String) { + val dataTrackSid = DataTrackSid(sid) + val unpublished = synchronized(lock) { + val matches = remoteTracks.filter { it.info.sid == dataTrackSid } + remoteTracks.removeAll { track -> matches.any { it === track } } + matches + } + for (track in unpublished) { + eventBus.tryPostEvent(IncomingDataTrackEvent.TrackUnpublished(dataTrackSid, track)) + } + } + } + + /** + * Returns a snapshot of the remote data tracks currently known to the + * UniFFI manager, including those whose publisher is not yet in the room. + */ + internal fun snapshotRemoteTracks(): List { + synchronized(lock) { + return remoteTracks.toList() + } + } + + /** + * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing a `JoinResponse` + * to the UniFFI manager so pre-existing remote data tracks are discovered. Pass the + * websocket bytes as received; re-encoding a decoded copy can drop newer fields. + */ + fun handleSfuJoinResponse(responseBytes: ByteArray) { + val manager = ensureManager() ?: return + try { + manager.handleSfuJoinResponse(responseBytes) + } catch (e: HandleSignalResponseException) { + LKLog.w(e) { "Failed to handle JoinResponse for data tracks" } + } + } + + /** + * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing a `ParticipantUpdate` + * to the UniFFI manager. Pass the websocket bytes as received. + */ + fun handleSfuParticipantUpdate(responseBytes: ByteArray, localParticipantIdentity: String) { + val manager = ensureManager() ?: return + try { + manager.handleSfuParticipantUpdate(responseBytes, localParticipantIdentity) + } catch (e: HandleSignalResponseException) { + LKLog.w(e) { "Failed to handle participant update for data tracks" } + } + } + + /** + * Forwards a serialized [livekit.LivekitRtc.SignalResponse] containing + * `DataTrackSubscriberHandles` to the UniFFI manager. Pass the websocket bytes as received. + */ + fun handleSubscriberHandles(responseBytes: ByteArray) { + val manager = ensureManager() ?: return + try { + manager.handleSubscriberHandles(responseBytes) + } catch (e: HandleSignalResponseException) { + LKLog.w(e) { "Failed to handle DataTrackSubscriberHandles" } + } + } + + /** + * Forwards a packet received on the `_data_track` data channel to the UniFFI manager. + * + * Called on a WebRTC callback thread, so nothing may escape: a throw here takes down the + * process rather than surfacing anywhere the app can handle it. + */ + fun handlePacketReceived(packet: ByteArray) { + val manager = ensureManager() ?: return + try { + manager.handlePacketReceived(packet) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + LKLog.w(e) { "Failed to handle a data track packet" } + } + } + + /** + * Resend subscription updates after reconnect so the SFU re-issues subscriber handles. + */ + fun resendSubscriptionUpdates() { + remoteManager?.resendSubscriptionUpdates() + } + + /** + * Shuts down the underlying UniFFI manager. A subsequent handle call creates a new one. + */ + fun close() { + synchronized(lock) { + (remoteManager as? AutoCloseable)?.close() + remoteManager = null + remoteTracks.clear() + } + } + + /** + * The UniFFI manager, or `null` if its native library could not be loaded. + * + * Loading can fail on a device the packaged APK has no ABI for, among other reasons. Data + * tracks are then unavailable — but this runs on every connect and on the WebRTC receive + * path, so a failure must not fail [io.livekit.android.room.Room.connect] or crash the + * process for apps that never publish or subscribe to one. The failure is latched so the + * load is not retried per call, and every entry point above degrades to a no-op. + */ + private fun ensureManager(): RemoteDataTrackManagerInterface? { + synchronized(lock) { + remoteManager?.let { return it } + if (nativeUnavailable) { + return null + } + return try { + remoteDataTrackManagerFactory.create(delegate, null).also { remoteManager = it } + } catch (e: LinkageError) { + nativeUnavailable = true + LKLog.e(e) { "Data tracks are unavailable: the native library failed to load." } + null + } + } + } +} From c6e7fbe4f4032e5fa6768ad35612dd5499d619f5 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 36/47] IMPORTANT(datatrack): route the subscriber channel and SFU updates into IncomingDataTrackManager This commit injects IncomingDataTrackManager into RTCEngine and feeds it. The engine makes these changes: - close() closes the manager and clears the local identity. - joinImpl() stores the local participant identity from the join response. - joinImpl() forwards the raw join bytes to the manager after the room has processed the participants. If the raw bytes are missing, it re-encodes the join response. It then asks the listener to reattach remote data tracks. - onParticipantUpdate() forwards the raw bytes and the local identity to the manager. It then asks the listener to reattach remote data tracks. - The subscriber peer connection stores the _data_track channel that the SFU opens. - onMessage() routes packets from that channel to the manager. Other channels keep the existing DataPacket path. - onDataTrackSubscriberHandles() forwards the bytes to the manager. - The Listener interface gets reattachRemoteDataTracks() with an empty default body. The order in joinImpl() is important. The listener processes the participants first. The manager processes the join bytes second. The native manager discovers tracks only after the publishers are known. This is the same order as in the Swift SDK. --- .../java/io/livekit/android/room/RTCEngine.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 6d9467fe..606d4ff4 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -31,6 +31,7 @@ import io.livekit.android.events.DisconnectReason import io.livekit.android.events.convert import io.livekit.android.room.datatrack.DataTrackPublishException import io.livekit.android.room.datatrack.DataTrackPublisherChannel +import io.livekit.android.room.datatrack.IncomingDataTrackManager import io.livekit.android.room.datatrack.OutgoingDataTrackManager import io.livekit.android.room.network.DefaultReconnectPolicy import io.livekit.android.room.network.ReconnectContext @@ -123,6 +124,7 @@ internal constructor( private val rtcThreadToken: RTCThreadToken, private val dataPacketCryptorFactory: DataPacketCryptorManager.Factory, private val outgoingDataTrackManager: OutgoingDataTrackManager, + private val incomingDataTrackManager: IncomingDataTrackManager, ) : SignalClient.Listener { internal var listener: Listener? = null @@ -178,6 +180,7 @@ internal constructor( private var connectOptions: ConnectOptions? = null private var lastRoomOptions: RoomOptions? = null private var participantSid: String? = null + private var localParticipantIdentity: String? = null internal val serverVersion: Semver? get() = client.serverVersion @@ -195,6 +198,7 @@ internal constructor( private var reliableDataChannelSub: DataChannel? = null private var lossyDataChannel: DataChannel? = null private var lossyDataChannelSub: DataChannel? = null + private var dataTrackDataChannelSub: DataChannel? = null private val dataTrackPublisherChannel = DataTrackPublisherChannel(rtcThreadToken) private var reliableDataChannelManager: DataChannelManager? = null private var reliableBufferedAmountJob: Job? = null @@ -264,7 +268,20 @@ internal constructor( val joinResponse = client.join(url, token, options, roomOptions) ensureActive() + if (joinResponse.hasParticipant()) { + localParticipantIdentity = joinResponse.participant.identity + } + // Participants first, then the original join bytes (Swift order): UniFFI discovers + // tracks once publishers are registered, and re-encoding would drop newer fields. listener?.onJoinResponse(joinResponse) + incomingDataTrackManager.handleSfuJoinResponse( + client.lastJoinEncoded + ?: LivekitRtc.SignalResponse.newBuilder() + .setJoin(joinResponse) + .build() + .toByteArray(), + ) + listener?.reattachRemoteDataTracks() isClosed = false listener?.onSignalConnected(false) @@ -329,6 +346,7 @@ internal constructor( when (dataChannel.label()) { RELIABLE_DATA_CHANNEL_LABEL -> reliableDataChannelSub = dataChannel LOSSY_DATA_CHANNEL_LABEL -> lossyDataChannelSub = dataChannel + DATA_TRACK_DATA_CHANNEL_LABEL -> dataTrackDataChannelSub = dataChannel else -> return@onDataChannel } dataChannel.registerObserver(DataChannelObserver(dataChannel)) @@ -468,10 +486,12 @@ internal constructor( connectOptions = null lastRoomOptions = null participantSid = null + localParticipantIdentity = null regionUrlProvider = null abortPendingPublishTracks() closeResources(reason) outgoingDataTrackManager.close() + incomingDataTrackManager.close() connectionState = ConnectionState.DISCONNECTED synchronized(reliableStateLock) { @@ -507,6 +527,7 @@ internal constructor( lossyDataChannelSubManager = null lossyDataChannelSub = null dataTrackPublisherChannel.detach() + dataTrackDataChannelSub = null isSubscriberPrimary = false } } @@ -1089,6 +1110,7 @@ internal constructor( fun onEngineDisconnected(reason: DisconnectReason) fun onFailToConnect(error: Throwable) fun onJoinResponse(response: JoinResponse) + fun reattachRemoteDataTracks() {} fun onAddTrack(receiver: RtpReceiver, track: MediaStreamTrack, streams: Array) fun onUpdateParticipants(updates: List) fun onActiveSpeakersUpdate(speakers: List) @@ -1271,6 +1293,11 @@ internal constructor( override fun onParticipantUpdate(updates: List, encoded: ByteArray) { listener?.onUpdateParticipants(updates) + val identity = localParticipantIdentity + if (identity != null) { + incomingDataTrackManager.handleSfuParticipantUpdate(encoded, identity) + } + listener?.reattachRemoteDataTracks() } override fun onSpeakersChanged(speakers: List) { @@ -1371,6 +1398,10 @@ internal constructor( outgoingDataTrackManager.handleSfuRequestResponse(encoded) } + override fun onDataTrackSubscriberHandles(encoded: ByteArray) { + incomingDataTrackManager.handleSubscriberHandles(encoded) + } + /** * Forwards an encoded [LivekitRtc.SignalRequest] produced by a UniFFI data track manager. */ @@ -1401,6 +1432,10 @@ internal constructor( if (buffer == null) { return } + if (dataChannel.label() == DATA_TRACK_DATA_CHANNEL_LABEL) { + incomingDataTrackManager.handlePacketReceived(ByteString.copyFrom(buffer.data).toByteArray()) + return + } var dp = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffer.data)) if (dp.sequence > 0 && dp.participantSid.isNotEmpty()) { From ea8e72fa6436d2969e36b7136e6cc20fd66a04bb Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 37/47] test(datatrack): cover incoming transport and join forwarding This commit adds IncomingDataTrackManagerMockE2ETest with three tests. The tests check these behaviors: - A packet on the subscriber _data_track channel reaches the mock remote manager. - connect() forwards the raw join bytes to the mock remote manager. - A native library failure does not break the room. The room connects, a participant joins, and a received packet is dropped without an exception. The helpers open a mock subscriber channel, deliver a packet on it, and find the remote participant. --- .../IncomingDataTrackManagerMockE2ETest.kt | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt new file mode 100644 index 00000000..66c70814 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datatrack + +import io.livekit.android.room.RTCEngine +import io.livekit.android.room.Room +import io.livekit.android.room.participant.Participant +import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.mock.MockDataChannel +import io.livekit.android.test.mock.TestData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import livekit.org.webrtc.DataChannel +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer + +@OptIn(ExperimentalCoroutinesApi::class) +class IncomingDataTrackManagerMockE2ETest : MockE2ETest() { + + @Test + fun subscriberDataTrackChannelForwardsPacketsToIncomingManager() = runTest { + connect() + val channel = openSubscriberDataTrackChannel() + val payload = byteArrayOf(9, 8, 7) + receiveDataTrackPacket(channel, payload) + + val packets = remoteDataTrackManagerFactory.manager.handledPackets + assertEquals(1, packets.size) + assertArrayEquals(payload, packets.single()) + } + + @Test + fun connectForwardsJoinToInjectedRemoteManager() = runTest { + connect() + + assertEquals(Room.State.CONNECTED, room.state) + val remote = remoteDataTrackManagerFactory.manager + assertTrue(remote.handledJoinResponses.isNotEmpty()) + assertArrayEquals(TestData.JOIN.toByteArray(), remote.handledJoinResponses.first()) + } + + /** + * The join path and the `_data_track` receive path both build the UniFFI manager on demand, + * and the receive path runs on a WebRTC callback thread. Neither may let a native-library + * failure escape, or apps that never touch data tracks lose the connection or the process. + */ + @Test + fun nativeLibraryFailureLeavesTheRoomUsable() = runTest { + remoteDataTrackManagerFactory.createError = UnsatisfiedLinkError("dlopen failed") + + connect() + assertEquals(Room.State.CONNECTED, room.state) + + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + assertNotNull(remoteParticipant()) + + // Received packets are dropped rather than thrown from the callback. + val channel = openSubscriberDataTrackChannel() + receiveDataTrackPacket(channel, byteArrayOf(1)) + advanceUntilIdle() + assertEquals(Room.State.CONNECTED, room.state) + } + + private fun openSubscriberDataTrackChannel(): MockDataChannel { + val channel = MockDataChannel(RTCEngine.DATA_TRACK_DATA_CHANNEL_LABEL) + getSubscriberPeerConnection().observer?.onDataChannel(channel) + return channel + } + + private fun receiveDataTrackPacket(channel: MockDataChannel, payload: ByteArray) { + channel.simulateBufferReceived( + DataChannel.Buffer(ByteBuffer.wrap(payload), true), + ) + } + + private fun remoteParticipant() = + room.remoteParticipants[Participant.Identity(TestData.REMOTE_PARTICIPANT.identity)]!! +} From 7b6128efaa56ebbe4fab02b8843001ab2fc234d3 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 38/47] IMPORTANT(datatrack): expose data tracks on RemoteParticipant This commit adds the dataTracks map to RemoteParticipant and the bookkeeping behind it. RemoteDataTrackCollection owns the observable map. The map is keyed by track name. The SID of a track changes when the publisher does a full reconnect. The name does not change. The collection has these operations: - add() attaches a track. It returns false if the same instance is already attached. It fires onPublished on success. - remove() detaches the track with a SID. - unpublish() detaches the track and always fires onUnpublished. This also covers a track that a full reconnect already detached. - unpublishAll() detaches every track and fires onUnpublished for each SID. - detachAll() drops every track without an event. RemoteParticipant creates the collection. The callbacks post ParticipantEvent.DataTrackPublished and ParticipantEvent.DataTrackUnpublished. The public dataTracks property is observable with the flow extension. Five internal extension functions on RemoteParticipant give the room access to the collection. The commit adds the two ParticipantEvent cases. It also updates the copyright year of that file. Review the name-based key and the identity-based duplicate check with care. --- .../android/events/ParticipantEvent.kt | 20 ++- .../participant/RemoteDataTrackCollection.kt | 140 ++++++++++++++++++ .../room/participant/RemoteParticipant.kt | 30 ++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteDataTrackCollection.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/events/ParticipantEvent.kt b/livekit-android-sdk/src/main/java/io/livekit/android/events/ParticipantEvent.kt index d189cc9d..550f432c 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/events/ParticipantEvent.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/events/ParticipantEvent.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ package io.livekit.android.events +import io.livekit.android.room.datatrack.DataTrackSid +import io.livekit.android.room.datatrack.RemoteDataTrack import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.room.participant.Participant import io.livekit.android.room.participant.ParticipantPermission @@ -120,6 +122,22 @@ sealed class ParticipantEvent(open val participant: Participant) : Event() { class TrackUnpublished(override val participant: RemoteParticipant, val publication: RemoteTrackPublication) : ParticipantEvent(participant) + /** + * A [RemoteParticipant] published a data track. + */ + class DataTrackPublished( + override val participant: RemoteParticipant, + val track: RemoteDataTrack, + ) : ParticipantEvent(participant) + + /** + * A [RemoteParticipant] unpublished a data track. + */ + class DataTrackUnpublished( + override val participant: RemoteParticipant, + val sid: DataTrackSid, + ) : ParticipantEvent(participant) + /** * Subscribed to a new track */ diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteDataTrackCollection.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteDataTrackCollection.kt new file mode 100644 index 00000000..e1a46cc3 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteDataTrackCollection.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.participant + +import io.livekit.android.room.datatrack.DataTrackSid +import io.livekit.android.room.datatrack.RemoteDataTrack +import io.livekit.android.util.MutableStateFlowDelegate +import io.livekit.android.util.flowDelegate + +/** + * Bookkeeping for the data tracks attached to a [RemoteParticipant]. + * + * Owns the delegate backing [RemoteParticipant.dataTracks], so the participant can expose the + * observable property without also owning the mutation logic. + */ +internal class RemoteDataTrackCollection( + private val onPublished: (RemoteDataTrack) -> Unit, + private val onUnpublished: (DataTrackSid) -> Unit, +) { + private val lock = Any() + + /** + * Backing delegate for [RemoteParticipant.dataTracks]. + */ + val delegate: MutableStateFlowDelegate> = flowDelegate(emptyMap()) + + private var tracks: Map by delegate + + /** + * Adds the track, returning `false` if this exact track is already attached. + */ + fun add(track: RemoteDataTrack): Boolean { + val attached = synchronized(lock) { + if (tracks.values.any { it === track }) { + return@synchronized false + } + tracks = tracks + (track.name to track) + true + } + if (attached) { + onPublished(track) + } + return attached + } + + fun remove(sid: DataTrackSid): RemoteDataTrack? { + // `info.sid` is an FFI call; resolve the instance before taking the lock. + val track = tracks.values.firstOrNull { it.info.sid == sid } ?: return null + synchronized(lock) { + if (tracks.values.none { it === track }) { + return null + } + tracks = tracks - track.name + return track + } + } + + /** + * Removes the track and notifies, even if it was not attached (for example after a full + * reconnect detached it). + */ + fun unpublish(sid: DataTrackSid) { + remove(sid) + onUnpublished(sid) + } + + /** + * Unpublishes every attached data track and notifies for each. + * + * @return The SIDs that were unpublished. + */ + fun unpublishAll(): List { + val previous = synchronized(lock) { + tracks.also { tracks = emptyMap() } + } + val sids = previous.values.map { it.info.sid } + for (sid in sids) { + onUnpublished(sid) + } + return sids + } + + /** + * Drops attached data tracks without notifying. + */ + fun detachAll() { + synchronized(lock) { + tracks = emptyMap() + } + } +} + +/** + * Adds the track, returning `false` if this exact track is already attached. + */ +internal fun RemoteParticipant.addDataTrack(track: RemoteDataTrack): Boolean = + dataTrackCollection.add(track) + +internal fun RemoteParticipant.removeDataTrack(sid: DataTrackSid): RemoteDataTrack? = + dataTrackCollection.remove(sid) + +/** + * Removes the track and emits [io.livekit.android.events.ParticipantEvent.DataTrackUnpublished], + * even if it was not attached (for example after a full reconnect detached it). + */ +internal fun RemoteParticipant.unpublishDataTrack(sid: DataTrackSid) { + dataTrackCollection.unpublish(sid) +} + +/** + * Unpublishes every attached data track and emits an unpublish event for each. + * + * @return The SIDs that were unpublished, for the room to emit matching + * [io.livekit.android.events.RoomEvent]s. + */ +internal fun RemoteParticipant.unpublishDataTracks(): List = + dataTrackCollection.unpublishAll() + +/** + * Drops attached data tracks without notifying. Used when the tracks outlive this participant + * object: a full reconnect recreates participants, but the incoming manager keeps its tracks + * and re-attaches them. + */ +internal fun RemoteParticipant.detachDataTracks() { + dataTrackCollection.detachAll() +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt index d80867af..08b62048 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/RemoteParticipant.kt @@ -23,6 +23,7 @@ import io.livekit.android.dagger.InjectionNames import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.room.SignalClient +import io.livekit.android.room.datatrack.RemoteDataTrack import io.livekit.android.room.track.KIND_AUDIO import io.livekit.android.room.track.KIND_VIDEO import io.livekit.android.room.track.RemoteAudioTrack @@ -31,6 +32,7 @@ import io.livekit.android.room.track.RemoteVideoTrack import io.livekit.android.room.track.Track import io.livekit.android.room.track.TrackException import io.livekit.android.util.CloseableCoroutineScope +import io.livekit.android.util.FlowObservable import io.livekit.android.util.LKLog import io.livekit.android.webrtc.RTCStatsGetter import kotlinx.coroutines.CoroutineDispatcher @@ -94,6 +96,34 @@ class RemoteParticipant( } private val coroutineScope = CloseableCoroutineScope(defaultDispatcher + SupervisorJob()) + internal val dataTrackCollection = RemoteDataTrackCollection( + onPublished = { track -> + eventBus.postEvent(ParticipantEvent.DataTrackPublished(this, track), scope) + }, + onUnpublished = { sid -> + eventBus.postEvent(ParticipantEvent.DataTrackUnpublished(this, sid), scope) + }, + ) + + /** + * Data tracks published by this participant, keyed by track name. + * + * Names are the stable identifier: a track's SID rotates when the publisher republishes + * after a full reconnect (the track object itself survives). + * + * ``` + * val track = participant.dataTracks["telemetry"] + * track?.subscribe()?.onSuccess { stream -> + * stream.flow.collect { frame -> process(frame.payload) } + * } + * ``` + * + * Changes can be observed by using [io.livekit.android.util.flow] + */ + @FlowObservable + @get:FlowObservable + val dataTracks: Map by dataTrackCollection.delegate + /** * Get a track publication with the corresponding sid. */ From f438ad86bd66d8052bfb59bc7062579a5bb6abcd Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 39/47] feat(datatrack): surface remote data track events on Room This commit connects IncomingDataTrackManager to Room and adds two room events. RoomEvent.DataTrackPublished fires when a remote participant publishes a data track. RoomEvent.DataTrackUnpublished fires when the track is unpublished. Room gets these changes: - The constructor receives IncomingDataTrackManager. - connect() starts a collector for the manager events. - A TrackPublished event attaches the track to its participant. If the participant is not in the room yet, the room logs and waits. - A TrackUnpublished event detaches the track and posts the room event. - reattachRemoteDataTracks() attaches every track from the manager snapshot. The engine calls it after a join and after each participant update. - handleParticipantDisconnect() unpublishes the data tracks of the participant and posts one room event per track. - The participant event collector forwards ParticipantEvent.DataTrackPublished as a room event. The commit also adds a documentation comment to the localParticipant property. RoomTest mocks the new constructor parameter. --- .../io/livekit/android/events/RoomEvent.kt | 37 ++++++++++++ .../main/java/io/livekit/android/room/Room.kt | 59 +++++++++++++++++++ .../java/io/livekit/android/room/RoomTest.kt | 12 ++++ 3 files changed, 108 insertions(+) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt b/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt index 4f2ca30b..b49a8509 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/events/RoomEvent.kt @@ -19,6 +19,8 @@ package io.livekit.android.events import io.livekit.android.annotations.Beta import io.livekit.android.e2ee.E2EEState import io.livekit.android.room.Room +import io.livekit.android.room.datatrack.DataTrackSid +import io.livekit.android.room.datatrack.RemoteDataTrack import io.livekit.android.room.participant.ConnectionQuality import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.room.participant.Participant @@ -156,6 +158,41 @@ sealed class RoomEvent(val room: Room) : Event() { class TrackUnpublished(room: Room, val publication: TrackPublication, val participant: Participant) : RoomEvent(room) + /** + * A [RemoteParticipant] published a data track. + * + * Fires for every track, including those already published when this participant was + * first seen and those reattached after a full reconnect. + * + * Collect frames in a separate coroutine so this event collector is not blocked. + * + * ``` + * room.events.collect { event -> + * if (event is RoomEvent.DataTrackPublished) { + * scope.launch { + * event.track.subscribe().onSuccess { stream -> + * stream.flow.collect { frame -> process(frame.payload) } + * } + * } + * } + * } + * ``` + */ + class DataTrackPublished( + room: Room, + val participant: RemoteParticipant, + val track: RemoteDataTrack, + ) : RoomEvent(room) + + /** + * A [RemoteParticipant] unpublished a data track. + */ + class DataTrackUnpublished( + room: Room, + val participant: RemoteParticipant, + val sid: DataTrackSid, + ) : RoomEvent(room) + /** * The [LocalParticipant] has subscribed to a new track. This event will always fire as * long as new tracks are ready for use. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt index 9d5c71bf..f3c7814c 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt @@ -48,6 +48,10 @@ import io.livekit.android.events.collect import io.livekit.android.memory.CloseableManager import io.livekit.android.renderer.TextureViewRenderer import io.livekit.android.room.datastream.incoming.IncomingDataStreamManager +import io.livekit.android.room.datatrack.DataTrackSid +import io.livekit.android.room.datatrack.IncomingDataTrackEvent +import io.livekit.android.room.datatrack.IncomingDataTrackManager +import io.livekit.android.room.datatrack.RemoteDataTrack import io.livekit.android.room.metrics.collectMetrics import io.livekit.android.room.network.NetworkCallbackManagerFactory import io.livekit.android.room.network.ReconnectPolicy @@ -59,7 +63,10 @@ import io.livekit.android.room.participant.ParticipantListener import io.livekit.android.room.participant.RemoteParticipant import io.livekit.android.room.participant.RpcHandler import io.livekit.android.room.participant.VideoTrackPublishDefaults +import io.livekit.android.room.participant.addDataTrack import io.livekit.android.room.participant.publishTracksInfo +import io.livekit.android.room.participant.unpublishDataTrack +import io.livekit.android.room.participant.unpublishDataTracks import io.livekit.android.room.provisions.LKObjects import io.livekit.android.room.rpc.RPC_REQUEST_DATA_STREAM_TOPIC import io.livekit.android.room.rpc.RPC_RESPONSE_DATA_STREAM_TOPIC @@ -150,6 +157,7 @@ constructor( private val connectionWarmer: ConnectionWarmer, private val audioRecordPrewarmer: AudioRecordPrewarmer, private val incomingDataStreamManager: IncomingDataStreamManager, + private val incomingDataTrackManager: IncomingDataTrackManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, private val remoteParticipantFactory: RemoteParticipant.Factory, @@ -351,6 +359,9 @@ constructor( */ var reconnectPolicy: ReconnectPolicy by engine::reconnectPolicy + /** + * The local participant. + */ val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = false).apply { internalListener = this@Room } @@ -483,6 +494,7 @@ constructor( // Setup local participant. localParticipant.reinitialize(options) setupLocalParticipantEventHandling() + setupIncomingDataTrackEventHandling() if (roomOptions.e2eeOptions != null) { e2eeManager = e2EEManagerFactory.create(roomOptions.e2eeOptions.keyProvider).apply { @@ -789,14 +801,53 @@ constructor( } } + private fun setupIncomingDataTrackEventHandling() { + coroutineScope.launch { + incomingDataTrackManager.events.collect { event -> + when (event) { + is IncomingDataTrackEvent.TrackPublished -> attachRemoteDataTrack(event.track) + is IncomingDataTrackEvent.TrackUnpublished -> unpublishRemoteDataTrack(event.sid, event.track) + } + } + } + } + + private fun attachRemoteDataTrack(track: RemoteDataTrack) { + val participant = remoteParticipants[track.publisherIdentity] + if (participant == null) { + LKLog.d { "Data track published by not-yet-known participant ${track.publisherIdentity}" } + return + } + participant.addDataTrack(track) + } + + private fun unpublishRemoteDataTrack(sid: DataTrackSid, track: RemoteDataTrack) { + val participant = remoteParticipants[track.publisherIdentity] ?: return + participant.unpublishDataTrack(sid) + eventBus.postEvent(RoomEvent.DataTrackUnpublished(this, participant, sid), coroutineScope) + } + + /** + * @suppress + */ + override fun reattachRemoteDataTracks() { + for (track in incomingDataTrackManager.snapshotRemoteTracks()) { + attachRemoteDataTrack(track) + } + } + private fun handleParticipantDisconnect(identity: Participant.Identity) { val newParticipants = mutableRemoteParticipants.toMutableMap() val removedParticipant = newParticipants.remove(identity) ?: return + val unpublishedDataSids = removedParticipant.unpublishDataTracks() removedParticipant.trackPublications.values.toList().forEach { publication -> removedParticipant.unpublishTrack(publication.sid, true) } mutableRemoteParticipants = newParticipants + for (sid in unpublishedDataSids) { + eventBus.postEvent(RoomEvent.DataTrackUnpublished(this, removedParticipant, sid), coroutineScope) + } eventBus.postEvent(RoomEvent.ParticipantDisconnected(this, removedParticipant), coroutineScope) localParticipant.handleParticipantDisconnect(identity) @@ -854,6 +905,14 @@ constructor( } } + is ParticipantEvent.DataTrackPublished -> eventBus.postEvent( + RoomEvent.DataTrackPublished( + room = this@Room, + participant = it.participant, + track = it.track, + ), + ) + is ParticipantEvent.TrackStreamStateChanged -> eventBus.postEvent( RoomEvent.TrackStreamStateChanged( this@Room, 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 9a71cb63..cde24d5f 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt @@ -30,6 +30,8 @@ import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.memory.CloseableManager import io.livekit.android.room.datastream.incoming.IncomingDataStreamManagerImpl +import io.livekit.android.room.datatrack.IncomingDataTrackEvent +import io.livekit.android.room.datatrack.IncomingDataTrackManager import io.livekit.android.room.network.NetworkCallbackManagerImpl import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.test.assert.assertIsClassList @@ -91,6 +93,9 @@ class RoomTest { @Mock lateinit var regionUrlProviderFactory: RegionUrlProvider.Factory + @Mock + lateinit var incomingDataTrackManager: IncomingDataTrackManager + lateinit var networkCallbackRegistry: MockNetworkCallbackRegistry var eglBase: EglBase = MockEglBase() @@ -114,6 +119,12 @@ class RoomTest { fun setup() { context = ApplicationProvider.getApplicationContext() networkCallbackRegistry = MockNetworkCallbackRegistry() + whenever(incomingDataTrackManager.events).thenReturn( + object : EventListenable { + override val events: SharedFlow = MutableSharedFlow() + }, + ) + whenever(incomingDataTrackManager.snapshotRemoteTracks()).thenReturn(emptyList()) room = Room( context = context, engine = rtcEngine, @@ -136,6 +147,7 @@ class RoomTest { connectionWarmer = MockConnectionWarmer(), audioRecordPrewarmer = NoAudioRecordPrewarmer(), incomingDataStreamManager = IncomingDataStreamManagerImpl(), + incomingDataTrackManager = incomingDataTrackManager, rpcClientManager = io.livekit.android.room.rpc.RpcClientManager( engine = rtcEngine, outgoingDataStreamManager = Mockito.mock(io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager::class.java), From 33df521ef4cdb9cb955e2f5efbaa974ad68d53cb Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 40/47] test(datatrack): cover attach, park, unpublish, and disconnect This commit adds four tests to IncomingDataTrackManagerMockE2ETest. The tests check these behaviors: - A published track attaches to its participant. The room and the participant each post one published event. - A track published before its participant joins is parked. The track attaches when the participant joins. - An unpublished track leaves the participant map. The room and the participant each post one unpublished event. - A participant disconnect unpublishes its data tracks and posts the events before the disconnect event. --- .../IncomingDataTrackManagerMockE2ETest.kt | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt index 66c70814..cb618389 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt @@ -16,10 +16,14 @@ package io.livekit.android.room.datatrack +import io.livekit.android.events.ParticipantEvent +import io.livekit.android.events.RoomEvent import io.livekit.android.room.RTCEngine import io.livekit.android.room.Room import io.livekit.android.room.participant.Participant import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.assert.assertIsClass +import io.livekit.android.test.events.EventCollector import io.livekit.android.test.mock.MockDataChannel import io.livekit.android.test.mock.TestData import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -28,6 +32,7 @@ import livekit.org.webrtc.DataChannel import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import java.nio.ByteBuffer @@ -57,6 +62,130 @@ class IncomingDataTrackManagerMockE2ETest : MockE2ETest() { assertArrayEquals(TestData.JOIN.toByteArray(), remote.handledJoinResponses.first()) } + @Test + fun remoteDataTrackPublishedAttachesToParticipant() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + assertArrayEquals( + TestData.PARTICIPANT_JOIN.toByteArray(), + remoteDataTrackManagerFactory.manager.handledParticipantUpdates.last(), + ) + + val participant = remoteParticipant() + val roomCollector = EventCollector(room.events, coroutineRule.scope) + val participantCollector = EventCollector(participant.events, coroutineRule.scope) + + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_test", + ) + advanceUntilIdle() + + val attached = participant.dataTracks["telemetry"] + assertNotNull(attached) + assertEquals("telemetry", attached!!.name) + assertEquals(DataTrackSid("DT_test"), attached.info.sid) + + val roomEvents = roomCollector.stopCollecting() + val participantEvents = participantCollector.stopCollecting() + + assertEquals(1, roomEvents.size) + assertIsClass(RoomEvent.DataTrackPublished::class.java, roomEvents.first()) + val roomEvent = roomEvents.first() as RoomEvent.DataTrackPublished + assertEquals(participant, roomEvent.participant) + assertEquals(attached, roomEvent.track) + + assertEquals(1, participantEvents.size) + assertIsClass(ParticipantEvent.DataTrackPublished::class.java, participantEvents.first()) + } + + @Test + fun remoteDataTrackPublishedBeforeParticipantIsParkedThenAttached() = runTest { + connect() + + val parkedCollector = EventCollector(room.events, coroutineRule.scope) + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "parked", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_parked", + ) + advanceUntilIdle() + + assertTrue(room.remoteParticipants.isEmpty()) + assertTrue(parkedCollector.stopCollecting().none { it is RoomEvent.DataTrackPublished }) + + val attachedCollector = EventCollector(room.events, coroutineRule.scope) + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + val participant = remoteParticipant() + val attached = participant.dataTracks["parked"] + assertNotNull(attached) + assertEquals("parked", attached!!.name) + + val events = attachedCollector.stopCollecting() + assertTrue(events.any { it is RoomEvent.DataTrackPublished }) + } + + @Test + fun remoteDataTrackUnpublishedRemovesFromParticipant() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + val participant = remoteParticipant() + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_unpub", + ) + advanceUntilIdle() + assertNotNull(participant.dataTracks["telemetry"]) + + val roomCollector = EventCollector(room.events, coroutineRule.scope) + val participantCollector = EventCollector(participant.events, coroutineRule.scope) + + remoteDataTrackManagerFactory.manager.simulateTrackUnpublished("DT_unpub") + advanceUntilIdle() + + assertNull(participant.dataTracks["telemetry"]) + + val roomEvents = roomCollector.stopCollecting() + val participantEvents = participantCollector.stopCollecting() + + assertEquals(1, roomEvents.size) + assertIsClass(RoomEvent.DataTrackUnpublished::class.java, roomEvents.first()) + assertEquals(DataTrackSid("DT_unpub"), (roomEvents.first() as RoomEvent.DataTrackUnpublished).sid) + + assertEquals(1, participantEvents.size) + assertIsClass(ParticipantEvent.DataTrackUnpublished::class.java, participantEvents.first()) + } + + @Test + fun participantDisconnectUnpublishesDataTracks() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_leave", + ) + advanceUntilIdle() + + val roomCollector = EventCollector(room.events, coroutineRule.scope) + simulateMessageFromServer(TestData.PARTICIPANT_DISCONNECT) + advanceUntilIdle() + + val events = roomCollector.stopCollecting() + assertTrue(events.any { it is RoomEvent.DataTrackUnpublished && it.sid == DataTrackSid("DT_leave") }) + assertTrue(events.any { it is RoomEvent.ParticipantDisconnected }) + assertTrue(room.remoteParticipants.isEmpty()) + } + /** * The join path and the `_data_track` receive path both build the UniFFI manager on demand, * and the receive path runs on a WebRTC callback thread. Neither may let a native-library From 3fb442c85c74743784a2455a87ad4790f0831d7b Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 41/47] IMPORTANT(datatrack): restore data track state across reconnects This commit makes data tracks survive a reconnect. RTCEngine gets these changes: - After a full reconnect, reconnect() calls republishTracks() on the outgoing manager. - After every reconnect, reconnect() calls resendSubscriptionUpdates() on the incoming manager. - sendSyncState() adds the published data tracks to the SyncState message. The function becomes suspend, because the outgoing manager reads the tracks with a suspend call. - Listener.onSignalConnected() becomes suspend for the same reason. - joinImpl() negotiates the publisher when hasPublished is true. Room gets these changes: - sendSyncState() and onSignalConnected() become suspend. - onFullReconnecting() detaches the data tracks of every remote participant before it removes the participants. The tracks survive in the incoming manager and reattach after the join. The hasPublished condition is the most important change. In subscriber-primary mode the engine creates the publisher connection only when something is published. After a full reconnect, hasPublished is still true, but the old condition did not negotiate. The publish then waited for ICE and never completed. The new condition negotiates on every full reconnect after a publish. This affects every session that has published, not only sessions that use data tracks. Review the hasPublished change with the most care. --- .../java/io/livekit/android/room/RTCEngine.kt | 25 ++++++++++++++++--- .../main/java/io/livekit/android/room/Room.kt | 6 +++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt index 606d4ff4..6efe936c 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt @@ -289,8 +289,10 @@ internal constructor( configure(joinResponse, options) - // create offer - if (!isSubscriberPrimary || joinResponse.fastPublish) { + // Subscriber-primary defers the publisher PC until something is published. After a full + // reconnect `hasPublished` is still set, so re-negotiate here — otherwise the ICE wait + // stalls and data-track republish never runs. + if (!isSubscriberPrimary || joinResponse.fastPublish || hasPublished) { negotiatePublisher() } client.onReadyForResponses() @@ -721,6 +723,10 @@ internal constructor( // Is connected, notify and return. regionUrlProvider?.clearAttemptedRegions() client.onPCConnected() + if (isFullReconnect) { + outgoingDataTrackManager.republishTracks() + } + incomingDataTrackManager.resendSubscriptionUpdates() listener?.onPostReconnect(isFullReconnect) return@launch } @@ -1123,7 +1129,7 @@ internal constructor( fun onSubscribedQualityUpdate(subscribedQualityUpdate: LivekitRtc.SubscribedQualityUpdate) fun onSubscriptionPermissionUpdate(subscriptionPermissionUpdate: LivekitRtc.SubscriptionPermissionUpdate) fun onSubscriptionError(subscriptionResponse: LivekitRtc.SubscriptionResponse) - fun onSignalConnected(isResume: Boolean) + suspend fun onSignalConnected(isResume: Boolean) fun onFullReconnecting() suspend fun onPostReconnect(isFullReconnect: Boolean) fun onLocalTrackUnpublished(trackUnpublished: LivekitRtc.TrackUnpublishedResponse) @@ -1538,7 +1544,7 @@ internal constructor( } } - fun sendSyncState( + suspend fun sendSyncState( subscription: LivekitRtc.UpdateSubscription, publishedTracks: List, ) { @@ -1571,6 +1577,16 @@ internal constructor( } } + val publishDataTracks = outgoingDataTrackManager.publishResponsesForSyncState().mapNotNull { bytes -> + try { + LivekitRtc.PublishDataTrackResponse.parseFrom(bytes) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + LKLog.w(e) { "Failed to parse PublishDataTrackResponse for sync state" } + null + } + } + val syncState = with(LivekitRtc.SyncState.newBuilder()) { if (answer != null) { setAnswer(answer) @@ -1580,6 +1596,7 @@ internal constructor( } setSubscription(subscription) addAllPublishTracks(publishedTracks) + addAllPublishDataTracks(publishDataTracks) addAllDataChannels(dataChannelInfos) addAllDatachannelReceiveStates(dataChannelReceiveStates) build() diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt index f3c7814c..d4164a72 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 @@ -64,6 +64,7 @@ import io.livekit.android.room.participant.RemoteParticipant import io.livekit.android.room.participant.RpcHandler import io.livekit.android.room.participant.VideoTrackPublishDefaults import io.livekit.android.room.participant.addDataTrack +import io.livekit.android.room.participant.detachDataTracks import io.livekit.android.room.participant.publishTracksInfo import io.livekit.android.room.participant.unpublishDataTrack import io.livekit.android.room.participant.unpublishDataTracks @@ -1104,7 +1105,7 @@ constructor( incomingDataStreamManager.clearOpenStreams() } - private fun sendSyncState() { + private suspend fun sendSyncState() { // Whether we're sending subscribed tracks or tracks to unsubscribe. val sendUnsub = connectOptions.autoSubscribe val participantTracksList = mutableListOf() @@ -1518,7 +1519,7 @@ constructor( /** * @suppress */ - override fun onSignalConnected(isResume: Boolean) { + override suspend fun onSignalConnected(isResume: Boolean) { if (isResume) { // during resume reconnection, need to send sync state upon signal connection. sendSyncState() @@ -1530,6 +1531,7 @@ constructor( */ override fun onFullReconnecting() { localParticipant.prepareForFullReconnect() + remoteParticipants.values.forEach { it.detachDataTracks() } remoteParticipants.keys.toMutableSet() // copy keys to avoid concurrent modifications. .forEach { identity -> handleParticipantDisconnect(identity) } } From 03fa4b99f271f4f03fa821207be95d2369f43459 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 42/47] test(datatrack): cover publisher reconnect and sync state This commit adds four tests to OutgoingDataTrackManagerMockE2ETest. The tests check these behaviors: - A publish that waits for the channel completes on the replacement channel after a full reconnect. - Packets go to the replacement channel after a full reconnect. - A full reconnect calls republishTracks() and reconnects the publisher peer connection. - A soft reconnect includes the published data tracks in the SyncState message. The publisherOfferHandler helper answers publisher offers. The reconnectWebsocket helper reopens the mock websocket and sends the join or reconnect response. --- .../OutgoingDataTrackManagerMockE2ETest.kt | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt index 0c03c04b..4bf31a3a 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt @@ -17,15 +17,24 @@ package io.livekit.android.room.datatrack import io.livekit.android.room.RTCEngine +import io.livekit.android.room.ReconnectType +import io.livekit.android.room.SignalClient import io.livekit.android.test.MockE2ETest import io.livekit.android.test.mock.MockDataChannel +import io.livekit.android.test.mock.SignalRequestHandler +import io.livekit.android.test.mock.TestData +import io.livekit.android.test.util.toPBByteString import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.yield +import livekit.LivekitRtc import livekit.org.webrtc.DataChannel +import livekit.org.webrtc.PeerConnection import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -137,6 +146,31 @@ class OutgoingDataTrackManagerMockE2ETest : MockE2ETest() { assertTrue(result.exceptionOrNull() is DataTrackPublishException.Timeout) } + @Test + fun publishDataTrackWaitsForReplacementChannelAcrossFullReconnect() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + publisherDataTrackChannel().state = DataChannel.State.CONNECTING + + val publish = async { room.localParticipant.publishDataTrack("telemetry") } + yield() + assertTrue(publish.isActive) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + yield() + assertTrue(publish.isActive) + + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val result = publish.await() + assertTrue(result.isSuccess) + assertEquals("telemetry", result.getOrThrow().info.name) + } + @Test fun publishDataTrackFailsIfDisconnectedWhileWaitingForChannel() = runTest { connect() @@ -154,6 +188,111 @@ class OutgoingDataTrackManagerMockE2ETest : MockE2ETest() { assertTrue(result.exceptionOrNull() is DataTrackPublishException.Disconnected) } + @Test + fun fullReconnectSendsDataTrackPacketsOnReplacementChannel() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + val original = publisherDataTrackChannel() + room.engine.sendDataTrackPackets(listOf(byteArrayOf(1))) + assertEquals(1, original.sentPayloads.size) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val replacement = publisherDataTrackChannel() + assertNotSame(original, replacement) + room.engine.sendDataTrackPackets(listOf(byteArrayOf(2))) + advanceUntilIdle() + assertEquals(1, replacement.sentPayloads.size) + assertArrayEquals(byteArrayOf(2), replacement.sentPayloads.single()) + } + + @Test + fun fullReconnectRenegotiatesPublisherForDataTrack() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + + val local = localDataTrackManagerFactory.manager + assertEquals(0, local.republishTracksCount) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + assertEquals(1, local.republishTracksCount) + assertEquals( + PeerConnection.PeerConnectionState.CONNECTED, + getPublisherPeerConnection().connectionState(), + ) + } + + @Test + fun softReconnectIncludesPublishedDataTracksInSyncState() = runTest { + room.setReconnectionType(ReconnectType.FORCE_SOFT_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val syncState = wsFactory.ws.sentRequests + .map { LivekitRtc.SignalRequest.parseFrom(it.toPBByteString()) } + .firstOrNull { it.hasSyncState() } + ?.syncState + assertNotNull(syncState) + assertEquals(1, syncState!!.publishDataTracksCount) + assertEquals("telemetry", syncState.getPublishDataTracks(0).info.name) + assertEquals("DT_mock", syncState.getPublishDataTracks(0).info.sid) + } + + private val publisherOfferHandler: SignalRequestHandler = { request -> + if (request.hasOffer()) { + val answer = with(LivekitRtc.SignalResponse.newBuilder()) { + answer = with(LivekitRtc.SessionDescription.newBuilder()) { + sdp = "remote_answer" + type = "answer" + id = request.offer.id + build() + } + build() + } + wsFactory.receiveMessage(answer) + true + } else { + false + } + } + private fun publisherDataTrackChannel() = getPublisherPeerConnection().dataChannels[RTCEngine.DATA_TRACK_DATA_CHANNEL_LABEL] as MockDataChannel + + private fun reconnectWebsocket() { + wsFactory.listener.onOpen(wsFactory.ws, createOpenResponse(wsFactory.request)) + val softReconnectParam = wsFactory.request.url + .queryParameter(SignalClient.CONNECT_QUERY_RECONNECT) + ?.toIntOrNull() + ?: 0 + + if (softReconnectParam == 0) { + simulateMessageFromServer(TestData.JOIN) + } else { + simulateMessageFromServer(TestData.RECONNECT) + } + } } From dbd65165640b33594e1dfa1af05d9aed95fd9ecf Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 43/47] test(datatrack): cover subscriber reconnect and reattach This commit adds four tests to IncomingDataTrackManagerMockE2ETest. The tests check these behaviors: - Packets on a replacement subscriber channel reach the manager after a full reconnect. The manager is not closed. - A full reconnect detaches the data tracks without an unpublished event. - A soft reconnect calls resendSubscriptionUpdates() once. - A full reconnect calls resendSubscriptionUpdates() once and leaves the room connected. The publisherOfferHandler and reconnectWebsocket helpers are the same as in the outgoing test. --- .../IncomingDataTrackManagerMockE2ETest.kt | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt index cb618389..b4f7f1f2 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt @@ -19,19 +19,25 @@ package io.livekit.android.room.datatrack import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.room.RTCEngine +import io.livekit.android.room.ReconnectType import io.livekit.android.room.Room +import io.livekit.android.room.SignalClient import io.livekit.android.room.participant.Participant import io.livekit.android.test.MockE2ETest import io.livekit.android.test.assert.assertIsClass import io.livekit.android.test.events.EventCollector import io.livekit.android.test.mock.MockDataChannel +import io.livekit.android.test.mock.SignalRequestHandler import io.livekit.android.test.mock.TestData import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle +import livekit.LivekitRtc import livekit.org.webrtc.DataChannel import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -52,6 +58,32 @@ class IncomingDataTrackManagerMockE2ETest : MockE2ETest() { assertArrayEquals(payload, packets.single()) } + @Test + fun fullReconnectForwardsPacketsOnReplacementSubscriberDataTrackChannel() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + val original = openSubscriberDataTrackChannel() + receiveDataTrackPacket(original, byteArrayOf(1)) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + val remote = remoteDataTrackManagerFactory.manager + assertFalse(remote.closed) + + val replacement = openSubscriberDataTrackChannel() + assertNotSame(original, replacement) + receiveDataTrackPacket(replacement, byteArrayOf(2)) + + assertEquals(2, remote.handledPackets.size) + assertArrayEquals(byteArrayOf(1), remote.handledPackets[0]) + assertArrayEquals(byteArrayOf(2), remote.handledPackets[1]) + } + @Test fun connectForwardsJoinToInjectedRemoteManager() = runTest { connect() @@ -186,6 +218,72 @@ class IncomingDataTrackManagerMockE2ETest : MockE2ETest() { assertTrue(room.remoteParticipants.isEmpty()) } + @Test + fun fullReconnectDetachesDataTracksWithoutUnpublishEvent() = runTest { + connect() + simulateMessageFromServer(TestData.PARTICIPANT_JOIN) + advanceUntilIdle() + + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + sid = "DT_reconnect", + ) + advanceUntilIdle() + + val roomCollector = EventCollector(room.events, coroutineRule.scope) + room.onFullReconnecting() + advanceUntilIdle() + + val events = roomCollector.stopCollecting() + assertTrue(events.none { it is RoomEvent.DataTrackUnpublished }) + } + + @Test + fun softReconnectResendsDataTrackSubscriptions() = runTest { + room.setReconnectionType(ReconnectType.FORCE_SOFT_RECONNECT) + connect() + + val remote = remoteDataTrackManagerFactory.manager + assertEquals(0, remote.resendSubscriptionUpdatesCount) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + assertEquals(1, remote.resendSubscriptionUpdatesCount) + } + + /** + * Resubscription follows transport connect directly and does not wait on the replacement + * subscriber `_data_track` channel, which the SFU opens on its own schedule. Packets on a + * channel that arrives later are still routed + * ([fullReconnectForwardsPacketsOnReplacementSubscriberDataTrackChannel]). + */ + @Test + fun fullReconnectResendsSubscriptionsOnceReconnected() = runTest { + room.setReconnectionType(ReconnectType.FORCE_FULL_RECONNECT) + wsFactory.registerSignalRequestHandler(publisherOfferHandler) + connect() + remoteDataTrackManagerFactory.manager.simulateTrackPublished( + name = "telemetry", + publisherIdentity = TestData.REMOTE_PARTICIPANT.identity, + ) + val remote = remoteDataTrackManagerFactory.manager + assertEquals(0, remote.resendSubscriptionUpdatesCount) + + disconnectPeerConnection() + testScheduler.advanceTimeBy(1000) + reconnectWebsocket() + connectPeerConnection() + advanceUntilIdle() + + assertEquals(1, remote.resendSubscriptionUpdatesCount) + assertEquals(Room.State.CONNECTED, room.state) + } + /** * The join path and the `_data_track` receive path both build the UniFFI manager on demand, * and the receive path runs on a WebRTC callback thread. Neither may let a native-library @@ -209,6 +307,24 @@ class IncomingDataTrackManagerMockE2ETest : MockE2ETest() { assertEquals(Room.State.CONNECTED, room.state) } + private val publisherOfferHandler: SignalRequestHandler = { request -> + if (request.hasOffer()) { + val answer = with(LivekitRtc.SignalResponse.newBuilder()) { + answer = with(LivekitRtc.SessionDescription.newBuilder()) { + sdp = "remote_answer" + type = "answer" + id = request.offer.id + build() + } + build() + } + wsFactory.receiveMessage(answer) + true + } else { + false + } + } + private fun openSubscriberDataTrackChannel(): MockDataChannel { val channel = MockDataChannel(RTCEngine.DATA_TRACK_DATA_CHANNEL_LABEL) getSubscriberPeerConnection().observer?.onDataChannel(channel) @@ -221,6 +337,20 @@ class IncomingDataTrackManagerMockE2ETest : MockE2ETest() { ) } + private fun reconnectWebsocket() { + wsFactory.listener.onOpen(wsFactory.ws, createOpenResponse(wsFactory.request)) + val softReconnectParam = wsFactory.request.url + .queryParameter(SignalClient.CONNECT_QUERY_RECONNECT) + ?.toIntOrNull() + ?: 0 + + if (softReconnectParam == 0) { + simulateMessageFromServer(TestData.JOIN) + } else { + simulateMessageFromServer(TestData.RECONNECT) + } + } + private fun remoteParticipant() = room.remoteParticipants[Participant.Identity(TestData.REMOTE_PARTICIPANT.identity)]!! } From 167704ec08ca80f248c4ddba220c028b3685fd7c Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 44/47] IMPORTANT(datatrack): encrypt data track frames through E2EEManager This commit adds end-to-end encryption for data track frames. DataTrackCryptor implements the UniFFI EncryptionProvider and DecryptionProvider interfaces. It uses the AES-GCM data path of E2EEManager. This is the same path as for data channel payloads. The cryptor gets the manager from a provider function on every call. A manager that is set after connect still applies. If the room has no manager, the cryptor throws the UniFFI failure exception. E2EEManager gets isDataTrackEncryptionEnabled(). It returns true when E2EE is enabled and the encryption type is not NONE. It does not check dataChannelEncryptionEnabled. That flag applies only to data channels. OutgoingDataTrackManager passes the cryptor to the factory only when isDataTrackEncryptionEnabled() is true. The presence of the provider marks every published track as encrypted. Subscribers read that flag from DataTrackInfo.usesE2ee. The decision is fixed when the native manager is created. IncomingDataTrackManager always passes the cryptor. The native manager decrypts only the tracks that are marked as encrypted. Review the encryption gate and the asymmetry between the two managers with care. --- .../livekit/android/e2ee/DataTrackCryptor.kt | 65 +++++++++++++++++++ .../io/livekit/android/e2ee/E2EEManager.kt | 11 ++++ .../datatrack/IncomingDataTrackManager.kt | 4 +- .../datatrack/OutgoingDataTrackManager.kt | 12 +++- 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/e2ee/DataTrackCryptor.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/DataTrackCryptor.kt b/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/DataTrackCryptor.kt new file mode 100644 index 00000000..998dfa63 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/DataTrackCryptor.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.e2ee + +import io.livekit.android.room.participant.Participant +import uniffi.livekit_datatrack.DecryptionException +import uniffi.livekit_datatrack.DecryptionProvider +import uniffi.livekit_datatrack.EncryptedPayload +import uniffi.livekit_datatrack.EncryptionException +import uniffi.livekit_datatrack.EncryptionProvider + +/** + * Bridges UniFFI data-track [EncryptionProvider] / [DecryptionProvider] to [E2EEManager]. + * + * Adds no key handling of its own — encryption rides [E2EEManager]'s existing AES-GCM data path + * (the same [DataPacketCryptorManager] used for data-channel payloads). The manager is resolved + * per call so one assigned after connecting still applies. + * + * @suppress + */ +internal class DataTrackCryptor( + private val e2eeManagerProvider: () -> E2EEManager?, +) : EncryptionProvider, DecryptionProvider { + + override fun encrypt(payload: ByteArray): EncryptedPayload { + val manager = requireManager { message -> EncryptionException.Failed(message) } + val packet = manager.encrypt(payload) + ?: throw EncryptionException.Failed("Failed to encrypt data track payload") + return EncryptedPayload( + payload = packet.payload, + iv = packet.iv, + keyIndex = packet.keyIndex.toUByte(), + ) + } + + override fun decrypt(payload: EncryptedPayload, senderIdentity: String): ByteArray { + val manager = requireManager { message -> DecryptionException.Failed(message) } + val packet = EncryptedPacket( + payload = payload.payload, + iv = payload.iv, + keyIndex = payload.keyIndex.toInt(), + ) + return manager.decrypt(Participant.Identity(senderIdentity), packet) + ?: throw DecryptionException.Failed("Failed to decrypt data track payload") + } + + private fun requireManager(failed: (String) -> T): E2EEManager { + return e2eeManagerProvider() + ?: throw failed("Room has no E2EE manager") + } +} diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/E2EEManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/E2EEManager.kt index bc3fc77b..40913db6 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/E2EEManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/e2ee/E2EEManager.kt @@ -32,6 +32,7 @@ import io.livekit.android.room.track.RemoteVideoTrack import io.livekit.android.room.track.Track import io.livekit.android.room.track.TrackPublication import io.livekit.android.util.LKLog +import livekit.LivekitModels.Encryption import livekit.org.webrtc.FrameCryptor import livekit.org.webrtc.FrameCryptor.FrameCryptionState import livekit.org.webrtc.FrameCryptorAlgorithm @@ -73,6 +74,16 @@ constructor( return enabled && dataChannelEncryptionEnabled } + /** + * Whether data-track frames should be encrypted: the runtime flag plus a configured + * encryption type (unlike the data-channel gate, which also requires + * [dataChannelEncryptionEnabled]). + */ + internal fun isDataTrackEncryptionEnabled(): Boolean { + val type = room?.e2eeOptions?.encryptionType ?: return false + return enabled && type != Encryption.Type.NONE + } + fun keyProvider(): KeyProvider { return this.keyProvider } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt index 6ba00b0e..bbffcc9a 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/IncomingDataTrackManager.kt @@ -16,6 +16,7 @@ package io.livekit.android.room.datatrack +import io.livekit.android.e2ee.DataTrackCryptor import io.livekit.android.events.BroadcastEventBus import io.livekit.android.room.RTCEngine import io.livekit.android.util.LKLog @@ -58,6 +59,7 @@ constructor( private var remoteManager: RemoteDataTrackManagerInterface? = null private var nativeUnavailable = false private val remoteTracks = mutableListOf() + private val cryptor = DataTrackCryptor { engineProvider.get().e2EEManager } /** * Handles events from the UniFFI remote data track manager. @@ -188,7 +190,7 @@ constructor( return null } return try { - remoteDataTrackManagerFactory.create(delegate, null).also { remoteManager = it } + remoteDataTrackManagerFactory.create(delegate, cryptor).also { remoteManager = it } } catch (e: LinkageError) { nativeUnavailable = true LKLog.e(e) { "Data tracks are unavailable: the native library failed to load." } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt index ee7197dd..b7d21ae6 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datatrack/OutgoingDataTrackManager.kt @@ -17,6 +17,7 @@ package io.livekit.android.room.datatrack import androidx.annotation.CheckResult +import io.livekit.android.e2ee.DataTrackCryptor import io.livekit.android.room.RTCEngine import io.livekit.android.util.LKLog import io.livekit.android.util.rethrowIfCancellationSignal @@ -48,6 +49,7 @@ constructor( private val lock = Any() private var localManager: LocalDataTrackManagerInterface? = null private var nativeUnavailable = false + private val cryptor = DataTrackCryptor { engineProvider.get().e2EEManager } /** * Handles events from the UniFFI local data track manager. @@ -177,8 +179,16 @@ constructor( if (nativeUnavailable) { return null } + // Whether frames are encrypted is fixed when the manager is built: unlike data + // channel payloads (a per-message property), data track encryption is a track-level + // protocol property that subscribers key their decryption on. The cryptor is passed + // only when E2EE is on — its presence is what marks published tracks as encrypted + // ([DataTrackInfo.usesE2ee]). + val encryptionProvider = cryptor.takeIf { + engineProvider.get().e2EEManager?.isDataTrackEncryptionEnabled() == true + } return try { - localDataTrackManagerFactory.create(delegate, null) + localDataTrackManagerFactory.create(delegate, encryptionProvider) .also { localManager = it } } catch (e: LinkageError) { nativeUnavailable = true From f0c3bceeb04db819c3e2436ff153fc09adc77ff6 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 45/47] test(datatrack): cover the E2EE bridge This commit adds DataTrackCryptorTest and one test in each mock end-to-end suite. DataTrackCryptorTest checks that encrypt() and decrypt() throw the UniFFI failure exception when the room has no E2EE manager. The outgoing test enables E2EE with a NoopKeyProvider. It checks that the factory receives an encryption provider. It encrypts three bytes and checks that the reversing test cryptor reverses them. It decrypts the result with the decryption provider and checks the round trip. The incoming test checks that the remote factory always receives a decryption provider. --- .../android/e2ee/DataTrackCryptorTest.kt | 57 +++++++++++++++++++ .../IncomingDataTrackManagerMockE2ETest.kt | 6 ++ .../OutgoingDataTrackManagerMockE2ETest.kt | 25 ++++++++ 3 files changed, 88 insertions(+) create mode 100644 livekit-android-test/src/test/java/io/livekit/android/e2ee/DataTrackCryptorTest.kt diff --git a/livekit-android-test/src/test/java/io/livekit/android/e2ee/DataTrackCryptorTest.kt b/livekit-android-test/src/test/java/io/livekit/android/e2ee/DataTrackCryptorTest.kt new file mode 100644 index 00000000..f4bf24d1 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/e2ee/DataTrackCryptorTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.e2ee + +import io.livekit.android.test.BaseTest +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import uniffi.livekit_datatrack.DecryptionException +import uniffi.livekit_datatrack.EncryptedPayload +import uniffi.livekit_datatrack.EncryptionException + +class DataTrackCryptorTest : BaseTest() { + + @Test + fun encryptThrowsWhenThereIsNoE2eeManager() { + val cryptor = DataTrackCryptor { null } + try { + cryptor.encrypt(byteArrayOf(1, 2, 3)) + fail("expected EncryptionException.Failed") + } catch (e: EncryptionException.Failed) { + assertTrue(e.message!!.contains("E2EE manager")) + } + } + + @Test + fun decryptThrowsWhenThereIsNoE2eeManager() { + val cryptor = DataTrackCryptor { null } + try { + cryptor.decrypt( + EncryptedPayload( + payload = byteArrayOf(1), + iv = byteArrayOf(2), + keyIndex = 0u, + ), + "sender", + ) + fail("expected DecryptionException.Failed") + } catch (e: DecryptionException.Failed) { + assertTrue(e.message!!.contains("E2EE manager")) + } + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt index b4f7f1f2..465b7162 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/IncomingDataTrackManagerMockE2ETest.kt @@ -94,6 +94,12 @@ class IncomingDataTrackManagerMockE2ETest : MockE2ETest() { assertArrayEquals(TestData.JOIN.toByteArray(), remote.handledJoinResponses.first()) } + @Test + fun incomingDataTrackAlwaysReceivesDecryptionProvider() = runTest { + connect() + assertNotNull(remoteDataTrackManagerFactory.lastDecryptionProvider) + } + @Test fun remoteDataTrackPublishedAttachesToParticipant() = runTest { connect() diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt index 4bf31a3a..ad60248d 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datatrack/OutgoingDataTrackManagerMockE2ETest.kt @@ -16,6 +16,7 @@ package io.livekit.android.room.datatrack +import io.livekit.android.e2ee.E2EEOptions import io.livekit.android.room.RTCEngine import io.livekit.android.room.ReconnectType import io.livekit.android.room.SignalClient @@ -23,6 +24,7 @@ import io.livekit.android.test.MockE2ETest import io.livekit.android.test.mock.MockDataChannel import io.livekit.android.test.mock.SignalRequestHandler import io.livekit.android.test.mock.TestData +import io.livekit.android.test.mock.e2ee.NoopKeyProvider import io.livekit.android.test.util.toPBByteString import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -90,6 +92,29 @@ class OutgoingDataTrackManagerMockE2ETest : MockE2ETest() { assertTrue(result.exceptionOrNull() is DataTrackPublishException.InvalidSchema) } + @Test + fun publishDataTrackPassesEncryptionProviderWhenE2eeEnabled() = runTest { + room.e2eeOptions = E2EEOptions(keyProvider = NoopKeyProvider()) + connect() + + val result = room.localParticipant.publishDataTrack("telemetry") + assertTrue(result.isSuccess) + + // Tests use ReversingDataPacketCryptorManager by default. + val encryptionProvider = localDataTrackManagerFactory.lastEncryptionProvider + assertNotNull(encryptionProvider) + val encrypted = encryptionProvider!!.encrypt(byteArrayOf(1, 2, 3)) + assertArrayEquals(byteArrayOf(3, 2, 1), encrypted.payload) + + val decryptionProvider = remoteDataTrackManagerFactory.lastDecryptionProvider + assertNotNull(decryptionProvider) + val decrypted = decryptionProvider!!.decrypt( + encrypted, + room.localParticipant.identity!!.value, + ) + assertArrayEquals(byteArrayOf(1, 2, 3), decrypted) + } + @Test fun publishDataTrackWaitsForPublisherChannelOpen() = runTest { connect() From 441d2cd91d37a130f7e85b8cc7876e20b1a97bd9 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 46/47] feat(sample): subscribe to remote data tracks in CallViewModel This commit makes the sample app receive data track frames. The view model subscribes to every data track when the room connects. It also subscribes when RoomEvent.DataTrackPublished fires. A synchronized set of tracks prevents a second subscription to the same track. The set compares tracks by identity. subscribeToDataTrack() starts a coroutine. The coroutine subscribes, collects the frames, and posts each payload as a UTF-8 string to the dataReceived flow. The coroutine removes the track from the set when the stream ends. The commit keeps a commented-out handler for RoomEvent.Reconnected. --- .../livekit/android/sample/CallViewModel.kt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) 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 b218278a..767f1fef 100644 --- a/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt +++ b/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt @@ -40,6 +40,7 @@ import io.livekit.android.events.collect import io.livekit.android.room.Room import io.livekit.android.room.datastream.StreamTextOptions import io.livekit.android.room.datastream.incoming.TextStreamReceiver +import io.livekit.android.room.datatrack.RemoteDataTrack import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.room.participant.Participant import io.livekit.android.room.participant.RemoteParticipant @@ -65,6 +66,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import livekit.org.webrtc.CameraXHelper +import java.util.Collections @OptIn(ExperimentalCamera2Interop::class) class CallViewModel( @@ -145,6 +147,10 @@ class CallViewModel( private val mutablePermissionAllowed = MutableStateFlow(true) val permissionAllowed = mutablePermissionAllowed.hide() + // Data tracks with a live subscription, so the same track isn't collected twice. + // RemoteDataTrack doesn't override equals, so this compares by identity. + private val subscribedDataTracks = Collections.synchronizedSet(mutableSetOf()) + // RPC tester state. Lives on the ViewModel so it survives dialog dismiss/reopen. private val mutableHandlers = MutableStateFlow>(emptyList()) val handlers: StateFlow> = mutableHandlers @@ -192,6 +198,10 @@ class CallViewModel( room.events.collect { when (it) { is RoomEvent.FailedToConnect -> mutableError.value = it.error + is RoomEvent.DataTrackPublished -> subscribeToDataTrack(it.track) +// // A full reconnect re-attaches the surviving tracks without republishing +// // events for them, so sweep again rather than waiting on DataTrackPublished. +// is RoomEvent.Reconnected -> subscribeToAvailableDataTracks() is RoomEvent.DataReceived -> { // Handling basic data packets. val identity = it.participant?.identity ?: "server" @@ -266,6 +276,9 @@ class CallViewModel( mutableEnhancedNsEnabled.postValue(room.audioProcessorIsEnabled) mutableEnableAudioProcessor.postValue(true) + // Data tracks already published when we joined. + subscribeToAvailableDataTracks() + // Create and publish audio/video tracks val localParticipant = room.localParticipant localParticipant.setMicrophoneEnabled(true) @@ -279,6 +292,46 @@ class CallViewModel( } } + /** + * Subscribes to every data track currently published in the room. + * + * [RoomEvent.DataTrackPublished] only fires while the room is connected, so tracks that were + * already published when we joined — or that were re-attached by a full reconnect — never + * announce themselves and have to be picked up from [RemoteParticipant.dataTracks] instead. + */ + private fun subscribeToAvailableDataTracks() { + room.remoteParticipants.values + .flatMap { participant -> participant.dataTracks.values } + .forEach { track -> subscribeToDataTrack(track) } + } + + /** + * Subscribes to [track] and forwards its frames to [dataReceived], ignoring tracks that are + * already being collected. + */ + private fun subscribeToDataTrack(track: RemoteDataTrack) { + if (!subscribedDataTracks.add(track)) { + return + } + viewModelScope.launch(Dispatchers.Default) { + val stream = track.subscribe() + .getOrElse { e -> + LKLog.e(e) { "Failed to subscribe to data track ${track.name}" } + subscribedDataTracks.remove(track) + return@launch + } + + // Ends on its own once the track is unpublished. + stream.flow.collect { frame -> + val message = frame.payload.toString(Charsets.UTF_8) + mutableDataReceived.emit("${track.publisherIdentity.value}/${track.name}: $message") + } + + // Allow a later republish under the same name to resubscribe. + subscribedDataTracks.remove(track) + } + } + private fun handlePrimarySpeaker(participantsList: List, speakers: List, room: Room?) { var speaker = mutablePrimarySpeaker.value From 58f0b6c7a375a1126e006ea83ca047ab24876716 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 14 Sep 2026 10:55:41 +0200 Subject: [PATCH 47/47] chore: update detekt baseline and copyright headers This commit updates the detekt baseline for the signatures that changed in this branch: - SignalClient.handleSignalResponse() and handleSignalResponseImpl() have a new parameter. - handleSignalResponseImpl() is now also a LongMethod. - LocalParticipant, RTCEngine, and Room have new constructor parameters. The commit also updates the copyright year in TestData.kt. That file has no other change. --- livekit-android-sdk/detekt-baseline-release.xml | 10 ++++++---- .../main/java/io/livekit/android/test/mock/TestData.kt | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/livekit-android-sdk/detekt-baseline-release.xml b/livekit-android-sdk/detekt-baseline-release.xml index 54ebf273..6bd3cb94 100644 --- a/livekit-android-sdk/detekt-baseline-release.xml +++ b/livekit-android-sdk/detekt-baseline-release.xml @@ -27,7 +27,7 @@ CyclomaticComplexMethod:Room.kt$Room$@Throws(Exception::class) suspend fun connect(url: String, token: String, options: ConnectOptions = ConnectOptions()) CyclomaticComplexMethod:RoomEvent.kt$fun LivekitModels.DisconnectReason?.convert(): DisconnectReason CyclomaticComplexMethod:SignalClient.kt$SignalClient$override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) - CyclomaticComplexMethod:SignalClient.kt$SignalClient$private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse) + CyclomaticComplexMethod:SignalClient.kt$SignalClient$private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse, encoded: ByteArray) EmptyFunctionBlock:RTCEngine.kt$RTCEngine${ } HasPlatformType:DataChannelManager.kt$DataChannelManager$@get:FlowObservable var state by flowDelegate(dataChannel.state()) private set IgnoredReturnValue:RpcServerManager.kt$RpcServerManager$publishRpcAck(callerIdentity, requestId) @@ -38,11 +38,12 @@ LargeClass:SignalClient.kt$SignalClient : WebSocketListener LongMethod:RTCEngine.kt$RTCEngine$@Synchronized @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) fun reconnect() LongMethod:Room.kt$Room$@Throws(Exception::class) suspend fun connect(url: String, token: String, options: ConnectOptions = ConnectOptions()) + LongMethod:SignalClient.kt$SignalClient$private fun handleSignalResponseImpl(ws: WebSocket, response: LivekitRtc.SignalResponse, encoded: ByteArray) LongParameterList:AudioBufferCallbackDispatcher.kt$AudioBufferCallback$(buffer: ByteBuffer, audioFormat: Int, channelCount: Int, sampleRate: Int, bytesRead: Int, captureTimeNs: Long) LongParameterList:KeyProvider.kt$BaseKeyProvider$( ratchetSalt: String = defaultRatchetSalt, uncryptedMagicBytes: String = defaultMagicBytes, ratchetWindowSize: Int = defaultRatchetWindowSize, override var enableSharedKey: Boolean = true, failureTolerance: Int = defaultFailureTolerance, keyRingSize: Int = defaultKeyRingSize, discardFrameWhenCryptorNotReady: Boolean = defaultDiscardFrameWhenCryptorNotReady, keyDerivationAlgorithm: FrameCryptorKeyDerivationAlgorithm = defaultKeyDerivationAlgorithm, ) LongParameterList:LiveKitOverrides.kt$AudioOptions$( /** * Override the default output [AudioType]. * * This affects the audio routing and how the audio is handled. Default is [AudioType.CallAudioType]. * * Note: if [audioHandler] is also passed, the values from [audioOutputType] will not be reflected in it, * and must be set yourself. */ val audioOutputType: AudioType? = null, /** * Override the default [AudioHandler]. * * Default is [AudioSwitchHandler]. * * Use [NoAudioHandler] to turn off automatic audio handling or * [AudioFocusHandler] to get simple audio focus handling. */ val audioHandler: AudioHandler? = null, /** * Override the default [AudioDeviceModule]. * * If a non-null value is passed, the library does not * take ownership of the object and will not release it upon [Room.release]. * It is the responsibility of the owner to call [AudioDeviceModule.release] when finished * with it to prevent memory leaks. */ val audioDeviceModule: AudioDeviceModule? = null, /** * Called after default setup to allow for customizations on the [JavaAudioDeviceModule]. * * Not used if [audioDeviceModule] is provided. * * Note: We require setting the [JavaAudioDeviceModule.Builder.setSamplesReadyCallback] to provide * support for [LocalAudioTrack.addSink]. If you wish to grab the audio samples * from the local microphone track, use [LocalAudioTrack.addSink] instead of setting your own * callback. */ val javaAudioDeviceModuleCustomizer: ((builder: JavaAudioDeviceModule.Builder) -> Unit)? = null, /** * On Android 11+, the audio mode will reset itself from [AudioManager.MODE_IN_COMMUNICATION] if * there is no audio playback or capture for 6 seconds (for example when joining a room with * no speakers and the local mic is muted.) This mode reset will cause unexpected * behavior when trying to change the volume, causing it to not properly change the volume. * * We use a workaround by playing a silent audio track to keep the communication mode from * resetting. * * Setting this flag to true will disable the workaround. * * This flag is a no-op when the audio mode is set to anything other than * [AudioManager.MODE_IN_COMMUNICATION]. */ val disableCommunicationModeWorkaround: Boolean = false, /** * Options for processing the mic and incoming audio. */ val audioProcessorOptions: AudioProcessorOptions? = null, /** * Devices may take some time initializing the audio stack for recording. * Prewarming allows starting up the underlying audio recording prior to publish, letting * the audio device be ready immediately when the track is fully published. * * If set to true, disables audio recording prewarming (and the related * [LocalAudioTrack.prewarm] function), and audio resources are only used while the * track is connected and published. Defaults to false. */ val disableAudioPrewarming: Boolean = false, ) LongParameterList:LocalAudioTrack.kt$LocalAudioTrack$( @Assisted name: String, @Assisted mediaTrack: livekit.org.webrtc.AudioTrack, @Assisted options: LocalAudioTrackOptions, private val audioProcessingController: AudioProcessingController, @Named(InjectionNames.DISPATCHER_DEFAULT) private val dispatcher: CoroutineDispatcher, @Named(InjectionNames.LOCAL_AUDIO_RECORD_SAMPLES_DISPATCHER) private val audioRecordSamplesDispatcher: AudioRecordSamplesDispatcher, @Named(InjectionNames.LOCAL_AUDIO_BUFFER_CALLBACK_DISPATCHER) private val audioBufferCallbackDispatcher: AudioBufferCallbackDispatcher, private val audioRecordPrewarmer: AudioRecordPrewarmer, rtcThreadToken: RTCThreadToken, ) - LongParameterList:LocalParticipant.kt$LocalParticipant$( @Assisted internal var dynacast: Boolean, internal val engine: RTCEngine, private val peerConnectionFactory: PeerConnectionFactory, private val context: Context, private val eglBase: EglBase, private val screencastVideoTrackFactory: LocalScreencastVideoTrack.Factory, private val videoTrackFactory: LocalVideoTrack.Factory, private val audioTrackFactory: LocalAudioTrack.Factory, private val defaultsManager: DefaultsManager, @Named(InjectionNames.DISPATCHER_DEFAULT) coroutineDispatcher: CoroutineDispatcher, @Named(InjectionNames.SENDER) private val capabilitiesGetter: CapabilitiesGetter, private val outgoingDataStreamManager: OutgoingDataStreamManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, ) + LongParameterList:LocalParticipant.kt$LocalParticipant$( @Assisted internal var dynacast: Boolean, internal val engine: RTCEngine, private val peerConnectionFactory: PeerConnectionFactory, private val context: Context, private val eglBase: EglBase, private val screencastVideoTrackFactory: LocalScreencastVideoTrack.Factory, private val videoTrackFactory: LocalVideoTrack.Factory, private val audioTrackFactory: LocalAudioTrack.Factory, private val defaultsManager: DefaultsManager, @Named(InjectionNames.DISPATCHER_DEFAULT) coroutineDispatcher: CoroutineDispatcher, @Named(InjectionNames.SENDER) private val capabilitiesGetter: CapabilitiesGetter, private val outgoingDataStreamManager: OutgoingDataStreamManager, private val outgoingDataTrackManager: OutgoingDataTrackManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, ) LongParameterList:LocalScreencastVideoTrack.kt$LocalScreencastVideoTrack$( @Assisted capturer: VideoCapturer, @Assisted source: VideoSource, @Assisted name: String, @Assisted options: LocalVideoTrackOptions, @Assisted rtcTrack: livekit.org.webrtc.VideoTrack, @Assisted mediaProjectionCallback: MediaProjectionCallback, peerConnectionFactory: PeerConnectionFactory, context: Context, eglBase: EglBase, defaultsManager: DefaultsManager, videoTrackFactory: LocalVideoTrack.Factory, rtcThreadToken: RTCThreadToken, ) LongParameterList:LocalScreencastVideoTrack.kt$LocalScreencastVideoTrack.Companion$( mediaProjectionPermissionResultData: Intent, peerConnectionFactory: PeerConnectionFactory, context: Context, name: String, options: LocalVideoTrackOptions, rootEglBase: EglBase, screencastVideoTrackFactory: Factory, videoProcessor: VideoProcessor?, onStop: (Track) -> Unit, ) LongParameterList:LocalScreencastVideoTrack.kt$LocalScreencastVideoTrack.Factory$( capturer: VideoCapturer, source: VideoSource, name: String, options: LocalVideoTrackOptions, rtcTrack: livekit.org.webrtc.VideoTrack, mediaProjectionCallback: MediaProjectionCallback, ) @@ -52,13 +53,14 @@ LongParameterList:LocalVideoTrack.kt$LocalVideoTrack.Factory$( capturer: VideoCapturer, source: VideoSource, name: String, options: LocalVideoTrackOptions, rtcTrack: livekit.org.webrtc.VideoTrack, dispatchObserver: CaptureDispatchObserver?, ) LongParameterList:MixerAudioBufferCallback.kt$MixerAudioBufferCallback$(originalBuffer: ByteBuffer, audioFormat: Int, channelCount: Int, sampleRate: Int, bytesRead: Int, captureTimeNs: Long) LongParameterList:PeerConnectionTransport.kt$PeerConnectionTransport$( @Assisted config: RTCConfiguration, @Assisted pcObserver: PeerConnection.Observer, @Assisted private val listener: Listener?, @Named(InjectionNames.DISPATCHER_IO) private val ioDispatcher: CoroutineDispatcher, connectionFactory: PeerConnectionFactory, private val sdpFactory: SdpFactory, private val rtcThreadToken: RTCThreadToken, ) + LongParameterList:RTCEngine.kt$RTCEngine$( val client: SignalClient, private val pctFactory: PeerConnectionTransport.Factory, @Named(InjectionNames.DISPATCHER_IO) private val ioDispatcher: CoroutineDispatcher, private val rtcThreadToken: RTCThreadToken, private val dataPacketCryptorFactory: DataPacketCryptorManager.Factory, private val outgoingDataTrackManager: OutgoingDataTrackManager, private val incomingDataTrackManager: IncomingDataTrackManager, ) LongParameterList:RTCMetricsManager.kt$( label: MetricLabel, strings: MutableList<String>, samples: List<MetricSample>, identity: Participant.Identity? = null, trackSid: String? = null, rid: String? = null, ) LongParameterList:RTCModule.kt$RTCModule$( @Named(InjectionNames.OVERRIDE_AUDIO_DEVICE_MODULE) audioDeviceModuleOverride: AudioDeviceModule?, @Named(InjectionNames.OVERRIDE_JAVA_AUDIO_DEVICE_MODULE_CUSTOMIZER) moduleCustomizer: ((builder: JavaAudioDeviceModule.Builder) -> Unit)?, audioOutputAttributes: AudioAttributes, appContext: Context, closeableManager: CloseableManager, communicationWorkaround: CommunicationWorkaround, @Named(InjectionNames.LOCAL_AUDIO_RECORD_SAMPLES_DISPATCHER) audioRecordSamplesDispatcher: AudioRecordSamplesDispatcher, @Named(InjectionNames.LOCAL_AUDIO_BUFFER_CALLBACK_DISPATCHER) audioBufferCallbackDispatcher: AudioBufferCallbackDispatcher, ) LongParameterList:RTCModule.kt$RTCModule$( @Suppress("UNUSED_PARAMETER") @Named(InjectionNames.LIB_WEBRTC_INITIALIZATION) webrtcInitialization: LibWebrtcInitialization, audioDeviceModule: AudioDeviceModule, videoEncoderFactory: VideoEncoderFactory, videoDecoderFactory: VideoDecoderFactory, @Named(InjectionNames.OVERRIDE_PEER_CONNECTION_FACTORY_OPTIONS) peerConnectionFactoryOptions: PeerConnectionFactory.Options?, memoryManager: CloseableManager, audioProcessingFactory: AudioProcessingFactory, ) LongParameterList:RTCStatsExt.kt$( trackIdentifier: String, ssrcs: Set<Long?>, codecIds: Set<String?>, localCandidateId: String?, remoteCandidateId: String?, statsMap: Map<String, RTCStats>, ) LongParameterList:RemoteParticipant.kt$RemoteParticipant$( mediaTrack: MediaStreamTrack, sid: String, statsGetter: RTCStatsGetter, receiver: RtpReceiver, autoManageVideo: Boolean = false, triesLeft: Int = 20, ) LongParameterList:RemoteParticipant.kt$RemoteParticipant$( sid: Sid, identity: Identity? = null, internal val signalClient: SignalClient, private val ioDispatcher: CoroutineDispatcher, defaultDispatcher: CoroutineDispatcher, private val audioTrackFactory: RemoteAudioTrack.Factory, private val videoTrackFactory: RemoteVideoTrack.Factory, ) - LongParameterList:Room.kt$Room$( @Assisted private val context: Context, internal val engine: RTCEngine, private val eglBase: EglBase, localParticipantFactory: LocalParticipant.Factory, private val defaultsManager: DefaultsManager, @Named(InjectionNames.DISPATCHER_DEFAULT) private val defaultDispatcher: CoroutineDispatcher, @Named(InjectionNames.DISPATCHER_IO) private val ioDispatcher: CoroutineDispatcher, /** * The [AudioHandler] for setting up the audio as need. * * By default, this is an instance of [AudioSwitchHandler]. * * This can be substituted for your own custom implementation through * [LiveKitOverrides.audioOptions] when creating the room with [LiveKit.create]. * * @see [audioSwitchHandler] * @see [AudioSwitchHandler] */ val audioHandler: AudioHandler, private val closeableManager: CloseableManager, private val e2EEManagerFactory: E2EEManager.Factory, private val communicationWorkaround: CommunicationWorkaround, val audioProcessingController: AudioProcessingController, /** * A holder for objects that are used internally within LiveKit. */ val lkObjects: LKObjects, networkCallbackManagerFactory: NetworkCallbackManagerFactory, private val audioDeviceModule: AudioDeviceModule, private val regionUrlProviderFactory: RegionUrlProvider.Factory, private val connectionWarmer: ConnectionWarmer, private val audioRecordPrewarmer: AudioRecordPrewarmer, private val incomingDataStreamManager: IncomingDataStreamManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, private val remoteParticipantFactory: RemoteParticipant.Factory, ) + LongParameterList:Room.kt$Room$( @Assisted private val context: Context, internal val engine: RTCEngine, private val eglBase: EglBase, localParticipantFactory: LocalParticipant.Factory, private val defaultsManager: DefaultsManager, @Named(InjectionNames.DISPATCHER_DEFAULT) private val defaultDispatcher: CoroutineDispatcher, @Named(InjectionNames.DISPATCHER_IO) private val ioDispatcher: CoroutineDispatcher, /** * The [AudioHandler] for setting up the audio as need. * * By default, this is an instance of [AudioSwitchHandler]. * * This can be substituted for your own custom implementation through * [LiveKitOverrides.audioOptions] when creating the room with [LiveKit.create]. * * @see [audioSwitchHandler] * @see [AudioSwitchHandler] */ val audioHandler: AudioHandler, private val closeableManager: CloseableManager, private val e2EEManagerFactory: E2EEManager.Factory, private val communicationWorkaround: CommunicationWorkaround, val audioProcessingController: AudioProcessingController, /** * A holder for objects that are used internally within LiveKit. */ val lkObjects: LKObjects, networkCallbackManagerFactory: NetworkCallbackManagerFactory, private val audioDeviceModule: AudioDeviceModule, private val regionUrlProviderFactory: RegionUrlProvider.Factory, private val connectionWarmer: ConnectionWarmer, private val audioRecordPrewarmer: AudioRecordPrewarmer, private val incomingDataStreamManager: IncomingDataStreamManager, private val incomingDataTrackManager: IncomingDataTrackManager, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, private val remoteParticipantFactory: RemoteParticipant.Factory, ) MapGetWithNotNullAssertionOperator:LocalParticipant.kt$LocalParticipant$sourcePubLocks[source]!! NestedBlockDepth:ByteStreamSender.kt$@CheckResult suspend fun ByteStreamSender.write(source: Source): Result<Unit> NestedBlockDepth:LocalParticipant.kt$LocalParticipant$@Throws(TrackException.PublishException::class) private suspend fun publishTrackImpl( track: Track, options: TrackPublishOptions, requestConfig: AddTrackRequest.Builder.() -> Unit, encodings: List<RtpParameters.Encoding> = emptyList(), publishListener: PublishListener? = null, ): LocalTrackPublication? @@ -71,7 +73,7 @@ NestedBlockDepth:RTCEngine.kt$RTCEngine$private fun makeRTCConfig( serverResponse: Either<JoinResponse, ReconnectResponse>, connectOptions: ConnectOptions, ): RTCConfiguration NestedBlockDepth:Room.kt$Room$override suspend fun onPostReconnect(isFullReconnect: Boolean) NestedBlockDepth:SignalClient.kt$SignalClient$override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) - NestedBlockDepth:SignalClient.kt$SignalClient$private fun handleSignalResponse(ws: WebSocket, response: LivekitRtc.SignalResponse) + NestedBlockDepth:SignalClient.kt$SignalClient$private fun handleSignalResponse(ws: WebSocket, response: LivekitRtc.SignalResponse, encoded: ByteArray) SwallowedException:FlowExt.kt$e: CancellationException SwallowedException:LocalVideoTrack.kt$LocalVideoTrack$e: Exception SwallowedException:TextureViewRenderer.kt$TextureViewRenderer$e: NotFoundException diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt index c5137cf9..46090290 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/TestData.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License.