From f86522513f39665a35c498d053e73ffabf7452a9 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 16:24:24 -0400 Subject: [PATCH 01/14] feat: add temporary maven local --- settings.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/settings.gradle b/settings.gradle index b5882cbb9..e5a958974 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,6 +17,7 @@ dependencyResolutionManagement { google() mavenCentral() maven { url 'https://jitpack.io' } + mavenLocal() } } include ':sample-app', ':sample-app-compose', ':livekit-android-sdk' From 975888ba31a0828d122dacafb7aee7ee31ebc11f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 17:08:50 -0400 Subject: [PATCH 02/14] chore(protocol): bump submodule to v1.50.4-4 for data streams v2 fields Data streams v2 needs three things that landed upstream in protocol #1621 (first tagged v1.46.8); this SDK was pinned at v1.45.8-29: - ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW = 2 (advertise) - ParticipantInfo.capabilities = 21 (read remote caps) - DataStream.Header.inline_content / compression (v2 wire fields) Pinned to 28e604c, the same commit client-sdk-swift@data-streams-v2 and rust-sdks use, so all three SDKs agree on the wire contract. Two unrelated breaks from crossing five minor protocol versions, both fixed here so the bump lands green: - SignalClient's messageCase `when` gained STORE_DATA_BLOB_RESPONSE and GET_DATA_BLOB_RESPONSE; stubbed as TODO like their neighbours. - RoomAgentDispatch gained an `attributes` map, which ProtoConverterTest requires the Kotlin DTO to mirror. Added as a nullable field with a default, so it is source- and serialization-compatible. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/java/io/livekit/android/room/SignalClient.kt | 8 ++++++++ .../src/main/java/io/livekit/android/token/TokenSource.kt | 4 ++++ protocol | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt index 3f2432705..91cf45ddc 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 @@ -876,6 +876,14 @@ constructor( // TODO } + LivekitRtc.SignalResponse.MessageCase.STORE_DATA_BLOB_RESPONSE -> { + // TODO + } + + LivekitRtc.SignalResponse.MessageCase.GET_DATA_BLOB_RESPONSE -> { + // TODO + } + LivekitRtc.SignalResponse.MessageCase.MESSAGE_NOT_SET, null, -> { 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 f665918fa..daf19ff6a 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 @@ -114,6 +114,10 @@ data class RoomAgentDispatch( * Optional deployment to target. Leave empty to target the production deployment. */ val deployment: String? = null, + /** + * Optional attributes to set on the dispatched agent participant. + */ + val attributes: Map? = null, ) @SuppressLint("UnsafeOptInUsageError") diff --git a/protocol b/protocol index 8381f2180..28e604c04 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 8381f2180c45ab926b3ebf19df0608f1dadcac1e +Subproject commit 28e604c046c6aec29757cabed341b86458cc40f9 From 6c4df627813cc008f53bd9e3b54218f6094d4b3f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 17:22:01 -0400 Subject: [PATCH 03/14] build: consume livekit-uniffi and make it loadable from unit tests Adds the livekit-uniffi dependency to the SDK and everything needed to exercise it from a host JVM test run. No SDK behavior changes yet. The artifact is unreleased, so it resolves from mavenLocal() (already enabled on this branch). Build it with `cargo make android-package-local` in rust-sdks, or for a JVM-test-only workflow just generate the Kotlin bindings plus a host cdylib -- no NDK required, and it avoids three Android target dirs' worth of disk. Three obstacles found and handled along the way: - Nobody had ever generated Kotlin bindings for the data stream FFI (`packages/kotlin/` did not exist in rust-sdks), and doing so surfaces two uniffi codegen bugs that stop the generated file compiling at all: a Rust method named `close` collides with the AutoCloseable `close()` uniffi synthesizes, and an error variant field named `message` collides with Throwable.message. scripts/patch-uniffi-kotlin.py patches the generated output -- a build artifact, so rust-sdks source is untouched -- following the precedent of the existing swift-workarounds task. Both still need a real upstream fix. - livekit-uniffi's AAR declares minSdk 24 against this SDK's 21. The native library is built for platform 21, so the declaration is the only conflict; overridden via tools:overrideLibrary in both manifests. - The AAR depends on jna's *aar*, which carries only Android dispatch libraries, so host JVM tests died in JNA before ever reaching our code. The plain jna jar is added as a test dependency for its desktop libjnidispatch. gradle/uniffi-native-lib.gradle points JNA at a host build of the library, auto-discovering a sibling rust-sdks checkout and overridable via LIVEKIT_UNIFFI_LIB_DIR. UniffiNativeLibraryTest asserts the library loads and its checksums match the bindings, so a broken setup fails once with a clear message instead of once per data stream test. Co-Authored-By: Claude Opus 5 (1M context) --- gradle/libs.versions.toml | 9 + gradle/uniffi-native-lib.gradle | 87 ++++++++ livekit-android-sdk/build.gradle | 1 + .../src/main/AndroidManifest.xml | 11 +- livekit-android-test/build.gradle | 11 ++ .../src/main/AndroidManifest.xml | 6 +- .../datastream/UniffiNativeLibraryTest.kt | 47 +++++ scripts/patch-uniffi-kotlin.py | 185 ++++++++++++++++++ 8 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 gradle/uniffi-native-lib.gradle create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datastream/UniffiNativeLibraryTest.kt create mode 100755 scripts/patch-uniffi-kotlin.py diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c3caf7b0..31c221852 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,13 @@ noise = "2.0.0" lifecycleProcess = "2.8.7" agp = "8.7.2" kotlin = "1.9.25" +# Data streams v2 core. Unreleased: build it from rust-sdks with +# `cargo make android-package-local` (see scripts/patch-uniffi-kotlin.py) and it resolves +# from mavenLocal(). Switch to a published version once livekit-uniffi ships this API. +livekit-uniffi = "0.0.1" +# Must match the version livekit-uniffi's AAR depends on. Only used to put the desktop JNA +# dispatch library on the unit-test classpath; see gradle/uniffi-native-lib.gradle. +jna = "5.16.0" [libraries] android-jain-sip-ri = { module = "javax.sip:android-jain-sip-ri", version.ref = "androidJainSipRi" } @@ -59,6 +66,8 @@ androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } +livekit-uniffi = { module = "io.livekit:livekit-uniffi-android", version.ref = "livekit-uniffi" } +jna-desktop = { module = "net.java.dev.jna:jna", version.ref = "jna" } okhttp-lib = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" } diff --git a/gradle/uniffi-native-lib.gradle b/gradle/uniffi-native-lib.gradle new file mode 100644 index 000000000..0212e3916 --- /dev/null +++ b/gradle/uniffi-native-lib.gradle @@ -0,0 +1,87 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Points JNA at a *host* build of liblivekit_uniffi for local unit tests. +// +// Data streams are implemented in the Rust core, so any test that exercises them has to load +// the native library. Unit tests here run on the host JVM (Robolectric), but the +// livekit-uniffi AAR only ships Android .so files -- so the host build has to be supplied +// separately. This mirrors how client-sdk-swift's tests link the real xcframework rather than +// faking the core. +// +// Resolution order: +// 1. LIVEKIT_UNIFFI_LIB_DIR environment variable +// 2. livekitUniffiLibDir Gradle property +// 3. auto-discovery in a sibling rust-sdks checkout, which is the layout the LiveKit SDKs +// already assume for local uniffi development +// +// To produce the host library: +// cd ../rust-sdks/livekit-uniffi +// CARGO_PROFILE_RELEASE_STRIP=false cargo build --release --target + +ext.resolveUniffiNativeLibDirs = { + def libNames = ["liblivekit_uniffi.dylib", "liblivekit_uniffi.so"] + def hasLib = { File dir -> dir.isDirectory() && libNames.any { new File(dir, it).isFile() } } + + def explicit = System.getenv("LIVEKIT_UNIFFI_LIB_DIR") ?: project.findProperty("livekitUniffiLibDir") + if (explicit) { + def dir = file(explicit) + if (!hasLib(dir)) { + throw new GradleException( + "LIVEKIT_UNIFFI_LIB_DIR/livekitUniffiLibDir is set to '${dir}' but no " + + "liblivekit_uniffi.{dylib,so} was found there.", + ) + } + return [dir] + } + + // Sibling rust-sdks checkout. `target//release` is where a --target build lands + // (the uniffi Makefile always passes --target); `target/release` covers a plain build. + def targetRoot = new File(rootDir, "../rust-sdks/target") + if (!targetRoot.isDirectory()) { + return [] + } + def candidates = [] + def plain = new File(targetRoot, "release") + if (hasLib(plain)) { + candidates << plain + } + targetRoot.listFiles({ File f -> f.isDirectory() } as FileFilter)?.sort { it.name }?.each { triple -> + def dir = new File(triple, "release") + if (hasLib(dir)) { + candidates << dir + } + } + return candidates +} + +ext.applyUniffiNativeLibPath = { Test task -> + def dirs = resolveUniffiNativeLibDirs() + if (dirs.isEmpty()) { + task.doFirst { + logger.warn( + "[livekit] No host build of liblivekit_uniffi found. Tests that exercise data " + + "streams will fail to load the native library.\n" + + " Build it: cd ../rust-sdks/livekit-uniffi && " + + "CARGO_PROFILE_RELEASE_STRIP=false cargo build --release --target \$(rustc -vV | " + + "sed -n 's/^host: //p')\n" + + " Or point at one: -PlivekitUniffiLibDir=/path/to/dir", + ) + } + return + } + task.systemProperty("jna.library.path", dirs.collect { it.absolutePath }.join(File.pathSeparator)) +} diff --git a/livekit-android-sdk/build.gradle b/livekit-android-sdk/build.gradle index dbc69f736..b86ece0ab 100644 --- a/livekit-android-sdk/build.gradle +++ b/livekit-android-sdk/build.gradle @@ -118,6 +118,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation libs.coroutines.lib implementation libs.kotlinx.serialization.json + implementation libs.livekit.uniffi api libs.webrtc api libs.okhttp.lib implementation libs.okhttp.coroutines diff --git a/livekit-android-sdk/src/main/AndroidManifest.xml b/livekit-android-sdk/src/main/AndroidManifest.xml index 800317f7f..83106a2ba 100644 --- a/livekit-android-sdk/src/main/AndroidManifest.xml +++ b/livekit-android-sdk/src/main/AndroidManifest.xml @@ -14,7 +14,16 @@ limitations under the License. --> - + + + + diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle index 1629569f0..f5c61813d 100644 --- a/livekit-android-test/build.gradle +++ b/livekit-android-test/build.gradle @@ -110,6 +110,10 @@ dependencies { implementation(project(":livekit-android-sdk")) implementation libs.coroutines.lib implementation libs.kotlinx.serialization.json + // The SDK keeps livekit-uniffi as `implementation` so the FFI types stay out of its public + // API. Tests here drive the FFI managers directly (capturing delegates, stub registries), so + // they need it on their own classpath. + implementation libs.livekit.uniffi api libs.okhttp.lib api libs.audioswitch implementation libs.androidx.annotation @@ -128,6 +132,9 @@ dependencies { testImplementation libs.junit testImplementation libs.robolectric + // livekit-uniffi depends on jna's *aar*, which only carries Android .so dispatch libs. Host + // JVM test runs need the plain jar, which bundles libjnidispatch for desktop platforms. + testImplementation libs.jna.desktop testImplementation libs.okhttp.mockwebserver testImplementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" kaptTest libs.dagger.compiler @@ -135,8 +142,12 @@ dependencies { androidTestImplementation libs.androidx.test.junit androidTestImplementation libs.espresso } +apply from: rootProject.file('gradle/uniffi-native-lib.gradle') + tasks.withType(Test).configureEach { systemProperty "robolectric.logging.enabled", true + // Data streams run through the Rust core, so unit tests need a host build of it. + applyUniffiNativeLibPath(it) } apply from: rootProject.file('gradle/gradle-mvn-push.gradle') diff --git a/livekit-android-test/src/main/AndroidManifest.xml b/livekit-android-test/src/main/AndroidManifest.xml index 8bdb7e14b..38ce537be 100644 --- a/livekit-android-test/src/main/AndroidManifest.xml +++ b/livekit-android-test/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ - + + + + diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/UniffiNativeLibraryTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/UniffiNativeLibraryTest.kt new file mode 100644 index 000000000..b1f0b3d3d --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/UniffiNativeLibraryTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datastream + +import io.livekit.android.test.BaseTest +import io.livekit.uniffi.buildVersion +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the one piece of infrastructure every other data stream test depends on: that the + * livekit-uniffi native library can be found and loaded from a host JVM test run. + * + * When this fails, every data stream test fails with an inscrutable + * [UnsatisfiedLinkError]/`NoClassDefFoundError` from deep inside JNA. Failing here first, with a + * pointer to the fix, saves that debugging. See gradle/uniffi-native-lib.gradle. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class UniffiNativeLibraryTest : BaseTest() { + @Test + fun loadsNativeLibrary() { + // buildVersion() is the cheapest possible FFI call: no arguments, no objects, and it + // forces the UniffiLib class-init that does Native.register() plus the checksum contract + // check between these Kotlin bindings and the .dylib/.so they were generated from. + val version = buildVersion() + + assertTrue( + "livekit-uniffi reported an empty build version", + version.isNotEmpty(), + ) + } +} diff --git a/scripts/patch-uniffi-kotlin.py b/scripts/patch-uniffi-kotlin.py new file mode 100755 index 000000000..fac934a1a --- /dev/null +++ b/scripts/patch-uniffi-kotlin.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# 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. +"""Work around two UniFFI Kotlin codegen bugs in the generated livekit-uniffi bindings. + +TEMPORARY LOCAL-DEV TOOL. The data stream FFI in livekit-uniffi has so far only been +consumed from Swift, Python and Node; nobody had generated its Kotlin bindings before +(`packages/kotlin/` did not exist in the rust-sdks tree). Doing so surfaces two codegen +bugs that make the generated file fail to compile. Both need a real upstream fix -- see +the notes on each patch below -- but neither is fixable from the consuming side, so this +script patches the generated output in place. + +Nothing in the rust-sdks tracked source is modified: `packages/kotlin/` is a build +artifact, and this mirrors the precedent already set by the `swift-workarounds` task in +livekit-uniffi/Makefile.toml, which perl-patches generated Swift for a different uniffi +bug (mozilla/uniffi-rs#2933). + +Run after `uniffi-bindgen generate --language kotlin` and before assembling the AAR. +Idempotent, and fails loudly if the expected patch sites are not found -- so a uniffi +upgrade that changes or fixes the codegen surfaces as an error here rather than as +silently unpatched (or double-patched) output. + +Usage: + scripts/patch-uniffi-kotlin.py +""" + +import re +import sys +from pathlib import Path + +# Rust objects whose exported `close()` collides with the generated AutoCloseable.close(). +WRITERS_WITH_CLOSE = ["ByteStreamWriter", "TextStreamWriter"] + +# Error types with a variant field literally named `message`. +ERROR_TYPE = "DataStreamException" +ERROR_CONVERTER = "FfiConverterTypeDataStreamError" +RENAMED_FIELD = "reason" + +TOP_LEVEL_DECL = re.compile( + r"^(?:public |internal |open |sealed |data |enum )*(?:class|object|interface|typealias|fun|val) ", + re.M, +) + + +def region(src: str, start_pattern: str) -> tuple[int, int]: + """Byte range of one top-level declaration, from its header to the next one.""" + m = re.search(start_pattern, src, re.M) + if not m: + raise SystemExit(f"patch-uniffi-kotlin: could not find {start_pattern!r}") + nxt = TOP_LEVEL_DECL.search(src, m.end()) + return m.start(), (nxt.start() if nxt else len(src)) + + +def patch_writer_close(src: str, cls: str) -> tuple[str, int]: + """Drop AutoCloseable from a writer class so its Rust `close()` can exist. + + UniFFI gives every object a non-suspend `close()` (aliasing `destroy()`) to satisfy + AutoCloseable. When the Rust object *also* exports a method named `close`, the two + differ only by `suspend` and Kotlin rejects them as conflicting overloads, so the + whole file fails to compile. + + Removing AutoCloseable (and its close() alias) keeps the Rust-facing `close()` -- + which Swift already depends on, so renaming the Rust method is not an option -- + while `Disposable.destroy()` remains for releasing the handle. The cost is that + these two types can no longer be used with Kotlin's `use {}`. + + Upstream fix would be for uniffi to name its resource-release method something that + cannot collide, or to skip AutoCloseable when the interface declares `close`. + """ + start, end = region(src, rf"^open class {cls}: ") + body = src[start:end] + changed = 0 + + supertypes = f"open class {cls}: Disposable, AutoCloseable, {cls}Interface" + if supertypes in body: + body = body.replace( + supertypes, + f"open class {cls}: Disposable, {cls}Interface", + 1, + ) + changed += 1 + + alias = " @Synchronized\n override fun close() {\n this.destroy()\n }\n" + if alias in body: + body = body.replace(alias, "", 1) + changed += 1 + + return src[:start] + body + src[end:], changed + + +def patch_error_message_field(src: str) -> tuple[str, int]: + """Rename error variant fields named `message` so they stop colliding. + + UniFFI emits, for a structured error variant carrying a `message` field: + + class AbnormalEnd(val `message`: kotlin.String) : DataStreamException() { + override val message get() = "message=${ `message` }" + } + + The constructor property and the overridden `Throwable.message` are both called + `message` in one class body, which does not compile (and their types differ -- + String vs String? -- so they cannot be merged into one override either). + + Renaming the constructor property sidesteps it. `Throwable.message` still carries + the text, and the raw string is reachable as `.reason`. + + Upstream fix would be for uniffi to avoid emitting a property that collides with + Throwable.message (or for livekit-uniffi to not name the field `message`). + """ + changed = 0 + for pattern in (rf"^sealed class {ERROR_TYPE}", rf"^public object {ERROR_CONVERTER}"): + start, end = region(src, pattern) + body = src[start:end] + before = body + # The constructor property declaration, and reads of it. `override val message` + # is written without backticks, so it is untouched by these. + body = body.replace("val `message`: kotlin.String", f"val `{RENAMED_FIELD}`: kotlin.String") + body = body.replace("${ `message` }", f"${{ `{RENAMED_FIELD}` }}") + body = body.replace("value.`message`", f"value.`{RENAMED_FIELD}`") + if body != before: + changed += 1 + src = src[:start] + body + src[end:] + return src, changed + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit(f"usage: {sys.argv[0]} ") + + target = Path(sys.argv[1]) / "io" / "livekit" / "uniffi" / "livekit_uniffi.kt" + if not target.is_file(): + raise SystemExit(f"patch-uniffi-kotlin: {target} not found; run the Kotlin bindgen first") + + src = original = target.read_text() + + total = 0 + for cls in WRITERS_WITH_CLOSE: + src, n = patch_writer_close(src, cls) + total += n + src, n = patch_error_message_field(src) + total += n + + if src == original: + # Either already patched or the codegen changed. Distinguish, so a uniffi + # upgrade that alters these shapes cannot pass silently. + already = "AutoCloseable" not in src.split("open class ByteStreamWriter: ")[1][:200] + if already: + print("patch-uniffi-kotlin: already patched, nothing to do") + return 0 + raise SystemExit( + "patch-uniffi-kotlin: found no patch sites and the file is not already patched.\n" + "The uniffi codegen likely changed. Re-check whether these bugs still exist " + "before removing this script." + ) + + target.write_text(src) + print(f"patch-uniffi-kotlin: applied {total} patch(es) to {target}") + + # Cheap self-check: the exact symptoms must be gone. + leftovers = [] + for cls in WRITERS_WITH_CLOSE: + head = src.split(f"open class {cls}: ")[1][:200] + if "AutoCloseable" in head: + leftovers.append(f"{cls} still implements AutoCloseable") + if "val `message`: kotlin.String" in src.split(f"sealed class {ERROR_TYPE}")[1].split("public object")[0]: + leftovers.append(f"{ERROR_TYPE} still declares a `message` field") + if leftovers: + raise SystemExit("patch-uniffi-kotlin: incomplete: " + "; ".join(leftovers)) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 506bd9e2414334997def713a2679975071b4e7f1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 17:24:01 -0400 Subject: [PATCH 04/14] feat(datastream): read peer client capabilities Adds the types and read path for per-peer feature negotiation, which the data streams v2 send path needs in order to decide whether a given recipient can accept an inline or compressed stream. - ClientCapability: public enum mirroring ClientInfo.Capability. fromProto returns null for unrecognized values instead of throwing, unlike most fromProto helpers here -- capabilities are an open set, so a peer on a newer SDK must be tolerated, not fatal. - ClientProtocolVersion.DATA_STREAM_V2 (2), documented as a baseline commitment rather than an optional feature. - Participant.capabilities, populated from ParticipantInfo.capabilities alongside the existing clientProtocol. Purely additive, and deliberately read-only for now: this SDK does not yet advertise v2 or any capability. Advertising has to wait until the receive path can actually handle inline and compressed streams, otherwise peers would start sending framings the current Kotlin implementation cannot parse. That flip lands with the cutover. Co-Authored-By: Claude Opus 5 (1M context) --- .../livekit/android/room/ClientCapability.kt | 83 +++++++++++++++++++ .../io/livekit/android/room/SignalClient.kt | 11 +++ .../android/room/participant/Participant.kt | 14 ++++ .../room/participant/ParticipantTest.kt | 54 ++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/ClientCapability.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/ClientCapability.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/ClientCapability.kt new file mode 100644 index 000000000..e5f86daa2 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/ClientCapability.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room + +import livekit.LivekitModels + +/** + * An optional feature capability a client advertises at connect time. + * + * Capabilities are independent feature flags, in contrast to [ClientProtocolVersion], which is a + * single monotonic version number. A client may speak a given client protocol yet still lack an + * individual capability, so the two are negotiated separately. + * + * Advertised by this SDK during the join handshake, mirrored by the server onto every + * `ParticipantInfo`, and readable per-peer via [io.livekit.android.room.participant.Participant.capabilities]. + */ +enum class ClientCapability(val value: Int) { + /** + * The client can accept RTP packet trailers passed through by the SFU instead of having + * them stripped. + */ + PACKET_TRAILER(1), + + /** + * The client can decompress a `deflate-raw` compressed data stream payload. + */ + COMPRESSION_DEFLATE_RAW(2), + ; + + fun toProto(): LivekitModels.ClientInfo.Capability { + return when (this) { + PACKET_TRAILER -> LivekitModels.ClientInfo.Capability.CAP_PACKET_TRAILER + COMPRESSION_DEFLATE_RAW -> LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW + } + } + + companion object { + /** + * Converts from the protobuf enum, returning null for values this SDK build does not + * recognize. + * + * Unlike most `fromProto` helpers in this SDK this never throws: capabilities are an + * open, forward-extensible set, so a peer or server advertising a newer capability must + * be ignored rather than crash us. + */ + fun fromProto(capability: LivekitModels.ClientInfo.Capability): ClientCapability? { + return when (capability) { + LivekitModels.ClientInfo.Capability.CAP_PACKET_TRAILER -> PACKET_TRAILER + LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW -> COMPRESSION_DEFLATE_RAW + LivekitModels.ClientInfo.Capability.CAP_UNUSED, + LivekitModels.ClientInfo.Capability.UNRECOGNIZED, + -> null + } + } + } +} + +/** + * The capabilities this SDK advertises to the server and, through it, to peers. + * + * Declared once so that every place which announces capabilities agrees. Data streams v2 + * compression is advertised unconditionally: deflate-raw is compressed and decompressed by the + * Rust data stream core, so support does not vary by device, API level, or room options. + * + * @suppress + */ +internal val ADVERTISED_CLIENT_CAPABILITIES = listOf( + ClientCapability.COMPRESSION_DEFLATE_RAW, +) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt index 91cf45ddc..1f50833ca 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 @@ -1076,6 +1076,17 @@ enum class ClientProtocolVersion(val value: Int) { * instead of inline packets, lifting the 15 KB payload limit. */ DATA_STREAM_RPC(1), + + /** + * Data streams v2: the client understands single-packet data streams, where a small finite + * payload is carried inline in the stream header rather than as separate chunk and trailer + * packets. + * + * This is a baseline commitment, not an optional feature -- a peer that sees this version may + * send inline streams without further negotiation. Optional v2 features, such as + * `deflate-raw` compression, are negotiated separately via [ClientCapability]. + */ + DATA_STREAM_V2(2), } class ServerInfo( diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/Participant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/Participant.kt index c3a6298b9..81ac71543 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/Participant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/Participant.kt @@ -22,6 +22,7 @@ import io.livekit.android.events.BroadcastEventBus import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.events.TrackEvent +import io.livekit.android.room.ClientCapability import io.livekit.android.room.ClientProtocolVersion import io.livekit.android.room.track.LocalTrackPublication import io.livekit.android.room.track.RemoteTrackPublication @@ -277,6 +278,18 @@ open class Participant( var clientProtocol: Int by flowDelegate(ClientProtocolVersion.DEFAULT.value) internal set + /** + * The optional feature capabilities this participant's client advertises. + * + * Mirrored by the server from the participant's `ClientInfo`. Values this SDK build does not + * recognize are omitted, so an empty list means the participant advertised nothing usable. + * Unlike [clientProtocol], which is a single version, capabilities are independent flags. + */ + @FlowObservable + @get:FlowObservable + var capabilities: List by flowDelegate(emptyList()) + internal set + /** * @suppress */ @@ -449,6 +462,7 @@ open class Participant( agentAttributes = AgentAttributes.fromStringMap(info.attributesMap) state = State.fromProto(info.state) clientProtocol = info.clientProtocol + capabilities = info.capabilitiesList.mapNotNull { ClientCapability.fromProto(it) } } override fun equals(other: Any?): Boolean { diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/participant/ParticipantTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/participant/ParticipantTest.kt index d2eb15568..0aa4769ce 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/participant/ParticipantTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/participant/ParticipantTest.kt @@ -17,6 +17,7 @@ package io.livekit.android.room.participant import io.livekit.android.events.ParticipantEvent +import io.livekit.android.room.ClientCapability import io.livekit.android.room.track.TrackPublication import io.livekit.android.room.types.AgentInput import io.livekit.android.room.types.AgentOutput @@ -281,6 +282,59 @@ class ParticipantTest { assertTrue(abs(lastSpokeAt!! - timestamp) < 1000) } + @Test + fun capabilitiesDefaultToEmpty() = runTest { + participant.updateFromInfo(INFO) + + assertEquals(emptyList(), participant.capabilities) + } + + @Test + fun capabilitiesFromInfo() = runTest { + participant.updateFromInfo( + INFO.toBuilder() + .addCapabilities(LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW) + .addCapabilities(LivekitModels.ClientInfo.Capability.CAP_PACKET_TRAILER) + .build(), + ) + + assertEquals( + listOf(ClientCapability.COMPRESSION_DEFLATE_RAW, ClientCapability.PACKET_TRAILER), + participant.capabilities, + ) + } + + /** + * Capabilities are an open set, so a peer running a newer SDK than us must be tolerated + * rather than throwing (which is what most of this SDK's other `fromProto` helpers do). + */ + @Test + fun capabilitiesDropUnknownValues() = runTest { + participant.updateFromInfo( + INFO.toBuilder() + .addCapabilities(LivekitModels.ClientInfo.Capability.CAP_UNUSED) + .addCapabilitiesValue(9999) + .addCapabilities(LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW) + .build(), + ) + + assertEquals(listOf(ClientCapability.COMPRESSION_DEFLATE_RAW), participant.capabilities) + } + + @Test + fun capabilitiesClearedByLaterUpdate() = runTest { + participant.updateFromInfo( + INFO.toBuilder() + .addCapabilities(LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW) + .build(), + ) + assertEquals(1, participant.capabilities.size) + + participant.updateFromInfo(INFO) + + assertEquals(emptyList(), participant.capabilities) + } + companion object { val INFO = LivekitModels.ParticipantInfo.newBuilder() .setSid("sid") From 666f77374b196d5c5e8c986d176683bf23a2e2bc Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 17:29:47 -0400 Subject: [PATCH 05/14] feat(datastream): add the DataStreams FFI coordinator Introduces the single place that touches livekit-uniffi. Not wired into anything yet -- nothing injects it, so this commit changes no behavior. DataStreams owns both FFI managers, the topic to handler registry, the FFI delegates and the error mapping, so that everything above it keeps dealing only in this SDK's own types. Notable choices: - The outgoing manager is built eagerly; the incoming one lazily on the first inbound packet. Its payload cap comes from RoomOptions, which is not final until connect() -- after this class is constructed -- so reading it eagerly would silently ignore a maxPayloadSize passed to connect(). - Outbound packets go through an unbounded channel drained by one coroutine. The FFI delegate is a synchronous callback on a Rust runtime thread and can neither block nor suspend, but sending has to await publisher connection and data channel backpressure. This keeps emission order and restores the backpressure the previous implementation had. - Stream handlers are dispatched onto our own scope rather than run inline on the FFI thread, so an app handler that blocks cannot stall the core's runtime and with it every other incoming stream. - Delegates hold their owner strongly, unlike Swift. The JVM collects cycles, so Swift's weak back-reference buys nothing here. What does matter is close() running: the FFI's handle map holds the delegates from a static root, so DataStreams registers with CloseableManager to release the native handles. - Room state the send path needs (remote identities, protocols, capabilities, the payload cap) arrives as assignable lambdas rather than by injecting Room, which would be a Dagger cycle. Same pattern Room already uses for the RPC managers. Also adds the additive options this needs: `compress` on both stream option classes and RoomOptions.dataStreamOptions.maxPayloadSize, all defaulted to previous behavior. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/io/livekit/android/RoomOptions.kt | 6 + .../room/datastream/DataStreamOptions.kt | 33 + .../android/room/datastream/DataStreams.kt | 638 ++++++++++++++++++ .../android/room/datastream/StreamOptions.kt | 20 + 4 files changed, 697 insertions(+) create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreamOptions.kt create mode 100644 livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt index 1919cd2c3..915217ab4 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt @@ -18,6 +18,7 @@ package io.livekit.android import io.livekit.android.e2ee.E2EEOptions import io.livekit.android.room.Room +import io.livekit.android.room.datastream.DataStreamOptions import io.livekit.android.room.network.ReconnectPolicy import io.livekit.android.room.participant.AudioTrackPublishDefaults import io.livekit.android.room.participant.VideoTrackPublishDefaults @@ -51,4 +52,9 @@ data class RoomOptions( * @see [Room.reconnectPolicy] */ val reconnectPolicy: ReconnectPolicy? = null, + + /** + * Room-wide data stream settings. + */ + val dataStreamOptions: DataStreamOptions = DataStreamOptions(), ) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreamOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreamOptions.kt new file mode 100644 index 000000000..9a6cc87c5 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreamOptions.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datastream + +/** + * Room-wide data stream settings. + * + * @see io.livekit.android.RoomOptions.dataStreamOptions + */ +data class DataStreamOptions( + /** + * Maximum size, in bytes, of a reassembled incoming data stream payload. + * + * A stream whose payload would exceed this fails its reader rather than buffering without + * bound, which keeps a misbehaving or malicious sender from growing memory regardless of the + * length its header declared. Null uses the built-in default. + */ + val maxPayloadSize: Long? = null, +) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt new file mode 100644 index 000000000..85f6f9326 --- /dev/null +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt @@ -0,0 +1,638 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datastream + +import io.livekit.android.dagger.InjectionNames +import io.livekit.android.memory.CloseableManager +import io.livekit.android.room.ClientCapability +import io.livekit.android.room.ClientProtocolVersion +import io.livekit.android.room.RTCEngine +import io.livekit.android.room.datastream.incoming.ByteStreamHandler +import io.livekit.android.room.datastream.incoming.ByteStreamReceiver +import io.livekit.android.room.datastream.incoming.TextStreamHandler +import io.livekit.android.room.datastream.incoming.TextStreamReceiver +import io.livekit.android.room.datastream.outgoing.ByteStreamSender +import io.livekit.android.room.datastream.outgoing.DataChunker +import io.livekit.android.room.datastream.outgoing.StreamDestination +import io.livekit.android.room.datastream.outgoing.TextStreamSender +import io.livekit.android.room.participant.Participant +import io.livekit.android.util.LKLog +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import livekit.LivekitModels +import java.io.Closeable +import java.util.Collections +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import io.livekit.uniffi.ByteStreamInfo as FfiByteStreamInfo +import io.livekit.uniffi.ByteStreamReader as FfiByteStreamReader +import io.livekit.uniffi.ByteStreamWriter as FfiByteStreamWriter +import io.livekit.uniffi.ClientCapability as FfiClientCapability +import io.livekit.uniffi.DataStreamException as FfiDataStreamException +import io.livekit.uniffi.IncomingDataStreamManager as FfiIncomingDataStreamManager +import io.livekit.uniffi.IncomingDataStreamManagerDelegate as FfiIncomingDelegate +import io.livekit.uniffi.OperationType as FfiOperationType +import io.livekit.uniffi.OutgoingDataStreamManager as FfiOutgoingDataStreamManager +import io.livekit.uniffi.OutgoingDataStreamManagerDelegate as FfiOutgoingDelegate +import io.livekit.uniffi.RemoteParticipantRegistryDelegate as FfiRegistryDelegate +import io.livekit.uniffi.StreamByteOptions as FfiStreamByteOptions +import io.livekit.uniffi.StreamTextOptions as FfiStreamTextOptions +import io.livekit.uniffi.TextStreamInfo as FfiTextStreamInfo +import io.livekit.uniffi.TextStreamReader as FfiTextStreamReader +import io.livekit.uniffi.TextStreamWriter as FfiTextStreamWriter + +/** + * Owns the livekit-uniffi data stream managers and the topic to handler registry, and routes + * packets between them and [RTCEngine]. + * + * This is the single place the FFI is touched. Everything above it -- the + * [io.livekit.android.room.datastream.incoming.IncomingDataStreamManager] and + * [io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager] implementations, the + * public sender and receiver types -- deals only in this SDK's own types. + * + * Room-scoped rather than session-scoped: stream handlers are registered before connecting (and by + * internal RPC wiring on every connect), so they and the managers holding them have to survive + * reconnects. Neither FFI manager holds a channel handle -- inbound packets are pushed in via + * [handleIncoming] and outbound ones are pulled out through a delegate -- so there is nothing + * transport-shaped to reopen when the connection changes. + * + * @suppress + */ +@Singleton +class DataStreams +@Inject +internal constructor( + private val engine: RTCEngine, + closeableManager: CloseableManager, + @Named(InjectionNames.DISPATCHER_IO) ioDispatcher: CoroutineDispatcher, +) : Closeable { + + /** + * Room state the send path needs, assigned by [io.livekit.android.room.Room] after + * construction. + * + * Injecting Room here would be a Dagger cycle, so these follow the same + * assign-a-lambda-afterwards pattern Room already uses for the RPC managers' + * `getRemoteClientProtocol`. + */ + internal var remoteIdentities: () -> List = { emptyList() } + internal var remoteClientProtocol: (Participant.Identity) -> Int = { ClientProtocolVersion.DEFAULT.value } + internal var remoteCapabilities: (Participant.Identity) -> List = { emptyList() } + + /** + * Cap on the size of a reassembled incoming payload, from + * [io.livekit.android.RoomOptions.dataStreamOptions]. Read lazily -- see [incomingManager]. + */ + internal var maxPayloadSize: () -> Long? = { null } + + private val coroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher) + + private val textStreamHandlers = Collections.synchronizedMap(mutableMapOf()) + private val byteStreamHandlers = Collections.synchronizedMap(mutableMapOf()) + + /** Topics we have already warned about, so an unhandled topic logs once rather than per stream. */ + private val warnedTopics = Collections.synchronizedSet(mutableSetOf()) + + /** + * Outbound packets waiting to go on the wire. + * + * The FFI delegate is a plain synchronous callback on a Rust runtime thread: it can neither + * block nor suspend, but sending has to await publisher connection and data channel + * backpressure. Handing off through an unbounded channel drained by a single coroutine keeps + * packets in the order the core emitted them while restoring the backpressure the previous + * implementation had. + */ + private val outboundPackets = Channel(Channel.UNLIMITED) + + private val outgoing: FfiOutgoingDataStreamManager = + FfiOutgoingDataStreamManager(OutgoingDelegate(), RegistryDelegate()) + + private val incomingLock = Any() + private var incoming: FfiIncomingDataStreamManager? = null + + init { + closeableManager.registerClosable(this) + coroutineScope.launch { + for (packet in outboundPackets) { + sendPacket(packet) + } + } + } + + /** + * The incoming manager, built on first use. + * + * Deliberately not built in `init`: its payload cap comes from the room options, which are not + * final until `connect` -- after this class is constructed. The first inbound packet can only + * arrive after connecting, so reading the cap here picks up a value passed to `connect`. + */ + private fun incomingManager(): FfiIncomingDataStreamManager { + synchronized(incomingLock) { + incoming?.let { return it } + val manager = FfiIncomingDataStreamManager( + delegate = IncomingDelegate(), + maxPayloadByteLength = maxPayloadSize()?.toULong(), + ) + incoming = manager + return manager + } + } + + // region Handler registration + + fun registerTextStreamHandler(topic: String, handler: TextStreamHandler) { + synchronized(textStreamHandlers) { + if (textStreamHandlers.containsKey(topic)) { + throw IllegalArgumentException("A text stream handler for topic $topic has already been set.") + } + textStreamHandlers[topic] = handler + } + } + + fun unregisterTextStreamHandler(topic: String) { + synchronized(textStreamHandlers) { + textStreamHandlers.remove(topic) + } + } + + fun registerByteStreamHandler(topic: String, handler: ByteStreamHandler) { + synchronized(byteStreamHandlers) { + if (byteStreamHandlers.containsKey(topic)) { + throw IllegalArgumentException("A byte stream handler for topic $topic has already been set.") + } + byteStreamHandlers[topic] = handler + } + } + + fun unregisterByteStreamHandler(topic: String) { + synchronized(byteStreamHandlers) { + byteStreamHandlers.remove(topic) + } + } + + // endregion + + // region Incoming + + /** + * Feeds a received data stream packet to the core, which re-decodes it itself. + * + * Cheap and non-blocking: the packet is queued and processed on the core's own loop, so this is + * safe to call from a data channel callback. Packets that are not data stream packets, or do + * not decode, are ignored by the core. + */ + fun handleIncoming(packet: LivekitModels.DataPacket) { + incomingManager().handlePacketReceived(packet.toByteArray()) + } + + /** + * Fails every open incoming stream so blocked readers raise instead of hanging. + * + * Handler registrations survive, so streams arriving after a reconnect are still delivered. + * A no-op if no packet has ever been received, since nothing can be open. + */ + fun abortAllStreams() { + synchronized(incomingLock) { incoming }?.abortAllStreams() + } + + /** + * Fails open incoming streams sent by [identity], for when that participant disconnects + * mid-send. Without this their readers would wait forever for chunks that will never arrive. + */ + fun abortStreamsFrom(identity: Participant.Identity) { + synchronized(incomingLock) { incoming }?.abortStreamsFrom(identity.value) + } + + // endregion + + // region Outgoing + + suspend fun streamText(options: StreamTextOptions): TextStreamSender { + val writer = mappingErrors { outgoing.streamText(options.toFfi()) } + return TextStreamSender( + info = writer.info().toSdk(currentEncryptionType()), + destination = TextWriterDestination(writer), + ) + } + + suspend fun streamBytes(options: StreamBytesOptions): ByteStreamSender { + val writer = mappingErrors { outgoing.streamBytes(options.toFfi()) } + return ByteStreamSender( + info = writer.info().toSdk(currentEncryptionType()), + destination = ByteWriterDestination(writer), + ) + } + + suspend fun sendText(text: String, options: StreamTextOptions): TextStreamInfo { + return mappingErrors { outgoing.sendText(text, options.toFfi()) } + .toSdk(currentEncryptionType()) + } + + suspend fun sendBytes(data: ByteArray, options: StreamBytesOptions): ByteStreamInfo { + return mappingErrors { outgoing.sendBytes(data, options.toFfi()) } + .toSdk(currentEncryptionType()) + } + + /** + * Sends [path] as a byte stream, read from disk by the core rather than buffered in memory. + * + * [options] is expected to already carry the name, MIME type and size resolved from the file: + * the core reads the bytes but does not inspect the file's metadata. + */ + suspend fun sendFile(path: String, options: StreamBytesOptions): ByteStreamInfo { + return mappingErrors { outgoing.sendFile(path, options.toFfi()) } + .toSdk(currentEncryptionType()) + } + + private suspend fun sendPacket(bytes: ByteArray) { + val packet = try { + LivekitModels.DataPacket.parseFrom(bytes) + } catch (e: Exception) { + LKLog.e(e) { "Unable to decode an outgoing data stream packet; dropping it." } + return + } + + engine.waitForBufferStatusLow(packet.kind) + val result = engine.sendData(packet) + if (result.isFailure) { + // The core acknowledges the send as soon as it hands the packet over, so there is + // nobody left to return this to; the originating send call has already returned. + LKLog.w(result.exceptionOrNull()) { "Failed to send a data stream packet." } + } + } + + // endregion + + /** + * The room's data channel encryption type. + * + * The core reports `NONE` on every stream: end to end encryption across the FFI is not + * implemented, and payload encryption still happens in [RTCEngine] on the whole packet, either + * side of the FFI. Stamping the room's real value keeps [StreamInfo.encryptionType] meaning + * what it did before. + */ + private fun currentEncryptionType(): LivekitModels.Encryption.Type { + return if (engine.e2EEManager?.isDataChannelEncryptionEnabled() == true) { + LivekitModels.Encryption.Type.GCM + } else { + LivekitModels.Encryption.Type.NONE + } + } + + override fun close() { + coroutineScope.cancel() + outboundPackets.close() + // Releases the native handles, and with them the core's reference to our delegates. Those + // delegates are held by a static handle map on the way in, so skipping this would keep this + // object -- and through it the engine -- reachable for the life of the process. + synchronized(incomingLock) { + incoming?.destroy() + incoming = null + } + outgoing.destroy() + } + + // region FFI delegates + + /** + * Receives encoded `DataPacket`s from the core and queues them for the reliable data channel. + * + * Unlike the Swift implementation this holds a strong reference to its owner: the JVM collects + * reference cycles, so the weak back-reference Swift needs to break an ARC cycle would buy + * nothing here. What does matter is [close] running, since the FFI's handle map holds this + * delegate from a static root. + */ + private inner class OutgoingDelegate : FfiOutgoingDelegate { + override fun onPacketsAvailable(packets: List) { + for (packet in packets) { + val result = outboundPackets.trySend(packet) + if (result.isFailure) { + LKLog.w { "Dropping an outgoing data stream packet: the send queue is closed." } + } + } + } + } + + /** + * Receives opened incoming streams from the core and routes them to a handler by topic. + * + * The core surfaces every stream regardless of topic; matching topics to handlers, and + * discarding streams nobody is listening for, is this SDK's job. + */ + private inner class IncomingDelegate : FfiIncomingDelegate { + override fun onTextStreamOpened(reader: FfiTextStreamReader, identity: String) { + val info = reader.info().toSdk(currentEncryptionType()) + val handler = textStreamHandlers[info.topic] + if (handler == null) { + warnMissingHandler("text", info.topic, info.id, identity) + return + } + deliver { + handler.invoke( + TextStreamReceiver(info, pumpText(reader)), + Participant.Identity(identity), + ) + } + } + + override fun onByteStreamOpened(reader: FfiByteStreamReader, identity: String) { + val info = reader.info().toSdk(currentEncryptionType()) + val handler = byteStreamHandlers[info.topic] + if (handler == null) { + warnMissingHandler("byte", info.topic, info.id, identity) + return + } + deliver { + handler.invoke( + ByteStreamReceiver(info, pumpBytes(reader)), + Participant.Identity(identity), + ) + } + } + } + + /** + * Read access to the room's remote participants, used by the core to resolve a broadcast's + * recipients and to decide per-recipient whether an inline or compressed framing is safe. + * + * Read live rather than cached: eligibility has to reflect who is in the room at send time. + */ + private inner class RegistryDelegate : FfiRegistryDelegate { + override fun remoteIdentities(): List { + return this@DataStreams.remoteIdentities().map { it.value } + } + + override fun remoteClientProtocol(identity: String): Int { + return this@DataStreams.remoteClientProtocol(Participant.Identity(identity)) + } + + override fun remoteCapabilities(identity: String): List { + return this@DataStreams.remoteCapabilities(Participant.Identity(identity)) + .map { it.toFfi() } + } + } + + // endregion + + /** + * Runs a stream handler off the FFI callback thread. + * + * Handlers are app code and are not required to return promptly, so running them inline would + * let one of them stall the core's runtime thread and with it every other incoming stream. + */ + private fun deliver(block: () -> Unit) { + coroutineScope.launch { + try { + block() + } catch (e: Exception) { + LKLog.e(e) { "Unhandled exception when invoking stream handler!" } + } + } + } + + private fun warnMissingHandler(kind: String, topic: String, id: String, identity: String) { + if (warnedTopics.add(topic)) { + LKLog.w { + "Received $kind stream for topic \"$topic\", but no handler was found. Ignoring. " + + "(stream $id from $identity)" + } + } + } + + /** + * Drains an FFI reader into the channel the public receivers are built on. + * + * Keeps [io.livekit.android.room.datastream.incoming.BaseStreamReceiver] and its `flow` / + * `readNext` / `readAll` surface exactly as it was: the channel closes normally when the + * stream ends, or with a [StreamException] when it fails. + */ + private fun pumpBytes(reader: FfiByteStreamReader): Channel { + return pump { reader.next() } + } + + /** + * As [pumpBytes], re-encoding each piece to UTF-8 because the public [TextStreamReceiver] + * decodes from a byte channel. + * + * Lossless: the core splits text on character boundaries, so every piece is independently + * valid UTF-8. Round-tripping keeps [TextStreamReceiver]'s public constructor untouched. + */ + private fun pumpText(reader: FfiTextStreamReader): Channel { + return pump { reader.next()?.toByteArray(Charsets.UTF_8) } + } + + private fun pump(next: suspend () -> ByteArray?): Channel { + val channel = Channel(capacity = Channel.UNLIMITED) + coroutineScope.launch { + try { + while (true) { + val chunk = next() ?: break + channel.send(chunk) + } + channel.close() + } catch (e: FfiDataStreamException) { + channel.close(e.toStreamException()) + } catch (e: Exception) { + channel.close(e) + } + } + return channel + } + + // region Writer destinations + + /** + * Bridges a public sender onto an FFI writer. + * + * The [DataChunker] handed in by [io.livekit.android.room.datastream.outgoing.BaseStreamSender] + * is deliberately ignored: chunking (including splitting text on character boundaries) now + * happens in the core, which also needs the whole write to decide on framing. + * + * [isOpen] is a snapshot rather than a query. The interface exposes it as a non-suspending + * property while the FFI's is a suspending call, so it is tracked locally: set false on close, + * and on a failed write, since a write only fails once the stream is finished. + */ + private abstract inner class WriterDestination : StreamDestination { + @Volatile + private var open = true + + override val isOpen: Boolean + get() = open + + protected abstract suspend fun writeToFfi(data: T) + protected abstract suspend fun closeFfi(reason: String?) + + override suspend fun write(data: T, chunker: DataChunker): Result { + return try { + writeToFfi(data) + Result.success(Unit) + } catch (e: FfiDataStreamException) { + open = false + Result.failure(e.toStreamException()) + } + } + + override suspend fun close(reason: String?) { + if (!open) { + return + } + open = false + try { + closeFfi(reason) + } catch (e: FfiDataStreamException) { + throw e.toStreamException() + } + } + } + + private inner class TextWriterDestination( + private val writer: FfiTextStreamWriter, + ) : WriterDestination() { + override suspend fun writeToFfi(data: String) = writer.write(data) + override suspend fun closeFfi(reason: String?) { + if (reason == null) writer.close() else writer.closeWithReason(reason) + } + } + + private inner class ByteWriterDestination( + private val writer: FfiByteStreamWriter, + ) : WriterDestination() { + override suspend fun writeToFfi(data: ByteArray) = writer.write(data) + override suspend fun closeFfi(reason: String?) { + if (reason == null) writer.close() else writer.closeWithReason(reason) + } + } + + // endregion + + private inline fun mappingErrors(body: () -> T): T { + try { + return body() + } catch (e: FfiDataStreamException) { + throw e.toStreamException() + } + } +} + +// region FFI type conversions + +internal fun FfiTextStreamInfo.toSdk(encryptionType: LivekitModels.Encryption.Type) = TextStreamInfo( + id = id, + topic = topic, + timestampMs = timestampMs, + totalSize = totalLength?.toLong(), + attributes = attributes, + operationType = operationType.toSdk(), + version = version, + replyToStreamId = replyToStreamId, + attachedStreamIds = attachedStreamIds, + generated = generated, + encryptionType = encryptionType, +) + +internal fun FfiByteStreamInfo.toSdk(encryptionType: LivekitModels.Encryption.Type) = ByteStreamInfo( + id = id, + topic = topic, + timestampMs = timestampMs, + totalSize = totalLength?.toLong(), + attributes = attributes, + mimeType = mimeType, + name = name, + encryptionType = encryptionType, +) + +internal fun FfiOperationType.toSdk(): TextStreamInfo.OperationType = when (this) { + FfiOperationType.CREATE -> TextStreamInfo.OperationType.CREATE + FfiOperationType.UPDATE -> TextStreamInfo.OperationType.UPDATE + FfiOperationType.DELETE -> TextStreamInfo.OperationType.DELETE + FfiOperationType.REACTION -> TextStreamInfo.OperationType.REACTION +} + +internal fun TextStreamInfo.OperationType.toFfi(): FfiOperationType = when (this) { + TextStreamInfo.OperationType.CREATE -> FfiOperationType.CREATE + TextStreamInfo.OperationType.UPDATE -> FfiOperationType.UPDATE + TextStreamInfo.OperationType.DELETE -> FfiOperationType.DELETE + TextStreamInfo.OperationType.REACTION -> FfiOperationType.REACTION +} + +internal fun ClientCapability.toFfi(): FfiClientCapability = when (this) { + ClientCapability.PACKET_TRAILER -> FfiClientCapability.PACKET_TRAILER + ClientCapability.COMPRESSION_DEFLATE_RAW -> FfiClientCapability.COMPRESSION_DEFLATE_RAW +} + +/** + * `totalSize` is intentionally not carried over: the core opens an incremental text stream as + * unknown-length, and its options have no field for a declared total. + */ +internal fun StreamTextOptions.toFfi() = FfiStreamTextOptions( + topic = topic, + attributes = attributes, + destinationIdentities = destinationIdentities.map { it.value }, + id = streamId, + operationType = operationType.toFfi(), + version = version, + replyToStreamId = replyToStreamId, + attachedStreamIds = attachedStreamIds, + generated = null, + compress = compress, + senderIdentity = null, +) + +internal fun StreamBytesOptions.toFfi() = FfiStreamByteOptions( + topic = topic, + attributes = attributes, + destinationIdentities = destinationIdentities.map { it.value }, + id = streamId, + mimeType = mimeType, + name = name, + totalLength = totalSize?.toULong(), + compress = compress, + senderIdentity = null, +) + +/** + * Maps a core error onto this SDK's existing [StreamException] hierarchy. + * + * Several core cases collapse onto one [StreamException]: the public hierarchy predates the core + * and is kept as-is for compatibility, so new failure modes are folded into the closest existing + * case rather than widening public API. The core's message is preserved where the target case can + * carry one. + */ +internal fun FfiDataStreamException.toStreamException(): StreamException = when (this) { + is FfiDataStreamException.AbnormalEnd -> StreamException.AbnormalEndException(reason) + is FfiDataStreamException.Io -> StreamException.AbnormalEndException(reason) + is FfiDataStreamException.Utf8 -> StreamException.DecodeFailedException() + is FfiDataStreamException.Decompression -> StreamException.DecodeFailedException() + is FfiDataStreamException.LengthExceeded -> StreamException.LengthExceededException() + is FfiDataStreamException.HeaderTooLarge -> StreamException.LengthExceededException() + is FfiDataStreamException.PayloadTooLarge -> StreamException.LengthExceededException() + is FfiDataStreamException.Incomplete -> StreamException.IncompleteException() + is FfiDataStreamException.EncryptionTypeMismatch -> StreamException.EncryptionTypeMismatch() + is FfiDataStreamException.AlreadyClosed -> StreamException.TerminatedException("Stream is closed!") + is FfiDataStreamException.InvalidHeader -> StreamException.TerminatedException(message) + is FfiDataStreamException.MissedChunk -> StreamException.TerminatedException(message) + is FfiDataStreamException.SendFailed -> StreamException.TerminatedException(message) + is FfiDataStreamException.Internal -> StreamException.TerminatedException(message) + is FfiDataStreamException.InvalidFileName -> StreamException.TerminatedException(message) +} + +// endregion diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.kt index 0a987eecf..c5ccfa633 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.kt @@ -30,8 +30,22 @@ data class StreamTextOptions( val replyToStreamId: String? = null, /** * The total exact size in bytes when encoded to UTF-8, if known. + * + * Ignored when opening an incremental stream with + * [io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager.streamText], which is + * always announced as unknown-length. */ val totalSize: Long? = null, + /** + * Whether the payload may be compressed, when every recipient supports it. + * + * Only applies to whole-payload sends such as + * [io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager.sendText]; incremental + * streams are never compressed. Compression is additionally skipped unless every recipient + * advertises [io.livekit.android.room.ClientCapability.COMPRESSION_DEFLATE_RAW], and is only + * kept when it actually makes the payload smaller, so leaving this on is safe. + */ + val compress: Boolean = true, ) data class StreamBytesOptions( @@ -51,4 +65,10 @@ data class StreamBytesOptions( * The total exact size in bytes, if known. */ val totalSize: Long? = null, + /** + * Whether the payload may be compressed, when every recipient supports it. + * + * @see StreamTextOptions.compress + */ + val compress: Boolean = true, ) From b72830c9939da8ddb3f354191878ab370862f26a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 18:32:48 -0400 Subject: [PATCH 06/14] feat(datastream)!: back data streams with the Rust core Cuts the data stream implementation over to livekit-uniffi. The hand-written packet building and stream reassembly are gone; the managers are now thin adapters over DataStreams. The public API is unchanged. IncomingDataStreamManager and OutgoingDataStreamManager keep their interfaces, so Room's and LocalParticipant's delegation is untouched, and TextStreamSender / ByteStreamSender / TextStreamReceiver / ByteStreamReceiver keep working by reusing their existing seams: an FFI-backed StreamDestination, and a Channel pumped from an FFI reader. The interface's handleStreamHeader/handleDataChunk/handleStreamTrailer remain as shims that rebuild a packet, though Room now forwards whole packets instead -- v2 headers carry inline content and compression that only the core reads. Behavior changes fall out of the implementation moving into a Rust actor, and existing tests were updated to match rather than papered over: - Sending and receiving are now asynchronous. A completed write means the core accepted the payload, not that it reached the wire, and an incoming stream is delivered after a round trip through the core. Tests that asserted synchronously now await the outcome. - Send failures no longer reach the caller: the core acknowledges a send when it hands the packets over. - StreamException.EncryptionTypeMismatch is unreachable; the core normalizes encryption type at the boundary. Three problems this shook out, all now handled: - Everything on the FFI boundary runs on a real dispatcher, never the caller's. The core resumes calls from its own runtime threads, which a virtual-time test dispatcher can never deliver; worse, an unconfined dispatcher resumed our coroutines *inline on a core runtime thread*, where waiting on data channel backpressure deadlocked the runtime and stopped every stream. - MockDataChannel's buffers became copy-on-write; sends now genuinely arrive from several threads and assertions iterate the list concurrently. - Test classes each get their own JVM. The core is process-global state loaded via JNA, and Robolectric's per-class classloaders re-initializing the bindings left callbacks pointing nowhere -- whole classes would time out depending on execution order. Costs ~90s on this module; see the comment in livekit-android-test/build.gradle. All 326 tests pass, verified stable over repeated cold runs. Co-Authored-By: Claude Opus 5 (1M context) --- gradle/uniffi-native-lib.gradle | 10 +- .../main/java/io/livekit/android/room/Room.kt | 41 +-- .../android/room/datastream/DataStreams.kt | 51 +++- .../incoming/IncomingDataStreamManager.kt | 268 +++--------------- .../outgoing/OutgoingDataStreamManager.kt | 258 ++++------------- livekit-android-test/build.gradle | 15 +- .../java/io/livekit/android/test/BaseTest.kt | 34 +++ .../android/test/mock/MockDataChannel.kt | 14 +- .../android/test/util/AwaitCondition.kt | 106 +++++++ .../java/io/livekit/android/room/RoomTest.kt | 9 +- .../RoomIncomingDataStreamMockE2ETest.kt | 14 +- .../RoomOutgoingDataStreamMockE2ETest.kt | 12 +- .../android/room/rpc/RpcV2MockE2ETest.kt | 128 +++++++-- 13 files changed, 445 insertions(+), 515 deletions(-) create mode 100644 livekit-android-test/src/main/java/io/livekit/android/test/util/AwaitCondition.kt diff --git a/gradle/uniffi-native-lib.gradle b/gradle/uniffi-native-lib.gradle index 0212e3916..579fc732e 100644 --- a/gradle/uniffi-native-lib.gradle +++ b/gradle/uniffi-native-lib.gradle @@ -68,7 +68,15 @@ ext.resolveUniffiNativeLibDirs = { return candidates } -ext.applyUniffiNativeLibPath = { Test task -> +ext.configureUniffiForUnitTests = { Test task -> + // The generated bindings pick their cleaner by API level, and at 34+ use + // android.system.SystemCleaner. Robolectric's implementation of that delegates to + // jdk.internal.ref.CleanerFactory, which the module system hides by default -- so merely + // constructing an FFI object throws IllegalAccessError. Opening the package up lets the + // Android code path run as written, rather than forcing every test that touches the FFI down + // to an older Robolectric SDK. + task.jvmArgs += ["--add-exports", "java.base/jdk.internal.ref=ALL-UNNAMED"] + def dirs = resolveUniffiNativeLibDirs() if (dirs.isEmpty()) { task.doFirst { diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt index 10aa87821..ce4ffca21 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt @@ -47,6 +47,8 @@ import io.livekit.android.events.RoomEvent import io.livekit.android.events.collect import io.livekit.android.memory.CloseableManager import io.livekit.android.renderer.TextureViewRenderer +import io.livekit.android.room.datastream.DataStreamOptions +import io.livekit.android.room.datastream.DataStreams import io.livekit.android.room.datastream.incoming.IncomingDataStreamManager import io.livekit.android.room.metrics.collectMetrics import io.livekit.android.room.network.NetworkCallbackManagerFactory @@ -150,6 +152,7 @@ constructor( private val connectionWarmer: ConnectionWarmer, private val audioRecordPrewarmer: AudioRecordPrewarmer, private val incomingDataStreamManager: IncomingDataStreamManager, + private val dataStreams: DataStreams, private val rpcClientManager: RpcClientManager, private val rpcServerManager: RpcServerManager, private val remoteParticipantFactory: RemoteParticipant.Factory, @@ -182,6 +185,13 @@ constructor( } rpcClientManager.getRemoteClientProtocol = getRemoteClientProtocol rpcServerManager.getRemoteClientProtocol = getRemoteClientProtocol + + // The data stream core decides per-recipient whether a v2 framing is safe, so give it a + // live view of the room. Lambdas rather than a Room reference, which would be a DI cycle. + dataStreams.remoteClientProtocol = getRemoteClientProtocol + dataStreams.remoteIdentities = { remoteParticipants.keys.toList() } + dataStreams.remoteCapabilities = { id -> remoteParticipants[id]?.capabilities ?: emptyList() } + dataStreams.maxPayloadSize = { dataStreamOptions.maxPayloadSize } } enum class State { @@ -316,6 +326,11 @@ constructor( */ var e2eeOptions: E2EEOptions? = null + /** + * Room-wide data stream settings. Must be set up prior to [connect]. + */ + var dataStreamOptions: DataStreamOptions = DataStreamOptions() + /** * Default options to use when creating an audio track. */ @@ -406,6 +421,7 @@ constructor( videoTrackPublishDefaults = videoTrackPublishDefaults, screenShareTrackCaptureDefaults = screenShareTrackCaptureDefaults, screenShareTrackPublishDefaults = screenShareTrackPublishDefaults, + dataStreamOptions = dataStreamOptions, ) /** @@ -647,6 +663,7 @@ constructor( adaptiveStream = options.adaptiveStream dynacast = options.dynacast e2eeOptions = options.e2eeOptions + dataStreamOptions = options.dataStreamOptions } /** @@ -794,6 +811,10 @@ constructor( eventBus.postEvent(RoomEvent.ParticipantDisconnected(this, removedParticipant), coroutineScope) localParticipant.handleParticipantDisconnect(identity) + + // Fail any stream they were midway through sending, so its reader raises instead of waiting + // for chunks that will never arrive. + dataStreams.abortStreamsFrom(identity) } fun getParticipantBySid(sid: String): Participant? { @@ -1341,22 +1362,10 @@ constructor( * @suppress */ override fun onDataStreamPacket(dp: LivekitModels.DataPacket, encryptionType: LivekitModels.Encryption.Type) { - when (dp.valueCase) { - LivekitModels.DataPacket.ValueCase.STREAM_HEADER -> { - incomingDataStreamManager.handleStreamHeader(dp.streamHeader, Participant.Identity(dp.participantIdentity), encryptionType) - } - - LivekitModels.DataPacket.ValueCase.STREAM_CHUNK -> { - incomingDataStreamManager.handleDataChunk(dp.streamChunk, encryptionType) - } - - LivekitModels.DataPacket.ValueCase.STREAM_TRAILER -> { - incomingDataStreamManager.handleStreamTrailer(dp.streamTrailer, encryptionType) - } - - // Ignore other cases. - else -> {} - } + // Forwarded whole rather than destructured: the core re-decodes the packet itself, and v2 + // headers carry fields (inline content, compression) that only it interprets. The packet has + // already been decrypted by the engine, which is what the core expects. + dataStreams.handleIncoming(dp) } /** diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt index 85f6f9326..eb2a865f8 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt @@ -16,7 +16,6 @@ package io.livekit.android.room.datastream -import io.livekit.android.dagger.InjectionNames import io.livekit.android.memory.CloseableManager import io.livekit.android.room.ClientCapability import io.livekit.android.room.ClientProtocolVersion @@ -33,15 +32,16 @@ import io.livekit.android.room.participant.Participant import io.livekit.android.util.LKLog import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import livekit.LivekitModels import java.io.Closeable import java.util.Collections import javax.inject.Inject -import javax.inject.Named import javax.inject.Singleton import io.livekit.uniffi.ByteStreamInfo as FfiByteStreamInfo import io.livekit.uniffi.ByteStreamReader as FfiByteStreamReader @@ -83,7 +83,6 @@ class DataStreams internal constructor( private val engine: RTCEngine, closeableManager: CloseableManager, - @Named(InjectionNames.DISPATCHER_IO) ioDispatcher: CoroutineDispatcher, ) : Closeable { /** @@ -104,7 +103,28 @@ internal constructor( */ internal var maxPayloadSize: () -> Long? = { null } - private val coroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher) + /** + * The dispatcher for everything on the FFI boundary: calls into the core, and the coroutines + * that react to its callbacks. + * + * Deliberately a real dispatcher, and deliberately not the injected one. Two reasons, both of + * which have bitten: + * + * - The core resumes a suspended call from a thread on its own runtime, so the continuation + * has to land on a dispatcher actually backed by threads. On a virtual-time test dispatcher + * it is queued for a scheduler nobody is advancing and the call never completes. + * - The core invokes our delegates synchronously on its runtime threads. An unconfined + * dispatcher resumes waiting coroutines *inline on the calling thread*, so the work those + * coroutines do -- awaiting a publisher connection, waiting out data channel backpressure -- + * would run on, and block, a core runtime thread. Enough of those and the core's runtime + * deadlocks and every stream stops. + * + * Confining all of it here keeps both hazards off callers, including SDK users whose own tests + * may run the SDK on a test dispatcher. + */ + private val ffiDispatcher: CoroutineDispatcher = Dispatchers.IO + + private val coroutineScope = CoroutineScope(SupervisorJob() + ffiDispatcher) private val textStreamHandlers = Collections.synchronizedMap(mutableMapOf()) private val byteStreamHandlers = Collections.synchronizedMap(mutableMapOf()) @@ -227,7 +247,7 @@ internal constructor( // region Outgoing suspend fun streamText(options: StreamTextOptions): TextStreamSender { - val writer = mappingErrors { outgoing.streamText(options.toFfi()) } + val writer = onFfi { outgoing.streamText(options.toFfi()) } return TextStreamSender( info = writer.info().toSdk(currentEncryptionType()), destination = TextWriterDestination(writer), @@ -235,7 +255,7 @@ internal constructor( } suspend fun streamBytes(options: StreamBytesOptions): ByteStreamSender { - val writer = mappingErrors { outgoing.streamBytes(options.toFfi()) } + val writer = onFfi { outgoing.streamBytes(options.toFfi()) } return ByteStreamSender( info = writer.info().toSdk(currentEncryptionType()), destination = ByteWriterDestination(writer), @@ -243,12 +263,12 @@ internal constructor( } suspend fun sendText(text: String, options: StreamTextOptions): TextStreamInfo { - return mappingErrors { outgoing.sendText(text, options.toFfi()) } + return onFfi { outgoing.sendText(text, options.toFfi()) } .toSdk(currentEncryptionType()) } suspend fun sendBytes(data: ByteArray, options: StreamBytesOptions): ByteStreamInfo { - return mappingErrors { outgoing.sendBytes(data, options.toFfi()) } + return onFfi { outgoing.sendBytes(data, options.toFfi()) } .toSdk(currentEncryptionType()) } @@ -259,7 +279,7 @@ internal constructor( * the core reads the bytes but does not inspect the file's metadata. */ suspend fun sendFile(path: String, options: StreamBytesOptions): ByteStreamInfo { - return mappingErrors { outgoing.sendFile(path, options.toFfi()) } + return onFfi { outgoing.sendFile(path, options.toFfi()) } .toSdk(currentEncryptionType()) } @@ -445,7 +465,7 @@ internal constructor( coroutineScope.launch { try { while (true) { - val chunk = next() ?: break + val chunk = withContext(ffiDispatcher) { next() } ?: break channel.send(chunk) } channel.close() @@ -483,7 +503,7 @@ internal constructor( override suspend fun write(data: T, chunker: DataChunker): Result { return try { - writeToFfi(data) + withContext(ffiDispatcher) { writeToFfi(data) } Result.success(Unit) } catch (e: FfiDataStreamException) { open = false @@ -497,7 +517,7 @@ internal constructor( } open = false try { - closeFfi(reason) + withContext(ffiDispatcher) { closeFfi(reason) } } catch (e: FfiDataStreamException) { throw e.toStreamException() } @@ -524,9 +544,12 @@ internal constructor( // endregion - private inline fun mappingErrors(body: () -> T): T { + /** + * Runs a suspending call into the core on [ffiDispatcher], translating its errors. + */ + private suspend fun onFfi(body: suspend () -> T): T { try { - return body() + return withContext(ffiDispatcher) { body() } } catch (e: FfiDataStreamException) { throw e.toStreamException() } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/incoming/IncomingDataStreamManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/incoming/IncomingDataStreamManager.kt index 337c02acd..b1dc24f90 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/incoming/IncomingDataStreamManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/incoming/IncomingDataStreamManager.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit, Inc. + * Copyright 2025-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,25 +16,15 @@ package io.livekit.android.room.datastream.incoming -import android.os.SystemClock import androidx.annotation.VisibleForTesting -import io.livekit.android.room.datastream.ByteStreamInfo -import io.livekit.android.room.datastream.StreamException -import io.livekit.android.room.datastream.StreamInfo -import io.livekit.android.room.datastream.TextStreamInfo +import io.livekit.android.room.datastream.DataStreams import io.livekit.android.room.participant.Participant -import io.livekit.android.util.LKLog -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import livekit.LivekitModels import livekit.LivekitModels.DataStream -import java.util.Collections import javax.inject.Inject -// Type-erased stream handler -private typealias AnyStreamHandler = (Channel, Participant.Identity) -> Unit - typealias ByteStreamHandler = (reader: ByteStreamReceiver, fromIdentity: Participant.Identity) -> Unit typealias TextStreamHandler = (reader: TextStreamReceiver, fromIdentity: Participant.Identity) -> Unit @@ -86,265 +76,81 @@ interface IncomingDataStreamManager { } /** + * Adapts [IncomingDataStreamManager] onto [DataStreams], which owns the actual implementation. + * * @suppress */ -class IncomingDataStreamManagerImpl @Inject constructor() : IncomingDataStreamManager { +class IncomingDataStreamManagerImpl @Inject constructor( + private val dataStreams: DataStreams, +) : IncomingDataStreamManager { - /** - * A stream descriptor for any open incoming streams. - */ - private data class Descriptor( - val streamInfo: StreamInfo, - /** - * Measured by SystemClock.elapsedRealtime() - */ - val openTime: Long, - /** - * The channel to pipe any incoming data into. - * - * Calling [Channel.close] will automatically call [closeStream] and remove the stream. - */ - val channel: Channel, - var readLength: Long = 0, - ) - - private val openStreams = Collections.synchronizedMap(mutableMapOf()) - private val textStreamHandlers = Collections.synchronizedMap(mutableMapOf()) - private val byteStreamHandlers = Collections.synchronizedMap(mutableMapOf()) - - /** - * Registers a text stream handler for [topic]. Only one handler can be set for a particular topic at a time. - * - * @throws IllegalArgumentException if a topic is already set. - */ override fun registerTextStreamHandler(topic: String, handler: TextStreamHandler) { - synchronized(textStreamHandlers) { - if (textStreamHandlers.containsKey(topic)) { - throw IllegalArgumentException("A text stream handler for topic $topic has already been set.") - } - - textStreamHandlers[topic] = handler - } + dataStreams.registerTextStreamHandler(topic, handler) } - /** - * Unregisters a previously registered text handler for [topic]. - */ override fun unregisterTextStreamHandler(topic: String) { - synchronized(textStreamHandlers) { - textStreamHandlers.remove(topic) - } + dataStreams.unregisterTextStreamHandler(topic) } - /** - * Registers a byte stream handler for [topic]. Only one handler can be set for a particular topic at a time. - * - * @throws IllegalArgumentException if a topic is already set. - */ override fun registerByteStreamHandler(topic: String, handler: ByteStreamHandler) { - synchronized(byteStreamHandlers) { - if (byteStreamHandlers.containsKey(topic)) { - throw IllegalArgumentException("A byte stream handler for topic $topic has already been set.") - } - - byteStreamHandlers[topic] = handler - } + dataStreams.registerByteStreamHandler(topic, handler) } - /** - * Unregisters a previously registered byte handler for [topic]. - */ override fun unregisterByteStreamHandler(topic: String) { - synchronized(byteStreamHandlers) { - byteStreamHandlers.remove(topic) - } + dataStreams.unregisterByteStreamHandler(topic) } /** * @suppress */ - override fun handleStreamHeader(header: DataStream.Header, fromIdentity: Participant.Identity, encryptionType: LivekitModels.Encryption.Type) { - val info = streamInfoFromHeader(header, encryptionType) ?: return - openStream(info, fromIdentity) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private fun openStream(info: StreamInfo, fromIdentity: Participant.Identity) { - if (openStreams.containsKey(info.id)) { - LKLog.w { "Stream already open for id ${info.id}" } - return - } - - val handler = getHandlerForInfo(info) - val channel = createChannelForStreamReceiver() - - val descriptor = Descriptor( - streamInfo = info, - openTime = SystemClock.elapsedRealtime(), - channel = channel, + override fun handleStreamHeader( + header: DataStream.Header, + fromIdentity: Participant.Identity, + encryptionType: LivekitModels.Encryption.Type, + ) { + dataStreams.handleIncoming( + LivekitModels.DataPacket.newBuilder() + .setParticipantIdentity(fromIdentity.value) + .setStreamHeader(header) + .build(), ) - - openStreams[info.id] = descriptor - channel.invokeOnClose { closeStream(id = info.id) } - LKLog.d { "Opened stream ${info.id}" } - - try { - handler.invoke(channel, fromIdentity) - } catch (e: Exception) { - LKLog.e(e) { "Unhandled exception when invoking stream handler!" } - } } /** * @suppress */ override fun handleDataChunk(chunk: DataStream.Chunk, encryptionType: LivekitModels.Encryption.Type) { - val content = chunk.content ?: return - val descriptor = openStreams[chunk.streamId] ?: return - - if (encryptionType != descriptor.streamInfo.encryptionType) { - descriptor.channel.close( - StreamException.EncryptionTypeMismatch( - "Encryption type mismatch for stream ${chunk.streamId}. Expected ${descriptor.streamInfo.encryptionType}, got $encryptionType", - ), - ) - } - - val totalReadLength = descriptor.readLength + content.size() - - val totalLength = descriptor.streamInfo.totalSize - if (totalLength != null) { - if (totalReadLength > totalLength) { - descriptor.channel.close(StreamException.LengthExceededException()) - return - } - } - descriptor.readLength = totalReadLength - descriptor.channel.trySend(content.toByteArray()) + // No sender identity: chunks are routed by stream id, and the identity recorded against an + // open stream comes from its header. + dataStreams.handleIncoming( + LivekitModels.DataPacket.newBuilder() + .setStreamChunk(chunk) + .build(), + ) } /** * @suppress */ override fun handleStreamTrailer(trailer: DataStream.Trailer, encryptionType: LivekitModels.Encryption.Type) { - val descriptor = openStreams[trailer.streamId] - if (descriptor == null) { - LKLog.w { "Received trailer for unknown stream: ${trailer.streamId}" } - return - } - - if (encryptionType != descriptor.streamInfo.encryptionType) { - descriptor.channel.close( - StreamException.EncryptionTypeMismatch( - "Encryption type mismatch for stream ${trailer.streamId}. Expected ${descriptor.streamInfo.encryptionType}, got $encryptionType", - ), - ) - } - - val totalLength = descriptor.streamInfo.totalSize - if (totalLength != null) { - if (descriptor.readLength != totalLength) { - descriptor.channel.close(StreamException.IncompleteException()) - return - } - } - - val reason = trailer.reason - - if (!reason.isNullOrEmpty()) { - // A non-empty reason string indicates an error - val exception = StreamException.AbnormalEndException(reason) - descriptor.channel.close(exception) - return - } - - // Close successfully. - descriptor.channel.close() - } - - private fun closeStream(id: String) { - synchronized(openStreams) { - val descriptor = openStreams[id] - if (descriptor == null) { - LKLog.d { "Attempted to close stream $id, but no descriptor was found." } - return - } - - descriptor.channel.close() - val openMillis = SystemClock.elapsedRealtime() - descriptor.openTime - LKLog.d { "Closed stream $id, (open for ${openMillis}ms" } - - openStreams.remove(id) - } + dataStreams.handleIncoming( + LivekitModels.DataPacket.newBuilder() + .setStreamTrailer(trailer) + .build(), + ) } /** * @suppress */ override fun clearOpenStreams() { - synchronized(openStreams) { - // Create a copy since closing the channel will also remove from openStreams, - // causing a ConcurrentModificationException - val descriptors = openStreams.values.toList() - for (descriptor in descriptors) { - descriptor.channel.close(StreamException.TerminatedException()) - } - openStreams.clear() - } - } - - private fun getHandlerForInfo(info: StreamInfo): AnyStreamHandler { - return when (info) { - is ByteStreamInfo -> { - val handler = byteStreamHandlers[info.topic] - { channel, identity -> - if (handler == null) { - LKLog.w { "Received byte stream for topic \"${info.topic}\", but no handler was found. Ignoring." } - } else { - handler.invoke(ByteStreamReceiver(info, channel), identity) - } - } - } - - is TextStreamInfo -> { - val handler = textStreamHandlers[info.topic] - - { channel, identity -> - if (handler == null) { - LKLog.w { "Received text stream for topic \"${info.topic}\", but no handler was found. Ignoring." } - } else { - handler.invoke(TextStreamReceiver(info, channel), identity) - } - } - } - } - } - - private fun streamInfoFromHeader(header: DataStream.Header, encryptionType: LivekitModels.Encryption.Type): StreamInfo? { - try { - return when (header.contentHeaderCase) { - DataStream.Header.ContentHeaderCase.TEXT_HEADER -> { - TextStreamInfo(header, header.textHeader, encryptionType) - } - - DataStream.Header.ContentHeaderCase.BYTE_HEADER -> { - ByteStreamInfo(header, header.byteHeader, encryptionType) - } - - DataStream.Header.ContentHeaderCase.CONTENTHEADER_NOT_SET, - null, - -> { - LKLog.i { "received header with non-set content header. streamId: ${header.streamId}, topic: ${header.topic}" } - null - } - } - } catch (e: Exception) { - LKLog.e(e) { "Exception when processing new stream header." } - return null - } + dataStreams.abortAllStreams() } companion object { + /** + * @suppress + */ @VisibleForTesting fun createChannelForStreamReceiver() = Channel( capacity = Int.MAX_VALUE, diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/outgoing/OutgoingDataStreamManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/outgoing/OutgoingDataStreamManager.kt index 08d0e858b..19149899f 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/outgoing/OutgoingDataStreamManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/outgoing/OutgoingDataStreamManager.kt @@ -17,24 +17,15 @@ package io.livekit.android.room.datastream.outgoing import androidx.annotation.CheckResult -import com.google.protobuf.ByteString -import io.livekit.android.room.RTCEngine import io.livekit.android.room.datastream.ByteStreamInfo +import io.livekit.android.room.datastream.DataStreams import io.livekit.android.room.datastream.StreamBytesOptions import io.livekit.android.room.datastream.StreamException -import io.livekit.android.room.datastream.StreamInfo import io.livekit.android.room.datastream.StreamTextOptions import io.livekit.android.room.datastream.TextStreamInfo -import io.livekit.android.room.participant.Participant -import io.livekit.android.util.LKLog -import livekit.LivekitModels -import livekit.LivekitModels.DataPacket -import livekit.LivekitModels.DataStream +import io.livekit.android.util.rethrowIfCancellationSignal import java.io.File import java.io.InputStream -import java.util.Collections -import java.util.Date -import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject interface OutgoingDataStreamManager { @@ -72,6 +63,25 @@ interface OutgoingDataStreamManager { } } + /** + * Send a byte payload through a data stream. + * + * Prefer this over opening a stream with [streamBytes] when the whole payload is already in + * memory: because the size is known up front, it can be sent as a single packet and compressed, + * where recipients support it. + */ + @CheckResult + suspend fun sendBytes(data: ByteArray, options: StreamBytesOptions = StreamBytesOptions()): Result { + return useStreamSender(streamBytes(options)) { + val result = write(data) + if (result.isFailure) { + throw (result.exceptionOrNull() ?: Exception("Unknown error.")) + } + close() + return@useStreamSender info + } + } + /** * Send a file through a data stream. */ @@ -89,219 +99,47 @@ interface OutgoingDataStreamManager { } /** + * Adapts [OutgoingDataStreamManager] onto [DataStreams], which owns the actual implementation. + * + * The whole-payload sends are overridden rather than left to the interface's default + * open-write-close implementations, so that they reach the core's one-shot paths and can be sent as + * a single packet and/or compressed. + * * @suppress */ -class OutgoingDataStreamManagerImpl -@Inject -constructor( - val engine: RTCEngine, +class OutgoingDataStreamManagerImpl @Inject constructor( + private val dataStreams: DataStreams, ) : OutgoingDataStreamManager { - private data class Descriptor( - val info: StreamInfo, - val destinationIdentityStrings: List, - var writtenLength: Long = 0L, - val nextChunkIndex: AtomicLong = AtomicLong(0), - ) - - private val openStreams = Collections.synchronizedMap(mutableMapOf()) - - @CheckResult - private suspend fun openStream( - info: StreamInfo, - destinationIdentities: List = emptyList(), - ): Result { - if (openStreams.containsKey(info.id)) { - throw StreamException.AlreadyOpenedException() - } - - val destinationIdentityStrings = destinationIdentities.map { it.value } - val headerPacket = with(DataPacket.newBuilder()) { - addAllDestinationIdentities(destinationIdentityStrings) - kind = DataPacket.Kind.RELIABLE - streamHeader = with(DataStream.Header.newBuilder()) { - this.streamId = info.id - this.topic = info.topic - this.timestamp = info.timestampMs - this.putAllAttributes(info.attributes) - - info.totalSize?.let { - this.totalLength = it - } - - when (info) { - is ByteStreamInfo -> { - this.mimeType = info.mimeType - this.byteHeader = with(DataStream.ByteHeader.newBuilder()) { - if (info.name != null) { - this.name = info.name - } - build() - } - } - - is TextStreamInfo -> { - textHeader = with(DataStream.TextHeader.newBuilder()) { - this.operationType = info.operationType.toProto() - this.version = info.version - if (info.replyToStreamId != null) { - this.replyToStreamId = info.replyToStreamId - } - this.addAllAttachedStreamIds(info.attachedStreamIds) - this.generated = info.generated - build() - } - } - } - build() - } - build() - } - - val result = engine.sendData(headerPacket) - if (result.isFailure) { - return result - } - - val descriptor = Descriptor(info, destinationIdentityStrings) - openStreams[info.id] = descriptor - - LKLog.d { "Opened send stream ${info.id}" } - return Result.success(Unit) + override suspend fun streamText(options: StreamTextOptions): TextStreamSender { + return dataStreams.streamText(options) } - @CheckResult - private suspend fun sendChunk(streamId: String, dataChunk: ByteArray): Result { - val descriptor = openStreams[streamId] ?: throw StreamException.UnknownStreamException() - val nextChunkIndex = descriptor.nextChunkIndex.getAndIncrement() - - val chunkPacket = with(DataPacket.newBuilder()) { - addAllDestinationIdentities(descriptor.destinationIdentityStrings) - kind = DataPacket.Kind.RELIABLE - streamChunk = with(DataStream.Chunk.newBuilder()) { - this.streamId = streamId - this.content = ByteString.copyFrom(dataChunk) - this.chunkIndex = nextChunkIndex - build() - } - build() - } - - engine.waitForBufferStatusLow(DataPacket.Kind.RELIABLE) - return engine.sendData(chunkPacket) + override suspend fun streamBytes(options: StreamBytesOptions): ByteStreamSender { + return dataStreams.streamBytes(options) } - private suspend fun closeStream(streamId: String, reason: String? = null) { - val descriptor = openStreams[streamId] ?: throw StreamException.UnknownStreamException() - - val trailerPacket = with(DataPacket.newBuilder()) { - addAllDestinationIdentities(descriptor.destinationIdentityStrings) - kind = DataPacket.Kind.RELIABLE - streamTrailer = with(DataStream.Trailer.newBuilder()) { - this.streamId = streamId - if (reason != null) { - this.reason = reason - } - build() - } - build() - } - - engine.waitForBufferStatusLow(DataPacket.Kind.RELIABLE) - val result = engine.sendData(trailerPacket) - - if (result.isFailure) { - // Log close failure only for now. - LKLog.w(result.exceptionOrNull()) { "Error when closing stream!" } - } - - openStreams.remove(streamId) - LKLog.d { "Closed send stream $streamId" } + override suspend fun sendText(text: String, options: StreamTextOptions): Result { + return runCatchingStream { dataStreams.sendText(text, options) } } - override suspend fun streamText(options: StreamTextOptions): TextStreamSender { - val streamInfo = TextStreamInfo( - id = options.streamId, - topic = options.topic, - timestampMs = Date().time, - totalSize = options.totalSize, - attributes = options.attributes, - operationType = options.operationType, - version = options.version, - replyToStreamId = options.replyToStreamId, - attachedStreamIds = options.attachedStreamIds, - generated = false, - encryptionType = if (engine.e2EEManager?.isDataChannelEncryptionEnabled() ?: false) { - LivekitModels.Encryption.Type.GCM - } else { - LivekitModels.Encryption.Type.NONE - }, - ) - - val streamId = options.streamId - val result = openStream(streamInfo, options.destinationIdentities) - - if (result.isFailure) { - throw result.exceptionOrNull() ?: StreamException.TerminatedException("Unknown failure when opening the stream!") - } - - val destination = ManagerStreamDestination(streamId) - return TextStreamSender( - streamInfo, - destination, - ) + override suspend fun sendBytes(data: ByteArray, options: StreamBytesOptions): Result { + return runCatchingStream { dataStreams.sendBytes(data, options) } } - override suspend fun streamBytes(options: StreamBytesOptions): ByteStreamSender { - val streamInfo = ByteStreamInfo( - id = options.streamId, - topic = options.topic, - timestampMs = Date().time, - totalSize = options.totalSize, - attributes = options.attributes, - mimeType = options.mimeType, - name = options.name, - encryptionType = if (engine.e2EEManager?.isDataChannelEncryptionEnabled() ?: false) { - LivekitModels.Encryption.Type.GCM - } else { - LivekitModels.Encryption.Type.NONE - }, - ) - - val streamId = options.streamId - val result = openStream(streamInfo, options.destinationIdentities) - - if (result.isFailure) { - throw result.exceptionOrNull() ?: StreamException.TerminatedException("Unknown failure when opening the stream!") - } - val destination = ManagerStreamDestination(streamId) - return ByteStreamSender( - streamInfo, - destination, - ) + override suspend fun sendFile(file: File, options: StreamBytesOptions): Result { + // Options are passed through untouched: the previous implementation did not infer a name, + // MIME type or size from the file either, and doing so now would change the bytes on the + // wire for existing callers. + return runCatchingStream { dataStreams.sendFile(file.absolutePath, options) } } - private inner class ManagerStreamDestination(val streamId: String) : StreamDestination { - override val isOpen: Boolean - get() = openStreams.contains(streamId) - - override suspend fun write(data: T, chunker: DataChunker): Result { - if (!isOpen) { - return Result.failure(StreamException.TerminatedException("Stream is closed!")) - } - val chunks = chunker.invoke(data, RTCEngine.TARGET_DATA_PACKET_SIZE) - - for (chunk in chunks) { - val result = sendChunk(streamId, chunk) - if (result.isFailure) { - return result - } - } - return Result.success(Unit) - } - - override suspend fun close(reason: String?) { - closeStream(streamId, reason) + private inline fun runCatchingStream(body: () -> T): Result { + return try { + Result.success(body()) + } catch (e: Exception) { + e.rethrowIfCancellationSignal() + Result.failure(e) } } } diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle index f5c61813d..aa055b91a 100644 --- a/livekit-android-test/build.gradle +++ b/livekit-android-test/build.gradle @@ -145,9 +145,22 @@ dependencies { apply from: rootProject.file('gradle/uniffi-native-lib.gradle') tasks.withType(Test).configureEach { + // One JVM per test class. + // + // The data stream core is a native library with process-global state (its async runtime, and + // the callback vtables the bindings register into it), loaded through JNA. Robolectric gives + // test classes their own classloaders, so a second class re-initializing those bindings can + // leave the first class's -- or its own -- callbacks pointing nowhere, and every data stream + // silently stops. It reproduced as whole test classes intermittently timing out depending on + // execution order. + // + // Isolating classes into their own JVMs sidesteps it, at the cost of roughly 90s on this + // module's suite. Worth revisiting if uniffi's Kotlin bindings ever become safe to initialize + // more than once in a process. + forkEvery = 1 systemProperty "robolectric.logging.enabled", true // Data streams run through the Rust core, so unit tests need a host build of it. - applyUniffiNativeLibPath(it) + configureUniffiForUnitTests(it) } apply from: rootProject.file('gradle/gradle-mvn-push.gradle') diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt b/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt index 866d1cb90..590f78c8c 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt @@ -56,4 +56,38 @@ abstract class BaseTest { dispatchTimeoutMs = dispatchTimeoutMs, testBody = testBody ) + + /** + * Waits for [condition], pumping the test scheduler while also yielding real time. + * + * Needed wherever work crosses onto a real thread -- notably anything going through the Rust + * data stream core. See io.livekit.android.test.util.awaitCondition. + */ + fun awaitCondition( + timeoutMs: Long = 5_000L, + message: String = "Condition was not met", + condition: () -> Boolean, + ) = io.livekit.android.test.util.awaitCondition( + scheduler = coroutineRule.dispatcher.scheduler, + timeoutMs = timeoutMs, + message = message, + condition = condition, + ) + + /** + * Waits until [snapshot] stops changing, pumping the test scheduler while also yielding real + * time. See io.livekit.android.test.util.awaitStable. + */ + fun awaitStable( + quietMs: Long = 100L, + minWaitMs: Long = 200L, + timeoutMs: Long = 5_000L, + snapshot: () -> Any?, + ) = io.livekit.android.test.util.awaitStable( + scheduler = coroutineRule.dispatcher.scheduler, + quietMs = quietMs, + minWaitMs = minWaitMs, + timeoutMs = timeoutMs, + snapshot = snapshot, + ) } diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockDataChannel.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockDataChannel.kt index 8dfa7e50d..b0a7fd2a4 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockDataChannel.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockDataChannel.kt @@ -17,14 +17,24 @@ package io.livekit.android.test.mock import livekit.org.webrtc.DataChannel +import java.util.concurrent.CopyOnWriteArrayList class MockDataChannel(private val label: String?) : DataChannel(1L) { var observer: Observer? = null - var sentBuffers = mutableListOf() + + /** + * Buffers passed to [send], in order. + * + * Copy-on-write because sends no longer all originate from the test thread: data streams are + * driven by the Rust core, which emits packets from its own runtime threads, while assertions + * iterate this list from the test thread. A synchronized list would not be enough -- it still + * throws ConcurrentModificationException when traversed during a concurrent add. + */ + var sentBuffers: MutableList = CopyOnWriteArrayList() /** Snapshot of the bytes visible at send time, captured via the Buffer's current position/limit. */ - var sentPayloads = mutableListOf() + var sentPayloads: MutableList = CopyOnWriteArrayList() var sendResult = true /** diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/util/AwaitCondition.kt b/livekit-android-test/src/main/java/io/livekit/android/test/util/AwaitCondition.kt new file mode 100644 index 000000000..1e81bc261 --- /dev/null +++ b/livekit-android-test/src/main/java/io/livekit/android/test/util/AwaitCondition.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.test.util + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineScheduler + +/** + * Waiting primitives for work that spans both the test dispatcher and real threads. + * + * Data streams are handled by the Rust core, which resumes calls on threads of its own. That leaves + * tests needing to make progress on two fronts at once, and neither of the usual tools does it: + * + * - advancing the test scheduler ([TestCoroutineScheduler.advanceUntilIdle] and friends) does + * nothing for work sitting on a real thread, and + * - waiting in real time (`withContext(Dispatchers.Default) { delay(..) }`) parks the test body, + * and while it is parked nobody is running the test scheduler -- so a coroutine on the test + * dispatcher waiting to resume from the core never does, and the wait deadlocks. + * + * These helpers interleave the two: pump the scheduler, check, then hand real time to the other + * threads with a blocking sleep on the test thread, and repeat. The sleep is deliberate; `delay` + * here would consume virtual time and never yield the CPU. + */ + +private const val POLL_MS = 2L + +/** + * Pumps [scheduler] and waits, in real time, for [condition] to hold. + * + * @throws AssertionError if [condition] has not become true within [timeoutMs]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun awaitCondition( + scheduler: TestCoroutineScheduler, + timeoutMs: Long = 5_000L, + message: String = "Condition was not met", + condition: () -> Boolean, +) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + scheduler.runCurrent() + if (condition()) { + return + } + Thread.sleep(POLL_MS) + } + scheduler.runCurrent() + if (!condition()) { + throw AssertionError("$message (waited ${timeoutMs}ms)") + } +} + +/** + * Pumps [scheduler] and waits until [snapshot] stops changing for [quietMs]. + * + * For when a test needs everything in flight to finish but has no single condition to wait on -- in + * particular before asserting something was *not* produced, where a fixed wait risks passing only + * because the check ran too early. + * + * [minWaitMs] matters as much as the quiet period: before the work has started, [snapshot] is + * trivially unchanging, and waiting for stability alone would return immediately having observed + * nothing. + * + * @param snapshot typically a count of observed side effects, e.g. packets sent so far. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun awaitStable( + scheduler: TestCoroutineScheduler, + quietMs: Long = 100L, + minWaitMs: Long = 200L, + timeoutMs: Long = 5_000L, + snapshot: () -> Any?, +) { + val start = System.currentTimeMillis() + val deadline = start + timeoutMs + var last: Any? = Unit + var stableSince = start + while (System.currentTimeMillis() < deadline) { + scheduler.runCurrent() + val now = System.currentTimeMillis() + val current = snapshot() + if (current != last) { + last = current + stableSince = now + } + if (now - stableSince >= quietMs && now - start >= minWaitMs) { + return + } + Thread.sleep(POLL_MS) + } + scheduler.runCurrent() +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt index 9a71cb630..5a2bc6005 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/RoomTest.kt @@ -29,6 +29,7 @@ import io.livekit.android.events.EventListenable import io.livekit.android.events.ParticipantEvent import io.livekit.android.events.RoomEvent import io.livekit.android.memory.CloseableManager +import io.livekit.android.room.datastream.DataStreams import io.livekit.android.room.datastream.incoming.IncomingDataStreamManagerImpl import io.livekit.android.room.network.NetworkCallbackManagerImpl import io.livekit.android.room.participant.LocalParticipant @@ -109,11 +110,16 @@ class RoomTest { } lateinit var room: Room + lateinit var dataStreams: DataStreams @Before fun setup() { context = ApplicationProvider.getApplicationContext() networkCallbackRegistry = MockNetworkCallbackRegistry() + dataStreams = DataStreams( + engine = rtcEngine, + closeableManager = CloseableManager(), + ) room = Room( context = context, engine = rtcEngine, @@ -135,7 +141,8 @@ class RoomTest { regionUrlProviderFactory = regionUrlProviderFactory, connectionWarmer = MockConnectionWarmer(), audioRecordPrewarmer = NoAudioRecordPrewarmer(), - incomingDataStreamManager = IncomingDataStreamManagerImpl(), + incomingDataStreamManager = IncomingDataStreamManagerImpl(dataStreams), + dataStreams = dataStreams, rpcClientManager = io.livekit.android.room.rpc.RpcClientManager( engine = rtcEngine, outgoingDataStreamManager = Mockito.mock(io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager::class.java), diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.kt index 8f88f898d..4145ac3cb 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.kt @@ -62,7 +62,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(0, ByteArray(1) { 1 }).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertEquals(1, collectedData.size) assertEquals(1, collectedData[0][0].toInt()) } @@ -102,7 +102,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(0, "hello".toByteArray()).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertEquals(1, collectedData.size) assertEquals("hello", collectedData[0]) } @@ -148,7 +148,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(2, "!".toByteArray()).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertEquals(3, collectedData.size) assertEquals("hello", collectedData[0]) assertEquals("world", collectedData[1]) @@ -190,7 +190,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(2, "!".toByteArray()).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertEquals(3, collectedData.size) assertEquals("hello", collectedData[0]) assertEquals("world", collectedData[1]) @@ -232,7 +232,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { } subDataChannel.observer?.onMessage(abnormalEnd.wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertTrue(threwOnce) } @@ -269,7 +269,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(0, ByteArray(2) { 1 }).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertTrue(threwOnce) } @@ -306,7 +306,7 @@ class RoomIncomingDataStreamMockE2ETest : MockE2ETest() { subDataChannel.observer?.onMessage(createStreamChunk(0, ByteArray(1) { 1 }).wrap()) subDataChannel.observer?.onMessage(createStreamTrailer().wrap()) - assertTrue(finished) + awaitCondition(message = "Stream handler did not finish") { finished } assertTrue(threwOnce) } diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomOutgoingDataStreamMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomOutgoingDataStreamMockE2ETest.kt index 0e6e39229..2b4a1cf33 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomOutgoingDataStreamMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomOutgoingDataStreamMockE2ETest.kt @@ -72,9 +72,13 @@ class RoomOutgoingDataStreamMockE2ETest : MockE2ETest() { sender.close() assertFalse(sender.isOpen) + // Packets reach the wire asynchronously now: the core hands them to us on its own thread + // and we queue them for the reliable channel, so a completed write does not mean sent. + awaitCondition(message = "Expected header, chunk and trailer to be sent") { + pubDataChannel.sentBuffers.size >= 3 + } val buffers = pubDataChannel.sentBuffers - println(buffers) assertEquals(3, buffers.size) val headerPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffers[0].data)) @@ -127,9 +131,13 @@ class RoomOutgoingDataStreamMockE2ETest : MockE2ETest() { assertFalse(sender.isOpen) + // Packets reach the wire asynchronously now: the core hands them to us on its own thread + // and we queue them for the reliable channel, so a completed write does not mean sent. + awaitCondition(message = "Expected header, chunk and trailer to be sent") { + pubDataChannel.sentBuffers.size >= 3 + } val buffers = pubDataChannel.sentBuffers - println(buffers) assertEquals(3, buffers.size) val headerPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffers[0].data)) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt index 7df4841e7..df7ab7353 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt @@ -88,7 +88,14 @@ class RpcV2MockE2ETest : MockE2ETest() { * Returns the request attributes and the assembled UTF-8 payload, or null if no such * stream is present. */ - private fun collectOutgoingV2Stream(topic: String): Pair, String>? { + private suspend fun collectOutgoingV2Stream(topic: String): Pair, String>? { + // Sending a stream now crosses into the Rust core and back on its own threads, so the + // packets are not on the mock channel the instant the calling coroutine yields. Waiting for + // them, then letting the test dispatcher run, also gets the sender past its send and into + // whatever it does next -- for an RPC caller, waiting for the ack the test is about to feed + // it. Without that the ack arrives before anyone is listening. + awaitStable { pubDataChannel.sentBuffers.size } + coroutineRule.dispatcher.scheduler.runCurrent() val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } val header = packets.firstOrNull { it.hasStreamHeader() && it.streamHeader.topic == topic } ?: return null @@ -107,6 +114,52 @@ class RpcV2MockE2ETest : MockE2ETest() { /** * Simulate an inbound v2 RPC request stream landing on the subscriber data channel. */ + /** + * Waits for in-flight data stream work to finish. + * + * Replaces advancing the test scheduler: data streams are now handled by the Rust core on its + * own threads, which no amount of virtual time will drive. Waiting for the sent-packet count to + * go quiet keeps the tests that assert a packet was *not* produced honest, which a fixed sleep + * would not. + */ + /** + * Waits for the caller's outgoing RPC request stream header to reach the mock channel. + * + * Publishing a request now goes through the Rust core on its own threads, so the header is not + * on the channel the moment the calling coroutine yields. + */ + private fun awaitRequestStreamHeader(): DataPacket { + fun headers() = pubDataChannel.sentBuffers.map { parsePacket(it) } + .filter { it.hasStreamHeader() && it.streamHeader.topic == RPC_REQUEST_DATA_STREAM_TOPIC } + + awaitCondition(message = "No outgoing RPC request stream was published") { + headers().isNotEmpty() + } + return headers().first() + } + + /** + * Waits for [job] to finish. + * + * Data streams cross into the Rust core and back on its own threads, so an RPC no longer + * settles just by advancing the test scheduler. Waiting on the job itself keeps this + * deterministic -- waiting a fixed period, or for output to go quiet, races the core's + * start-up on a cold run. + */ + private fun awaitJob(job: kotlinx.coroutines.Deferred<*>) { + awaitCondition(message = "RPC did not complete") { job.isCompleted } + coroutineRule.dispatcher.scheduler.advanceUntilIdle() + } + + /** + * Waits for a packet matching [predicate] to reach the mock publisher channel. + */ + private fun awaitPacket(message: String, predicate: (DataPacket) -> Boolean) { + awaitCondition(message = message) { + pubDataChannel.sentBuffers.map { parsePacket(it) }.any(predicate) + } + } + private fun simulateIncomingRequestStream( requestId: String, method: String, @@ -249,7 +302,7 @@ class RpcV2MockE2ETest : MockE2ETest() { val requestId = attrs[RpcRequestAttrs.REQUEST_ID]!! subDataChannel.simulateBufferReceived(createAck(requestId)) simulateIncomingResponseStream(requestId, "bye") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals("bye", rpcJob.await()) } @@ -277,7 +330,7 @@ class RpcV2MockE2ETest : MockE2ETest() { subDataChannel.simulateBufferReceived(createAck(requestId)) simulateIncomingResponseStream(requestId, largePayload) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals(largePayload, rpcJob.await()) } @@ -289,7 +342,7 @@ class RpcV2MockE2ETest : MockE2ETest() { room.localParticipant.registerRpcMethod("hello") { "pong" } simulateIncomingRequestStream("req-1", "hello", "ping") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No ack for req-1") { it.hasRpcAck() && it.rpcAck.requestId == "req-1" } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } @@ -317,7 +370,7 @@ class RpcV2MockE2ETest : MockE2ETest() { val largeResponse = "X".repeat(20_000) room.localParticipant.registerRpcMethod("echo") { largeResponse } simulateIncomingRequestStream("req-large", "echo", "ping") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No ack for req-large") { it.hasRpcAck() && it.rpcAck.requestId == "req-large" } val outgoing = collectOutgoingV2Stream(RPC_RESPONSE_DATA_STREAM_TOPIC) assertNotNull("expected a v2 response stream for a 20k response", outgoing) @@ -336,7 +389,9 @@ class RpcV2MockE2ETest : MockE2ETest() { simulateRemoteJoinAsV2() simulateIncomingRequestStream("req-x", "unknown-method", "") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No error response for req-x") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-x" && it.rpcResponse.hasError() + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } // Ack first @@ -362,7 +417,9 @@ class RpcV2MockE2ETest : MockE2ETest() { throw RuntimeException("oops") } simulateIncomingRequestStream("req-app-err", "boom", "") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No error response for req-app-err") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-app-err" && it.rpcResponse.hasError() + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } val errorResponse = packets.firstOrNull { @@ -383,7 +440,9 @@ class RpcV2MockE2ETest : MockE2ETest() { val custom = RpcError(101, "custom error") room.localParticipant.registerRpcMethod("err") { throw custom } simulateIncomingRequestStream("req-custom", "err", "") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No error response for req-custom") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-custom" && it.rpcResponse.hasError() + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } val errorResponse = packets.firstOrNull { @@ -447,7 +506,7 @@ class RpcV2MockE2ETest : MockE2ETest() { val requestId = outgoing.first[RpcRequestAttrs.REQUEST_ID]!! subDataChannel.simulateBufferReceived(createAck(requestId)) subDataChannel.simulateBufferReceived(createV1Response(requestId, error = customError)) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals(customError, rpcJob.await()) } @@ -473,7 +532,7 @@ class RpcV2MockE2ETest : MockE2ETest() { coroutineRule.dispatcher.scheduler.runCurrent() simulateMessageFromServer(TestData.PARTICIPANT_DISCONNECT) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals( RpcError.BuiltinRpcError.RECIPIENT_DISCONNECTED.create(), @@ -498,17 +557,13 @@ class RpcV2MockE2ETest : MockE2ETest() { payload = "p1", ) } - coroutineRule.dispatcher.scheduler.runCurrent() - - val firstHeader = pubDataChannel.sentBuffers - .map { parsePacket(it) } - .first { it.hasStreamHeader() && it.streamHeader.topic == RPC_REQUEST_DATA_STREAM_TOPIC } + val firstHeader = awaitRequestStreamHeader() val requestId1 = firstHeader.streamHeader.attributesMap[RpcRequestAttrs.REQUEST_ID]!! // No advanceTimeBy / no scheduler tick between publish and reply. subDataChannel.simulateBufferReceived(createAck(requestId1)) simulateIncomingResponseStream(requestId1, "r1", streamId = "resp-1") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob1) assertEquals("r1", rpcJob1.await()) // A second back-to-back call works — proves no orphaned pending entries. @@ -520,15 +575,12 @@ class RpcV2MockE2ETest : MockE2ETest() { payload = "p2", ) } - coroutineRule.dispatcher.scheduler.runCurrent() - val secondHeader = pubDataChannel.sentBuffers - .map { parsePacket(it) } - .first { it.hasStreamHeader() && it.streamHeader.topic == RPC_REQUEST_DATA_STREAM_TOPIC } + val secondHeader = awaitRequestStreamHeader() val requestId2 = secondHeader.streamHeader.attributesMap[RpcRequestAttrs.REQUEST_ID]!! assertTrue("second call must get a fresh request id", requestId2 != requestId1) subDataChannel.simulateBufferReceived(createAck(requestId2)) simulateIncomingResponseStream(requestId2, "r2", streamId = "resp-2") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob2) assertEquals("r2", rpcJob2.await()) } @@ -566,7 +618,7 @@ class RpcV2MockE2ETest : MockE2ETest() { payload = "p", ) } - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals("during", rpcJob.await()) } @@ -605,7 +657,9 @@ class RpcV2MockE2ETest : MockE2ETest() { // Now deliver a late ack + response. Must not throw or double-resolve. subDataChannel.simulateBufferReceived(createAck(requestId)) simulateIncomingResponseStream(requestId, "too-late", streamId = "resp-late") - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + // Already completed by the timeout; let the late delivery be processed, then re-check. + awaitJob(rpcJob) + awaitStable { pubDataChannel.sentBuffers.size } // The original completion stays at CONNECTION_TIMEOUT; the rpcJob doesn't change. assertEquals(RpcError.BuiltinRpcError.CONNECTION_TIMEOUT.create(), rpcJob.await()) @@ -656,7 +710,12 @@ class RpcV2MockE2ETest : MockE2ETest() { ) } } - coroutineRule.dispatcher.scheduler.runCurrent() + awaitCondition(message = "Expected five outgoing RPC request streams") { + pubDataChannel.sentBuffers.map { parsePacket(it) }.count { + it.hasStreamHeader() && it.streamHeader.topic == RPC_REQUEST_DATA_STREAM_TOPIC + } == 5 + } + awaitStable { pubDataChannel.sentBuffers.size } // Five separate v2 request streams should have been produced. val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } @@ -685,6 +744,9 @@ class RpcV2MockE2ETest : MockE2ETest() { subDataChannel.simulateBufferReceived(createAck(requestId)) simulateIncomingResponseStream(requestId, responsePayload) } + awaitCondition(message = "Not all concurrent RPCs completed") { + deferred.all { it.isCompleted } + } coroutineRule.dispatcher.scheduler.advanceUntilIdle() // Each call resolves with its own response, not cross-talked. @@ -717,7 +779,7 @@ class RpcV2MockE2ETest : MockE2ETest() { val requestId = rpcRequest!!.rpcRequest.id subDataChannel.simulateBufferReceived(createAck(requestId)) subDataChannel.simulateBufferReceived(createV1Response(requestId, payload = "bye")) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals("bye", rpcJob.await()) } @@ -742,7 +804,9 @@ class RpcV2MockE2ETest : MockE2ETest() { build() } subDataChannel.simulateBufferReceived(v1Request.toDataChannelBuffer()) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No response for req-v1") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-v1" + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } // Ack @@ -811,7 +875,9 @@ class RpcV2MockE2ETest : MockE2ETest() { throw RuntimeException("oops") } subDataChannel.simulateBufferReceived(v1RequestPacket("req-v1-app", "boom", "")) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No error response for req-v1-app") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-v1-app" && it.rpcResponse.hasError() + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } val errorResponse = packets.firstOrNull { @@ -836,7 +902,9 @@ class RpcV2MockE2ETest : MockE2ETest() { val custom = RpcError(101, "custom v1 err") room.localParticipant.registerRpcMethod("err") { throw custom } subDataChannel.simulateBufferReceived(v1RequestPacket("req-v1-cust", "err", "")) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitPacket("No error response for req-v1-cust") { + it.hasRpcResponse() && it.rpcResponse.requestId == "req-v1-cust" && it.rpcResponse.hasError() + } val packets = pubDataChannel.sentBuffers.map { parsePacket(it) } val errorResponse = packets.firstOrNull { @@ -905,7 +973,7 @@ class RpcV2MockE2ETest : MockE2ETest() { val rpcRequest = packets.first { it.hasRpcRequest() }.rpcRequest subDataChannel.simulateBufferReceived(createAck(rpcRequest.id)) subDataChannel.simulateBufferReceived(createV1Response(rpcRequest.id, error = customError)) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals(customError, rpcJob.await()) } @@ -932,7 +1000,7 @@ class RpcV2MockE2ETest : MockE2ETest() { coroutineRule.dispatcher.scheduler.runCurrent() simulateMessageFromServer(TestData.PARTICIPANT_DISCONNECT) - coroutineRule.dispatcher.scheduler.advanceUntilIdle() + awaitJob(rpcJob) assertEquals( RpcError.BuiltinRpcError.RECIPIENT_DISCONNECTED.create(), From ada893225b1ea958a7266ceffc19e1ff49dbcab2 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 18:45:42 -0400 Subject: [PATCH 07/14] feat(datastream): advertise data streams v2, and cover the FFI glue with tests Completes the cutover by telling peers what we can now do, and adds the tests for the parts of it that only fail in interop. Advertisement, deferred until the receive path could handle what it invites: - ConnectOptions.clientProtocol now defaults to DATA_STREAM_V2. - The connect URL carries a `capabilities` param, and ClientInfo the matching repeated field, both sourced from one ADVERTISED_CLIENT_CAPABILITIES list. Compression is advertised unconditionally, since it is done by the core rather than a platform codec. Tests (54 new, 380 total, all passing): - DataStreamsV2SendTest walks the framing matrix from the spec's "Minimum required test cases" -- pre-v2 room, all-v2 room, v2-without-the-capability, mixed room, targeted subsets, compress opt-out, incremental writers -- and asserts on the packets that reach the engine. These are really tests of our registry wiring: the core picks the framing from what we report about each recipient, so getting that wrong produces packets a peer cannot read while everything still looks fine locally. - DataStreamsV2ReceiveTest covers the framings only v2 produces (inline, inline compressed, a deflate stream spread across chunks), plus topic routing, sender identity, abort-on-disconnect, the payload cap, and a multi-byte text round trip through the byte channel the public reader uses. - DataStreamsConversionTest pins the translation layer, in particular the error mapping, which is lossy by design -- several core failures fold onto one pre-existing public exception, and a wrong fold is invisible. - ConnectionParamsTest asserts the advertisement on the actual connect URL. The Swift SDK shipped this wiring broken on one of its two connect paths, so it is asserted on the wire rather than on the values feeding it. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/io/livekit/android/ConnectOptions.kt | 2 +- .../io/livekit/android/room/SignalClient.kt | 7 + .../io/livekit/android/stats/ClientInfo.kt | 4 +- .../android/room/ConnectionParamsTest.kt | 104 ++++ .../datastream/DataStreamsConversionTest.kt | 248 +++++++++ .../datastream/DataStreamsV2ReceiveTest.kt | 433 +++++++++++++++ .../room/datastream/DataStreamsV2SendTest.kt | 504 ++++++++++++++++++ 7 files changed, 1300 insertions(+), 2 deletions(-) create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/ConnectionParamsTest.kt create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2ReceiveTest.kt create mode 100644 livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2SendTest.kt diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt index b7fe11b86..8051d2188 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt @@ -60,7 +60,7 @@ data class ConnectOptions( * for peer-to-peer feature negotiation (RPC v2, etc.). Defaults to the latest * version supported by this SDK build. */ - val clientProtocol: ClientProtocolVersion = ClientProtocolVersion.DATA_STREAM_RPC, + val clientProtocol: ClientProtocolVersion = ClientProtocolVersion.DATA_STREAM_V2, ) { internal var reconnect: Boolean = false internal var participantSid: String? = null diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt index 1f50833ca..6090ebc57 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt @@ -242,6 +242,12 @@ constructor( addParam(CONNECT_QUERY_NETWORK_TYPE, networkInfo.getNetworkType().protoName) addParam(CONNECT_QUERY_CLIENT_PROTOCOL, options.clientProtocol.value.toString()) + // Capabilities go out as a comma separated list of protobuf enum names, which is the form + // the server parses on this path. Peers read them back off ParticipantInfo. + if (clientInfo.capabilitiesCount > 0) { + addParam(CONNECT_QUERY_CAPABILITIES, clientInfo.capabilitiesList.joinToString(",") { it.name }) + } + return queryBuilder.toString() } @@ -1004,6 +1010,7 @@ constructor( const val CONNECT_QUERY_NETWORK_TYPE = "network" const val CONNECT_QUERY_PARTICIPANT_SID = "sid" const val CONNECT_QUERY_CLIENT_PROTOCOL = "client_protocol" + const val CONNECT_QUERY_CAPABILITIES = "capabilities" const val SD_TYPE_ANSWER = "answer" const val SD_TYPE_OFFER = "offer" diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/stats/ClientInfo.kt b/livekit-android-sdk/src/main/java/io/livekit/android/stats/ClientInfo.kt index 19e1b941f..824b2c8a2 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/stats/ClientInfo.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/stats/ClientInfo.kt @@ -18,12 +18,13 @@ package io.livekit.android.stats import android.os.Build import io.livekit.android.BuildConfig +import io.livekit.android.room.ADVERTISED_CLIENT_CAPABILITIES import io.livekit.android.room.ClientProtocolVersion import io.livekit.android.room.SignalClient import livekit.LivekitModels internal fun getClientInfo( - clientProtocol: ClientProtocolVersion = ClientProtocolVersion.DATA_STREAM_RPC, + clientProtocol: ClientProtocolVersion = ClientProtocolVersion.DATA_STREAM_V2, ) = with(LivekitModels.ClientInfo.newBuilder()) { sdk = LivekitModels.ClientInfo.SDK.ANDROID version = BuildConfig.VERSION_NAME @@ -34,5 +35,6 @@ internal fun getClientInfo( val model = Build.MODEL ?: "" deviceModel = ("$vendor $model").trim() this.clientProtocol = clientProtocol.value + addAllCapabilities(ADVERTISED_CLIENT_CAPABILITIES.map { it.toProto() }) build() } diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/ConnectionParamsTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/ConnectionParamsTest.kt new file mode 100644 index 000000000..bb85b7842 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/ConnectionParamsTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room + +import io.livekit.android.ConnectOptions +import io.livekit.android.stats.getClientInfo +import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.mock.TestData +import kotlinx.coroutines.launch +import kotlinx.coroutines.ExperimentalCoroutinesApi +import livekit.LivekitModels +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * What this SDK tells the server, and through it every peer, about the data stream features it + * supports. + * + * Getting this wrong is invisible locally and only shows up as peers never using v2 framings, so + * it is asserted on the actual connect URL rather than on the values feeding it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectionParamsTest : MockE2ETest() { + + @Test + fun advertisesDataStreamV2ClientProtocol() = runTest { + connect() + + val url = wsFactory.request.url.toString() + + assertTrue( + "connect URL should advertise client_protocol=2, was: $url", + url.contains("client_protocol=${ClientProtocolVersion.DATA_STREAM_V2.value}"), + ) + } + + @Test + fun advertisesCompressionCapability() = runTest { + connect() + + val url = wsFactory.request.url.toString() + val capabilities = wsFactory.request.url.queryParameter("capabilities") + + assertTrue( + "connect URL should carry a capabilities param, was: $url", + capabilities != null, + ) + // The server parses these as protobuf enum names, so the exact spelling is wire contract. + assertTrue( + "capabilities should advertise deflate-raw support, was: $capabilities", + capabilities!!.split(",").contains("CAP_COMPRESSION_DEFLATE_RAW"), + ) + } + + /** + * Compression is done by the Rust core rather than a platform codec, so there is no device or + * API level on which we support v2 but cannot decompress. + */ + @Test + fun clientInfoCarriesTheSameCapabilities() { + val clientInfo = getClientInfo(ClientProtocolVersion.DATA_STREAM_V2) + + assertEquals(ClientProtocolVersion.DATA_STREAM_V2.value, clientInfo.clientProtocol) + assertEquals( + listOf(LivekitModels.ClientInfo.Capability.CAP_COMPRESSION_DEFLATE_RAW), + clientInfo.capabilitiesList, + ) + } + + @Test + fun clientProtocolIsOverridableByConnectOptions() = runTest { + val job = coroutineRule.scope.launch { + room.connect( + url = TestData.EXAMPLE_URL, + token = "token", + options = ConnectOptions(clientProtocol = ClientProtocolVersion.DATA_STREAM_RPC), + ) + } + prepareSignal() + job.join() + + val url = wsFactory.request.url.toString() + + assertTrue( + "explicit clientProtocol should be honored, was: $url", + url.contains("client_protocol=${ClientProtocolVersion.DATA_STREAM_RPC.value}"), + ) + } +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt new file mode 100644 index 000000000..06aa9d383 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt @@ -0,0 +1,248 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datastream + +import io.livekit.android.room.ClientCapability +import io.livekit.android.room.participant.Participant +import livekit.LivekitModels +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import io.livekit.uniffi.ByteStreamInfo as FfiByteStreamInfo +import io.livekit.uniffi.ClientCapability as FfiClientCapability +import io.livekit.uniffi.DataStreamException as FfiDataStreamException +import io.livekit.uniffi.EncryptionType as FfiEncryptionType +import io.livekit.uniffi.OperationType as FfiOperationType +import io.livekit.uniffi.TextStreamInfo as FfiTextStreamInfo + +/** + * The pure translation layer between the core's types and this SDK's. + * + * Worth testing directly because most of it is only reachable through a live stream otherwise, and + * because the error mapping is lossy by design: the public [StreamException] hierarchy predates the + * core and several new failure modes fold onto one existing case. A wrong fold turns a diagnosable + * failure into a misleading one, and nothing else would catch it. + */ +class DataStreamsConversionTest { + + private val encryption = LivekitModels.Encryption.Type.GCM + + // region Info records + + @Test + fun textStreamInfoConversion() { + val info = FfiTextStreamInfo( + id = "id", + topic = "topic", + timestampMs = 1_700_000_000_000, + totalLength = 42UL, + attributes = mapOf("a" to "b"), + mimeType = "text/plain", + operationType = FfiOperationType.UPDATE, + version = 3, + replyToStreamId = "reply-to", + attachedStreamIds = listOf("att"), + generated = true, + encryptionType = FfiEncryptionType.NONE, + ).toSdk(encryption) + + assertEquals("id", info.id) + assertEquals("topic", info.topic) + assertEquals(1_700_000_000_000, info.timestampMs) + assertEquals(42L, info.totalSize) + assertEquals(mapOf("a" to "b"), info.attributes) + assertEquals(TextStreamInfo.OperationType.UPDATE, info.operationType) + assertEquals(3, info.version) + assertEquals("reply-to", info.replyToStreamId) + assertEquals(listOf("att"), info.attachedStreamIds) + assertTrue(info.generated) + // The core reports NONE on every stream; the room's real value is stamped on instead. + assertEquals(encryption, info.encryptionType) + } + + @Test + fun byteStreamInfoConversion() { + val info = FfiByteStreamInfo( + id = "id", + topic = "topic", + timestampMs = 5, + totalLength = null, + attributes = emptyMap(), + mimeType = "image/png", + name = "pic.png", + encryptionType = FfiEncryptionType.NONE, + ).toSdk(encryption) + + assertEquals("pic.png", info.name) + assertEquals("image/png", info.mimeType) + assertNull("an unknown-length stream must not report a size", info.totalSize) + assertEquals(encryption, info.encryptionType) + } + + // endregion + + // region Enums + + @Test + fun operationTypeRoundTrips() { + for (value in TextStreamInfo.OperationType.entries) { + assertEquals(value, value.toFfi().toSdk()) + } + } + + @Test + fun clientCapabilityMapsToFfi() { + assertEquals(FfiClientCapability.PACKET_TRAILER, ClientCapability.PACKET_TRAILER.toFfi()) + assertEquals( + FfiClientCapability.COMPRESSION_DEFLATE_RAW, + ClientCapability.COMPRESSION_DEFLATE_RAW.toFfi(), + ) + } + + // endregion + + // region Options + + @Test + fun textOptionsConversion() { + val ffi = StreamTextOptions( + topic = "topic", + attributes = mapOf("k" to "v"), + streamId = "sid", + destinationIdentities = listOf(Participant.Identity("alice"), Participant.Identity("bob")), + operationType = TextStreamInfo.OperationType.REACTION, + version = 2, + attachedStreamIds = listOf("a"), + replyToStreamId = "r", + compress = false, + ).toFfi() + + assertEquals("topic", ffi.topic) + assertEquals(mapOf("k" to "v"), ffi.attributes) + assertEquals("sid", ffi.id) + assertEquals(listOf("alice", "bob"), ffi.destinationIdentities) + assertEquals(FfiOperationType.REACTION, ffi.operationType) + assertEquals(2, ffi.version) + assertEquals(listOf("a"), ffi.attachedStreamIds) + assertEquals("r", ffi.replyToStreamId) + assertEquals(false, ffi.compress) + } + + @Test + fun byteOptionsConversion() { + val ffi = StreamBytesOptions( + topic = "topic", + streamId = "sid", + destinationIdentities = listOf(Participant.Identity("alice")), + mimeType = "application/pdf", + name = "doc.pdf", + totalSize = 99, + ).toFfi() + + assertEquals("application/pdf", ffi.mimeType) + assertEquals("doc.pdf", ffi.name) + assertEquals(99UL, ffi.totalLength) + assertEquals(listOf("alice"), ffi.destinationIdentities) + assertEquals(true, ffi.compress) + } + + /** + * An incremental text stream is opened as unknown-length, so a declared total has nowhere to go. + * Asserted so the drop stays deliberate rather than becoming a silent surprise. + */ + @Test + fun textOptionsTotalSizeIsNotCarriedOver() { + val options = StreamTextOptions(topic = "topic", totalSize = 1234) + + // StreamTextOptions has no total length on the FFI side at all; nothing to assert but that + // conversion succeeds and the caller's value is not smuggled elsewhere. + val ffi = options.toFfi() + assertEquals("topic", ffi.topic) + assertEquals(1234L, options.totalSize) + } + + // endregion + + // region Error mapping + + @Test + fun abnormalEndCarriesItsMessage() { + val mapped = FfiDataStreamException.AbnormalEnd("sender gave up").toStreamException() + + assertTrue(mapped is StreamException.AbnormalEndException) + assertTrue( + "the core's reason should survive, was: ${mapped.message}", + mapped.message?.contains("sender gave up") == true, + ) + } + + @Test + fun ioErrorsReadAsAbnormalEnd() { + assertTrue( + FfiDataStreamException.Io("disk went away").toStreamException() + is StreamException.AbnormalEndException, + ) + } + + @Test + fun decodeFailures() { + assertTrue(FfiDataStreamException.Utf8("bad").toStreamException() is StreamException.DecodeFailedException) + assertTrue(FfiDataStreamException.Decompression().toStreamException() is StreamException.DecodeFailedException) + } + + /** + * Three distinct size failures fold onto one public case. Left as-is rather than widening + * public API, but pinned here so the fold is a decision rather than an accident. + */ + @Test + fun sizeFailuresFoldOntoLengthExceeded() { + assertTrue(FfiDataStreamException.LengthExceeded().toStreamException() is StreamException.LengthExceededException) + assertTrue(FfiDataStreamException.HeaderTooLarge().toStreamException() is StreamException.LengthExceededException) + assertTrue(FfiDataStreamException.PayloadTooLarge().toStreamException() is StreamException.LengthExceededException) + } + + @Test + fun incompleteAndEncryptionMismatch() { + assertTrue(FfiDataStreamException.Incomplete().toStreamException() is StreamException.IncompleteException) + assertTrue( + FfiDataStreamException.EncryptionTypeMismatch().toStreamException() + is StreamException.EncryptionTypeMismatch, + ) + } + + @Test + fun remainingFailuresReadAsTerminated() { + val terminated = listOf( + FfiDataStreamException.AlreadyClosed(), + FfiDataStreamException.InvalidHeader(), + FfiDataStreamException.MissedChunk(), + FfiDataStreamException.SendFailed(), + FfiDataStreamException.Internal(), + FfiDataStreamException.InvalidFileName(), + ) + + for (error in terminated) { + assertTrue( + "${error::class.simpleName} should map to TerminatedException", + error.toStreamException() is StreamException.TerminatedException, + ) + } + } + + // endregion +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2ReceiveTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2ReceiveTest.kt new file mode 100644 index 000000000..a12317942 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2ReceiveTest.kt @@ -0,0 +1,433 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datastream + +import com.google.protobuf.ByteString +import io.livekit.android.memory.CloseableManager +import io.livekit.android.room.RTCEngine +import io.livekit.android.room.datastream.incoming.ByteStreamReceiver +import io.livekit.android.room.datastream.incoming.TextStreamReceiver +import io.livekit.android.room.participant.Participant +import io.livekit.android.test.BaseTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import livekit.LivekitModels.DataPacket +import livekit.LivekitModels.DataStream +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.stub +import org.robolectric.RobolectricTestRunner +import java.util.concurrent.CopyOnWriteArrayList +import java.util.zip.Deflater + +/** + * The v2 receive path through [DataStreams]. + * + * The core does the reassembly and decompression, so what is actually under test here is our glue: + * that we hand it whole packets, route the streams it opens to the right topic handler, and surface + * their content and failures through the SDK's own reader types. + * + * The legacy header/chunk/trailer cases are already covered at the Room level by + * RoomIncomingDataStreamMockE2ETest; these are the v2 framings and the wiring around them. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class DataStreamsV2ReceiveTest : BaseTest() { + + @Mock + lateinit var engine: RTCEngine + + private lateinit var dataStreams: DataStreams + + private val textStreams = CopyOnWriteArrayList>() + private val byteStreams = CopyOnWriteArrayList>() + + private companion object { + const val TOPIC = "topic" + const val SENDER = "alice" + const val STREAM_ID = "stream-1" + } + + @Before + fun setup() { + engine.stub { on { e2EEManager } doReturn null } + dataStreams = DataStreams(engine = engine, closeableManager = CloseableManager()) + dataStreams.registerTextStreamHandler(TOPIC) { reader, identity -> textStreams.add(reader to identity) } + dataStreams.registerByteStreamHandler(TOPIC) { reader, identity -> byteStreams.add(reader to identity) } + } + + @After + fun tearDown() { + dataStreams.close() + } + + // region Packet building + + private fun deflateRaw(input: ByteArray): ByteArray { + val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, /* nowrap = */ true) + deflater.setInput(input) + deflater.finish() + val out = ByteArray(input.size * 2 + 64) + val written = deflater.deflate(out) + deflater.end() + return out.copyOf(written) + } + + private fun header( + text: Boolean, + inlineContent: ByteArray? = null, + compression: DataStream.CompressionType = DataStream.CompressionType.NONE, + totalLength: Long? = null, + attributes: Map = emptyMap(), + topic: String = TOPIC, + streamId: String = STREAM_ID, + sender: String = SENDER, + ): DataPacket = DataPacket.newBuilder() + .setParticipantIdentity(sender) + .setStreamHeader( + DataStream.Header.newBuilder() + .setStreamId(streamId) + .setTopic(topic) + .setTimestamp(0) + .putAllAttributes(attributes) + .setCompression(compression) + .apply { + if (inlineContent != null) setInlineContent(ByteString.copyFrom(inlineContent)) + if (totalLength != null) setTotalLength(totalLength) + if (text) { + mimeType = "text/plain" + textHeader = DataStream.TextHeader.newBuilder() + .setOperationType(DataStream.OperationType.CREATE) + .build() + } else { + mimeType = "application/octet-stream" + byteHeader = DataStream.ByteHeader.newBuilder().setName("blob").build() + } + } + .build(), + ) + .build() + + private fun chunk(content: ByteArray, index: Long = 0, streamId: String = STREAM_ID): DataPacket = + DataPacket.newBuilder() + .setParticipantIdentity(SENDER) + .setStreamChunk( + DataStream.Chunk.newBuilder() + .setStreamId(streamId) + .setChunkIndex(index) + .setContent(ByteString.copyFrom(content)) + .build(), + ) + .build() + + private fun trailer(reason: String = "", streamId: String = STREAM_ID): DataPacket = + DataPacket.newBuilder() + .setParticipantIdentity(SENDER) + .setStreamTrailer( + DataStream.Trailer.newBuilder().setStreamId(streamId).setReason(reason).build(), + ) + .build() + + private fun awaitTextStream(): TextStreamReceiver { + awaitCondition(message = "No text stream was opened") { textStreams.isNotEmpty() } + return textStreams.first().first + } + + private fun awaitByteStream(): ByteStreamReceiver { + awaitCondition(message = "No byte stream was opened") { byteStreams.isNotEmpty() } + return byteStreams.first().first + } + + // endregion + + // region Inline (single packet) streams + + @Test + fun inlineUncompressedText() = runTest { + val text = "hello world" + dataStreams.handleIncoming( + header(text = true, inlineContent = text.toByteArray(), totalLength = text.length.toLong()), + ) + + // Deliberately no chunk or trailer: an inline stream is complete on its own. + assertEquals(text, awaitTextStream().readAll().joinToString("")) + } + + @Test + fun inlineCompressedText() = runTest { + val text = "hello hello compressible world" + dataStreams.handleIncoming( + header( + text = true, + inlineContent = deflateRaw(text.toByteArray()), + compression = DataStream.CompressionType.DEFLATE_RAW, + totalLength = text.toByteArray().size.toLong(), + ), + ) + + assertEquals(text, awaitTextStream().readAll().joinToString("")) + } + + @Test + fun inlineUncompressedBytes() = runTest { + val payload = byteArrayOf(1, 2, 3) + dataStreams.handleIncoming( + header(text = false, inlineContent = payload, totalLength = 3), + ) + + val received = awaitByteStream().readAll().reduce { a, b -> a + b } + assertEquals(payload.toList(), received.toList()) + } + + @Test + fun inlineCompressedBytes() = runTest { + val payload = ByteArray(500) { (it % 7).toByte() } + dataStreams.handleIncoming( + header( + text = false, + inlineContent = deflateRaw(payload), + compression = DataStream.CompressionType.DEFLATE_RAW, + totalLength = payload.size.toLong(), + ), + ) + + val received = awaitByteStream().readAll().reduce { a, b -> a + b } + assertEquals(payload.toList(), received.toList()) + } + + // endregion + + // region Chunked compressed streams + + /** + * A compressed stream is one deflate stream spread across chunks, so the receiver has to feed + * them through a single decompressor in order rather than decompressing each chunk. + */ + @Test + fun chunkedCompressedTextSplitAcrossChunks() = runTest { + val text = buildString { repeat(2_000) { append("compress me please ") } } + val compressed = deflateRaw(text.toByteArray()) + assertTrue("test needs the compressed form to span chunks", compressed.size > 1) + val split = compressed.size / 2 + + dataStreams.handleIncoming( + header( + text = true, + compression = DataStream.CompressionType.DEFLATE_RAW, + totalLength = text.toByteArray().size.toLong(), + ), + ) + dataStreams.handleIncoming(chunk(compressed.copyOfRange(0, split), index = 0)) + dataStreams.handleIncoming(chunk(compressed.copyOfRange(split, compressed.size), index = 1)) + dataStreams.handleIncoming(trailer()) + + assertEquals(text, awaitTextStream().readAll().joinToString("")) + } + + /** + * Text crosses the FFI as decoded strings but the SDK's reader is built on a byte channel, so + * the glue re-encodes. Multi-byte characters would break if a chunk were ever split mid + * character. + */ + @Test + fun multiByteTextRoundTrips() = runTest { + val text = "héllo → 世界 🎉 café" + dataStreams.handleIncoming( + header( + text = true, + inlineContent = text.toByteArray(), + totalLength = text.toByteArray().size.toLong(), + ), + ) + + assertEquals(text, awaitTextStream().readAll().joinToString("")) + } + + // endregion + + // region Routing and lifecycle + + @Test + fun streamOnUnhandledTopicIsIgnored() = runTest { + dataStreams.handleIncoming( + header(text = true, inlineContent = "hi".toByteArray(), topic = "other-topic"), + ) + // Then a stream we do handle, as a barrier proving the first was processed and dropped. + dataStreams.handleIncoming( + header(text = true, inlineContent = "hi".toByteArray(), streamId = "stream-2"), + ) + + awaitCondition { textStreams.isNotEmpty() } + awaitStable { textStreams.size } + assertEquals(1, textStreams.size) + assertEquals("stream-2", textStreams.first().first.info.id) + } + + @Test + fun senderIdentityIsSurfacedToTheHandler() = runTest { + dataStreams.handleIncoming(header(text = true, inlineContent = "hi".toByteArray())) + + awaitCondition { textStreams.isNotEmpty() } + assertEquals(Participant.Identity(SENDER), textStreams.first().second) + } + + @Test + fun headerAttributesReachStreamInfo() = runTest { + dataStreams.handleIncoming( + header( + text = true, + inlineContent = "hi".toByteArray(), + attributes = mapOf("foo" to "bar"), + ), + ) + + val info = awaitTextStream().info + assertEquals(STREAM_ID, info.id) + assertEquals(TOPIC, info.topic) + assertEquals("bar", info.attributes["foo"]) + } + + @Test + fun unregisteringAHandlerStopsDelivery() = runTest { + dataStreams.unregisterTextStreamHandler(TOPIC) + + dataStreams.handleIncoming(header(text = true, inlineContent = "hi".toByteArray())) + // Byte stream on the same topic still has a handler, and acts as the barrier. + dataStreams.handleIncoming( + header(text = false, inlineContent = byteArrayOf(1), streamId = "stream-b"), + ) + + awaitCondition { byteStreams.isNotEmpty() } + awaitStable { textStreams.size } + assertTrue(textStreams.isEmpty()) + } + + /** A sender that disconnects mid-stream must fail its readers rather than leave them waiting. */ + @Test + fun abortStreamsFromFailsThatSendersOpenStreams() = runTest { + dataStreams.handleIncoming(header(text = true, totalLength = 100)) + dataStreams.handleIncoming(chunk("partial".toByteArray())) + val reader = awaitTextStream() + + dataStreams.abortStreamsFrom(Participant.Identity(SENDER)) + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue("expected a StreamException, got $error", error is StreamException) + } + + @Test + fun abortAllStreamsFailsOpenStreams() = runTest { + dataStreams.handleIncoming(header(text = true, totalLength = 100)) + dataStreams.handleIncoming(chunk("partial".toByteArray())) + val reader = awaitTextStream() + + dataStreams.abortAllStreams() + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue("expected a StreamException, got $error", error is StreamException) + } + + /** Handler registrations outlive an abort, so streams after a reconnect are still delivered. */ + @Test + fun handlersSurviveAbortAllStreams() = runTest { + dataStreams.abortAllStreams() + + dataStreams.handleIncoming(header(text = true, inlineContent = "after".toByteArray())) + + assertEquals("after", awaitTextStream().readAll().joinToString("")) + } + + // endregion + + // region Failures + + @Test + fun trailerWithReasonSurfacesAbnormalEnd() = runTest { + dataStreams.handleIncoming(header(text = true)) + val reader = awaitTextStream() + dataStreams.handleIncoming(chunk("partial".toByteArray())) + dataStreams.handleIncoming(trailer(reason = "sender gave up")) + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue( + "expected AbnormalEndException, got $error", + error is StreamException.AbnormalEndException, + ) + } + + @Test + fun shortStreamSurfacesIncomplete() = runTest { + dataStreams.handleIncoming(header(text = true, totalLength = 100)) + val reader = awaitTextStream() + dataStreams.handleIncoming(chunk("tiny".toByteArray())) + dataStreams.handleIncoming(trailer()) + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue("expected IncompleteException, got $error", error is StreamException.IncompleteException) + } + + @Test + fun overlongStreamSurfacesLengthExceeded() = runTest { + dataStreams.handleIncoming(header(text = true, totalLength = 3)) + val reader = awaitTextStream() + dataStreams.handleIncoming(chunk("far too much content".toByteArray())) + dataStreams.handleIncoming(trailer()) + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue( + "expected LengthExceededException, got $error", + error is StreamException.LengthExceededException, + ) + } + + /** + * The payload cap is read when the first packet arrives, not at construction, so that a value + * passed to connect() is picked up. + */ + @Test + fun maxPayloadSizeIsEnforced() = runTest { + dataStreams.maxPayloadSize = { 16 } + + dataStreams.handleIncoming(header(text = true)) + val reader = awaitTextStream() + dataStreams.handleIncoming(chunk(ByteArray(1_000) { 'a'.code.toByte() })) + + val error = runCatching { reader.readAll() }.exceptionOrNull() + assertTrue("expected a StreamException, got $error", error is StreamException) + } + + @Test + fun nonDataStreamPacketsAreIgnored() = runTest { + // A user packet has no stream fields at all; the core should drop it without complaint. + dataStreams.handleIncoming( + DataPacket.newBuilder() + .setParticipantIdentity(SENDER) + .setUser(livekit.LivekitModels.UserPacket.newBuilder().setPayload(ByteString.copyFromUtf8("x"))) + .build(), + ) + dataStreams.handleIncoming(header(text = true, inlineContent = "ok".toByteArray())) + + assertEquals("ok", awaitTextStream().readAll().joinToString("")) + } + + // endregion +} diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2SendTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2SendTest.kt new file mode 100644 index 000000000..4aded9f69 --- /dev/null +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2SendTest.kt @@ -0,0 +1,504 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datastream + +import io.livekit.android.memory.CloseableManager +import io.livekit.android.room.ClientCapability +import io.livekit.android.room.ClientProtocolVersion +import io.livekit.android.room.RTCEngine +import io.livekit.android.room.participant.Participant +import io.livekit.android.test.BaseTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import livekit.LivekitModels +import livekit.LivekitModels.DataPacket +import livekit.LivekitModels.DataStream +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.stub +import org.robolectric.RobolectricTestRunner +import java.util.concurrent.CopyOnWriteArrayList + +/** + * The v2 send path, end to end through [DataStreams] and out to a stubbed engine. + * + * These are the interop-critical cases: whether a payload goes out as one inline packet or as + * header/chunks/trailer, and whether it is compressed, is decided by the core from what we tell it + * about each recipient. That makes this as much a test of our registry wiring and options mapping + * as of the framing itself -- if we report capabilities wrongly, the core silently picks a framing + * a peer cannot read, and nothing here fails locally. + * + * Cases follow the matrix in rust-sdks/DATA_STREAMS_SPEC.md ("Minimum required test cases"). + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class DataStreamsV2SendTest : BaseTest() { + + @Mock + lateinit var engine: RTCEngine + + private lateinit var dataStreams: DataStreams + private val sentPackets = CopyOnWriteArrayList() + + /** identity -> (clientProtocol, capabilities) */ + private var remotes: Map>> = emptyMap() + + private companion object { + const val TOPIC = "topic" + + val PRE_V2 = mapOf( + "alice" to (ClientProtocolVersion.DEFAULT.value to emptyList()), + "bob" to (ClientProtocolVersion.DEFAULT.value to emptyList()), + "jim" to (ClientProtocolVersion.DATA_STREAM_RPC.value to emptyList()), + ) + + val ALL_V2 = mapOf( + "alice" to (ClientProtocolVersion.DATA_STREAM_V2.value to listOf(ClientCapability.COMPRESSION_DEFLATE_RAW)), + "bob" to (ClientProtocolVersion.DATA_STREAM_V2.value to listOf(ClientCapability.COMPRESSION_DEFLATE_RAW)), + "noCompression" to (ClientProtocolVersion.DATA_STREAM_V2.value to emptyList()), + ) + + val MIXED = PRE_V2 + ALL_V2 + + /** Compresses well, and short enough that the inline header still fits the MTU budget. */ + const val COMPRESSIBLE = "hello hello compressible world" + } + + @Before + fun setup() { + engine.stub { + onBlocking { sendData(any()) } doAnswer { invocation -> + sentPackets.add(invocation.arguments[0] as DataPacket) + Result.success(Unit) + } + onBlocking { waitForBufferStatusLow(any()) } doReturn Unit + on { e2EEManager } doReturn null + } + dataStreams = DataStreams(engine = engine, closeableManager = CloseableManager()) + dataStreams.remoteIdentities = { remotes.keys.map { Participant.Identity(it) } } + dataStreams.remoteClientProtocol = { id -> remotes[id.value]?.first ?: ClientProtocolVersion.DEFAULT.value } + dataStreams.remoteCapabilities = { id -> remotes[id.value]?.second ?: emptyList() } + } + + @After + fun tearDown() { + dataStreams.close() + } + + // region Helpers + + private fun awaitPackets(count: Int): List { + awaitCondition(message = "Expected $count packet(s), saw ${sentPackets.size}") { + sentPackets.size >= count + } + // Give any extra packets a chance to show up, so "exactly N" assertions are meaningful. + awaitStable { sentPackets.size } + return sentPackets.toList() + } + + private fun destinations(vararg identities: String) = identities.map { Participant.Identity(it) } + + private val DataPacket.header: DataStream.Header get() = streamHeader + + // endregion + + // region A room where every recipient predates v2 + + @Test + fun preV2RoomSendsLegacyThreePackets() = runTest { + remotes = PRE_V2 + + dataStreams.sendText("hello world", StreamTextOptions(topic = TOPIC)) + + val packets = awaitPackets(3) + assertEquals(3, packets.size) + assertTrue(packets[0].hasStreamHeader()) + assertTrue(packets[0].header.hasTextHeader()) + assertTrue(packets[1].hasStreamChunk()) + assertTrue(packets[2].hasStreamTrailer()) + + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertFalse("a pre-v2 recipient must never be sent inline content", packets[0].header.hasInlineContent()) + assertEquals("hello world", packets[1].streamChunk.content.toStringUtf8()) + } + + @Test + fun preV2RoomSendsBytesUncompressed() = runTest { + remotes = PRE_V2 + + dataStreams.sendBytes(byteArrayOf(0, 1, 2, 3), StreamBytesOptions(topic = TOPIC)) + + val packets = awaitPackets(3) + assertEquals(3, packets.size) + assertTrue(packets[0].header.hasByteHeader()) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertFalse(packets[0].header.hasInlineContent()) + assertArrayEqualsBytes(byteArrayOf(0, 1, 2, 3), packets[1].streamChunk.content.toByteArray()) + } + + // endregion + + // region A room where every recipient speaks v2 + + @Test + fun v2RoomSendsCompressibleTextAsOneCompressedPacket() = runTest { + remotes = ALL_V2 + + dataStreams.sendText( + COMPRESSIBLE, + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + + val packets = awaitPackets(1) + assertEquals("inline sends are a single packet", 1, packets.size) + assertEquals(DataStream.CompressionType.DEFLATE_RAW, packets[0].header.compression) + assertTrue(packets[0].header.hasInlineContent()) + assertFalse( + "inline content should be compressed, not the raw text", + packets[0].header.inlineContent.toStringUtf8() == COMPRESSIBLE, + ) + } + + @Test + fun v2RoomSendsIncompressibleTextInlineButRaw() = runTest { + remotes = ALL_V2 + + // Too short for deflate to win; the core keeps the raw bytes rather than growing them. + dataStreams.sendText( + "short", + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertEquals("short", packets[0].header.inlineContent.toStringUtf8()) + } + + /** + * The two v2 features are gated independently: inline on the protocol version, compression on + * the capability. A recipient that advertises v2 but no codec still gets the single packet. + */ + @Test + fun recipientWithoutCompressionCapabilityStillGetsInline() = runTest { + remotes = ALL_V2 + + dataStreams.sendText( + COMPRESSIBLE, + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("noCompression")), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertEquals(COMPRESSIBLE, packets[0].header.inlineContent.toStringUtf8()) + } + + @Test + fun compressOptOutStillSendsInline() = runTest { + remotes = ALL_V2 + + dataStreams.sendText( + COMPRESSIBLE, + StreamTextOptions( + topic = TOPIC, + destinationIdentities = destinations("alice", "bob"), + compress = false, + ), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertEquals(COMPRESSIBLE, packets[0].header.inlineContent.toStringUtf8()) + } + + @Test + fun v2RoomSendsShortBytesInline() = runTest { + remotes = ALL_V2 + + dataStreams.sendBytes( + byteArrayOf(0, 1, 2, 3), + StreamBytesOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertTrue(packets[0].header.hasByteHeader()) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertArrayEqualsBytes(byteArrayOf(0, 1, 2, 3), packets[0].header.inlineContent.toByteArray()) + } + + /** + * A payload too big to fit a header packet falls back to chunks, but stays compressed. + */ + @Test + fun largePayloadFallsBackToCompressedChunks() = runTest { + remotes = ALL_V2 + // Only somewhat compressible: repetitive text would deflate small enough to still fit + // inline, which is a different case (and covered above). Seeded, so the size is stable. + val random = java.util.Random(1234) + val alphabet = ('a'..'z') + ('A'..'Z') + ('0'..'9') + val payload = buildString { + repeat(50) { + append("hello world") + repeat(1_000) { append(alphabet[random.nextInt(alphabet.size)]) } + } + } + + dataStreams.sendText( + payload, + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + + val packets = awaitPackets(3) + assertTrue("expected a chunked send, got ${packets.size} packet(s)", packets.size > 1) + assertEquals(DataStream.CompressionType.DEFLATE_RAW, packets[0].header.compression) + assertFalse(packets[0].header.hasInlineContent()) + assertTrue(packets.last().hasStreamTrailer()) + + val compressedSize = packets.filter { it.hasStreamChunk() }.sumOf { it.streamChunk.content.size() } + assertTrue( + "compressed chunks ($compressedSize) should be smaller than the payload (${payload.length})", + compressedSize < payload.length, + ) + } + + // endregion + + // region Incremental writers + + /** + * Incremental writers are never inlined or compressed: the payload is not known up front, and + * the core cannot flush a deflate stream mid-write. + */ + @Test + fun streamTextIsNeverInlineOrCompressed() = runTest { + remotes = ALL_V2 + + val sender = dataStreams.streamText( + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + awaitPackets(1) + assertEquals(DataStream.CompressionType.NONE, sentPackets[0].header.compression) + assertFalse(sentPackets[0].header.hasInlineContent()) + + assertTrue(sender.write(COMPRESSIBLE).isSuccess) + val afterWrite = awaitPackets(2) + assertTrue(afterWrite[1].hasStreamChunk()) + assertEquals(COMPRESSIBLE, afterWrite[1].streamChunk.content.toStringUtf8()) + + sender.close() + val afterClose = awaitPackets(3) + assertTrue(afterClose[2].hasStreamTrailer()) + assertTrue(afterClose[2].streamTrailer.reason.isEmpty()) + } + + @Test + fun streamBytesIsNeverInlineOrCompressed() = runTest { + remotes = ALL_V2 + + val sender = dataStreams.streamBytes( + StreamBytesOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + assertTrue(sender.write(byteArrayOf(0, 1, 2, 3)).isSuccess) + sender.close() + + val packets = awaitPackets(3) + assertEquals(3, packets.size) + assertTrue(packets[0].header.hasByteHeader()) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertArrayEqualsBytes(byteArrayOf(0, 1, 2, 3), packets[1].streamChunk.content.toByteArray()) + } + + /** A non-null close reason travels in the trailer, which the receiver reads as an error. */ + @Test + fun closeWithReasonSetsTrailerReason() = runTest { + remotes = ALL_V2 + + val sender = dataStreams.streamText(StreamTextOptions(topic = TOPIC)) + sender.close(reason = "because") + + val packets = awaitPackets(2) + val trailer = packets.first { it.hasStreamTrailer() }.streamTrailer + assertEquals("because", trailer.reason) + } + + // endregion + + // region Mixed rooms + + /** + * Eligibility is unanimous across recipients. One pre-v2 participant in a broadcast is enough + * to drop the whole send back to a framing everyone understands. + */ + @Test + fun broadcastToMixedRoomFallsBackToLegacy() = runTest { + remotes = MIXED + + dataStreams.sendText(COMPRESSIBLE, StreamTextOptions(topic = TOPIC)) + + val packets = awaitPackets(3) + assertEquals(3, packets.size) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertFalse(packets[0].header.hasInlineContent()) + assertEquals(COMPRESSIBLE, packets[1].streamChunk.content.toStringUtf8()) + } + + /** Narrowing the same send to capable recipients re-enables both v2 features. */ + @Test + fun targetedSendToCapableSubsetOfMixedRoomUsesV2() = runTest { + remotes = MIXED + + dataStreams.sendText( + COMPRESSIBLE, + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob")), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertEquals(DataStream.CompressionType.DEFLATE_RAW, packets[0].header.compression) + } + + @Test + fun targetedSendIncludingAnIncapableRecipientDropsCompression() = runTest { + remotes = MIXED + + dataStreams.sendText( + COMPRESSIBLE, + StreamTextOptions(topic = TOPIC, destinationIdentities = destinations("alice", "bob", "noCompression")), + ) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertEquals(DataStream.CompressionType.NONE, packets[0].header.compression) + assertEquals(COMPRESSIBLE, packets[0].header.inlineContent.toStringUtf8()) + } + + /** + * An empty room has nobody who could fail to understand v2, so the fast path applies. + */ + @Test + fun emptyRoomIsEligibleForV2() = runTest { + remotes = emptyMap() + + dataStreams.sendText(COMPRESSIBLE, StreamTextOptions(topic = TOPIC)) + + val packets = awaitPackets(1) + assertEquals(1, packets.size) + assertTrue(packets[0].header.hasInlineContent()) + } + + // endregion + + // region Options mapping + + @Test + fun textOptionsReachTheWire() = runTest { + remotes = PRE_V2 + + dataStreams.sendText( + "hi", + StreamTextOptions( + topic = TOPIC, + attributes = mapOf("foo" to "bar"), + streamId = "explicit-id", + destinationIdentities = destinations("alice"), + operationType = TextStreamInfo.OperationType.UPDATE, + version = 7, + replyToStreamId = "earlier", + attachedStreamIds = listOf("att-1"), + ), + ) + + val header = awaitPackets(3)[0].header + assertEquals("explicit-id", header.streamId) + assertEquals(TOPIC, header.topic) + assertEquals("bar", header.attributesMap["foo"]) + assertEquals(listOf("alice"), sentPackets[0].destinationIdentitiesList) + assertEquals(DataStream.OperationType.UPDATE, header.textHeader.operationType) + assertEquals(7, header.textHeader.version) + assertEquals("earlier", header.textHeader.replyToStreamId) + assertEquals(listOf("att-1"), header.textHeader.attachedStreamIdsList) + } + + @Test + fun byteOptionsReachTheWire() = runTest { + remotes = PRE_V2 + + dataStreams.sendBytes( + byteArrayOf(1, 2, 3), + StreamBytesOptions( + topic = TOPIC, + attributes = mapOf("k" to "v"), + streamId = "bytes-id", + mimeType = "image/png", + name = "pic.png", + totalSize = 3, + ), + ) + + val header = awaitPackets(3)[0].header + assertEquals("bytes-id", header.streamId) + assertEquals("image/png", header.mimeType) + assertEquals("pic.png", header.byteHeader.name) + assertEquals("v", header.attributesMap["k"]) + assertEquals(3L, header.totalLength) + } + + /** Everything the core emits is reliable; data streams must not race down the lossy channel. */ + @Test + fun packetsAreSentReliably() = runTest { + remotes = PRE_V2 + + dataStreams.sendText("hi", StreamTextOptions(topic = TOPIC)) + + awaitPackets(3).forEach { + assertEquals(DataPacket.Kind.RELIABLE, it.kind) + } + } + + @Test + fun returnedInfoDescribesTheStream() = runTest { + remotes = PRE_V2 + + val info = dataStreams.sendText( + "hello", + StreamTextOptions(topic = TOPIC, streamId = "id-1", attributes = mapOf("a" to "b")), + ) + + assertEquals("id-1", info.id) + assertEquals(TOPIC, info.topic) + assertEquals("b", info.attributes["a"]) + assertEquals(5L, info.totalSize) + assertEquals(LivekitModels.Encryption.Type.NONE, info.encryptionType) + } + + // endregion + + private fun assertArrayEqualsBytes(expected: ByteArray, actual: ByteArray) { + assertEquals(expected.toList(), actual.toList()) + } +} From d11b57e9937ce008fd16ed57306bae89b6915aa7 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 18:59:04 -0400 Subject: [PATCH 08/14] style: apply spotless, and drain stragglers before RPC test tear-down spotlessCheck is a CI gate; this is its output plus one flake fix. awaitJob waited only for the RPC itself, but a completed call can still have siblings finishing behind it -- closing the request stream, emitting a disconnect event -- whose continuations are posted back to the test dispatcher from the core's threads. Occasionally one landed after the test body returned and surfaced as "unfinished coroutines found during the tear-down". Pump briefly after the job completes so they run inside the test. Verified over four consecutive cold runs of the full suite. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/io/livekit/android/room/datastream/StreamOptions.kt | 2 +- .../src/main/java/io/livekit/android/test/BaseTest.kt | 2 +- .../java/io/livekit/android/room/ConnectionParamsTest.kt | 2 +- .../android/room/datastream/DataStreamsConversionTest.kt | 4 ++-- .../android/room/datastream/DataStreamsV2ReceiveTest.kt | 3 ++- .../room/datastream/RoomIncomingDataStreamMockE2ETest.kt | 2 +- .../java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt | 5 +++++ 7 files changed, 13 insertions(+), 7 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.kt index c5ccfa633..dfc855c8c 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamOptions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 LiveKit, Inc. + * Copyright 2025-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt b/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt index 590f78c8c..0e458acee 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/BaseTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/ConnectionParamsTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/ConnectionParamsTest.kt index bb85b7842..78c6b4330 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/ConnectionParamsTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/ConnectionParamsTest.kt @@ -20,8 +20,8 @@ import io.livekit.android.ConnectOptions import io.livekit.android.stats.getClientInfo import io.livekit.android.test.MockE2ETest import io.livekit.android.test.mock.TestData -import kotlinx.coroutines.launch import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import livekit.LivekitModels import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt index 06aa9d383..b53f72597 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt @@ -195,7 +195,7 @@ class DataStreamsConversionTest { fun ioErrorsReadAsAbnormalEnd() { assertTrue( FfiDataStreamException.Io("disk went away").toStreamException() - is StreamException.AbnormalEndException, + is StreamException.AbnormalEndException, ) } @@ -221,7 +221,7 @@ class DataStreamsConversionTest { assertTrue(FfiDataStreamException.Incomplete().toStreamException() is StreamException.IncompleteException) assertTrue( FfiDataStreamException.EncryptionTypeMismatch().toStreamException() - is StreamException.EncryptionTypeMismatch, + is StreamException.EncryptionTypeMismatch, ) } diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2ReceiveTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2ReceiveTest.kt index a12317942..725add814 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2ReceiveTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsV2ReceiveTest.kt @@ -83,7 +83,8 @@ class DataStreamsV2ReceiveTest : BaseTest() { // region Packet building private fun deflateRaw(input: ByteArray): ByteArray { - val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, /* nowrap = */ true) + // nowrap = true gives raw DEFLATE, with no zlib header, which is the wire format. + val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, true) deflater.setInput(input) deflater.finish() val out = ByteArray(input.size * 2 + 64) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.kt index 4145ac3cb..ff46f36f5 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/RoomIncomingDataStreamMockE2ETest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt index df7ab7353..640147e61 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/rpc/RpcV2MockE2ETest.kt @@ -148,6 +148,11 @@ class RpcV2MockE2ETest : MockE2ETest() { */ private fun awaitJob(job: kotlinx.coroutines.Deferred<*>) { awaitCondition(message = "RPC did not complete") { job.isCompleted } + // A completed job can still have siblings finishing behind it -- closing the request + // stream, emitting a disconnect event -- whose continuations are posted back to the test + // dispatcher from the core's threads. Keep pumping briefly so they run here rather than + // showing up as unfinished coroutines at tear-down. + awaitStable(quietMs = 50, minWaitMs = 100) { pubDataChannel.sentBuffers.size } coroutineRule.dispatcher.scheduler.advanceUntilIdle() } From e64c274527e8830e739f0dc0379be7a9c3d6badd Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 6 Aug 2026 13:44:45 -0400 Subject: [PATCH 09/14] feat(datastream): make the core's errors one to one with StreamException Every failure the core can report is now distinguishable, rather than several folding onto the nearest pre-existing case. New exceptions: - HeaderTooLargeException, PayloadTooLargeException. Both are subclasses of LengthExceededException rather than siblings, so existing code catching that keeps catching every size-limit failure while new code can tell them apart. Neither failure mode existed before v2 (there was no header budget and no payload cap), so nothing was relying on the old folding. - InternalException, which previously arrived as a TerminatedException. TerminatedException gains a `reason`, defaulted and @JvmOverloads'd so single-argument construction is unchanged. It separates the five remaining cases that share the type -- already closed, invalid header, missed chunk, send failed, invalid file name -- plus IO. Io no longer maps to AbnormalEndException. A local file read failing is not the remote closing the stream on us, which is what that exception documents; it is now TerminatedException with reason IO. The one caveat: StreamException is sealed, so an exhaustive `when` over it in consumer code will need a branch for InternalException. Catch-based handling, which is how exceptions are used here, is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/room/datastream/DataStreams.kt | 50 ++++++---- .../room/datastream/StreamException.kt | 62 ++++++++++++- .../datastream/DataStreamsConversionTest.kt | 91 +++++++++++++------ 3 files changed, 156 insertions(+), 47 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt index eb2a865f8..fdabf9754 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt @@ -633,29 +633,43 @@ internal fun StreamBytesOptions.toFfi() = FfiStreamByteOptions( ) /** - * Maps a core error onto this SDK's existing [StreamException] hierarchy. + * Maps a core error onto this SDK's [StreamException] hierarchy, one to one. * - * Several core cases collapse onto one [StreamException]: the public hierarchy predates the core - * and is kept as-is for compatibility, so new failure modes are folded into the closest existing - * case rather than widening public API. The core's message is preserved where the target case can - * carry one. + * Every case the core can report is distinguishable here, either by its own exception type or by + * [StreamException.TerminatedException.Reason]. The size failures are modelled as subclasses of + * [StreamException.LengthExceededException] so that existing code catching that still catches them. */ internal fun FfiDataStreamException.toStreamException(): StreamException = when (this) { is FfiDataStreamException.AbnormalEnd -> StreamException.AbnormalEndException(reason) - is FfiDataStreamException.Io -> StreamException.AbnormalEndException(reason) - is FfiDataStreamException.Utf8 -> StreamException.DecodeFailedException() - is FfiDataStreamException.Decompression -> StreamException.DecodeFailedException() - is FfiDataStreamException.LengthExceeded -> StreamException.LengthExceededException() - is FfiDataStreamException.HeaderTooLarge -> StreamException.LengthExceededException() - is FfiDataStreamException.PayloadTooLarge -> StreamException.LengthExceededException() + is FfiDataStreamException.Utf8 -> StreamException.DecodeFailedException(reason) + is FfiDataStreamException.Decompression -> StreamException.DecodeFailedException("Decompression failed") + is FfiDataStreamException.LengthExceeded -> StreamException.LengthExceededException(message) + is FfiDataStreamException.HeaderTooLarge -> StreamException.HeaderTooLargeException(message) + is FfiDataStreamException.PayloadTooLarge -> StreamException.PayloadTooLargeException(message) is FfiDataStreamException.Incomplete -> StreamException.IncompleteException() - is FfiDataStreamException.EncryptionTypeMismatch -> StreamException.EncryptionTypeMismatch() - is FfiDataStreamException.AlreadyClosed -> StreamException.TerminatedException("Stream is closed!") - is FfiDataStreamException.InvalidHeader -> StreamException.TerminatedException(message) - is FfiDataStreamException.MissedChunk -> StreamException.TerminatedException(message) - is FfiDataStreamException.SendFailed -> StreamException.TerminatedException(message) - is FfiDataStreamException.Internal -> StreamException.TerminatedException(message) - is FfiDataStreamException.InvalidFileName -> StreamException.TerminatedException(message) + is FfiDataStreamException.EncryptionTypeMismatch -> StreamException.EncryptionTypeMismatch(message) + is FfiDataStreamException.Internal -> StreamException.InternalException(message) + + // No dedicated type; told apart by their reason. + is FfiDataStreamException.AlreadyClosed -> + StreamException.TerminatedException(message, StreamException.TerminatedException.Reason.ALREADY_CLOSED) + + is FfiDataStreamException.InvalidHeader -> + StreamException.TerminatedException(message, StreamException.TerminatedException.Reason.INVALID_HEADER) + + is FfiDataStreamException.MissedChunk -> + StreamException.TerminatedException(message, StreamException.TerminatedException.Reason.MISSED_CHUNK) + + is FfiDataStreamException.SendFailed -> + StreamException.TerminatedException(message, StreamException.TerminatedException.Reason.SEND_FAILED) + + is FfiDataStreamException.InvalidFileName -> + StreamException.TerminatedException(message, StreamException.TerminatedException.Reason.INVALID_FILE_NAME) + + // A local file read or write failing is not the remote closing on us, so this is terminated + // rather than an abnormal end. + is FfiDataStreamException.Io -> + StreamException.TerminatedException(reason, StreamException.TerminatedException.Reason.IO) } // endregion diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamException.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamException.kt index 1e51135bc..58f533cf2 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamException.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/StreamException.kt @@ -29,13 +29,34 @@ sealed class StreamException(message: String? = null) : Exception(message) { /** * Incoming chunk data could not be decoded. + * + * Covers both invalid UTF-8 in a text stream and a compressed stream that could not be + * decompressed. */ - class DecodeFailedException : StreamException() + class DecodeFailedException(message: String? = null) : StreamException(message) /** * Length exceeded total length specified in stream header. */ - class LengthExceededException : StreamException() + open class LengthExceededException(message: String? = null) : StreamException(message) + + /** + * A stream header was too large to send. + * + * A header travels in a single packet, so its attributes, topic and framing together have to + * fit the packet budget. Raised in place of sending an oversized header. + * + * A subclass of [LengthExceededException] so that code catching that keeps catching every + * size-limit failure. + */ + class HeaderTooLargeException(message: String? = null) : LengthExceededException(message) + + /** + * An incoming stream's payload exceeded the maximum accepted size. + * + * @see io.livekit.android.room.datastream.DataStreamOptions.maxPayloadSize + */ + class PayloadTooLargeException(message: String? = null) : LengthExceededException(message) /** * Length is less than total length specified in stream header. @@ -44,8 +65,38 @@ sealed class StreamException(message: String? = null) : Exception(message) { /** * Stream terminated before completion. + * + * [reason] distinguishes why, for the cases that do not have a dedicated exception. */ - class TerminatedException(message: String? = null) : StreamException(message) + class TerminatedException + @JvmOverloads + constructor( + message: String? = null, + val reason: Reason = Reason.UNKNOWN, + ) : StreamException(message) { + enum class Reason { + /** No specific reason was reported. */ + UNKNOWN, + + /** The stream had already been closed. */ + ALREADY_CLOSED, + + /** An incoming header could not be understood. */ + INVALID_HEADER, + + /** A chunk arrived out of order, leaving a gap the stream cannot recover from. */ + MISSED_CHUNK, + + /** A packet could not be handed to the transport. */ + SEND_FAILED, + + /** A file name was not a plain name, or tried to escape its directory. */ + INVALID_FILE_NAME, + + /** Reading or writing the underlying file failed. */ + IO, + } + } /** * Cannot perform operations on an unknown stream. @@ -66,4 +117,9 @@ sealed class StreamException(message: String? = null) : Exception(message) { * Encryption of the data chunks did not match the declared encryption type. */ class EncryptionTypeMismatch(message: String? = null) : StreamException(message) + + /** + * A stream failed for a reason internal to the SDK, with no more specific cause available. + */ + class InternalException(message: String? = null) : StreamException(message) } diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt index b53f72597..8b15b4244 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/datastream/DataStreamsConversionTest.kt @@ -20,6 +20,7 @@ import io.livekit.android.room.ClientCapability import io.livekit.android.room.participant.Participant import livekit.LivekitModels import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -192,56 +193,94 @@ class DataStreamsConversionTest { } @Test - fun ioErrorsReadAsAbnormalEnd() { + fun decodeFailuresKeepTheirDetail() { + val utf8 = FfiDataStreamException.Utf8("invalid byte").toStreamException() + assertTrue(utf8 is StreamException.DecodeFailedException) assertTrue( - FfiDataStreamException.Io("disk went away").toStreamException() - is StreamException.AbnormalEndException, + "the core's detail should survive, was: ${utf8.message}", + utf8.message?.contains("invalid byte") == true, ) - } - @Test - fun decodeFailures() { - assertTrue(FfiDataStreamException.Utf8("bad").toStreamException() is StreamException.DecodeFailedException) - assertTrue(FfiDataStreamException.Decompression().toStreamException() is StreamException.DecodeFailedException) + assertTrue( + FfiDataStreamException.Decompression().toStreamException() + is StreamException.DecodeFailedException, + ) } /** - * Three distinct size failures fold onto one public case. Left as-is rather than widening - * public API, but pinned here so the fold is a decision rather than an accident. + * The size failures each get their own type, but remain [StreamException.LengthExceededException] + * subclasses so that code catching that keeps catching all of them. */ @Test - fun sizeFailuresFoldOntoLengthExceeded() { - assertTrue(FfiDataStreamException.LengthExceeded().toStreamException() is StreamException.LengthExceededException) - assertTrue(FfiDataStreamException.HeaderTooLarge().toStreamException() is StreamException.LengthExceededException) - assertTrue(FfiDataStreamException.PayloadTooLarge().toStreamException() is StreamException.LengthExceededException) + fun sizeFailuresAreDistinctYetStillLengthExceeded() { + val lengthExceeded = FfiDataStreamException.LengthExceeded().toStreamException() + val headerTooLarge = FfiDataStreamException.HeaderTooLarge().toStreamException() + val payloadTooLarge = FfiDataStreamException.PayloadTooLarge().toStreamException() + + assertTrue(headerTooLarge is StreamException.HeaderTooLargeException) + assertTrue(payloadTooLarge is StreamException.PayloadTooLargeException) + + // All three catchable as the pre-existing type. + for (error in listOf(lengthExceeded, headerTooLarge, payloadTooLarge)) { + assertTrue( + "${error::class.simpleName} should be a LengthExceededException", + error is StreamException.LengthExceededException, + ) + } + // ...but the plain case is not one of the new subtypes. + assertFalse(lengthExceeded is StreamException.HeaderTooLargeException) + assertFalse(lengthExceeded is StreamException.PayloadTooLargeException) } @Test - fun incompleteAndEncryptionMismatch() { + fun incompleteAndEncryptionMismatchAndInternal() { assertTrue(FfiDataStreamException.Incomplete().toStreamException() is StreamException.IncompleteException) assertTrue( FfiDataStreamException.EncryptionTypeMismatch().toStreamException() is StreamException.EncryptionTypeMismatch, ) + assertTrue(FfiDataStreamException.Internal().toStreamException() is StreamException.InternalException) } + /** + * The cases without a dedicated type stay tellable apart by their reason, which is what makes + * the mapping one to one rather than lossy. + */ @Test - fun remainingFailuresReadAsTerminated() { - val terminated = listOf( - FfiDataStreamException.AlreadyClosed(), - FfiDataStreamException.InvalidHeader(), - FfiDataStreamException.MissedChunk(), - FfiDataStreamException.SendFailed(), - FfiDataStreamException.Internal(), - FfiDataStreamException.InvalidFileName(), + fun terminatedCasesAreDisambiguatedByReason() { + val expected = mapOf( + FfiDataStreamException.AlreadyClosed() to StreamException.TerminatedException.Reason.ALREADY_CLOSED, + FfiDataStreamException.InvalidHeader() to StreamException.TerminatedException.Reason.INVALID_HEADER, + FfiDataStreamException.MissedChunk() to StreamException.TerminatedException.Reason.MISSED_CHUNK, + FfiDataStreamException.SendFailed() to StreamException.TerminatedException.Reason.SEND_FAILED, + FfiDataStreamException.InvalidFileName() to StreamException.TerminatedException.Reason.INVALID_FILE_NAME, + FfiDataStreamException.Io("disk went away") to StreamException.TerminatedException.Reason.IO, ) - for (error in terminated) { + for ((ffi, reason) in expected) { + val mapped = ffi.toStreamException() assertTrue( - "${error::class.simpleName} should map to TerminatedException", - error.toStreamException() is StreamException.TerminatedException, + "${ffi::class.simpleName} should map to TerminatedException, was ${mapped::class.simpleName}", + mapped is StreamException.TerminatedException, ) + assertEquals(reason, (mapped as StreamException.TerminatedException).reason) } + + // Every reason but UNKNOWN is actually produced by some core failure. + val produced = expected.values.toSet() + val unmapped = StreamException.TerminatedException.Reason.entries + .filterNot { it == StreamException.TerminatedException.Reason.UNKNOWN } + .filterNot { it in produced } + assertTrue("reasons never produced by the mapping: $unmapped", unmapped.isEmpty()) + } + + /** The default keeps the pre-existing single-argument construction working. */ + @Test + fun terminatedReasonDefaultsToUnknown() { + assertEquals( + StreamException.TerminatedException.Reason.UNKNOWN, + StreamException.TerminatedException("boom").reason, + ) } // endregion From 808c713fea6f71be7d9d37f9926a97f0fe41e878 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 6 Aug 2026 13:45:40 -0400 Subject: [PATCH 10/14] revert: drop the unrelated RoomAgentDispatch.attributes addition Surfacing that field was fallout from the protocol bump, not part of data streams, and it does not belong in this change. ProtoConverterTest requires every proto field to be mirrored on the Kotlin DTO, which is why it was added. Whitelisted instead, alongside the fields already listed there, so the test states plainly that it is not surfaced yet. Worth knowing: the SDK therefore cannot set agent dispatch attributes, which the server now accepts. That is a real gap, just an unrelated one -- if it is wanted, it should be its own change. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/java/io/livekit/android/token/TokenSource.kt | 4 ---- .../test/java/io/livekit/android/proto/ProtoConverterTest.kt | 4 +++- 2 files changed, 3 insertions(+), 5 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 daf19ff6a..f665918fa 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 @@ -114,10 +114,6 @@ data class RoomAgentDispatch( * Optional deployment to target. Leave empty to target the production deployment. */ val deployment: String? = null, - /** - * Optional attributes to set on the dispatched agent participant. - */ - val attributes: Map? = null, ) @SuppressLint("UnsafeOptInUsageError") diff --git a/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt b/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt index 8bd9a933f..d6a4fac89 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt @@ -98,7 +98,9 @@ class ProtoConverterTest( ProtoConverterTestCase( LivekitAgentDispatch.RoomAgentDispatch::class.java, RoomAgentDispatch::class.java, - whitelist = listOf("restartPolicy", "deployment"), + // `attributes` arrived with a protocol bump and is not surfaced yet; adding it + // would be an unrelated public API change. + whitelist = listOf("restartPolicy", "deployment", "attributes"), ), ) From c9338e3149ae983d275584492a33b69c73036dc3 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 6 Aug 2026 13:59:47 -0400 Subject: [PATCH 11/14] test(datastream): verify on a real device, and fix a 16 KB page-size blocker Adds instrumented tests and, in running them, found that the SDK could not have worked on a 16 KB page-size device at all. livekit-uniffi's AAR pins jna 5.16.0, whose libjnidispatch.so fails to load there: E linker: ".../libjnidispatch.so" program alignment (8192) cannot be smaller than system page size (16384) which surfaces as an inscrutable `NoClassDefFoundError: com.sun.jna.Native` -- JNA's classpath fallback masking the real dlopen failure. Our own liblivekit_uniffi.so is fine (NDK r27 aligns to 16 KB by default), and so is WebRTC's; JNA's prebuilt library is the only one that fails. Verified by loading all three directly: only jnidispatch failed, and jna 5.19.1 loads. The SDK now overrides the transitive pin. This matters beyond the emulator: Android requires apps targeting API 35+ to support 16 KB page sizes, and such devices ship today, so every data stream would have died on the first FFI call. The real fix belongs in livekit-uniffi's own build. DataStreamsOnDeviceTest (10 tests, passing on an API 37 arm64 emulator) covers what the host JVM tests cannot: - the AAR's .so loading on Android, through JNA, from packaged jniLibs; - the bindings' Android cleaner path, chosen at API 34+, which uses android.system.SystemCleaner. Under Robolectric that throws IllegalAccessError and needs a JVM flag, so this is the only place it runs as written; - the v2 framings produced on-device matching the host build's, including a send-to-receive loopback through the core that reconstructs a compressed inline payload; - this SDK's conversions and error mapping in an Android runtime. No mocking framework: androidTest has no Mockito here, so these drive the FFI directly with a capturing delegate, the same seam client-sdk-swift's tests use. Co-Authored-By: Claude Opus 5 (1M context) --- gradle/libs.versions.toml | 8 +- livekit-android-sdk/build.gradle | 4 + livekit-android-test/build.gradle | 5 + .../datastream/DataStreamsOnDeviceTest.kt | 353 ++++++++++++++++++ 4 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 livekit-android-test/src/androidTest/java/io/livekit/android/room/datastream/DataStreamsOnDeviceTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31c221852..eb75d5c7a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,9 +36,11 @@ kotlin = "1.9.25" # `cargo make android-package-local` (see scripts/patch-uniffi-kotlin.py) and it resolves # from mavenLocal(). Switch to a published version once livekit-uniffi ships this API. livekit-uniffi = "0.0.1" -# Must match the version livekit-uniffi's AAR depends on. Only used to put the desktop JNA -# dispatch library on the unit-test classpath; see gradle/uniffi-native-lib.gradle. -jna = "5.16.0" +# livekit-uniffi's AAR pins jna 5.16.0, whose libjnidispatch.so fails to dlopen on 16 KB +# page-size devices ("program alignment (8192) cannot be smaller than system page size"). +# 5.19.1 loads correctly there, so the SDK overrides the transitive pin. Also used for the +# desktop JNA dispatch library on the unit-test classpath; see gradle/uniffi-native-lib.gradle. +jna = "5.19.1" [libraries] android-jain-sip-ri = { module = "javax.sip:android-jain-sip-ri", version.ref = "androidJainSipRi" } diff --git a/livekit-android-sdk/build.gradle b/livekit-android-sdk/build.gradle index b86ece0ab..cf7d470bf 100644 --- a/livekit-android-sdk/build.gradle +++ b/livekit-android-sdk/build.gradle @@ -119,6 +119,10 @@ dependencies { implementation libs.coroutines.lib implementation libs.kotlinx.serialization.json implementation libs.livekit.uniffi + // livekit-uniffi depends on jna 5.16.0, whose native dispatch library cannot be loaded on + // 16 KB page-size devices -- which Android now requires apps targeting API 35+ to support. + // Overriding to a version that loads there; remove once the AAR itself moves up. + implementation "net.java.dev.jna:jna:${libs.versions.jna.get()}@aar" api libs.webrtc api libs.okhttp.lib implementation libs.okhttp.coroutines diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle index aa055b91a..5fa7954bf 100644 --- a/livekit-android-test/build.gradle +++ b/livekit-android-test/build.gradle @@ -141,6 +141,11 @@ dependencies { androidTestImplementation libs.androidx.test.junit androidTestImplementation libs.espresso + // Instrumented tests drive the FFI directly to prove the native library works on a real + // device; see DataStreamsOnDeviceTest. + androidTestImplementation libs.livekit.uniffi + androidTestImplementation libs.coroutines.lib + androidTestImplementation libs.protobuf.javalite } apply from: rootProject.file('gradle/uniffi-native-lib.gradle') diff --git a/livekit-android-test/src/androidTest/java/io/livekit/android/room/datastream/DataStreamsOnDeviceTest.kt b/livekit-android-test/src/androidTest/java/io/livekit/android/room/datastream/DataStreamsOnDeviceTest.kt new file mode 100644 index 000000000..1e3ecb3ea --- /dev/null +++ b/livekit-android-test/src/androidTest/java/io/livekit/android/room/datastream/DataStreamsOnDeviceTest.kt @@ -0,0 +1,353 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.room.datastream + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.livekit.android.room.ClientCapability +import io.livekit.android.room.ClientProtocolVersion +import io.livekit.uniffi.buildVersion +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import livekit.LivekitModels +import livekit.LivekitModels.DataPacket +import livekit.LivekitModels.DataStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import io.livekit.uniffi.ByteStreamReader as FfiByteStreamReader +import io.livekit.uniffi.ClientCapability as FfiClientCapability +import io.livekit.uniffi.IncomingDataStreamManager as FfiIncomingDataStreamManager +import io.livekit.uniffi.IncomingDataStreamManagerDelegate as FfiIncomingDelegate +import io.livekit.uniffi.OutgoingDataStreamManager as FfiOutgoingDataStreamManager +import io.livekit.uniffi.OutgoingDataStreamManagerDelegate as FfiOutgoingDelegate +import io.livekit.uniffi.RemoteParticipantRegistryDelegate as FfiRegistryDelegate +import io.livekit.uniffi.StreamTextOptions as FfiStreamTextOptions +import io.livekit.uniffi.TextStreamReader as FfiTextStreamReader + +/** + * Runs the data stream core on a real Android device. + * + * The unit tests exercise the same code but on a host JVM against a host build of the library, + * which leaves a few things unproven that only Android can settle: + * + * - that the `.so` in the AAR loads on Android at all, through JNA, from the packaged jniLibs; + * - that the bindings' Android-specific resource cleaner works. It is selected at API 34 and + * above and uses `android.system.SystemCleaner`, which under Robolectric throws + * IllegalAccessError and needs a JVM flag to work around. This is the only place that path runs + * as written; + * - that the Rust core's async runtime and its threads behave on Android's runtime; + * - that the v2 framings are byte-identical to what the host build produces. + * + * Deliberately no mocking framework: androidTest has no Mockito here, so this drives the FFI + * directly with a capturing delegate, the same seam the Swift SDK's tests use. That also means it + * covers the layer this SDK owns -- the conversions and the error mapping -- without needing an + * engine or a room. + */ +@RunWith(AndroidJUnit4::class) +class DataStreamsOnDeviceTest { + + private companion object { + const val TOPIC = "topic" + const val SENDER = "alice" + const val COMPRESSIBLE = "hello hello compressible world" + val TIMEOUT = 10L to TimeUnit.SECONDS + } + + private class CapturingDelegate : FfiOutgoingDelegate { + val packets = CopyOnWriteArrayList() + override fun onPacketsAvailable(packets: List) { + for (bytes in packets) { + this.packets.add(DataPacket.parseFrom(bytes)) + } + } + } + + /** Reports whatever the test wants the core to believe about the room. */ + private class StubRegistry( + private val protocol: Int, + private val capabilities: List, + private val identities: List = listOf(SENDER), + ) : FfiRegistryDelegate { + override fun remoteClientProtocol(identity: String) = protocol + override fun remoteCapabilities(identity: String) = capabilities.map { it.toFfi() } + override fun remoteIdentities() = identities + } + + private class OpenedStreams : FfiIncomingDelegate { + val text = CopyOnWriteArrayList() + val bytes = CopyOnWriteArrayList() + val latch = CountDownLatch(1) + + override fun onTextStreamOpened(reader: FfiTextStreamReader, identity: String) { + text.add(reader) + latch.countDown() + } + + override fun onByteStreamOpened(reader: FfiByteStreamReader, identity: String) { + bytes.add(reader) + latch.countDown() + } + } + + private fun CapturingDelegate.awaitPackets(count: Int) { + val deadline = System.currentTimeMillis() + TIMEOUT.first * 1000 + while (packets.size < count && System.currentTimeMillis() < deadline) { + Thread.sleep(10) + } + // Settle briefly so "exactly N" assertions mean something. + Thread.sleep(200) + assertTrue( + "expected at least $count packet(s) on device, saw ${packets.size}", + packets.size >= count, + ) + } + + /** + * The library has to load before anything else here can work, and this is the call that forces + * both the JNA registration and the checksum check between the bindings and the `.so`. + */ + @Test + fun nativeLibraryLoadsOnDevice() { + val version = buildVersion() + + assertTrue("livekit-uniffi reported an empty build version", version.isNotEmpty()) + } + + /** + * The cleaner the bindings pick is API dependent, and the branch taken here is the one the host + * tests cannot run. Asserted so a device running an older API is not silently taken as + * covering it. + */ + @Test + fun runsOnAnApiLevelThatUsesTheAndroidCleaner() { + assertTrue( + "this device is API ${Build.VERSION.SDK_INT}; the Android cleaner path needs 34+", + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE, + ) + } + + @Test + fun sendsLegacyFramingToPreV2Recipients() = runBlocking { + val delegate = CapturingDelegate() + val manager = FfiOutgoingDataStreamManager( + delegate, + StubRegistry(ClientProtocolVersion.DEFAULT.value, emptyList()), + ) + + withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + manager.sendText("hello world", FfiStreamTextOptions(topic = TOPIC, attributes = emptyMap())) + } + delegate.awaitPackets(3) + + assertEquals(3, delegate.packets.size) + val header = delegate.packets[0].streamHeader + assertTrue(delegate.packets[0].hasStreamHeader()) + assertEquals(DataStream.CompressionType.NONE, header.compression) + assertFalse(header.hasInlineContent()) + assertEquals("hello world", delegate.packets[1].streamChunk.content.toStringUtf8()) + assertTrue(delegate.packets[2].hasStreamTrailer()) + manager.destroy() + } + + @Test + fun sendsInlineCompressedToV2Recipients() = runBlocking { + val delegate = CapturingDelegate() + val manager = FfiOutgoingDataStreamManager( + delegate, + StubRegistry( + ClientProtocolVersion.DATA_STREAM_V2.value, + listOf(ClientCapability.COMPRESSION_DEFLATE_RAW), + ), + ) + + withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + manager.sendText(COMPRESSIBLE, FfiStreamTextOptions(topic = TOPIC, attributes = emptyMap())) + } + delegate.awaitPackets(1) + + assertEquals("an inline send is a single packet", 1, delegate.packets.size) + val header = delegate.packets[0].streamHeader + assertEquals(DataStream.CompressionType.DEFLATE_RAW, header.compression) + assertTrue(header.hasInlineContent()) + assertFalse( + "inline content should be compressed, not raw text", + header.inlineContent.toStringUtf8() == COMPRESSIBLE, + ) + manager.destroy() + } + + /** Compression is gated on the capability even when the protocol version allows inline. */ + @Test + fun omitsCompressionForRecipientsWithoutTheCapability() = runBlocking { + val delegate = CapturingDelegate() + val manager = FfiOutgoingDataStreamManager( + delegate, + StubRegistry(ClientProtocolVersion.DATA_STREAM_V2.value, emptyList()), + ) + + withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + manager.sendText(COMPRESSIBLE, FfiStreamTextOptions(topic = TOPIC, attributes = emptyMap())) + } + delegate.awaitPackets(1) + + assertEquals(1, delegate.packets.size) + val header = delegate.packets[0].streamHeader + assertEquals(DataStream.CompressionType.NONE, header.compression) + assertEquals(COMPRESSIBLE, header.inlineContent.toStringUtf8()) + manager.destroy() + } + + /** + * Loops the core's own output back into its input on the device. + * + * The strongest single check available without a server: whatever framing the send path chose -- + * here inline and compressed -- the receive path reconstructs the original payload from exactly + * those bytes, with both halves running on Android. + */ + @Test + fun compressedInlineStreamRoundTripsThroughTheCore() = runBlocking { + val payload = buildString { repeat(200) { append("round trip me ") } } + + val sent = CapturingDelegate() + val outgoing = FfiOutgoingDataStreamManager( + sent, + StubRegistry( + ClientProtocolVersion.DATA_STREAM_V2.value, + listOf(ClientCapability.COMPRESSION_DEFLATE_RAW), + ), + ) + withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + outgoing.sendText(payload, FfiStreamTextOptions(topic = TOPIC, attributes = emptyMap())) + } + sent.awaitPackets(1) + assertEquals(DataStream.CompressionType.DEFLATE_RAW, sent.packets[0].streamHeader.compression) + + val opened = OpenedStreams() + val incoming = FfiIncomingDataStreamManager(opened, null) + for (packet in sent.packets) { + // Stamp a sender, which the wire carries but a capturing delegate never sees. + incoming.handlePacketReceived( + packet.toBuilder().setParticipantIdentity(SENDER).build().toByteArray(), + ) + } + + assertTrue( + "the core never surfaced the stream it had just produced", + opened.latch.await(TIMEOUT.first, TIMEOUT.second), + ) + val received = withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + opened.text.first().readAll() + } + + assertEquals(payload, received) + assertEquals(TOPIC, opened.text.first().info().topic) + outgoing.destroy() + incoming.destroy() + } + + /** Multi-byte text has to survive the encode/decode the SDK's reader glue does. */ + @Test + fun multiByteTextRoundTripsOnDevice() = runBlocking { + val text = "héllo → 世界 🎉 café" + + val sent = CapturingDelegate() + val outgoing = FfiOutgoingDataStreamManager( + sent, + StubRegistry(ClientProtocolVersion.DEFAULT.value, emptyList()), + ) + withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + outgoing.sendText(text, FfiStreamTextOptions(topic = TOPIC, attributes = emptyMap())) + } + sent.awaitPackets(3) + + val opened = OpenedStreams() + val incoming = FfiIncomingDataStreamManager(opened, null) + for (packet in sent.packets) { + incoming.handlePacketReceived( + packet.toBuilder().setParticipantIdentity(SENDER).build().toByteArray(), + ) + } + assertTrue(opened.latch.await(TIMEOUT.first, TIMEOUT.second)) + + val received = withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + opened.text.first().readAll() + } + // Round-tripped through the byte channel the public reader is built on, as the SDK does. + val throughByteChannel = received.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8) + + assertEquals(text, throughByteChannel) + outgoing.destroy() + incoming.destroy() + } + + // region This SDK's own translation layer, exercised in an Android runtime + + @Test + fun streamInfoConversionWorksOnDevice() = runBlocking { + val sent = CapturingDelegate() + val outgoing = FfiOutgoingDataStreamManager( + sent, + StubRegistry(ClientProtocolVersion.DEFAULT.value, emptyList()), + ) + + val info = withTimeout(TIMEOUT.second.toMillis(TIMEOUT.first)) { + outgoing.sendText( + "hello", + FfiStreamTextOptions(topic = TOPIC, attributes = mapOf("a" to "b"), id = "id-1"), + ) + }.toSdk(LivekitModels.Encryption.Type.GCM) + + assertEquals("id-1", info.id) + assertEquals(TOPIC, info.topic) + assertEquals("b", info.attributes["a"]) + assertEquals(5L, info.totalSize) + assertEquals(LivekitModels.Encryption.Type.GCM, info.encryptionType) + outgoing.destroy() + } + + @Test + fun capabilityAndOperationTypeMappingWorksOnDevice() { + assertEquals( + FfiClientCapability.COMPRESSION_DEFLATE_RAW, + ClientCapability.COMPRESSION_DEFLATE_RAW.toFfi(), + ) + for (value in TextStreamInfo.OperationType.entries) { + assertEquals(value, value.toFfi().toSdk()) + } + } + + @Test + fun errorMappingWorksOnDevice() { + val header = io.livekit.uniffi.DataStreamException.HeaderTooLarge().toStreamException() + assertTrue(header is StreamException.HeaderTooLargeException) + assertTrue("must stay catchable as the older type", header is StreamException.LengthExceededException) + + val terminated = io.livekit.uniffi.DataStreamException.MissedChunk().toStreamException() + assertEquals( + StreamException.TerminatedException.Reason.MISSED_CHUNK, + (terminated as StreamException.TerminatedException).reason, + ) + } + + // endregion +} From 80d6a3197a82d2f8f809460fe4bd3a3b7e0e3a76 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 6 Aug 2026 15:35:01 -0400 Subject: [PATCH 12/14] build: fix the uniffi name collisions upstream, drop the patch script Replaces scripts/patch-uniffi-kotlin.py, which rewrote the generated bindings after the fact, with fixes in livekit-uniffi itself. The bindings now compile as generated, so there is no post-processing step to remember or keep working. The two collisions needed different mechanisms: - `close` is fixed by [bindings.kotlin.rename] in livekit-uniffi's uniffi.toml, which is per-language exactly as wanted: Kotlin sees `closeStream()` while Swift, Python and Node keep `close()`. Only this SDK's two call sites change. - `message` could not be. UniFFI keys its rename table by crate name but looks up enum and record *members* by the item's full module path, so a rename for anything declared in a submodule is accepted and silently ignored -- which is also why the method rename works, since methods key off the crate name. That looks like an upstream bug and is worth reporting. There is no field-level `#[uniffi(name)]` attribute in 0.31 either (uniffi_macros takes field names straight from the Rust identifier), so the field is renamed to `reason` in Rust. That is global rather than Kotlin-only, but it is a better name for an error detail anyway, and Swift binds these positionally so its mapping is unaffected. Renaming the field changes the FFI metadata, so the AAR and all three Android libraries were rebuilt; the checksum check between bindings and .so would fail otherwise. Verified: 380 unit tests and 10 instrumented tests on an API 37 emulator, both against bindings generated with no manual edits. Co-Authored-By: Claude Opus 5 (1M context) --- gradle/libs.versions.toml | 4 +- .../android/room/datastream/DataStreams.kt | 7 +- scripts/patch-uniffi-kotlin.py | 185 ------------------ 3 files changed, 7 insertions(+), 189 deletions(-) delete mode 100755 scripts/patch-uniffi-kotlin.py diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eb75d5c7a..f58c24665 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,8 +33,8 @@ lifecycleProcess = "2.8.7" agp = "8.7.2" kotlin = "1.9.25" # Data streams v2 core. Unreleased: build it from rust-sdks with -# `cargo make android-package-local` (see scripts/patch-uniffi-kotlin.py) and it resolves -# from mavenLocal(). Switch to a published version once livekit-uniffi ships this API. +# `cargo make android-package-local` and it resolves from mavenLocal(). Switch to a published +# version once livekit-uniffi ships this API. livekit-uniffi = "0.0.1" # livekit-uniffi's AAR pins jna 5.16.0, whose libjnidispatch.so fails to dlopen on 16 KB # page-size devices ("program alignment (8192) cannot be smaller than system page size"). diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt index fdabf9754..818503259 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/DataStreams.kt @@ -528,8 +528,11 @@ internal constructor( private val writer: FfiTextStreamWriter, ) : WriterDestination() { override suspend fun writeToFfi(data: String) = writer.write(data) + + // closeStream, not close: the latter is the AutoCloseable one uniffi generates for + // releasing the handle. See the rename in livekit-uniffi's uniffi.toml. override suspend fun closeFfi(reason: String?) { - if (reason == null) writer.close() else writer.closeWithReason(reason) + if (reason == null) writer.closeStream() else writer.closeWithReason(reason) } } @@ -538,7 +541,7 @@ internal constructor( ) : WriterDestination() { override suspend fun writeToFfi(data: ByteArray) = writer.write(data) override suspend fun closeFfi(reason: String?) { - if (reason == null) writer.close() else writer.closeWithReason(reason) + if (reason == null) writer.closeStream() else writer.closeWithReason(reason) } } diff --git a/scripts/patch-uniffi-kotlin.py b/scripts/patch-uniffi-kotlin.py deleted file mode 100755 index fac934a1a..000000000 --- a/scripts/patch-uniffi-kotlin.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -# 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. -"""Work around two UniFFI Kotlin codegen bugs in the generated livekit-uniffi bindings. - -TEMPORARY LOCAL-DEV TOOL. The data stream FFI in livekit-uniffi has so far only been -consumed from Swift, Python and Node; nobody had generated its Kotlin bindings before -(`packages/kotlin/` did not exist in the rust-sdks tree). Doing so surfaces two codegen -bugs that make the generated file fail to compile. Both need a real upstream fix -- see -the notes on each patch below -- but neither is fixable from the consuming side, so this -script patches the generated output in place. - -Nothing in the rust-sdks tracked source is modified: `packages/kotlin/` is a build -artifact, and this mirrors the precedent already set by the `swift-workarounds` task in -livekit-uniffi/Makefile.toml, which perl-patches generated Swift for a different uniffi -bug (mozilla/uniffi-rs#2933). - -Run after `uniffi-bindgen generate --language kotlin` and before assembling the AAR. -Idempotent, and fails loudly if the expected patch sites are not found -- so a uniffi -upgrade that changes or fixes the codegen surfaces as an error here rather than as -silently unpatched (or double-patched) output. - -Usage: - scripts/patch-uniffi-kotlin.py -""" - -import re -import sys -from pathlib import Path - -# Rust objects whose exported `close()` collides with the generated AutoCloseable.close(). -WRITERS_WITH_CLOSE = ["ByteStreamWriter", "TextStreamWriter"] - -# Error types with a variant field literally named `message`. -ERROR_TYPE = "DataStreamException" -ERROR_CONVERTER = "FfiConverterTypeDataStreamError" -RENAMED_FIELD = "reason" - -TOP_LEVEL_DECL = re.compile( - r"^(?:public |internal |open |sealed |data |enum )*(?:class|object|interface|typealias|fun|val) ", - re.M, -) - - -def region(src: str, start_pattern: str) -> tuple[int, int]: - """Byte range of one top-level declaration, from its header to the next one.""" - m = re.search(start_pattern, src, re.M) - if not m: - raise SystemExit(f"patch-uniffi-kotlin: could not find {start_pattern!r}") - nxt = TOP_LEVEL_DECL.search(src, m.end()) - return m.start(), (nxt.start() if nxt else len(src)) - - -def patch_writer_close(src: str, cls: str) -> tuple[str, int]: - """Drop AutoCloseable from a writer class so its Rust `close()` can exist. - - UniFFI gives every object a non-suspend `close()` (aliasing `destroy()`) to satisfy - AutoCloseable. When the Rust object *also* exports a method named `close`, the two - differ only by `suspend` and Kotlin rejects them as conflicting overloads, so the - whole file fails to compile. - - Removing AutoCloseable (and its close() alias) keeps the Rust-facing `close()` -- - which Swift already depends on, so renaming the Rust method is not an option -- - while `Disposable.destroy()` remains for releasing the handle. The cost is that - these two types can no longer be used with Kotlin's `use {}`. - - Upstream fix would be for uniffi to name its resource-release method something that - cannot collide, or to skip AutoCloseable when the interface declares `close`. - """ - start, end = region(src, rf"^open class {cls}: ") - body = src[start:end] - changed = 0 - - supertypes = f"open class {cls}: Disposable, AutoCloseable, {cls}Interface" - if supertypes in body: - body = body.replace( - supertypes, - f"open class {cls}: Disposable, {cls}Interface", - 1, - ) - changed += 1 - - alias = " @Synchronized\n override fun close() {\n this.destroy()\n }\n" - if alias in body: - body = body.replace(alias, "", 1) - changed += 1 - - return src[:start] + body + src[end:], changed - - -def patch_error_message_field(src: str) -> tuple[str, int]: - """Rename error variant fields named `message` so they stop colliding. - - UniFFI emits, for a structured error variant carrying a `message` field: - - class AbnormalEnd(val `message`: kotlin.String) : DataStreamException() { - override val message get() = "message=${ `message` }" - } - - The constructor property and the overridden `Throwable.message` are both called - `message` in one class body, which does not compile (and their types differ -- - String vs String? -- so they cannot be merged into one override either). - - Renaming the constructor property sidesteps it. `Throwable.message` still carries - the text, and the raw string is reachable as `.reason`. - - Upstream fix would be for uniffi to avoid emitting a property that collides with - Throwable.message (or for livekit-uniffi to not name the field `message`). - """ - changed = 0 - for pattern in (rf"^sealed class {ERROR_TYPE}", rf"^public object {ERROR_CONVERTER}"): - start, end = region(src, pattern) - body = src[start:end] - before = body - # The constructor property declaration, and reads of it. `override val message` - # is written without backticks, so it is untouched by these. - body = body.replace("val `message`: kotlin.String", f"val `{RENAMED_FIELD}`: kotlin.String") - body = body.replace("${ `message` }", f"${{ `{RENAMED_FIELD}` }}") - body = body.replace("value.`message`", f"value.`{RENAMED_FIELD}`") - if body != before: - changed += 1 - src = src[:start] + body + src[end:] - return src, changed - - -def main() -> int: - if len(sys.argv) != 2: - raise SystemExit(f"usage: {sys.argv[0]} ") - - target = Path(sys.argv[1]) / "io" / "livekit" / "uniffi" / "livekit_uniffi.kt" - if not target.is_file(): - raise SystemExit(f"patch-uniffi-kotlin: {target} not found; run the Kotlin bindgen first") - - src = original = target.read_text() - - total = 0 - for cls in WRITERS_WITH_CLOSE: - src, n = patch_writer_close(src, cls) - total += n - src, n = patch_error_message_field(src) - total += n - - if src == original: - # Either already patched or the codegen changed. Distinguish, so a uniffi - # upgrade that alters these shapes cannot pass silently. - already = "AutoCloseable" not in src.split("open class ByteStreamWriter: ")[1][:200] - if already: - print("patch-uniffi-kotlin: already patched, nothing to do") - return 0 - raise SystemExit( - "patch-uniffi-kotlin: found no patch sites and the file is not already patched.\n" - "The uniffi codegen likely changed. Re-check whether these bugs still exist " - "before removing this script." - ) - - target.write_text(src) - print(f"patch-uniffi-kotlin: applied {total} patch(es) to {target}") - - # Cheap self-check: the exact symptoms must be gone. - leftovers = [] - for cls in WRITERS_WITH_CLOSE: - head = src.split(f"open class {cls}: ")[1][:200] - if "AutoCloseable" in head: - leftovers.append(f"{cls} still implements AutoCloseable") - if "val `message`: kotlin.String" in src.split(f"sealed class {ERROR_TYPE}")[1].split("public object")[0]: - leftovers.append(f"{ERROR_TYPE} still declares a `message` field") - if leftovers: - raise SystemExit("patch-uniffi-kotlin: incomplete: " + "; ".join(leftovers)) - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) From fa009f0ffbbecc626f84456c741fd936c5e93a80 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 6 Aug 2026 16:52:46 -0400 Subject: [PATCH 13/14] fix(sample): stop LeakCanary crashing sample-app on launch Every debug launch of sample-app dies in Activity.onCreate: java.lang.AbstractMethodError: abstract method "androidx.lifecycle.ViewModel androidx.lifecycle.ViewModelProvider$Factory.create(kotlin.reflect.KClass, androidx.lifecycle.viewmodel.CreationExtras)" on receiver leakcanary.internal.ViewModelClearedWatcher$... LeakCanary watches ViewModels by registering a ViewModelProvider.Factory, and it is binary-incompatible with the lifecycle 2.8.0 this project resolves: 2.8.0's KMP refactor made `create(KClass, CreationExtras)` part of the interface, and LeakCanary implements only the older overload. Checked 2.14 as well as the pinned 2.8.1 -- both crash -- so this is not a stale-version problem and there is no version to bump to. Removing the auto-install provider is LeakCanary's own documented way to turn it off, and it is the smallest change that gets the app running. It is scoped to sample-app's debug manifest, so nothing else is affected. Pre-existing and unrelated to data streams -- the same dependency is on main -- but it blocks running the sample app at all, which is where the data streams panel in the next commit lives. Co-Authored-By: Claude Opus 5 (1M context) --- sample-app/src/debug/AndroidManifest.xml | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 sample-app/src/debug/AndroidManifest.xml diff --git a/sample-app/src/debug/AndroidManifest.xml b/sample-app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..fd5e252ea --- /dev/null +++ b/sample-app/src/debug/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + From b991d5ed64949826e823c5ad5eaad644b79a275c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 6 Aug 2026 16:53:18 -0400 Subject: [PATCH 14/14] feat(sample): add a data streams panel to sample-app Adds a developer panel for exercising data streams by hand, reached from a new icon beside the RPC tester in the in-call controls. Conceptually the Android counterpart of livekit-examples/rust-dev-client#18, and structured the same way that PR describes: a send section and a subscriptions section. Until now the sample could only send a fixed `lk.chat` text stream from an AlertDialog and surfaced receipts as a Toast -- no topic, no destination, no byte streams, and nowhere to watch what arrived. Send: text or bytes, a topic, a destination (a remote participant or everyone), and a content box with `hello world` and `20k random` presets. Bytes are the UTF-8 of the same box. The result line reports the new stream's id, or the error. The 20k preset is deliberately random rather than a repeated character: random data does not compress, so it exercises the compressed multi-packet path instead of collapsing into a single inline packet. Subscribe: register a topic as text or bytes and watch it fill up. Each subscription is a card with its own scrolling list of arrivals, newest first, showing sender, size, time and a preview -- truncated for text, hex plus utf8 for bytes, and capped at 100 per topic. State lives on CallViewModel next to the RPC tester's, so subscriptions keep collecting while the panel is closed and are unregistered with the room in onCleared. Subscriptions are keyed on (topic, kind) because text and byte handlers are separate registries and the same topic can carry one of each. A topic something else already owns -- `lk.chat`, or the RPC topics the Room registers -- comes back as a failed Result and is shown, not thrown. Read failures are recorded as the preview, so a PayloadTooLarge or an aborted sender is visible rather than silent. Verified against a local livekit-server with two emulators in one room: text round-tripped, 20k random arrived as 19.5KB with the preview truncated, bytes and text coexisted on one topic with the hex/utf8 preview correct, the destination dropdown listed the peer, and subscribing to `lk.chat` was refused without a crash. Co-Authored-By: Claude Opus 5 (1M context) --- .../livekit/android/sample/CallViewModel.kt | 158 ++++++++++ .../main/res/drawable/baseline_stream_24.xml | 10 + .../io/livekit/android/sample/CallActivity.kt | 5 + .../dialog/DataStreamsDialogFragment.kt | 298 ++++++++++++++++++ .../src/main/res/layout/call_activity.xml | 10 + .../main/res/layout/dialog_data_streams.xml | 261 +++++++++++++++ .../res/layout/item_data_stream_received.xml | 27 ++ .../layout/item_data_stream_subscription.xml | 60 ++++ 8 files changed, 829 insertions(+) create mode 100644 sample-app-common/src/main/res/drawable/baseline_stream_24.xml create mode 100644 sample-app/src/main/java/io/livekit/android/sample/dialog/DataStreamsDialogFragment.kt create mode 100644 sample-app/src/main/res/layout/dialog_data_streams.xml create mode 100644 sample-app/src/main/res/layout/item_data_stream_received.xml create mode 100644 sample-app/src/main/res/layout/item_data_stream_subscription.xml diff --git a/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt b/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt index 1f6f9bceb..05a004e80 100644 --- a/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt +++ b/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt @@ -38,6 +38,7 @@ import io.livekit.android.e2ee.E2EEOptions import io.livekit.android.events.RoomEvent import io.livekit.android.events.collect import io.livekit.android.room.Room +import io.livekit.android.room.datastream.StreamBytesOptions import io.livekit.android.room.datastream.StreamTextOptions import io.livekit.android.room.datastream.incoming.TextStreamReceiver import io.livekit.android.room.participant.LocalParticipant @@ -155,6 +156,12 @@ class CallViewModel( private val mutableHandlers = MutableStateFlow>(emptyList()) val handlers: StateFlow> = mutableHandlers + // Data stream tester state. On the ViewModel for the same reason, and because the handlers are + // registered against the room rather than the dialog -- they should keep collecting while the + // panel is closed. + private val mutableStreamSubscriptions = MutableStateFlow>(emptyList()) + val streamSubscriptions: StateFlow> = mutableStreamSubscriptions + /** * The token source used to fetch connection details for the room. * @@ -382,6 +389,12 @@ class CallViewModel( } mutableHandlers.value = emptyList() + // Same for data stream subscriptions. + mutableStreamSubscriptions.value.forEach { subscription -> + runCatching { unsubscribeFromStream(subscription.topic, subscription.kind) } + } + mutableStreamSubscriptions.value = emptyList() + // Make sure to release any resources associated with LiveKit room.disconnect() room.release() @@ -431,6 +444,95 @@ class CallViewModel( } } + // region Data stream tester + + /** + * Registers a handler for [topic] and starts collecting what arrives on it. + * + * Text and byte handlers are separate registries in the SDK, so the same topic can carry one of + * each; subscriptions are keyed on the pair. + * + * @return a failure if the topic is already taken. That is not exceptional -- `lk.chat` is + * registered by this ViewModel and `lk.rpc_request`/`lk.rpc_response` by the Room -- so it is + * returned for the caller to show rather than thrown. + */ + fun subscribeToStream(topic: String, kind: StreamKind): Result { + if (topic.isBlank()) { + return Result.failure(IllegalArgumentException("Enter a topic")) + } + if (mutableStreamSubscriptions.value.any { it.topic == topic && it.kind == kind }) { + return Result.failure(IllegalArgumentException("Already subscribed to $topic")) + } + + val state = StreamSubscriptionState(topic = topic, kind = kind) + return runCatching { + when (kind) { + StreamKind.TEXT -> room.registerTextStreamHandler(topic) { receiver, identity -> + viewModelScope.launch { + val (size, preview) = runCatching { receiver.readAll().joinToString("") } + .fold( + onSuccess = { it.toByteArray().size to truncateChars(it) }, + onFailure = { 0 to "" }, + ) + state.record(identity, size, preview) + } + } + + StreamKind.BYTES -> room.registerByteStreamHandler(topic) { receiver, identity -> + viewModelScope.launch { + val (size, preview) = runCatching { + receiver.readAll().fold(ByteArray(0)) { acc, chunk -> acc + chunk } + }.fold( + onSuccess = { it.size to bytesPreview(it) }, + onFailure = { 0 to "" }, + ) + state.record(identity, size, preview) + } + } + } + mutableStreamSubscriptions.value = mutableStreamSubscriptions.value + state + } + } + + fun unsubscribeFromStream(topic: String, kind: StreamKind) { + when (kind) { + StreamKind.TEXT -> room.unregisterTextStreamHandler(topic) + StreamKind.BYTES -> room.unregisterByteStreamHandler(topic) + } + mutableStreamSubscriptions.value = mutableStreamSubscriptions.value + .filterNot { it.topic == topic && it.kind == kind } + } + + /** + * Sends [content] as a data stream, returning the new stream's id. + * + * A null [destination] broadcasts. Which framing this actually produces on the wire -- one + * inline packet, or a compressed multi-packet stream, or plain chunks -- is decided by the core + * from what every recipient advertises, so it is worth varying the destination when testing. + */ + suspend fun sendDataStream( + kind: StreamKind, + topic: String, + destination: Participant.Identity?, + content: String, + ): Result { + val destinations = listOfNotNull(destination) + return when (kind) { + StreamKind.TEXT -> room.localParticipant + .sendText(content, StreamTextOptions(topic = topic, destinationIdentities = destinations)) + .map { it.id } + + StreamKind.BYTES -> room.localParticipant + .sendBytes( + content.toByteArray(), + StreamBytesOptions(topic = topic, destinationIdentities = destinations), + ) + .map { it.id } + } + } + + // endregion + fun registerRpcHandler(method: String, initialResponse: String) { if (method.isBlank()) return val state = RpcHandlerState( @@ -576,3 +678,59 @@ sealed class RpcRequestResult { data class Success(val response: String) : RpcRequestResult() data class Error(val code: Int?, val message: String) : RpcRequestResult() } + +/** Whether a stream carries text or raw bytes. Applies to both sending and subscribing. */ +enum class StreamKind(val label: String) { + TEXT("text"), + BYTES("bytes"), +} + +data class ReceivedStreamRecord( + /** 1-based arrival number within its subscription. */ + val n: Long, + val sender: Participant.Identity, + val receivedAtMs: Long, + /** Size of the whole payload, not of the truncated preview. */ + val size: Int, + val preview: String, +) + +class StreamSubscriptionState( + val topic: String, + val kind: StreamKind, +) { + val received = MutableStateFlow>(emptyList()) + val count = MutableStateFlow(0L) + + /** Newest first, capped so a chatty topic cannot grow without bound. */ + fun record(sender: Participant.Identity, size: Int, preview: String) { + val n = count.value + 1 + count.value = n + received.value = ( + listOf( + ReceivedStreamRecord( + n = n, + sender = sender, + receivedAtMs = System.currentTimeMillis(), + size = size, + preview = preview, + ), + ) + received.value + ).take(MAX_RECEIVED_PER_TOPIC) + } +} + +private const val MAX_RECEIVED_PER_TOPIC = 100 +private const val PREVIEW_CHARS = 256 +private const val PREVIEW_BYTES = 64 + +private fun truncateChars(s: String): String = + if (s.length <= PREVIEW_CHARS) s else s.take(PREVIEW_CHARS) + "..." + +private fun bytesPreview(data: ByteArray): String { + val shown = data.take(PREVIEW_BYTES) + val ellipsis = if (data.size > PREVIEW_BYTES) "..." else "" + val hex = shown.joinToString(" ") { "%02x".format(it) } + val utf8 = shown.toByteArray().toString(Charsets.UTF_8) + return "hex: $hex$ellipsis\nutf8: $utf8$ellipsis" +} diff --git a/sample-app-common/src/main/res/drawable/baseline_stream_24.xml b/sample-app-common/src/main/res/drawable/baseline_stream_24.xml new file mode 100644 index 000000000..a012ea4f6 --- /dev/null +++ b/sample-app-common/src/main/res/drawable/baseline_stream_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt b/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt index 98ee95708..cbd2e1461 100644 --- a/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt +++ b/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt @@ -35,6 +35,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.xwray.groupie.GroupieAdapter import io.livekit.android.sample.common.R import io.livekit.android.sample.databinding.CallActivityBinding +import io.livekit.android.sample.dialog.DataStreamsDialogFragment import io.livekit.android.sample.dialog.RpcTestDialogFragment import io.livekit.android.sample.dialog.showAudioProcessorSwitchDialog import io.livekit.android.sample.dialog.showDebugMenuDialog @@ -224,6 +225,10 @@ class CallActivity : AppCompatActivity() { binding.rpcTest.setOnClickListener { RpcTestDialogFragment().show(supportFragmentManager, "rpc_test") } + + binding.dataStreams.setOnClickListener { + DataStreamsDialogFragment().show(supportFragmentManager, "data_streams") + } } override fun onResume() { diff --git a/sample-app/src/main/java/io/livekit/android/sample/dialog/DataStreamsDialogFragment.kt b/sample-app/src/main/java/io/livekit/android/sample/dialog/DataStreamsDialogFragment.kt new file mode 100644 index 000000000..19415858f --- /dev/null +++ b/sample-app/src/main/java/io/livekit/android/sample/dialog/DataStreamsDialogFragment.kt @@ -0,0 +1,298 @@ +/* + * Copyright 2026 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.sample.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.xwray.groupie.GroupieAdapter +import com.xwray.groupie.viewbinding.BindableItem +import com.xwray.groupie.viewbinding.GroupieViewHolder +import io.livekit.android.room.participant.Participant +import io.livekit.android.room.participant.RemoteParticipant +import io.livekit.android.sample.CallViewModel +import io.livekit.android.sample.ReceivedStreamRecord +import io.livekit.android.sample.StreamKind +import io.livekit.android.sample.StreamSubscriptionState +import io.livekit.android.sample.databinding.DialogDataStreamsBinding +import io.livekit.android.sample.databinding.ItemDataStreamReceivedBinding +import io.livekit.android.sample.databinding.ItemDataStreamSubscriptionBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.random.Random + +private const val HELLO_CONTENT = "hello world" +private const val TWENTY_K_SIZE = 20_000 +private const val ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +/** + * 20k of random characters. + * + * Deliberately random rather than a repeated character: random data does not compress, so a v2 + * send of this has to fall back to a compressed multi-packet stream, where a repetitive payload of + * the same size would shrink under the packet budget and go out as a single inline packet. + */ +private fun twentyKRandom(): String { + val random = Random.Default + return buildString(TWENTY_K_SIZE) { + repeat(TWENTY_K_SIZE) { append(ALPHABET[random.nextInt(ALPHABET.length)]) } + } +} + +/** + * A panel for exercising data streams by hand: send text or bytes on a topic to one participant or + * everyone, and subscribe to topics to watch what arrives. + */ +class DataStreamsDialogFragment : DialogFragment() { + + private var _binding: DialogDataStreamsBinding? = null + private val binding get() = _binding!! + + private val viewModel: CallViewModel by activityViewModels() + + /** Parallel to the destination spinner's entries; null at index 0 means broadcast. */ + private val destinations = mutableListOf(null) + private lateinit var destinationAdapter: ArrayAdapter + private val subscriptionsAdapter = GroupieAdapter() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = DialogDataStreamsBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.closeButton.setOnClickListener { dismiss() } + + destinationAdapter = ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_item, + mutableListOf(BROADCAST_LABEL), + ) + destinationAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.destinationSpinner.adapter = destinationAdapter + + binding.presetHello.setOnClickListener { binding.contentEdit.setText(HELLO_CONTENT) } + binding.preset20kRandom.setOnClickListener { binding.contentEdit.setText(twentyKRandom()) } + + binding.sendButton.setOnClickListener { send() } + binding.subscribeButton.setOnClickListener { subscribe() } + + binding.subscriptionsList.layoutManager = LinearLayoutManager(requireContext()) + binding.subscriptionsList.adapter = subscriptionsAdapter + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.participants + .map { list -> list.filterIsInstance() } + .collect { remotes -> updateDestinations(remotes) } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.streamSubscriptions.collect { subscriptions -> + subscriptionsAdapter.update( + subscriptions.map { DataStreamSubscriptionItem(it, viewModel) }, + ) + } + } + } + } + + /** Keeps the spinner in step with the room, preserving the selection where it still exists. */ + private fun updateDestinations(remotes: List) { + val previous = destinations.getOrNull(binding.destinationSpinner.selectedItemPosition) + + destinations.clear() + destinations.add(null) + destinations.addAll(remotes.mapNotNull { it.identity }) + + destinationAdapter.clear() + destinationAdapter.add(BROADCAST_LABEL) + destinationAdapter.addAll(destinations.drop(1).map { it?.value ?: "(unknown)" }) + + // Falls back to broadcast if the participant we were targeting has left. + val restored = destinations.indexOf(previous).takeIf { it >= 0 } ?: 0 + binding.destinationSpinner.setSelection(restored) + } + + private fun sendKind(): StreamKind = + if (binding.sendKindBytes.isChecked) StreamKind.BYTES else StreamKind.TEXT + + private fun send() { + val topic = binding.sendTopicEdit.text.toString().trim() + if (topic.isEmpty()) { + Toast.makeText(requireContext(), "Enter a topic", Toast.LENGTH_SHORT).show() + return + } + val destination = destinations.getOrNull(binding.destinationSpinner.selectedItemPosition) + val content = binding.contentEdit.text.toString() + val kind = sendKind() + + binding.sendButton.isEnabled = false + binding.sendResultText.visibility = View.GONE + binding.sendSpinner.visibility = View.VISIBLE + + viewLifecycleOwner.lifecycleScope.launch { + val result = viewModel.sendDataStream(kind, topic, destination, content) + val b = _binding ?: return@launch + b.sendSpinner.visibility = View.GONE + b.sendButton.isEnabled = true + b.sendResultText.visibility = View.VISIBLE + b.sendResultText.text = result.fold( + onSuccess = { "OK: stream ${it.take(8)} (${content.toByteArray().size}B ${kind.label})" }, + onFailure = { "Error: ${it.message ?: it::class.java.simpleName}" }, + ) + } + } + + private fun subscribe() { + val topic = binding.subscribeTopicEdit.text.toString().trim() + val kind = if (binding.subscribeKindBytes.isChecked) StreamKind.BYTES else StreamKind.TEXT + + viewModel.subscribeToStream(topic, kind) + .onSuccess { binding.subscribeTopicEdit.text.clear() } + .onFailure { + // Expected for a topic something else already owns, such as `lk.chat`. + Toast.makeText( + requireContext(), + it.message ?: "Could not subscribe to $topic", + Toast.LENGTH_LONG, + ).show() + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onDestroyView() { + binding.subscriptionsList.adapter = null + super.onDestroyView() + _binding = null + } + + private companion object { + const val BROADCAST_LABEL = "Everyone (broadcast)" + } +} + +private val receivedTimeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + +/** One subscribed topic, with its own scrolling list of what has arrived on it. */ +class DataStreamSubscriptionItem( + private val state: StreamSubscriptionState, + private val viewModel: CallViewModel, +) : BindableItem() { + + private var scope: CoroutineScope? = null + + override fun initializeViewBinding(view: View): ItemDataStreamSubscriptionBinding = + ItemDataStreamSubscriptionBinding.bind(view) + + override fun getLayout(): Int = io.livekit.android.sample.R.layout.item_data_stream_subscription + + override fun bind(viewBinding: ItemDataStreamSubscriptionBinding, position: Int) { + viewBinding.topicName.text = "${state.topic} [${state.kind.label}]" + + viewBinding.unsubscribeButton.setOnClickListener { + viewModel.unsubscribeFromStream(state.topic, state.kind) + } + + val receivedAdapter = GroupieAdapter() + viewBinding.receivedList.layoutManager = LinearLayoutManager(viewBinding.root.context) + viewBinding.receivedList.adapter = receivedAdapter + + scope?.cancel() + val newScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + scope = newScope + newScope.launch { + state.received.collect { records -> + viewBinding.receivedCount.text = "Received (${state.count.value})" + viewBinding.emptyLabel.visibility = if (records.isEmpty()) View.VISIBLE else View.GONE + viewBinding.receivedList.visibility = if (records.isEmpty()) View.GONE else View.VISIBLE + receivedAdapter.update(records.map { DataStreamReceivedItem(it) }) + } + } + } + + override fun unbind(viewHolder: GroupieViewHolder) { + scope?.cancel() + scope = null + viewHolder.binding.receivedList.adapter = null + super.unbind(viewHolder) + } + + override fun isSameAs(other: com.xwray.groupie.Item<*>): Boolean = + other is DataStreamSubscriptionItem && + other.state.topic == state.topic && + other.state.kind == state.kind + + override fun hasSameContentAs(other: com.xwray.groupie.Item<*>): Boolean = + other is DataStreamSubscriptionItem && other.state === state +} + +/** One received stream: where it came from, how big it was, and a preview of the payload. */ +class DataStreamReceivedItem(private val record: ReceivedStreamRecord) : + BindableItem() { + + override fun initializeViewBinding(view: View): ItemDataStreamReceivedBinding = + ItemDataStreamReceivedBinding.bind(view) + + override fun getLayout(): Int = io.livekit.android.sample.R.layout.item_data_stream_received + + override fun bind(viewBinding: ItemDataStreamReceivedBinding, position: Int) { + val time = receivedTimeFormat.format(Date(record.receivedAtMs)) + viewBinding.meta.text = + "#${record.n} | ${record.sender.value} | ${formatSize(record.size)} | $time" + viewBinding.preview.text = record.preview + } + + override fun isSameAs(other: com.xwray.groupie.Item<*>): Boolean = + other is DataStreamReceivedItem && + other.record.n == record.n && + other.record.sender == record.sender + + private fun formatSize(bytes: Int): String = + if (bytes < 1024) "${bytes}B" else "%.1fKB".format(bytes / 1024f) +} diff --git a/sample-app/src/main/res/layout/call_activity.xml b/sample-app/src/main/res/layout/call_activity.xml index a76c2088b..ca7910c46 100644 --- a/sample-app/src/main/res/layout/call_activity.xml +++ b/sample-app/src/main/res/layout/call_activity.xml @@ -149,5 +149,15 @@ android:padding="@dimen/control_padding" android:src="@drawable/baseline_swap_horiz_24" app:tint="@android:color/white" /> + + diff --git a/sample-app/src/main/res/layout/dialog_data_streams.xml b/sample-app/src/main/res/layout/dialog_data_streams.xml new file mode 100644 index 000000000..4e62e9276 --- /dev/null +++ b/sample-app/src/main/res/layout/dialog_data_streams.xml @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +