Committed by
GitHub
Implement data streams (#625)
* Implement data streams * Emit warning logs for unhandled datastreams * Fix stream closing
正在显示
26 个修改的文件
包含
1692 行增加
和
49 行删除
.changeset/wise-cherries-speak.md
0 → 100644
| 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 | +) |
livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/incoming/BaseStreamReceiver.kt
0 → 100644
| 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 | +} |
livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/incoming/ByteStreamReceiver.kt
0 → 100644
| 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 | +} |
livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/incoming/TextStreamReceiver.kt
0 → 100644
| 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 | +} |
livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/outgoing/BaseStreamSender.kt
0 → 100644
| 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> |
livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/outgoing/ByteStreamSender.kt
0 → 100644
| 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 | +} |
livekit-android-sdk/src/main/java/io/livekit/android/room/datastream/outgoing/TextStreamSender.kt
0 → 100644
| 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 |
livekit-android-test/src/test/java/io/livekit/android/room/datastream/StreamReaderTest.kt
0 → 100644
| 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 | +} |
-
请 注册 或 登录 后发表评论