davidliu
Committed by GitHub

Implement data streams (#625)

* Implement data streams

* Emit warning logs for unhandled datastreams

* Fix stream closing
正在显示 26 个修改的文件 包含 1692 行增加49 行删除
  1 +---
  2 +"client-sdk-android": minor
  3 +---
  4 +
  5 +Implement data streams feature
@@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
3 <words> 3 <words>
4 <w>bitrates</w> 4 <w>bitrates</w>
5 <w>capturer</w> 5 <w>capturer</w>
  6 + <w>chunker</w>
6 <w>exts</w> 7 <w>exts</w>
7 <w>msid</w> 8 <w>msid</w>
8 </words> 9 </words>
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.coroutines
  18 +
  19 +import kotlinx.coroutines.cancel
  20 +import kotlinx.coroutines.coroutineScope
  21 +import kotlinx.coroutines.currentCoroutineContext
  22 +import kotlinx.coroutines.flow.Flow
  23 +import kotlinx.coroutines.flow.collect
  24 +import kotlinx.coroutines.flow.flow
  25 +import kotlinx.coroutines.flow.takeWhile
  26 +import kotlinx.coroutines.launch
  27 +import kotlin.coroutines.cancellation.CancellationException
  28 +
  29 +fun <T> Flow<T>.takeUntilSignal(signal: Flow<Unit?>): Flow<T> = flow {
  30 + try {
  31 + coroutineScope {
  32 + launch {
  33 + signal.takeWhile { it == null }.collect()
  34 + this@coroutineScope.cancel()
  35 + }
  36 +
  37 + collect {
  38 + emit(it)
  39 + }
  40 + }
  41 + } catch (e: CancellationException) {
  42 + // ignore
  43 + }
  44 +}
  45 +
  46 +fun <T> Flow<T>.cancelOnSignal(signal: Flow<Unit?>): Flow<T> = flow {
  47 + coroutineScope {
  48 + launch {
  49 + signal.takeWhile { it == null }.collect()
  50 + currentCoroutineContext().cancel()
  51 + }
  52 +
  53 + collect {
  54 + emit(it)
  55 + }
  56 + currentCoroutineContext().cancel()
  57 + }
  58 +}
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.dagger
  18 +
  19 +import dagger.Binds
  20 +import dagger.Module
  21 +import io.livekit.android.room.datastream.incoming.IncomingDataStreamManager
  22 +import io.livekit.android.room.datastream.incoming.IncomingDataStreamManagerImpl
  23 +import io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager
  24 +import io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManagerImpl
  25 +
  26 +/**
  27 + * @suppress
  28 + */
  29 +@Module
  30 +abstract class InternalBindsModule {
  31 + @Binds
  32 + abstract fun incomingDataStreamManager(manager: IncomingDataStreamManagerImpl): IncomingDataStreamManager
  33 +
  34 + @Binds
  35 + abstract fun outgoingDataStreamManager(manager: OutgoingDataStreamManagerImpl): OutgoingDataStreamManager
  36 +}
1 /* 1 /*
2 - * Copyright 2023-2024 LiveKit, Inc. 2 + * Copyright 2023-2025 LiveKit, Inc.
3 * 3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License. 5 * you may not use this file except in compliance with the License.
@@ -38,6 +38,7 @@ import javax.inject.Singleton @@ -38,6 +38,7 @@ import javax.inject.Singleton
38 OverridesModule::class, 38 OverridesModule::class,
39 AudioHandlerModule::class, 39 AudioHandlerModule::class,
40 MemoryModule::class, 40 MemoryModule::class,
  41 + InternalBindsModule::class,
41 ], 42 ],
42 ) 43 )
43 interface LiveKitComponent { 44 interface LiveKitComponent {
@@ -38,6 +38,7 @@ import io.livekit.android.util.LKLog @@ -38,6 +38,7 @@ import io.livekit.android.util.LKLog
38 import io.livekit.android.util.flowDelegate 38 import io.livekit.android.util.flowDelegate
39 import io.livekit.android.util.nullSafe 39 import io.livekit.android.util.nullSafe
40 import io.livekit.android.util.withCheckLock 40 import io.livekit.android.util.withCheckLock
  41 +import io.livekit.android.webrtc.DataChannelManager
41 import io.livekit.android.webrtc.RTCStatsGetter 42 import io.livekit.android.webrtc.RTCStatsGetter
42 import io.livekit.android.webrtc.copy 43 import io.livekit.android.webrtc.copy
43 import io.livekit.android.webrtc.isConnected 44 import io.livekit.android.webrtc.isConnected
@@ -165,6 +166,10 @@ internal constructor( @@ -165,6 +166,10 @@ internal constructor(
165 private var reliableDataChannelSub: DataChannel? = null 166 private var reliableDataChannelSub: DataChannel? = null
166 private var lossyDataChannel: DataChannel? = null 167 private var lossyDataChannel: DataChannel? = null
167 private var lossyDataChannelSub: DataChannel? = null 168 private var lossyDataChannelSub: DataChannel? = null
  169 + private var reliableDataChannelManager: DataChannelManager? = null
  170 + private var reliableDataChannelSubManager: DataChannelManager? = null
  171 + private var lossyDataChannelManager: DataChannelManager? = null
  172 + private var lossyDataChannelSubManager: DataChannelManager? = null
168 173
169 private var isSubscriberPrimary = false 174 private var isSubscriberPrimary = false
170 private var isClosed = true 175 private var isClosed = true
@@ -406,19 +411,17 @@ internal constructor( @@ -406,19 +411,17 @@ internal constructor(
406 subscriber?.closeBlocking() 411 subscriber?.closeBlocking()
407 subscriber = null 412 subscriber = null
408 413
409 - fun DataChannel?.completeDispose() {  
410 - this?.unregisterObserver()  
411 - this?.close()  
412 - this?.dispose()  
413 - }  
414 -  
415 - reliableDataChannel?.completeDispose() 414 + reliableDataChannelManager?.dispose()
  415 + reliableDataChannelManager = null
416 reliableDataChannel = null 416 reliableDataChannel = null
417 - reliableDataChannelSub?.completeDispose() 417 + reliableDataChannelSubManager?.dispose()
  418 + reliableDataChannelSubManager = null
418 reliableDataChannelSub = null 419 reliableDataChannelSub = null
419 - lossyDataChannel?.completeDispose() 420 + lossyDataChannelManager?.dispose()
  421 + lossyDataChannelManager = null
420 lossyDataChannel = null 422 lossyDataChannel = null
421 - lossyDataChannelSub?.completeDispose() 423 + lossyDataChannelSubManager?.dispose()
  424 + lossyDataChannelSubManager = null
422 lossyDataChannelSub = null 425 lossyDataChannelSub = null
423 isSubscriberPrimary = false 426 isSubscriberPrimary = false
424 } 427 }
@@ -634,6 +637,22 @@ internal constructor( @@ -634,6 +637,22 @@ internal constructor(
634 channel.send(buf) 637 channel.send(buf)
635 } 638 }
636 639
  640 + internal suspend fun waitForBufferStatusLow(kind: LivekitModels.DataPacket.Kind) {
  641 + ensurePublisherConnected(kind)
  642 + val manager = when (kind) {
  643 + LivekitModels.DataPacket.Kind.RELIABLE -> reliableDataChannelManager
  644 + LivekitModels.DataPacket.Kind.LOSSY -> lossyDataChannelManager
  645 + LivekitModels.DataPacket.Kind.UNRECOGNIZED -> {
  646 + throw IllegalArgumentException()
  647 + }
  648 + }
  649 +
  650 + if (manager == null) {
  651 + throw IllegalStateException("Not connected!")
  652 + }
  653 + manager.waitForBufferedAmountLow(DATA_CHANNEL_LOW_THRESHOLD.toLong())
  654 + }
  655 +
637 private suspend fun ensurePublisherConnected(kind: LivekitModels.DataPacket.Kind) { 656 private suspend fun ensurePublisherConnected(kind: LivekitModels.DataPacket.Kind) {
638 if (!isSubscriberPrimary) { 657 if (!isSubscriberPrimary) {
639 return 658 return
@@ -802,6 +821,7 @@ internal constructor( @@ -802,6 +821,7 @@ internal constructor(
802 fun onTranscriptionReceived(transcription: LivekitModels.Transcription) 821 fun onTranscriptionReceived(transcription: LivekitModels.Transcription)
803 fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed) 822 fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed)
804 fun onRpcPacketReceived(dp: LivekitModels.DataPacket) 823 fun onRpcPacketReceived(dp: LivekitModels.DataPacket)
  824 + fun onDataStreamPacket(dp: LivekitModels.DataPacket)
805 } 825 }
806 826
807 companion object { 827 companion object {
@@ -817,11 +837,13 @@ internal constructor( @@ -817,11 +837,13 @@ internal constructor(
817 */ 837 */
818 @VisibleForTesting 838 @VisibleForTesting
819 const val LOSSY_DATA_CHANNEL_LABEL = "_lossy" 839 const val LOSSY_DATA_CHANNEL_LABEL = "_lossy"
820 - internal const val MAX_DATA_PACKET_SIZE = 15360 // 15 KB 840 + internal const val MAX_DATA_PACKET_SIZE = 15 * 1024 // 15 KB
821 private const val MAX_RECONNECT_RETRIES = 10 841 private const val MAX_RECONNECT_RETRIES = 10
822 private const val MAX_RECONNECT_TIMEOUT = 60 * 1000 842 private const val MAX_RECONNECT_TIMEOUT = 60 * 1000
823 private const val MAX_ICE_CONNECT_TIMEOUT_MS = 20000 843 private const val MAX_ICE_CONNECT_TIMEOUT_MS = 20000
824 844
  845 + private const val DATA_CHANNEL_LOW_THRESHOLD = 64 * 1024 // 64 KB
  846 +
825 internal val CONN_CONSTRAINTS = MediaConstraints().apply { 847 internal val CONN_CONSTRAINTS = MediaConstraints().apply {
826 with(optional) { 848 with(optional) {
827 add(MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true")) 849 add(MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true"))
@@ -1079,16 +1101,11 @@ internal constructor( @@ -1079,16 +1101,11 @@ internal constructor(
1079 LKLog.v { "invalid value for data packet" } 1101 LKLog.v { "invalid value for data packet" }
1080 } 1102 }
1081 1103
1082 - LivekitModels.DataPacket.ValueCase.STREAM_HEADER -> {  
1083 - // TODO  
1084 - }  
1085 -  
1086 - LivekitModels.DataPacket.ValueCase.STREAM_CHUNK -> {  
1087 - // TODO  
1088 - }  
1089 -  
1090 - LivekitModels.DataPacket.ValueCase.STREAM_TRAILER -> {  
1091 - // TODO 1104 + LivekitModels.DataPacket.ValueCase.STREAM_HEADER,
  1105 + LivekitModels.DataPacket.ValueCase.STREAM_CHUNK,
  1106 + LivekitModels.DataPacket.ValueCase.STREAM_TRAILER,
  1107 + -> {
  1108 + listener?.onDataStreamPacket(dp)
1092 } 1109 }
1093 } 1110 }
1094 } 1111 }
@@ -42,6 +42,7 @@ import io.livekit.android.e2ee.E2EEOptions @@ -42,6 +42,7 @@ import io.livekit.android.e2ee.E2EEOptions
42 import io.livekit.android.events.* 42 import io.livekit.android.events.*
43 import io.livekit.android.memory.CloseableManager 43 import io.livekit.android.memory.CloseableManager
44 import io.livekit.android.renderer.TextureViewRenderer 44 import io.livekit.android.renderer.TextureViewRenderer
  45 +import io.livekit.android.room.datastream.incoming.IncomingDataStreamManager
45 import io.livekit.android.room.metrics.collectMetrics 46 import io.livekit.android.room.metrics.collectMetrics
46 import io.livekit.android.room.network.NetworkCallbackManagerFactory 47 import io.livekit.android.room.network.NetworkCallbackManagerFactory
47 import io.livekit.android.room.participant.* 48 import io.livekit.android.room.participant.*
@@ -106,7 +107,8 @@ constructor( @@ -106,7 +107,8 @@ constructor(
106 private val regionUrlProviderFactory: RegionUrlProvider.Factory, 107 private val regionUrlProviderFactory: RegionUrlProvider.Factory,
107 private val connectionWarmer: ConnectionWarmer, 108 private val connectionWarmer: ConnectionWarmer,
108 private val audioRecordPrewarmer: AudioRecordPrewarmer, 109 private val audioRecordPrewarmer: AudioRecordPrewarmer,
109 -) : RTCEngine.Listener, ParticipantListener { 110 + private val incomingDataStreamManager: IncomingDataStreamManager,
  111 +) : RTCEngine.Listener, ParticipantListener, IncomingDataStreamManager by incomingDataStreamManager {
110 112
111 private lateinit var coroutineScope: CoroutineScope 113 private lateinit var coroutineScope: CoroutineScope
112 private val eventBus = BroadcastEventBus<RoomEvent>() 114 private val eventBus = BroadcastEventBus<RoomEvent>()
@@ -907,6 +909,7 @@ constructor( @@ -907,6 +909,7 @@ constructor(
907 name = null 909 name = null
908 isRecording = false 910 isRecording = false
909 sidToIdentity.clear() 911 sidToIdentity.clear()
  912 + incomingDataStreamManager.clearOpenStreams()
910 } 913 }
911 914
912 private fun sendSyncState() { 915 private fun sendSyncState() {
@@ -1193,6 +1196,28 @@ constructor( @@ -1193,6 +1196,28 @@ constructor(
1193 /** 1196 /**
1194 * @suppress 1197 * @suppress
1195 */ 1198 */
  1199 + override fun onDataStreamPacket(dp: LivekitModels.DataPacket) {
  1200 + when (dp.valueCase) {
  1201 + LivekitModels.DataPacket.ValueCase.STREAM_HEADER -> {
  1202 + incomingDataStreamManager.handleStreamHeader(dp.streamHeader, Participant.Identity(dp.participantIdentity))
  1203 + }
  1204 +
  1205 + LivekitModels.DataPacket.ValueCase.STREAM_CHUNK -> {
  1206 + incomingDataStreamManager.handleDataChunk(dp.streamChunk)
  1207 + }
  1208 +
  1209 + LivekitModels.DataPacket.ValueCase.STREAM_TRAILER -> {
  1210 + incomingDataStreamManager.handleStreamTrailer(dp.streamTrailer)
  1211 + }
  1212 +
  1213 + // Ignore other cases.
  1214 + else -> {}
  1215 + }
  1216 + }
  1217 +
  1218 + /**
  1219 + * @suppress
  1220 + */
1196 override fun onTranscriptionReceived(transcription: LivekitModels.Transcription) { 1221 override fun onTranscriptionReceived(transcription: LivekitModels.Transcription) {
1197 if (transcription.segmentsList.isEmpty()) { 1222 if (transcription.segmentsList.isEmpty()) {
1198 LKLog.d { "Received transcription segments are empty." } 1223 LKLog.d { "Received transcription segments are empty." }
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream
  18 +
  19 +sealed class StreamException(message: String? = null) : Exception(message) {
  20 + class AlreadyOpenedException : StreamException()
  21 + class AbnormalEndException(message: String?) : StreamException(message)
  22 + class DecodeFailedException : StreamException()
  23 + class LengthExceededException : StreamException()
  24 + class IncompleteException : StreamException()
  25 + class TerminatedException : StreamException()
  26 + class UnknownStreamException : StreamException()
  27 + class NotDirectoryException : StreamException()
  28 + class FileInfoUnavailableException : StreamException()
  29 +}
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream
  18 +
  19 +import livekit.LivekitModels
  20 +import livekit.LivekitModels.DataStream.ByteHeader
  21 +import livekit.LivekitModels.DataStream.Header
  22 +import livekit.LivekitModels.DataStream.TextHeader
  23 +
  24 +sealed class StreamInfo(
  25 + open val id: String,
  26 + open val topic: String,
  27 + open val timestampMs: Long,
  28 + open val totalSize: Long?,
  29 + open val attributes: Map<String, String>,
  30 +)
  31 +
  32 +data class TextStreamInfo(
  33 + override val id: String,
  34 + override val topic: String,
  35 + override val timestampMs: Long,
  36 + override val totalSize: Long?,
  37 + override val attributes: Map<String, String>,
  38 + val operationType: OperationType,
  39 + val version: Int,
  40 + val replyToStreamId: String?,
  41 + val attachedStreamIds: List<String>,
  42 + val generated: Boolean,
  43 +) : StreamInfo(id, topic, timestampMs, totalSize, attributes) {
  44 + constructor(header: Header, textHeader: TextHeader) : this(
  45 + id = header.streamId,
  46 + topic = header.topic,
  47 + timestampMs = header.timestamp,
  48 + totalSize = if (header.hasTotalLength()) {
  49 + header.totalLength
  50 + } else {
  51 + null
  52 + },
  53 + attributes = header.attributesMap.toMap(),
  54 + operationType = OperationType.fromProto(textHeader.operationType),
  55 + version = textHeader.version,
  56 + replyToStreamId = if (!textHeader.replyToStreamId.isNullOrEmpty()) {
  57 + textHeader.replyToStreamId
  58 + } else {
  59 + null
  60 + },
  61 + attachedStreamIds = textHeader.attachedStreamIdsList ?: emptyList(),
  62 + generated = textHeader.generated,
  63 + )
  64 +
  65 + enum class OperationType {
  66 + CREATE,
  67 + UPDATE,
  68 + DELETE,
  69 + REACTION;
  70 +
  71 + /**
  72 + * @throws IllegalArgumentException [operationType] is unrecognized
  73 + */
  74 + fun toProto(): LivekitModels.DataStream.OperationType {
  75 + return when (this) {
  76 + CREATE -> LivekitModels.DataStream.OperationType.CREATE
  77 + UPDATE -> LivekitModels.DataStream.OperationType.UPDATE
  78 + DELETE -> LivekitModels.DataStream.OperationType.DELETE
  79 + REACTION -> LivekitModels.DataStream.OperationType.REACTION
  80 + }
  81 + }
  82 +
  83 + companion object {
  84 + /**
  85 + * @throws IllegalArgumentException [operationType] is unrecognized
  86 + */
  87 + fun fromProto(operationType: LivekitModels.DataStream.OperationType): OperationType {
  88 + return when (operationType) {
  89 + LivekitModels.DataStream.OperationType.CREATE -> CREATE
  90 + LivekitModels.DataStream.OperationType.UPDATE -> UPDATE
  91 + LivekitModels.DataStream.OperationType.DELETE -> DELETE
  92 + LivekitModels.DataStream.OperationType.REACTION -> REACTION
  93 + LivekitModels.DataStream.OperationType.UNRECOGNIZED -> throw IllegalArgumentException("Unrecognized operation type!")
  94 + }
  95 + }
  96 + }
  97 + }
  98 +}
  99 +
  100 +data class ByteStreamInfo(
  101 + override val id: String,
  102 + override val topic: String,
  103 + override val timestampMs: Long,
  104 + override val totalSize: Long?,
  105 + override val attributes: Map<String, String>,
  106 + val mimeType: String,
  107 + val name: String?,
  108 +) : StreamInfo(id, topic, timestampMs, totalSize, attributes) {
  109 + constructor(header: Header, byteHeader: ByteHeader) : this(
  110 + id = header.streamId,
  111 + topic = header.topic,
  112 + timestampMs = header.timestamp,
  113 + totalSize = if (header.hasTotalLength()) {
  114 + header.totalLength
  115 + } else {
  116 + null
  117 + },
  118 + attributes = header.attributesMap.toMap(),
  119 + mimeType = header.mimeType,
  120 + name = byteHeader.name,
  121 + )
  122 +}
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream
  18 +
  19 +import io.livekit.android.room.participant.Participant
  20 +import livekit.LivekitModels
  21 +import java.util.UUID
  22 +
  23 +interface StreamOptions {
  24 + val topic: String?
  25 + val attributes: Map<String, String>?
  26 + val totalLength: Long?
  27 + val mimeType: String?
  28 + val encryptionType: LivekitModels.Encryption.Type?
  29 + val destinationIdentities: List<Participant.Identity>
  30 +}
  31 +
  32 +data class StreamTextOptions(
  33 + val topic: String = "",
  34 + val attributes: Map<String, String> = emptyMap(),
  35 + val streamId: String = UUID.randomUUID().toString(),
  36 + val destinationIdentities: List<Participant.Identity> = emptyList(),
  37 + val operationType: TextStreamInfo.OperationType,
  38 + val version: Int = 0,
  39 + val attachedStreamIds: List<String> = emptyList(),
  40 + val replyToStreamId: String? = null,
  41 + /**
  42 + * The total exact size in bytes, if known.
  43 + */
  44 + val totalSize: Long? = null,
  45 +)
  46 +
  47 +data class StreamBytesOptions(
  48 + val topic: String = "",
  49 + val attributes: Map<String, String> = emptyMap(),
  50 + val streamId: String = UUID.randomUUID().toString(),
  51 + val destinationIdentities: List<Participant.Identity> = emptyList(),
  52 + /**
  53 + * The mime type of the stream data. Defaults to application/octet-stream
  54 + */
  55 + val mimeType: String = "application/octet-stream",
  56 + /**
  57 + * The name of the file being sent.
  58 + */
  59 + val name: String,
  60 + /**
  61 + * The total exact size in bytes, if known.
  62 + */
  63 + val totalSize: Long? = null,
  64 +)
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream.incoming
  18 +
  19 +import kotlinx.coroutines.channels.Channel
  20 +import kotlinx.coroutines.flow.Flow
  21 +import kotlinx.coroutines.flow.catch
  22 +import kotlinx.coroutines.flow.first
  23 +import kotlinx.coroutines.flow.fold
  24 +
  25 +abstract class BaseStreamReceiver<T>(private val source: Channel<ByteArray>) {
  26 +
  27 + abstract val flow: Flow<T>
  28 +
  29 + internal fun close(error: Exception?) {
  30 + source.close(cause = error)
  31 + }
  32 +
  33 + /**
  34 + * Suspends and waits for the next piece of data.
  35 + *
  36 + * @return the next available piece of data.
  37 + * @throws NoSuchElementException when the stream is closed and no more data is available.
  38 + */
  39 + suspend fun readNext(): T {
  40 + return flow.first()
  41 + }
  42 +
  43 + /**
  44 + * Suspends and waits for all available data until the stream is closed.
  45 + */
  46 + suspend fun readAll(): List<T> {
  47 + flow.catch { }
  48 + return flow.fold(mutableListOf()) { acc, value ->
  49 + acc.add(value)
  50 + return@fold acc
  51 + }
  52 + }
  53 +}
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream.incoming
  18 +
  19 +import io.livekit.android.room.datastream.ByteStreamInfo
  20 +import kotlinx.coroutines.channels.Channel
  21 +import kotlinx.coroutines.flow.Flow
  22 +import kotlinx.coroutines.flow.receiveAsFlow
  23 +
  24 +class ByteStreamReceiver(
  25 + val info: ByteStreamInfo,
  26 + channel: Channel<ByteArray>,
  27 +) : BaseStreamReceiver<ByteArray>(channel) {
  28 + override val flow: Flow<ByteArray> = channel.receiveAsFlow()
  29 +}
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream.incoming
  18 +
  19 +import android.os.SystemClock
  20 +import androidx.annotation.VisibleForTesting
  21 +import io.livekit.android.room.datastream.ByteStreamInfo
  22 +import io.livekit.android.room.datastream.StreamException
  23 +import io.livekit.android.room.datastream.StreamInfo
  24 +import io.livekit.android.room.datastream.TextStreamInfo
  25 +import io.livekit.android.room.participant.Participant
  26 +import io.livekit.android.util.LKLog
  27 +import kotlinx.coroutines.ExperimentalCoroutinesApi
  28 +import kotlinx.coroutines.channels.BufferOverflow
  29 +import kotlinx.coroutines.channels.Channel
  30 +import livekit.LivekitModels.DataStream
  31 +import java.util.Collections
  32 +import javax.inject.Inject
  33 +
  34 +// Type-erased stream handler
  35 +private typealias AnyStreamHandler = (Channel<ByteArray>, Participant.Identity) -> Unit
  36 +
  37 +typealias ByteStreamHandler = (reader: ByteStreamReceiver, fromIdentity: Participant.Identity) -> Unit
  38 +typealias TextStreamHandler = (reader: TextStreamReceiver, fromIdentity: Participant.Identity) -> Unit
  39 +
  40 +interface IncomingDataStreamManager {
  41 +
  42 + /**
  43 + * Registers a text stream handler for [topic]. Only one handler can be set for a particular topic at a time.
  44 + *
  45 + * @throws IllegalArgumentException if a topic is already set.
  46 + */
  47 + fun registerTextStreamHandler(topic: String, handler: TextStreamHandler)
  48 +
  49 + /**
  50 + * Unregisters a previously registered text handler for [topic].
  51 + */
  52 + fun unregisterTextStreamHandler(topic: String)
  53 +
  54 + /**
  55 + * Registers a byte stream handler for [topic]. Only one handler can be set for a particular topic at a time.
  56 + *
  57 + * @throws IllegalArgumentException if a topic is already set.
  58 + */
  59 + fun registerByteStreamHandler(topic: String, handler: ByteStreamHandler)
  60 +
  61 + /**
  62 + * Unregisters a previously registered byte handler for [topic].
  63 + */
  64 + fun unregisterByteStreamHandler(topic: String)
  65 +
  66 + /**
  67 + * @suppress
  68 + */
  69 + fun handleStreamHeader(header: DataStream.Header, fromIdentity: Participant.Identity)
  70 +
  71 + /**
  72 + * @suppress
  73 + */
  74 + fun handleDataChunk(chunk: DataStream.Chunk)
  75 +
  76 + /**
  77 + * @suppress
  78 + */
  79 + fun handleStreamTrailer(trailer: DataStream.Trailer)
  80 +
  81 + /**
  82 + * @suppress
  83 + */
  84 + fun clearOpenStreams()
  85 +}
  86 +
  87 +/**
  88 + * @suppress
  89 + */
  90 +class IncomingDataStreamManagerImpl @Inject constructor() : IncomingDataStreamManager {
  91 +
  92 + private data class Descriptor(
  93 + val streamInfo: StreamInfo,
  94 + /**
  95 + * Measured by SystemClock.elapsedRealtime()
  96 + */
  97 + val openTime: Long,
  98 + val channel: Channel<ByteArray>,
  99 + var readLength: Long = 0,
  100 + )
  101 +
  102 + private val openStreams = Collections.synchronizedMap(mutableMapOf<String, Descriptor>())
  103 + private val textStreamHandlers = Collections.synchronizedMap(mutableMapOf<String, TextStreamHandler>())
  104 + private val byteStreamHandlers = Collections.synchronizedMap(mutableMapOf<String, ByteStreamHandler>())
  105 +
  106 + /**
  107 + * Registers a text stream handler for [topic]. Only one handler can be set for a particular topic at a time.
  108 + *
  109 + * @throws IllegalArgumentException if a topic is already set.
  110 + */
  111 + override fun registerTextStreamHandler(topic: String, handler: TextStreamHandler) {
  112 + synchronized(textStreamHandlers) {
  113 + if (textStreamHandlers.containsKey(topic)) {
  114 + throw IllegalArgumentException("A text stream handler for topic $topic has already been set.")
  115 + }
  116 +
  117 + textStreamHandlers[topic] = handler
  118 + }
  119 + }
  120 +
  121 + /**
  122 + * Unregisters a previously registered text handler for [topic].
  123 + */
  124 + override fun unregisterTextStreamHandler(topic: String) {
  125 + synchronized(textStreamHandlers) {
  126 + textStreamHandlers.remove(topic)
  127 + }
  128 + }
  129 +
  130 + /**
  131 + * Registers a byte stream handler for [topic]. Only one handler can be set for a particular topic at a time.
  132 + *
  133 + * @throws IllegalArgumentException if a topic is already set.
  134 + */
  135 + override fun registerByteStreamHandler(topic: String, handler: ByteStreamHandler) {
  136 + synchronized(byteStreamHandlers) {
  137 + if (byteStreamHandlers.containsKey(topic)) {
  138 + throw IllegalArgumentException("A byte stream handler for topic $topic has already been set.")
  139 + }
  140 +
  141 + byteStreamHandlers[topic] = handler
  142 + }
  143 + }
  144 +
  145 + /**
  146 + * Unregisters a previously registered byte handler for [topic].
  147 + */
  148 + override fun unregisterByteStreamHandler(topic: String) {
  149 + synchronized(byteStreamHandlers) {
  150 + byteStreamHandlers.remove(topic)
  151 + }
  152 + }
  153 +
  154 + /**
  155 + * @suppress
  156 + */
  157 + override fun handleStreamHeader(header: DataStream.Header, fromIdentity: Participant.Identity) {
  158 + val info = streamInfoFromHeader(header) ?: return
  159 + openStream(info, fromIdentity)
  160 + }
  161 +
  162 + @OptIn(ExperimentalCoroutinesApi::class)
  163 + private fun openStream(info: StreamInfo, fromIdentity: Participant.Identity) {
  164 + if (openStreams.containsKey(info.id)) {
  165 + LKLog.w { "Stream already open for id ${info.id}" }
  166 + return
  167 + }
  168 +
  169 + val handler = getHandlerForInfo(info)
  170 + val channel = createChannelForStreamReceiver()
  171 +
  172 + val descriptor = Descriptor(
  173 + streamInfo = info,
  174 + openTime = SystemClock.elapsedRealtime(),
  175 + channel = channel,
  176 + )
  177 +
  178 + openStreams[info.id] = descriptor
  179 + channel.invokeOnClose { closeStream(id = info.id) }
  180 + LKLog.d { "Opened stream ${info.id}" }
  181 +
  182 + try {
  183 + handler.invoke(channel, fromIdentity)
  184 + } catch (e: Exception) {
  185 + LKLog.e(e) { "Unhandled exception when invoking stream handler!" }
  186 + }
  187 + }
  188 +
  189 + /**
  190 + * @suppress
  191 + */
  192 + override fun handleDataChunk(chunk: DataStream.Chunk) {
  193 + val content = chunk.content ?: return
  194 + val descriptor = openStreams[chunk.streamId] ?: return
  195 + val totalReadLength = descriptor.readLength + content.size()
  196 +
  197 + val totalLength = descriptor.streamInfo.totalSize
  198 + if (totalLength != null) {
  199 + if (totalReadLength > totalLength) {
  200 + descriptor.channel.close(StreamException.LengthExceededException())
  201 + return
  202 + }
  203 + }
  204 + descriptor.readLength = totalReadLength
  205 + descriptor.channel.trySend(content.toByteArray())
  206 + }
  207 +
  208 + /**
  209 + * @suppress
  210 + */
  211 + override fun handleStreamTrailer(trailer: DataStream.Trailer) {
  212 + val descriptor = openStreams[trailer.streamId]
  213 + if (descriptor == null) {
  214 + LKLog.w { "Received trailer for unknown stream: ${trailer.streamId}" }
  215 + return
  216 + }
  217 +
  218 + val totalLength = descriptor.streamInfo.totalSize
  219 + if (totalLength != null) {
  220 + if (descriptor.readLength != totalLength) {
  221 + descriptor.channel.close(StreamException.IncompleteException())
  222 + return
  223 + }
  224 + }
  225 +
  226 + val reason = trailer.reason
  227 +
  228 + if (!reason.isNullOrEmpty()) {
  229 + // A non-empty reason string indicates an error
  230 + val exception = StreamException.AbnormalEndException(reason)
  231 + descriptor.channel.close(exception)
  232 + return
  233 + }
  234 +
  235 + // Close successfully.
  236 + descriptor.channel.close()
  237 + }
  238 +
  239 + private fun closeStream(id: String) {
  240 + synchronized(openStreams) {
  241 + val descriptor = openStreams[id]
  242 + if (descriptor == null) {
  243 + LKLog.d { "Attempted to close stream $id, but no descriptor was found." }
  244 + return
  245 + }
  246 +
  247 + descriptor.channel.close()
  248 + val openMillis = SystemClock.elapsedRealtime() - descriptor.openTime
  249 + LKLog.d { "Closed stream $id, (open for ${openMillis}ms" }
  250 +
  251 + openStreams.remove(id)
  252 + }
  253 + }
  254 +
  255 + /**
  256 + * @suppress
  257 + */
  258 + override fun clearOpenStreams() {
  259 + synchronized(openStreams) {
  260 + for (descriptor in openStreams.values) {
  261 + descriptor.channel.close(StreamException.TerminatedException())
  262 + }
  263 + openStreams.clear()
  264 + }
  265 + }
  266 +
  267 + private fun getHandlerForInfo(info: StreamInfo): AnyStreamHandler {
  268 + return when (info) {
  269 + is ByteStreamInfo -> {
  270 + val handler = byteStreamHandlers[info.topic]
  271 + { channel, identity ->
  272 + if (handler == null) {
  273 + LKLog.w { "Received byte stream for topic \"${info.topic}\", but no handler was found. Ignoring." }
  274 + } else {
  275 + handler.invoke(ByteStreamReceiver(info, channel), identity)
  276 + }
  277 + }
  278 + }
  279 +
  280 + is TextStreamInfo -> {
  281 + val handler = textStreamHandlers[info.topic]
  282 +
  283 + { channel, identity ->
  284 + if (handler == null) {
  285 + LKLog.w { "Received text stream for topic \"${info.topic}\", but no handler was found. Ignoring." }
  286 + } else {
  287 + handler.invoke(TextStreamReceiver(info, channel), identity)
  288 + }
  289 + }
  290 + }
  291 + }
  292 + }
  293 +
  294 + private fun streamInfoFromHeader(header: DataStream.Header): StreamInfo? {
  295 + try {
  296 + return when (header.contentHeaderCase) {
  297 + DataStream.Header.ContentHeaderCase.TEXT_HEADER -> {
  298 + TextStreamInfo(header, header.textHeader)
  299 + }
  300 +
  301 + DataStream.Header.ContentHeaderCase.BYTE_HEADER -> {
  302 + ByteStreamInfo(header, header.byteHeader)
  303 + }
  304 +
  305 + DataStream.Header.ContentHeaderCase.CONTENTHEADER_NOT_SET,
  306 + null,
  307 + -> {
  308 + LKLog.i { "received header with non-set content header. streamId: ${header.streamId}, topic: ${header.topic}" }
  309 + null
  310 + }
  311 + }
  312 + } catch (e: Exception) {
  313 + LKLog.e(e) { "Exception when processing new stream header." }
  314 + return null
  315 + }
  316 + }
  317 +
  318 + companion object {
  319 + @VisibleForTesting
  320 + fun createChannelForStreamReceiver() = Channel<ByteArray>(
  321 + capacity = Int.MAX_VALUE,
  322 + onBufferOverflow = BufferOverflow.SUSPEND,
  323 + )
  324 + }
  325 +}
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream.incoming
  18 +
  19 +import io.livekit.android.room.datastream.TextStreamInfo
  20 +import kotlinx.coroutines.channels.Channel
  21 +import kotlinx.coroutines.flow.Flow
  22 +import kotlinx.coroutines.flow.map
  23 +import kotlinx.coroutines.flow.receiveAsFlow
  24 +
  25 +class TextStreamReceiver(
  26 + val info: TextStreamInfo,
  27 + source: Channel<ByteArray>,
  28 +) : BaseStreamReceiver<String>(source) {
  29 + override val flow: Flow<String> = source.receiveAsFlow()
  30 + .map { bytes -> bytes.toString(Charsets.UTF_8) }
  31 +}
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream.outgoing
  18 +
  19 +import io.livekit.android.room.datastream.StreamException
  20 +
  21 +abstract class BaseStreamSender<T>(
  22 + internal val destination: StreamDestination<T>,
  23 +) {
  24 +
  25 + suspend fun write(data: T) {
  26 + if (!destination.isOpen) {
  27 + throw StreamException.TerminatedException()
  28 + }
  29 +
  30 + writeImpl(data)
  31 + }
  32 +
  33 + internal abstract suspend fun writeImpl(data: T)
  34 + suspend fun close(reason: String? = null) {
  35 + destination.close(reason)
  36 + }
  37 +}
  38 +
  39 +/**
  40 + * @suppress
  41 + */
  42 +interface StreamDestination<T> {
  43 + val isOpen: Boolean
  44 + suspend fun write(data: T, chunker: DataChunker<T>)
  45 + suspend fun close(reason: String?)
  46 +}
  47 +
  48 +internal typealias DataChunker<T> = (data: T, chunkSize: Int) -> List<ByteArray>
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream.outgoing
  18 +
  19 +import io.livekit.android.room.datastream.ByteStreamInfo
  20 +import okio.Buffer
  21 +import okio.FileSystem
  22 +import okio.Path.Companion.toPath
  23 +import okio.Source
  24 +import okio.source
  25 +import java.io.InputStream
  26 +import java.util.Arrays
  27 +
  28 +class ByteStreamSender(
  29 + val info: ByteStreamInfo,
  30 + destination: StreamDestination<ByteArray>,
  31 +) : BaseStreamSender<ByteArray>(destination = destination) {
  32 + override suspend fun writeImpl(data: ByteArray) {
  33 + destination.write(data, byteDataChunker)
  34 + }
  35 +}
  36 +
  37 +private val byteDataChunker: DataChunker<ByteArray> = { data: ByteArray, chunkSize: Int ->
  38 + (data.indices step chunkSize)
  39 + .map { index -> Arrays.copyOfRange(data, index, index + chunkSize) }
  40 +}
  41 +
  42 +/**
  43 + * Reads the file and writes it to the data stream.
  44 + *
  45 + * @throws
  46 + */
  47 +suspend fun ByteStreamSender.writeFile(filePath: String) {
  48 + write(FileSystem.SYSTEM.source(filePath.toPath()))
  49 +}
  50 +
  51 +/**
  52 + * Reads the input stream and sends it to the data stream.
  53 + */
  54 +suspend fun ByteStreamSender.write(input: InputStream) {
  55 + write(input.source())
  56 +}
  57 +
  58 +/**
  59 + * Reads the source and sends it to the data stream.
  60 + */
  61 +suspend fun ByteStreamSender.write(source: Source) {
  62 + val buffer = Buffer()
  63 + while (true) {
  64 + val readLen = source.read(buffer, 4096)
  65 + if (readLen == -1L) {
  66 + break
  67 + }
  68 +
  69 + write(buffer.readByteArray())
  70 + }
  71 +}
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream.outgoing
  18 +
  19 +import com.google.protobuf.ByteString
  20 +import io.livekit.android.room.RTCEngine
  21 +import io.livekit.android.room.datastream.ByteStreamInfo
  22 +import io.livekit.android.room.datastream.StreamBytesOptions
  23 +import io.livekit.android.room.datastream.StreamException
  24 +import io.livekit.android.room.datastream.StreamInfo
  25 +import io.livekit.android.room.datastream.StreamTextOptions
  26 +import io.livekit.android.room.datastream.TextStreamInfo
  27 +import io.livekit.android.room.participant.Participant
  28 +import io.livekit.android.util.LKLog
  29 +import livekit.LivekitModels.DataPacket
  30 +import livekit.LivekitModels.DataStream
  31 +import java.util.Collections
  32 +import java.util.Date
  33 +import java.util.concurrent.atomic.AtomicLong
  34 +import javax.inject.Inject
  35 +
  36 +interface OutgoingDataStreamManager {
  37 + /**
  38 + * Start sending a stream of text
  39 + */
  40 + suspend fun streamText(options: StreamTextOptions): TextStreamSender
  41 +
  42 + /**
  43 + * Start sending a stream of bytes
  44 + */
  45 + suspend fun streamBytes(options: StreamBytesOptions): ByteStreamSender
  46 +}
  47 +
  48 +/**
  49 + * @suppress
  50 + */
  51 +class OutgoingDataStreamManagerImpl
  52 +@Inject
  53 +constructor(
  54 + val engine: RTCEngine,
  55 +) : OutgoingDataStreamManager {
  56 +
  57 + private data class Descriptor(
  58 + val info: StreamInfo,
  59 + val destinationIdentityStrings: List<String>,
  60 + var writtenLength: Long = 0L,
  61 + val nextChunkIndex: AtomicLong = AtomicLong(0),
  62 + )
  63 +
  64 + private val openStreams = Collections.synchronizedMap(mutableMapOf<String, Descriptor>())
  65 +
  66 + private suspend fun openStream(
  67 + info: StreamInfo,
  68 + destinationIdentities: List<Participant.Identity> = emptyList(),
  69 + ) {
  70 + if (openStreams.containsKey(info.id)) {
  71 + throw StreamException.AlreadyOpenedException()
  72 + }
  73 +
  74 + val destinationIdentityStrings = destinationIdentities.map { it.value }
  75 + val headerPacket = with(DataPacket.newBuilder()) {
  76 + addAllDestinationIdentities(destinationIdentityStrings)
  77 + kind = DataPacket.Kind.RELIABLE
  78 + streamHeader = with(DataStream.Header.newBuilder()) {
  79 + this.streamId = info.id
  80 + this.topic = info.topic
  81 + this.timestamp = info.timestampMs
  82 + this.putAllAttributes(info.attributes)
  83 +
  84 + info.totalSize?.let {
  85 + this.totalLength = it
  86 + }
  87 +
  88 + when (info) {
  89 + is ByteStreamInfo -> {
  90 + this.mimeType = info.mimeType
  91 + this.byteHeader = with(DataStream.ByteHeader.newBuilder()) {
  92 + this.name = name
  93 + build()
  94 + }
  95 + }
  96 +
  97 + is TextStreamInfo -> {
  98 + textHeader = with(DataStream.TextHeader.newBuilder()) {
  99 + this.operationType = info.operationType.toProto()
  100 + this.version = info.version
  101 + this.replyToStreamId = info.replyToStreamId
  102 + this.addAllAttachedStreamIds(info.attachedStreamIds)
  103 + this.generated = info.generated
  104 + build()
  105 + }
  106 + }
  107 + }
  108 + build()
  109 + }
  110 + build()
  111 + }
  112 +
  113 + engine.sendData(headerPacket)
  114 +
  115 + val descriptor = Descriptor(info, destinationIdentityStrings)
  116 + openStreams[info.id] = descriptor
  117 +
  118 + LKLog.d { "Opened send stream ${info.id}" }
  119 + }
  120 +
  121 + private suspend fun sendChunk(streamId: String, dataChunk: ByteArray) {
  122 + val descriptor = openStreams[streamId] ?: throw StreamException.UnknownStreamException()
  123 + val nextChunkIndex = descriptor.nextChunkIndex.getAndIncrement()
  124 +
  125 + val chunkPacket = with(DataPacket.newBuilder()) {
  126 + addAllDestinationIdentities(descriptor.destinationIdentityStrings)
  127 + kind = DataPacket.Kind.RELIABLE
  128 + streamChunk = with(DataStream.Chunk.newBuilder()) {
  129 + this.streamId = streamId
  130 + this.content = ByteString.copyFrom(dataChunk)
  131 + this.chunkIndex = nextChunkIndex
  132 + build()
  133 + }
  134 + build()
  135 + }
  136 +
  137 + engine.waitForBufferStatusLow(DataPacket.Kind.RELIABLE)
  138 + engine.sendData(chunkPacket)
  139 + }
  140 +
  141 + private suspend fun closeStream(streamId: String, reason: String? = null) {
  142 + val descriptor = openStreams[streamId] ?: throw StreamException.UnknownStreamException()
  143 +
  144 + val trailerPacket = with(DataPacket.newBuilder()) {
  145 + addAllDestinationIdentities(descriptor.destinationIdentityStrings)
  146 + kind = DataPacket.Kind.RELIABLE
  147 + streamTrailer = with(DataStream.Trailer.newBuilder()) {
  148 + this.streamId = streamId
  149 + if (reason != null) {
  150 + this.reason = reason
  151 + }
  152 + build()
  153 + }
  154 + build()
  155 + }
  156 +
  157 + engine.waitForBufferStatusLow(DataPacket.Kind.RELIABLE)
  158 + engine.sendData(trailerPacket)
  159 +
  160 + openStreams.remove(streamId)
  161 + LKLog.d { "Closed send stream $streamId" }
  162 + }
  163 +
  164 + override suspend fun streamText(options: StreamTextOptions): TextStreamSender {
  165 + val streamInfo = TextStreamInfo(
  166 + id = options.streamId,
  167 + topic = options.topic,
  168 + timestampMs = Date().time,
  169 + totalSize = options.totalSize,
  170 + attributes = options.attributes,
  171 + operationType = options.operationType,
  172 + version = options.version,
  173 + replyToStreamId = options.replyToStreamId,
  174 + attachedStreamIds = options.attachedStreamIds,
  175 + generated = false,
  176 + )
  177 +
  178 + val streamId = options.streamId
  179 + openStream(streamInfo, options.destinationIdentities)
  180 +
  181 + val destination = ManagerStreamDestination<String>(streamId)
  182 + return TextStreamSender(
  183 + streamInfo,
  184 + destination,
  185 + )
  186 + }
  187 +
  188 + override suspend fun streamBytes(options: StreamBytesOptions): ByteStreamSender {
  189 + val streamInfo = ByteStreamInfo(
  190 + id = options.streamId,
  191 + topic = options.topic,
  192 + timestampMs = Date().time,
  193 + totalSize = options.totalSize,
  194 + attributes = options.attributes,
  195 + mimeType = options.mimeType,
  196 + name = options.name,
  197 + )
  198 +
  199 + val streamId = options.streamId
  200 + openStream(streamInfo, options.destinationIdentities)
  201 +
  202 + val destination = ManagerStreamDestination<ByteArray>(streamId)
  203 + return ByteStreamSender(
  204 + streamInfo,
  205 + destination,
  206 + )
  207 + }
  208 +
  209 + private inner class ManagerStreamDestination<T>(val streamId: String) : StreamDestination<T> {
  210 + override val isOpen: Boolean
  211 + get() = openStreams.contains(streamId)
  212 +
  213 + override suspend fun write(data: T, chunker: DataChunker<T>) {
  214 + val chunks = chunker.invoke(data, RTCEngine.MAX_DATA_PACKET_SIZE)
  215 +
  216 + for (chunk in chunks) {
  217 + sendChunk(streamId, chunk)
  218 + }
  219 + }
  220 +
  221 + override suspend fun close(reason: String?) {
  222 + closeStream(streamId, reason)
  223 + }
  224 + }
  225 +}
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream.outgoing
  18 +
  19 +import io.livekit.android.room.datastream.TextStreamInfo
  20 +import java.util.Arrays
  21 +
  22 +class TextStreamSender(
  23 + val info: TextStreamInfo,
  24 + destination: StreamDestination<String>,
  25 +) : BaseStreamSender<String>(destination) {
  26 + override suspend fun writeImpl(data: String) {
  27 + destination.write(data, stringChunker)
  28 + }
  29 +}
  30 +
  31 +private val stringChunker: DataChunker<String> = { text: String, chunkSize: Int ->
  32 + val utf8Array = text.toByteArray(Charsets.UTF_8)
  33 +
  34 + val result = mutableListOf<ByteArray>()
  35 + var startIndex = 0
  36 + var endIndex = 0
  37 + var i = 0
  38 + while (i < utf8Array.size) {
  39 + val nextHead = utf8Array[i].toInt()
  40 + val nextCharPointSize = if ((nextHead and 0b1111_1000) == 0b1111_0000) {
  41 + 4
  42 + } else if ((nextHead and 0b1111_0000) == 0b1110_0000) {
  43 + 3
  44 + } else if ((nextHead and 0b1110_0000) == 0b1100_0000) {
  45 + 2
  46 + } else {
  47 + 1
  48 + }
  49 +
  50 + val curLength = endIndex - startIndex
  51 + if (curLength + nextCharPointSize > chunkSize) {
  52 + result.add(Arrays.copyOfRange(utf8Array, startIndex, endIndex))
  53 + startIndex = endIndex
  54 + }
  55 + i += nextCharPointSize
  56 + endIndex = i
  57 + }
  58 +
  59 + // Last chunk done manually
  60 + if (startIndex != endIndex) {
  61 + result.add(Arrays.copyOfRange(utf8Array, startIndex, endIndex))
  62 + }
  63 +
  64 + result
  65 +}
@@ -34,6 +34,7 @@ import io.livekit.android.room.DefaultsManager @@ -34,6 +34,7 @@ import io.livekit.android.room.DefaultsManager
34 import io.livekit.android.room.RTCEngine 34 import io.livekit.android.room.RTCEngine
35 import io.livekit.android.room.Room 35 import io.livekit.android.room.Room
36 import io.livekit.android.room.TrackBitrateInfo 36 import io.livekit.android.room.TrackBitrateInfo
  37 +import io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager
37 import io.livekit.android.room.isSVCCodec 38 import io.livekit.android.room.isSVCCodec
38 import io.livekit.android.room.rpc.RpcManager 39 import io.livekit.android.room.rpc.RpcManager
39 import io.livekit.android.room.track.DataPublishReliability 40 import io.livekit.android.room.track.DataPublishReliability
@@ -106,7 +107,8 @@ internal constructor( @@ -106,7 +107,8 @@ internal constructor(
106 coroutineDispatcher: CoroutineDispatcher, 107 coroutineDispatcher: CoroutineDispatcher,
107 @Named(InjectionNames.SENDER) 108 @Named(InjectionNames.SENDER)
108 private val capabilitiesGetter: CapabilitiesGetter, 109 private val capabilitiesGetter: CapabilitiesGetter,
109 -) : Participant(Sid(""), null, coroutineDispatcher) { 110 + private val outgoingDataStreamManager: OutgoingDataStreamManager,
  111 +) : Participant(Sid(""), null, coroutineDispatcher), OutgoingDataStreamManager by outgoingDataStreamManager {
110 112
111 var audioTrackCaptureDefaults: LocalAudioTrackOptions by defaultsManager::audioTrackCaptureDefaults 113 var audioTrackCaptureDefaults: LocalAudioTrackOptions by defaultsManager::audioTrackCaptureDefaults
112 var audioTrackPublishDefaults: AudioTrackPublishDefaults by defaultsManager::audioTrackPublishDefaults 114 var audioTrackPublishDefaults: AudioTrackPublishDefaults by defaultsManager::audioTrackPublishDefaults
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.webrtc
  18 +
  19 +import io.livekit.android.coroutines.cancelOnSignal
  20 +import io.livekit.android.room.RTCEngine
  21 +import io.livekit.android.util.FlowObservable
  22 +import io.livekit.android.util.flow
  23 +import io.livekit.android.util.flowDelegate
  24 +import io.livekit.android.webrtc.peerconnection.executeOnRTCThread
  25 +import kotlinx.coroutines.flow.collect
  26 +import kotlinx.coroutines.flow.map
  27 +import kotlinx.coroutines.flow.takeWhile
  28 +import livekit.org.webrtc.DataChannel
  29 +
  30 +/**
  31 + * @suppress
  32 + */
  33 +class DataChannelManager(
  34 + val dataChannel: DataChannel,
  35 + private val dataMessageListener: DataChannel.Observer,
  36 +) : DataChannel.Observer {
  37 +
  38 + @get:FlowObservable
  39 + var disposed by flowDelegate(false)
  40 + private set
  41 +
  42 + @get:FlowObservable
  43 + var bufferedAmount by flowDelegate(0L)
  44 + private set
  45 +
  46 + @get:FlowObservable
  47 + var state by flowDelegate(dataChannel.state())
  48 + private set
  49 +
  50 + suspend fun waitForBufferedAmountLow(amount: Long = RTCEngine.MAX_DATA_PACKET_SIZE.toLong()) {
  51 + val signal = ::disposed.flow.map { if (it) Unit else null }
  52 +
  53 + ::bufferedAmount.flow
  54 + .cancelOnSignal(signal)
  55 + .takeWhile { it > amount }
  56 + .collect()
  57 + }
  58 +
  59 + override fun onBufferedAmountChange(previousAmount: Long) {
  60 + bufferedAmount = dataChannel.bufferedAmount()
  61 + }
  62 +
  63 + override fun onStateChange() {
  64 + state = dataChannel.state()
  65 + }
  66 +
  67 + override fun onMessage(buffer: DataChannel.Buffer) {
  68 + dataMessageListener.onMessage(buffer)
  69 + }
  70 +
  71 + fun dispose() {
  72 + synchronized(this) {
  73 + if (disposed) {
  74 + return
  75 + }
  76 + disposed = true
  77 + }
  78 + executeOnRTCThread {
  79 + dataChannel.unregisterObserver()
  80 + dataChannel.close()
  81 + dataChannel.dispose()
  82 + }
  83 + }
  84 +
  85 + interface Listener {
  86 + fun onBufferedAmountChange(dataChannel: DataChannel, newAmount: Long, previousAmount: Long)
  87 + fun onStateChange(dataChannel: DataChannel, state: DataChannel.State)
  88 + fun onMessage(dataChannel: DataChannel, buffer: DataChannel.Buffer)
  89 + }
  90 +}
1 /* 1 /*
2 - * Copyright 2023-2024 LiveKit, Inc. 2 + * Copyright 2023-2025 LiveKit, Inc.
3 * 3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License. 5 * you may not use this file except in compliance with the License.
@@ -16,11 +16,9 @@ @@ -16,11 +16,9 @@
16 16
17 package io.livekit.android.test.coroutines 17 package io.livekit.android.test.coroutines
18 18
19 -import kotlinx.coroutines.CancellationException  
20 -import kotlinx.coroutines.cancel  
21 -import kotlinx.coroutines.coroutineScope  
22 -import kotlinx.coroutines.flow.*  
23 -import kotlinx.coroutines.launch 19 +import io.livekit.android.coroutines.takeUntilSignal
  20 +import kotlinx.coroutines.flow.Flow
  21 +import kotlinx.coroutines.flow.fold
24 22
25 /** 23 /**
26 * Collect all items until signal is given. 24 * Collect all items until signal is given.
@@ -31,21 +29,3 @@ suspend fun <T> Flow<T>.toListUntilSignal(signal: Flow<Unit?>): List<T> { @@ -31,21 +29,3 @@ suspend fun <T> Flow<T>.toListUntilSignal(signal: Flow<Unit?>): List<T> {
31 list.plus(event) 29 list.plus(event)
32 } 30 }
33 } 31 }
34 -  
35 -fun <T> Flow<T>.takeUntilSignal(signal: Flow<Unit?>): Flow<T> = flow {  
36 - try {  
37 - coroutineScope {  
38 - launch {  
39 - signal.takeWhile { it == null }.collect()  
40 - println("signalled")  
41 - this@coroutineScope.cancel()  
42 - }  
43 -  
44 - collect {  
45 - emit(it)  
46 - }  
47 - }  
48 - } catch (e: CancellationException) {  
49 - // ignore  
50 - }  
51 -}  
1 /* 1 /*
2 - * Copyright 2023-2024 LiveKit, Inc. 2 + * Copyright 2023-2025 LiveKit, Inc.
3 * 3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License. 5 * you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@ package io.livekit.android.test.mock.dagger @@ -19,6 +19,7 @@ package io.livekit.android.test.mock.dagger
19 import android.content.Context 19 import android.content.Context
20 import dagger.BindsInstance 20 import dagger.BindsInstance
21 import dagger.Component 21 import dagger.Component
  22 +import io.livekit.android.dagger.InternalBindsModule
22 import io.livekit.android.dagger.JsonFormatModule 23 import io.livekit.android.dagger.JsonFormatModule
23 import io.livekit.android.dagger.LiveKitComponent 24 import io.livekit.android.dagger.LiveKitComponent
24 import io.livekit.android.dagger.MemoryModule 25 import io.livekit.android.dagger.MemoryModule
@@ -36,6 +37,7 @@ import javax.inject.Singleton @@ -36,6 +37,7 @@ import javax.inject.Singleton
36 TestAudioHandlerModule::class, 37 TestAudioHandlerModule::class,
37 JsonFormatModule::class, 38 JsonFormatModule::class,
38 MemoryModule::class, 39 MemoryModule::class,
  40 + InternalBindsModule::class,
39 ], 41 ],
40 ) 42 )
41 interface TestLiveKitComponent : LiveKitComponent { 43 interface TestLiveKitComponent : LiveKitComponent {
@@ -27,6 +27,9 @@ import org.junit.Test @@ -27,6 +27,9 @@ import org.junit.Test
27 import org.junit.runner.RunWith 27 import org.junit.runner.RunWith
28 import org.junit.runners.Parameterized 28 import org.junit.runners.Parameterized
29 29
  30 +/**
  31 + * A test that ensures all the proto enum cases match their sdk counterparts.
  32 + */
30 @RunWith(Parameterized::class) 33 @RunWith(Parameterized::class)
31 class ProtoConverterTest( 34 class ProtoConverterTest(
32 val protoClass: Class<*>, 35 val protoClass: Class<*>,
  1 +/*
  2 + * Copyright 2023-2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room
  18 +
  19 +import com.google.protobuf.ByteString
  20 +import io.livekit.android.room.datastream.StreamException
  21 +import io.livekit.android.test.MockE2ETest
  22 +import io.livekit.android.test.assert.assertIsClass
  23 +import io.livekit.android.test.mock.MockDataChannel
  24 +import io.livekit.android.test.mock.MockPeerConnection
  25 +import kotlinx.coroutines.CoroutineScope
  26 +import kotlinx.coroutines.ExperimentalCoroutinesApi
  27 +import kotlinx.coroutines.currentCoroutineContext
  28 +import kotlinx.coroutines.flow.catch
  29 +import kotlinx.coroutines.launch
  30 +import livekit.LivekitModels.DataPacket
  31 +import livekit.LivekitModels.DataStream
  32 +import livekit.LivekitModels.DataStream.OperationType
  33 +import livekit.LivekitModels.DataStream.TextHeader
  34 +import livekit.org.webrtc.DataChannel
  35 +import org.junit.Assert.assertEquals
  36 +import org.junit.Assert.assertTrue
  37 +import org.junit.Test
  38 +import java.nio.ByteBuffer
  39 +
  40 +@OptIn(ExperimentalCoroutinesApi::class)
  41 +class RoomDataStreamMockE2ETest : MockE2ETest() {
  42 + @Test
  43 + fun dataStream() = runTest {
  44 + connect()
  45 + val subPeerConnection = component.rtcEngine().getSubscriberPeerConnection() as MockPeerConnection
  46 + val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL)
  47 + subPeerConnection.observer?.onDataChannel(subDataChannel)
  48 +
  49 + val scope = CoroutineScope(currentCoroutineContext())
  50 + val collectedData = mutableListOf<ByteArray>()
  51 + var finished = false
  52 + room.registerByteStreamHandler("topic") { reader, _ ->
  53 + scope.launch {
  54 + reader.flow.collect {
  55 + collectedData.add(it)
  56 + }
  57 + finished = true
  58 + }
  59 + }
  60 +
  61 + subDataChannel.observer?.onMessage(createStreamHeader().wrap())
  62 + subDataChannel.observer?.onMessage(createStreamChunk(0, ByteArray(1) { 1 }).wrap())
  63 + subDataChannel.observer?.onMessage(createStreamTrailer().wrap())
  64 +
  65 + assertTrue(finished)
  66 + assertEquals(1, collectedData.size)
  67 + assertEquals(1, collectedData[0][0].toInt())
  68 + }
  69 +
  70 + @Test
  71 + fun textStream() = runTest {
  72 + connect()
  73 + val subPeerConnection = component.rtcEngine().getSubscriberPeerConnection() as MockPeerConnection
  74 + val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL)
  75 + subPeerConnection.observer?.onDataChannel(subDataChannel)
  76 +
  77 + val scope = CoroutineScope(currentCoroutineContext())
  78 + val collectedData = mutableListOf<String>()
  79 + var finished = false
  80 + room.registerTextStreamHandler("topic") { reader, _ ->
  81 + scope.launch {
  82 + reader.flow.collect {
  83 + collectedData.add(it)
  84 + }
  85 + finished = true
  86 + }
  87 + }
  88 +
  89 + val textStreamHeader = with(createStreamHeader().toBuilder()) {
  90 + streamHeader = with(streamHeader.toBuilder()) {
  91 + clearByteHeader()
  92 + textHeader = with(TextHeader.newBuilder()) {
  93 + operationType = OperationType.CREATE
  94 + generated = false
  95 + build()
  96 + }
  97 + build()
  98 + }
  99 + build()
  100 + }
  101 + subDataChannel.observer?.onMessage(textStreamHeader.wrap())
  102 + subDataChannel.observer?.onMessage(createStreamChunk(0, "hello".toByteArray()).wrap())
  103 + subDataChannel.observer?.onMessage(createStreamTrailer().wrap())
  104 +
  105 + assertTrue(finished)
  106 + assertEquals(1, collectedData.size)
  107 + assertEquals("hello", collectedData[0])
  108 + }
  109 +
  110 + @Test
  111 + fun dataStreamTerminated() = runTest {
  112 + connect()
  113 + val subPeerConnection = component.rtcEngine().getSubscriberPeerConnection() as MockPeerConnection
  114 + val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL)
  115 + subPeerConnection.observer?.onDataChannel(subDataChannel)
  116 +
  117 + val scope = CoroutineScope(currentCoroutineContext())
  118 + var finished = false
  119 + var threwOnce = false
  120 + room.registerByteStreamHandler("topic") { reader, _ ->
  121 + scope.launch {
  122 + reader.flow
  123 + .catch {
  124 + assertIsClass(StreamException.AbnormalEndException::class.java, it)
  125 + threwOnce = true
  126 + }
  127 + .collect {
  128 + }
  129 + finished = true
  130 + }
  131 + }
  132 +
  133 + subDataChannel.observer?.onMessage(createStreamHeader().wrap())
  134 +
  135 + val abnormalEnd = with(DataPacket.newBuilder()) {
  136 + streamTrailer = with(DataStream.Trailer.newBuilder()) {
  137 + streamId = "streamId"
  138 + reason = "reason"
  139 + build()
  140 + }
  141 + build()
  142 + }
  143 + subDataChannel.observer?.onMessage(abnormalEnd.wrap())
  144 +
  145 + assertTrue(finished)
  146 + assertTrue(threwOnce)
  147 + }
  148 +
  149 + @Test
  150 + fun dataStreamLengthExceeded() = runTest {
  151 + connect()
  152 + val subPeerConnection = component.rtcEngine().getSubscriberPeerConnection() as MockPeerConnection
  153 + val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL)
  154 + subPeerConnection.observer?.onDataChannel(subDataChannel)
  155 +
  156 + val scope = CoroutineScope(currentCoroutineContext())
  157 + var finished = false
  158 + var threwOnce = false
  159 + room.registerByteStreamHandler("topic") { reader, _ ->
  160 + scope.launch {
  161 + reader.flow
  162 + .catch {
  163 + assertIsClass(StreamException.LengthExceededException::class.java, it)
  164 + threwOnce = true
  165 + }
  166 + .collect {
  167 + }
  168 + finished = true
  169 + }
  170 + }
  171 + val header = with(createStreamHeader().toBuilder()) {
  172 + streamHeader = with(streamHeader.toBuilder()) {
  173 + totalLength = 1
  174 + build()
  175 + }
  176 + build()
  177 + }
  178 + subDataChannel.observer?.onMessage(header.wrap())
  179 + subDataChannel.observer?.onMessage(createStreamChunk(0, ByteArray(2) { 1 }).wrap())
  180 + subDataChannel.observer?.onMessage(createStreamTrailer().wrap())
  181 +
  182 + assertTrue(finished)
  183 + assertTrue(threwOnce)
  184 + }
  185 +
  186 + @Test
  187 + fun dataStreamIncomplete() = runTest {
  188 + connect()
  189 + val subPeerConnection = component.rtcEngine().getSubscriberPeerConnection() as MockPeerConnection
  190 + val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL)
  191 + subPeerConnection.observer?.onDataChannel(subDataChannel)
  192 +
  193 + val scope = CoroutineScope(currentCoroutineContext())
  194 + var finished = false
  195 + var threwOnce = false
  196 + room.registerByteStreamHandler("topic") { reader, _ ->
  197 + scope.launch {
  198 + reader.flow
  199 + .catch {
  200 + assertIsClass(StreamException.IncompleteException::class.java, it)
  201 + threwOnce = true
  202 + }
  203 + .collect {
  204 + }
  205 + finished = true
  206 + }
  207 + }
  208 + val header = with(createStreamHeader().toBuilder()) {
  209 + streamHeader = with(streamHeader.toBuilder()) {
  210 + totalLength = 2
  211 + build()
  212 + }
  213 + build()
  214 + }
  215 + subDataChannel.observer?.onMessage(header.wrap())
  216 + subDataChannel.observer?.onMessage(createStreamChunk(0, ByteArray(1) { 1 }).wrap())
  217 + subDataChannel.observer?.onMessage(createStreamTrailer().wrap())
  218 +
  219 + assertTrue(finished)
  220 + assertTrue(threwOnce)
  221 + }
  222 +
  223 + private fun DataPacket.wrap() = DataChannel.Buffer(
  224 + ByteBuffer.wrap(this.toByteArray()),
  225 + true,
  226 + )
  227 +
  228 + fun createStreamHeader() = with(DataPacket.newBuilder()) {
  229 + streamHeader = with(DataStream.Header.newBuilder()) {
  230 + streamId = "streamId"
  231 + topic = "topic"
  232 + timestamp = 0L
  233 + clearTotalLength()
  234 + mimeType = "mime"
  235 +
  236 + byteHeader = with(DataStream.ByteHeader.newBuilder()) {
  237 + name = "name"
  238 + build()
  239 + }
  240 + build()
  241 + }
  242 + build()
  243 + }
  244 +
  245 + fun createStreamChunk(index: Int, bytes: ByteArray) = with(DataPacket.newBuilder()) {
  246 + streamChunk = with(DataStream.Chunk.newBuilder()) {
  247 + streamId = "streamId"
  248 + chunkIndex = index.toLong()
  249 + content = ByteString.copyFrom(bytes)
  250 + build()
  251 + }
  252 + build()
  253 + }
  254 +
  255 + fun createStreamTrailer() = with(DataPacket.newBuilder()) {
  256 + streamTrailer = with(DataStream.Trailer.newBuilder()) {
  257 + streamId = "streamId"
  258 + build()
  259 + }
  260 + build()
  261 + }
  262 +}
@@ -29,6 +29,7 @@ import io.livekit.android.events.EventListenable @@ -29,6 +29,7 @@ import io.livekit.android.events.EventListenable
29 import io.livekit.android.events.ParticipantEvent 29 import io.livekit.android.events.ParticipantEvent
30 import io.livekit.android.events.RoomEvent 30 import io.livekit.android.events.RoomEvent
31 import io.livekit.android.memory.CloseableManager 31 import io.livekit.android.memory.CloseableManager
  32 +import io.livekit.android.room.datastream.incoming.IncomingDataStreamManagerImpl
32 import io.livekit.android.room.network.NetworkCallbackManagerImpl 33 import io.livekit.android.room.network.NetworkCallbackManagerImpl
33 import io.livekit.android.room.participant.LocalParticipant 34 import io.livekit.android.room.participant.LocalParticipant
34 import io.livekit.android.test.assert.assertIsClassList 35 import io.livekit.android.test.assert.assertIsClassList
@@ -133,6 +134,7 @@ class RoomTest { @@ -133,6 +134,7 @@ class RoomTest {
133 regionUrlProviderFactory = regionUrlProviderFactory, 134 regionUrlProviderFactory = regionUrlProviderFactory,
134 connectionWarmer = MockConnectionWarmer(), 135 connectionWarmer = MockConnectionWarmer(),
135 audioRecordPrewarmer = NoAudioRecordPrewarmer(), 136 audioRecordPrewarmer = NoAudioRecordPrewarmer(),
  137 + incomingDataStreamManager = IncomingDataStreamManagerImpl(),
136 ) 138 )
137 } 139 }
138 140
  1 +/*
  2 + * Copyright 2025 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.datastream
  18 +
  19 +import io.livekit.android.room.datastream.incoming.ByteStreamReceiver
  20 +import io.livekit.android.room.datastream.incoming.IncomingDataStreamManagerImpl
  21 +import io.livekit.android.test.BaseTest
  22 +import kotlinx.coroutines.ExperimentalCoroutinesApi
  23 +import kotlinx.coroutines.channels.Channel
  24 +import kotlinx.coroutines.runBlocking
  25 +import org.junit.Assert.assertEquals
  26 +import org.junit.Assert.assertTrue
  27 +import org.junit.Before
  28 +import org.junit.Test
  29 +
  30 +@OptIn(ExperimentalCoroutinesApi::class)
  31 +class StreamReaderTest : BaseTest() {
  32 +
  33 + lateinit var channel: Channel<ByteArray>
  34 + lateinit var reader: ByteStreamReceiver
  35 +
  36 + @Before
  37 + fun setup() {
  38 + channel = IncomingDataStreamManagerImpl.createChannelForStreamReceiver()
  39 + channel.trySend(ByteArray(1) { 0 })
  40 + channel.trySend(ByteArray(1) { 1 })
  41 + channel.trySend(ByteArray(1) { 2 })
  42 + channel.close()
  43 + val streamInfo = ByteStreamInfo(id = "id", topic = "topic", timestampMs = 3, totalSize = null, attributes = mapOf(), mimeType = "mime", name = null)
  44 + reader = ByteStreamReceiver(streamInfo, channel)
  45 + }
  46 +
  47 + @Test
  48 + fun buffersDataUntilSubscribed() = runTest {
  49 + var count = 0
  50 + runBlocking {
  51 + reader.flow.collect {
  52 + assertEquals(count, it[0].toInt())
  53 + count++
  54 + }
  55 + }
  56 +
  57 + assertEquals(3, count)
  58 + }
  59 +
  60 + @Test
  61 + fun readEach() = runTest {
  62 + runBlocking {
  63 + for (i in 0..2) {
  64 + val next = reader.readNext()
  65 + assertEquals(i, next[0].toInt())
  66 + }
  67 + }
  68 + }
  69 +
  70 + @Test
  71 + fun readAll() = runTest {
  72 + runBlocking {
  73 + val data = reader.readAll()
  74 + assertEquals(3, data.size)
  75 + for (i in 0..2) {
  76 + assertEquals(i, data[i][0].toInt())
  77 + }
  78 + }
  79 + }
  80 +
  81 + @Test
  82 + fun overreadThrows() = runTest {
  83 + var threwOnce = false
  84 + runBlocking {
  85 + try {
  86 + for (i in 0..3) {
  87 + val next = reader.readNext()
  88 + assertEquals(i, next[0].toInt())
  89 + }
  90 + } catch (e: NoSuchElementException) {
  91 + threwOnce = true
  92 + }
  93 + }
  94 +
  95 + assertTrue(threwOnce)
  96 + }
  97 +}