davidliu
Committed by GitHub

Add pre-connect audio for use with agents (#666)

* Update protocol submodule to v1.38.0

* Basic Preconnect audio buffer implementation

* Add Participant.State and related events

* prerecording full implementation

* Fix outgoing byte datastreams incorrectly padding data

* Add pre-connect audio for use with agents
正在显示 22 个修改的文件 包含 806 行增加16 行删除
  1 +---
  2 +"client-sdk-android": minor
  3 +---
  4 +
  5 +Add pre-connect audio for use with agents
  6 +
  7 +See Room.withPreconnectAudio for details.
  1 +---
  2 +"client-sdk-android": patch
  3 +---
  4 +
  5 +Fix outgoing datastreams incorrectly padding data
  1 +---
  2 +"client-sdk-android": minor
  3 +---
  4 +
  5 +Add Participant.State and related events
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.
@@ -26,6 +26,7 @@ import io.livekit.android.audio.AudioProcessorOptions @@ -26,6 +26,7 @@ import io.livekit.android.audio.AudioProcessorOptions
26 import io.livekit.android.audio.AudioSwitchHandler 26 import io.livekit.android.audio.AudioSwitchHandler
27 import io.livekit.android.audio.NoAudioHandler 27 import io.livekit.android.audio.NoAudioHandler
28 import io.livekit.android.room.Room 28 import io.livekit.android.room.Room
  29 +import io.livekit.android.room.track.LocalAudioTrack
29 import livekit.org.webrtc.EglBase 30 import livekit.org.webrtc.EglBase
30 import livekit.org.webrtc.PeerConnectionFactory 31 import livekit.org.webrtc.PeerConnectionFactory
31 import livekit.org.webrtc.VideoDecoderFactory 32 import livekit.org.webrtc.VideoDecoderFactory
@@ -33,6 +34,7 @@ import livekit.org.webrtc.VideoEncoderFactory @@ -33,6 +34,7 @@ import livekit.org.webrtc.VideoEncoderFactory
33 import livekit.org.webrtc.audio.AudioDeviceModule 34 import livekit.org.webrtc.audio.AudioDeviceModule
34 import livekit.org.webrtc.audio.JavaAudioDeviceModule 35 import livekit.org.webrtc.audio.JavaAudioDeviceModule
35 import okhttp3.OkHttpClient 36 import okhttp3.OkHttpClient
  37 +
36 /** 38 /**
37 * Overrides to replace LiveKit internally used components with custom implementations. 39 * Overrides to replace LiveKit internally used components with custom implementations.
38 */ 40 */
@@ -110,6 +112,11 @@ class AudioOptions( @@ -110,6 +112,11 @@ class AudioOptions(
110 * Called after default setup to allow for customizations on the [JavaAudioDeviceModule]. 112 * Called after default setup to allow for customizations on the [JavaAudioDeviceModule].
111 * 113 *
112 * Not used if [audioDeviceModule] is provided. 114 * Not used if [audioDeviceModule] is provided.
  115 + *
  116 + * Note: We require setting the [JavaAudioDeviceModule.Builder.setSamplesReadyCallback] to provide
  117 + * support for [LocalAudioTrack.addSink]. If you wish to grab the audio samples
  118 + * from the local microphone track, use [LocalAudioTrack.addSink] instead of setting your own
  119 + * callback.
113 */ 120 */
114 val javaAudioDeviceModuleCustomizer: ((builder: JavaAudioDeviceModule.Builder) -> Unit)? = null, 121 val javaAudioDeviceModuleCustomizer: ((builder: JavaAudioDeviceModule.Builder) -> Unit)? = null,
115 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.audio
  18 +
  19 +import android.os.SystemClock
  20 +import io.livekit.android.audio.PreconnectAudioBuffer.Companion.DEFAULT_TOPIC
  21 +import io.livekit.android.audio.PreconnectAudioBuffer.Companion.TIMEOUT
  22 +import io.livekit.android.events.RoomEvent
  23 +import io.livekit.android.events.collect
  24 +import io.livekit.android.room.ConnectionState
  25 +import io.livekit.android.room.Room
  26 +import io.livekit.android.room.datastream.StreamBytesOptions
  27 +import io.livekit.android.room.participant.Participant
  28 +import io.livekit.android.util.LKLog
  29 +import io.livekit.android.util.flow
  30 +import kotlinx.coroutines.cancel
  31 +import kotlinx.coroutines.coroutineScope
  32 +import kotlinx.coroutines.delay
  33 +import kotlinx.coroutines.flow.collect
  34 +import kotlinx.coroutines.flow.takeWhile
  35 +import kotlinx.coroutines.launch
  36 +import livekit.org.webrtc.AudioTrackSink
  37 +import java.io.ByteArrayOutputStream
  38 +import java.nio.ByteBuffer
  39 +import kotlin.math.min
  40 +import kotlin.time.Duration
  41 +import kotlin.time.Duration.Companion.seconds
  42 +
  43 +internal class PreconnectAudioBuffer
  44 +internal constructor(timeout: Duration) : AudioTrackSink {
  45 +
  46 + companion object {
  47 + const val DEFAULT_TOPIC = "lk.agent.pre-connect-audio-buffer"
  48 + val TIMEOUT = 10.seconds
  49 + }
  50 +
  51 + private val outputStreamLock = Any()
  52 + private val outputStream by lazy {
  53 + ByteArrayOutputStream()
  54 + }
  55 +
  56 + private lateinit var collectedBytes: ByteArray
  57 +
  58 + private val tempArray = ByteArray(1024)
  59 + private var initialTime = -1L
  60 +
  61 + private var bitsPerSample = 16
  62 + private var sampleRate = 48000 // default sampleRate from JavaAudioDeviceModule
  63 + private var numberOfChannels = 1 // default channels from JavaAudioDeviceModule
  64 +
  65 + private var isRecording = true
  66 + private val timeoutMs = timeout.inWholeMilliseconds
  67 +
  68 + fun startRecording() {
  69 + isRecording = true
  70 + }
  71 +
  72 + fun stopRecording() {
  73 + synchronized(outputStreamLock) {
  74 + if (isRecording) {
  75 + collectedBytes = outputStream.toByteArray()
  76 + isRecording = false
  77 + }
  78 + }
  79 + }
  80 +
  81 + fun clear() {
  82 + stopRecording()
  83 + collectedBytes = ByteArray(0)
  84 + }
  85 +
  86 + override fun onData(
  87 + audioData: ByteBuffer,
  88 + bitsPerSample: Int,
  89 + sampleRate: Int,
  90 + numberOfChannels: Int,
  91 + numberOfFrames: Int,
  92 + absoluteCaptureTimestampMs: Long,
  93 + ) {
  94 + if (!isRecording) {
  95 + return
  96 + }
  97 + if (initialTime == -1L) {
  98 + initialTime = SystemClock.elapsedRealtime()
  99 + }
  100 +
  101 + this.bitsPerSample = bitsPerSample
  102 + this.sampleRate = sampleRate
  103 + this.numberOfChannels = numberOfChannels
  104 + val currentTime = SystemClock.elapsedRealtime()
  105 + // Limit reached, don't buffer any more.
  106 + if (currentTime - initialTime > timeoutMs) {
  107 + return
  108 + }
  109 +
  110 + audioData.rewind()
  111 +
  112 + synchronized(outputStreamLock) {
  113 + if (audioData.hasArray()) {
  114 + outputStream.write(audioData.array())
  115 + } else {
  116 + while (audioData.hasRemaining()) {
  117 + val readBytes = min(tempArray.size, audioData.remaining())
  118 + audioData.get(tempArray, 0, readBytes)
  119 + outputStream.write(tempArray)
  120 + }
  121 + }
  122 + }
  123 + }
  124 +
  125 + suspend fun sendAudioData(room: Room, trackSid: String?, agentIdentities: List<Participant.Identity>, topic: String = DEFAULT_TOPIC) {
  126 + if (agentIdentities.isEmpty()) {
  127 + return
  128 + }
  129 +
  130 + val audioData = outputStream.toByteArray()
  131 + if (audioData.size <= 1024) {
  132 + LKLog.i { "Audio data size too small, nothing to send." }
  133 + return
  134 + }
  135 +
  136 + val sender = room.localParticipant.streamBytes(
  137 + StreamBytesOptions(
  138 + topic = topic,
  139 + attributes = mapOf(
  140 + "sampleRate" to "${this.sampleRate}",
  141 + "channels" to "${this.numberOfChannels}",
  142 + "trackId" to (trackSid ?: ""),
  143 + ),
  144 + destinationIdentities = agentIdentities,
  145 + totalSize = audioData.size.toLong(),
  146 + name = "preconnect-audio-buffer",
  147 + ),
  148 + )
  149 +
  150 + try {
  151 + sender.write(audioData)
  152 + sender.close()
  153 + } catch (e: Exception) {
  154 + sender.close(e.localizedMessage)
  155 + }
  156 +
  157 + val samples = audioData.size / (numberOfChannels * bitsPerSample / 8)
  158 + val duration = samples.toFloat() / sampleRate
  159 + LKLog.i { "Sent ${duration}s (${audioData.size / 1024}KB) of audio data to ${agentIdentities.size} agent(s) (${agentIdentities.joinToString(",")})" }
  160 + }
  161 +}
  162 +
  163 +/**
  164 + * Starts a pre-connect audio recording that will be sent to
  165 + * any agents that connect within the [timeout]. This speeds up
  166 + * preceived connection times, as the user can start speaking
  167 + * prior to actual connection with the agent.
  168 + *
  169 + * This will automatically be cleaned up when the room disconnects or the operation fails.
  170 + *
  171 + * Example:
  172 + * ```
  173 + * try {
  174 + * room.withPreconnectAudio {
  175 + * // Audio is being captured automatically
  176 + * // Perform any other (async) setup here
  177 + * val (url, token) = tokenService.fetchConnectionDetails()
  178 + * room.connect(
  179 + * url = url,
  180 + * token = token,
  181 + * )
  182 + * room.localParticipant.setMicrophoneEnabled(true)
  183 + * }
  184 + * } catch (e: Throwable) {
  185 + * Log.e(TAG, "Error!")
  186 + * }
  187 + * ```
  188 + * @param timeout the timeout for the remote participant to subscribe to the audio track.
  189 + * The room connection needs to be established and the remote participant needs to subscribe to the audio track
  190 + * before the timeout is reached. Otherwise, the audio stream will be flushed without sending.
  191 + * @param topic the topic to send the preconnect audio buffer to. By default this is configured for
  192 + * use with LiveKit Agents.
  193 + * @param onError The error handler to call when an error occurs while sending the audio buffer.
  194 + * @param operation The connection lambda to call with the pre-connect audio.
  195 + *
  196 + */
  197 +suspend fun <T> Room.withPreconnectAudio(
  198 + timeout: Duration = TIMEOUT,
  199 + topic: String = DEFAULT_TOPIC,
  200 + onError: ((e: Exception) -> Unit)? = null,
  201 + operation: suspend () -> T,
  202 +) = coroutineScope {
  203 + isPrerecording = true
  204 + val audioTrack = localParticipant.getOrCreateDefaultAudioTrack()
  205 + val preconnectAudioBuffer = PreconnectAudioBuffer(timeout)
  206 +
  207 + LKLog.v { "Starting preconnect audio buffer" }
  208 + preconnectAudioBuffer.startRecording()
  209 + audioTrack.addSink(preconnectAudioBuffer)
  210 + audioTrack.prewarm()
  211 +
  212 + fun stopRecording() {
  213 + if (!isPrerecording) {
  214 + return
  215 + }
  216 +
  217 + LKLog.v { "Stopping preconnect audio buffer" }
  218 + audioTrack.removeSink(preconnectAudioBuffer)
  219 + preconnectAudioBuffer.stopRecording()
  220 + isPrerecording = false
  221 + }
  222 +
  223 + // Clear the preconnect audio buffer after the timeout to free memory.
  224 + launch {
  225 + delay(TIMEOUT)
  226 + preconnectAudioBuffer.clear()
  227 + }
  228 +
  229 + val sentIdentities = mutableSetOf<Participant.Identity>()
  230 + launch {
  231 + suspend fun handleSendIfNeeded(participant: Participant) {
  232 + coroutineScope inner@{
  233 + engine::connectionState.flow
  234 + .takeWhile { it != ConnectionState.CONNECTED }
  235 + .collect()
  236 + val kind = participant.kind
  237 + val state = participant.state
  238 + val identity = participant.identity
  239 + if (sentIdentities.contains(identity) || kind != Participant.Kind.AGENT || state != Participant.State.ACTIVE || identity == null) {
  240 + return@inner
  241 + }
  242 +
  243 + stopRecording()
  244 + launch {
  245 + try {
  246 + preconnectAudioBuffer.sendAudioData(
  247 + room = this@withPreconnectAudio,
  248 + trackSid = audioTrack.sid,
  249 + agentIdentities = listOf(identity),
  250 + topic = topic,
  251 + )
  252 + sentIdentities.add(identity)
  253 + } catch (e: Exception) {
  254 + LKLog.w(e) { "Error occurred while sending the audio preconnect data." }
  255 + onError?.invoke(e)
  256 + }
  257 + }
  258 + }
  259 + }
  260 +
  261 + events.collect { event ->
  262 + when (event) {
  263 + is RoomEvent.LocalTrackSubscribed -> {
  264 + LKLog.i { "Local audio track has been subscribed to, stopping preconnect audio recording." }
  265 + stopRecording()
  266 + }
  267 +
  268 + is RoomEvent.ParticipantConnected -> {
  269 + // agents may connect with ACTIVE state and not trigger a participant state changed.
  270 + handleSendIfNeeded(event.participant)
  271 + }
  272 +
  273 + is RoomEvent.ParticipantStateChanged -> {
  274 + handleSendIfNeeded(event.participant)
  275 + }
  276 +
  277 + is RoomEvent.Disconnected -> {
  278 + cancel()
  279 + }
  280 +
  281 + else -> {
  282 + // Intentionally blank.
  283 + }
  284 + }
  285 + }
  286 + }
  287 +
  288 + val retValue: T
  289 + try {
  290 + retValue = operation.invoke()
  291 + } catch (e: Exception) {
  292 + cancel()
  293 + throw e
  294 + }
  295 +
  296 + return@coroutineScope retValue
  297 +}
@@ -197,4 +197,13 @@ sealed class ParticipantEvent(open val participant: Participant) : Event() { @@ -197,4 +197,13 @@ sealed class ParticipantEvent(open val participant: Participant) : Event() {
197 */ 197 */
198 val publication: TrackPublication?, 198 val publication: TrackPublication?,
199 ) : ParticipantEvent(participant) 199 ) : ParticipantEvent(participant)
  200 +
  201 + /**
  202 + * A participant's state has changed.
  203 + */
  204 + class StateChanged(
  205 + override val participant: Participant,
  206 + val newState: Participant.State,
  207 + val oldState: Participant.State,
  208 + ) : ParticipantEvent(participant)
200 } 209 }
@@ -271,6 +271,16 @@ sealed class RoomEvent(val room: Room) : Event() { @@ -271,6 +271,16 @@ sealed class RoomEvent(val room: Room) : Event() {
271 */ 271 */
272 val publication: TrackPublication?, 272 val publication: TrackPublication?,
273 ) : RoomEvent(room) 273 ) : RoomEvent(room)
  274 +
  275 + /**
  276 + * The state for a participant has changed.
  277 + */
  278 + class ParticipantStateChanged(
  279 + room: Room,
  280 + val participant: Participant,
  281 + val newState: Participant.State,
  282 + val oldState: Participant.State,
  283 + ) : RoomEvent(room)
274 } 284 }
275 285
276 enum class DisconnectReason { 286 enum class DisconnectReason {
@@ -288,6 +298,7 @@ enum class DisconnectReason { @@ -288,6 +298,7 @@ enum class DisconnectReason {
288 USER_UNAVAILABLE, 298 USER_UNAVAILABLE,
289 USER_REJECTED, 299 USER_REJECTED,
290 SIP_TRUNK_FAILURE, 300 SIP_TRUNK_FAILURE,
  301 + CONNECTION_TIMEOUT,
291 } 302 }
292 303
293 /** 304 /**
@@ -308,6 +319,7 @@ fun LivekitModels.DisconnectReason?.convert(): DisconnectReason { @@ -308,6 +319,7 @@ fun LivekitModels.DisconnectReason?.convert(): DisconnectReason {
308 LivekitModels.DisconnectReason.USER_UNAVAILABLE -> DisconnectReason.USER_UNAVAILABLE 319 LivekitModels.DisconnectReason.USER_UNAVAILABLE -> DisconnectReason.USER_UNAVAILABLE
309 LivekitModels.DisconnectReason.USER_REJECTED -> DisconnectReason.USER_REJECTED 320 LivekitModels.DisconnectReason.USER_REJECTED -> DisconnectReason.USER_REJECTED
310 LivekitModels.DisconnectReason.SIP_TRUNK_FAILURE -> DisconnectReason.SIP_TRUNK_FAILURE 321 LivekitModels.DisconnectReason.SIP_TRUNK_FAILURE -> DisconnectReason.SIP_TRUNK_FAILURE
  322 + LivekitModels.DisconnectReason.CONNECTION_TIMEOUT -> DisconnectReason.CONNECTION_TIMEOUT
311 LivekitModels.DisconnectReason.UNKNOWN_REASON, 323 LivekitModels.DisconnectReason.UNKNOWN_REASON,
312 LivekitModels.DisconnectReason.UNRECOGNIZED, 324 LivekitModels.DisconnectReason.UNRECOGNIZED,
313 null, 325 null,
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.
@@ -24,6 +24,9 @@ import io.livekit.android.room.track.ScreenSharePresets @@ -24,6 +24,9 @@ import io.livekit.android.room.track.ScreenSharePresets
24 import javax.inject.Inject 24 import javax.inject.Inject
25 import javax.inject.Singleton 25 import javax.inject.Singleton
26 26
  27 +/**
  28 + * @suppress
  29 + */
27 @Singleton 30 @Singleton
28 class DefaultsManager 31 class DefaultsManager
29 @Inject 32 @Inject
@@ -34,4 +37,6 @@ constructor() { @@ -34,4 +37,6 @@ constructor() {
34 var videoTrackPublishDefaults: VideoTrackPublishDefaults = VideoTrackPublishDefaults() 37 var videoTrackPublishDefaults: VideoTrackPublishDefaults = VideoTrackPublishDefaults()
35 var screenShareTrackCaptureDefaults: LocalVideoTrackOptions = LocalVideoTrackOptions(isScreencast = true, captureParams = ScreenSharePresets.ORIGINAL.capture) 38 var screenShareTrackCaptureDefaults: LocalVideoTrackOptions = LocalVideoTrackOptions(isScreencast = true, captureParams = ScreenSharePresets.ORIGINAL.capture)
36 var screenShareTrackPublishDefaults: VideoTrackPublishDefaults = VideoTrackPublishDefaults(videoEncoding = ScreenSharePresets.ORIGINAL.encoding) 39 var screenShareTrackPublishDefaults: VideoTrackPublishDefaults = VideoTrackPublishDefaults(videoEncoding = ScreenSharePresets.ORIGINAL.encoding)
  40 +
  41 + var isPrerecording: Boolean = false
37 } 42 }
@@ -110,7 +110,7 @@ internal constructor( @@ -110,7 +110,7 @@ internal constructor(
110 * Reflects the combined connection state of SignalClient and primary PeerConnection. 110 * Reflects the combined connection state of SignalClient and primary PeerConnection.
111 */ 111 */
112 @FlowObservable 112 @FlowObservable
113 - @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) 113 + @get:FlowObservable
114 var connectionState: ConnectionState by flowDelegate(ConnectionState.DISCONNECTED) { newVal, oldVal -> 114 var connectionState: ConnectionState by flowDelegate(ConnectionState.DISCONNECTED) { newVal, oldVal ->
115 if (newVal == oldVal) { 115 if (newVal == oldVal) {
116 return@flowDelegate 116 return@flowDelegate
@@ -302,7 +302,8 @@ internal constructor( @@ -302,7 +302,8 @@ internal constructor(
302 RELIABLE_DATA_CHANNEL_LABEL, 302 RELIABLE_DATA_CHANNEL_LABEL,
303 reliableInit, 303 reliableInit,
304 ).also { dataChannel -> 304 ).also { dataChannel ->
305 - dataChannel.registerObserver(DataChannelObserver(dataChannel)) 305 + reliableDataChannelManager = DataChannelManager(dataChannel, DataChannelObserver(dataChannel))
  306 + dataChannel.registerObserver(reliableDataChannelManager)
306 } 307 }
307 } 308 }
308 309
@@ -315,7 +316,8 @@ internal constructor( @@ -315,7 +316,8 @@ internal constructor(
315 LOSSY_DATA_CHANNEL_LABEL, 316 LOSSY_DATA_CHANNEL_LABEL,
316 lossyInit, 317 lossyInit,
317 ).also { dataChannel -> 318 ).also { dataChannel ->
318 - dataChannel.registerObserver(DataChannelObserver(dataChannel)) 319 + lossyDataChannelManager = DataChannelManager(dataChannel, DataChannelObserver(dataChannel))
  320 + dataChannel.registerObserver(lossyDataChannelManager)
319 } 321 }
320 } 322 }
321 } 323 }
@@ -1095,6 +1097,7 @@ internal constructor( @@ -1095,6 +1097,7 @@ internal constructor(
1095 -> { 1097 -> {
1096 listener?.onRpcPacketReceived(dp) 1098 listener?.onRpcPacketReceived(dp)
1097 } 1099 }
  1100 +
1098 LivekitModels.DataPacket.ValueCase.VALUE_NOT_SET, 1101 LivekitModels.DataPacket.ValueCase.VALUE_NOT_SET,
1099 null, 1102 null,
1100 -> { 1103 -> {
@@ -76,7 +76,7 @@ class Room @@ -76,7 +76,7 @@ class Room
76 @AssistedInject 76 @AssistedInject
77 constructor( 77 constructor(
78 @Assisted private val context: Context, 78 @Assisted private val context: Context,
79 - private val engine: RTCEngine, 79 + internal val engine: RTCEngine,
80 private val eglBase: EglBase, 80 private val eglBase: EglBase,
81 localParticipantFactory: LocalParticipant.Factory, 81 localParticipantFactory: LocalParticipant.Factory,
82 private val defaultsManager: DefaultsManager, 82 private val defaultsManager: DefaultsManager,
@@ -320,6 +320,8 @@ constructor( @@ -320,6 +320,8 @@ constructor(
320 320
321 private var transcriptionReceivedTimes = mutableMapOf<String, Long>() 321 private var transcriptionReceivedTimes = mutableMapOf<String, Long>()
322 322
  323 + internal var isPrerecording by defaultsManager::isPrerecording
  324 +
323 private fun getCurrentRoomOptions(): RoomOptions = 325 private fun getCurrentRoomOptions(): RoomOptions =
324 RoomOptions( 326 RoomOptions(
325 adaptiveStream = adaptiveStream, 327 adaptiveStream = adaptiveStream,
@@ -678,6 +680,15 @@ constructor( @@ -678,6 +680,15 @@ constructor(
678 ) 680 )
679 } 681 }
680 682
  683 + is ParticipantEvent.StateChanged -> {
  684 + RoomEvent.ParticipantStateChanged(
  685 + this@Room,
  686 + it.participant,
  687 + it.newState,
  688 + it.oldState,
  689 + )
  690 + }
  691 +
681 else -> { 692 else -> {
682 // do nothing 693 // do nothing
683 } 694 }
@@ -808,6 +819,17 @@ constructor( @@ -808,6 +819,17 @@ constructor(
808 ), 819 ),
809 ) 820 )
810 821
  822 + is ParticipantEvent.StateChanged -> {
  823 + eventBus.postEvent(
  824 + RoomEvent.ParticipantStateChanged(
  825 + room = this@Room,
  826 + participant = it.participant,
  827 + newState = it.newState,
  828 + oldState = it.oldState,
  829 + ),
  830 + )
  831 + }
  832 +
811 else -> { 833 else -> {
812 // do nothing 834 // do nothing
813 } 835 }
@@ -780,6 +780,10 @@ constructor( @@ -780,6 +780,10 @@ constructor(
780 -> { 780 -> {
781 LKLog.v { "empty messageCase!" } 781 LKLog.v { "empty messageCase!" }
782 } 782 }
  783 +
  784 + LivekitRtc.SignalResponse.MessageCase.ROOM_MOVED -> {
  785 + // TODO
  786 + }
783 } 787 }
784 } 788 }
785 789
@@ -45,4 +45,7 @@ interface StreamDestination<T> { @@ -45,4 +45,7 @@ interface StreamDestination<T> {
45 suspend fun close(reason: String?) 45 suspend fun close(reason: String?)
46 } 46 }
47 47
48 -internal typealias DataChunker<T> = (data: T, chunkSize: Int) -> List<ByteArray> 48 +/**
  49 + * @suppress
  50 + */
  51 +typealias DataChunker<T> = (data: T, chunkSize: Int) -> List<ByteArray>
@@ -24,6 +24,7 @@ import okio.Source @@ -24,6 +24,7 @@ import okio.Source
24 import okio.source 24 import okio.source
25 import java.io.InputStream 25 import java.io.InputStream
26 import java.util.Arrays 26 import java.util.Arrays
  27 +import kotlin.math.min
27 28
28 class ByteStreamSender( 29 class ByteStreamSender(
29 val info: ByteStreamInfo, 30 val info: ByteStreamInfo,
@@ -36,7 +37,16 @@ class ByteStreamSender( @@ -36,7 +37,16 @@ class ByteStreamSender(
36 37
37 private val byteDataChunker: DataChunker<ByteArray> = { data: ByteArray, chunkSize: Int -> 38 private val byteDataChunker: DataChunker<ByteArray> = { data: ByteArray, chunkSize: Int ->
38 (data.indices step chunkSize) 39 (data.indices step chunkSize)
39 - .map { index -> Arrays.copyOfRange(data, index, index + chunkSize) } 40 + .map { index ->
  41 + Arrays.copyOfRange(
  42 + /* original = */
  43 + data,
  44 + /* from = */
  45 + index,
  46 + /* to = */
  47 + min(index + chunkSize, data.size),
  48 + )
  49 + }
40 } 50 }
41 51
42 /** 52 /**
@@ -67,6 +67,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine @@ -67,6 +67,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
67 import kotlinx.coroutines.sync.Mutex 67 import kotlinx.coroutines.sync.Mutex
68 import kotlinx.coroutines.sync.withLock 68 import kotlinx.coroutines.sync.withLock
69 import livekit.LivekitModels 69 import livekit.LivekitModels
  70 +import livekit.LivekitModels.AudioTrackFeature
70 import livekit.LivekitModels.Codec 71 import livekit.LivekitModels.Codec
71 import livekit.LivekitModels.DataPacket 72 import livekit.LivekitModels.DataPacket
72 import livekit.LivekitModels.TrackInfo 73 import livekit.LivekitModels.TrackInfo
@@ -434,7 +435,7 @@ internal constructor( @@ -434,7 +435,7 @@ internal constructor(
434 options: AudioTrackPublishOptions = AudioTrackPublishOptions( 435 options: AudioTrackPublishOptions = AudioTrackPublishOptions(
435 null, 436 null,
436 audioTrackPublishDefaults, 437 audioTrackPublishDefaults,
437 - ), 438 + ).copy(preconnect = defaultsManager.isPrerecording),
438 publishListener: PublishListener? = null, 439 publishListener: PublishListener? = null,
439 ): Boolean { 440 ): Boolean {
440 val encodings = listOf( 441 val encodings = listOf(
@@ -450,6 +451,7 @@ internal constructor( @@ -450,6 +451,7 @@ internal constructor(
450 requestConfig = { 451 requestConfig = {
451 disableDtx = !options.dtx 452 disableDtx = !options.dtx
452 disableRed = !options.red 453 disableRed = !options.red
  454 + addAllAudioFeatures(options.getFeaturesList())
453 source = options.source?.toProto() ?: LivekitModels.TrackSource.MICROPHONE 455 source = options.source?.toProto() ?: LivekitModels.TrackSource.MICROPHONE
454 }, 456 },
455 encodings = encodings, 457 encodings = encodings,
@@ -459,7 +461,7 @@ internal constructor( @@ -459,7 +461,7 @@ internal constructor(
459 if (publication != null) { 461 if (publication != null) {
460 val job = scope.launch { 462 val job = scope.launch {
461 track::features.flow.collect { 463 track::features.flow.collect {
462 - engine.updateLocalAudioTrack(publication.sid, it) 464 + engine.updateLocalAudioTrack(publication.sid, it + options.getFeaturesList())
463 } 465 }
464 } 466 }
465 jobs[publication] = job 467 jobs[publication] = job
@@ -1763,6 +1765,7 @@ data class AudioTrackPublishOptions( @@ -1763,6 +1765,7 @@ data class AudioTrackPublishOptions(
1763 override val red: Boolean = true, 1765 override val red: Boolean = true,
1764 override val source: Track.Source? = null, 1766 override val source: Track.Source? = null,
1765 override val stream: String? = null, 1767 override val stream: String? = null,
  1768 + val preconnect: Boolean = false,
1766 ) : BaseAudioTrackPublishOptions(), TrackPublishOptions { 1769 ) : BaseAudioTrackPublishOptions(), TrackPublishOptions {
1767 constructor( 1770 constructor(
1768 name: String? = null, 1771 name: String? = null,
@@ -1777,6 +1780,17 @@ data class AudioTrackPublishOptions( @@ -1777,6 +1780,17 @@ data class AudioTrackPublishOptions(
1777 source = source, 1780 source = source,
1778 stream = stream, 1781 stream = stream,
1779 ) 1782 )
  1783 +
  1784 + internal fun getFeaturesList(): Set<AudioTrackFeature> {
  1785 + val features = mutableSetOf<AudioTrackFeature>()
  1786 + if (!dtx) {
  1787 + features.add(AudioTrackFeature.TF_NO_DTX)
  1788 + }
  1789 + if (preconnect) {
  1790 + features.add(AudioTrackFeature.TF_PRECONNECT_BUFFER)
  1791 + }
  1792 + return features
  1793 + }
1780 } 1794 }
1781 1795
1782 data class ParticipantTrackPermission( 1796 data class ParticipantTrackPermission(
@@ -93,6 +93,27 @@ open class Participant( @@ -93,6 +93,27 @@ open class Participant(
93 @VisibleForTesting set 93 @VisibleForTesting set
94 94
95 /** 95 /**
  96 + * The participant state.
  97 + *
  98 + * Changes can be observed by using [io.livekit.android.util.flow]
  99 + */
  100 + @FlowObservable
  101 + @get:FlowObservable
  102 + var state: State by flowDelegate(State.UNKNOWN) { newState, oldState ->
  103 + if (newState != oldState) {
  104 + eventBus.postEvent(
  105 + ParticipantEvent.StateChanged(
  106 + participant = this,
  107 + newState = newState,
  108 + oldState = oldState,
  109 + ),
  110 + scope,
  111 + )
  112 + }
  113 + }
  114 + @VisibleForTesting set
  115 +
  116 + /**
96 * Changes can be observed by using [io.livekit.android.util.flow] 117 * Changes can be observed by using [io.livekit.android.util.flow]
97 */ 118 */
98 @FlowObservable 119 @FlowObservable
@@ -377,6 +398,7 @@ open class Participant( @@ -377,6 +398,7 @@ open class Participant(
377 permissions = ParticipantPermission.fromProto(info.permission) 398 permissions = ParticipantPermission.fromProto(info.permission)
378 } 399 }
379 attributes = info.attributesMap 400 attributes = info.attributesMap
  401 + state = State.fromProto(info.state)
380 } 402 }
381 403
382 override fun equals(other: Any?): Boolean { 404 override fun equals(other: Any?): Boolean {
@@ -474,6 +496,34 @@ open class Participant( @@ -474,6 +496,34 @@ open class Participant(
474 } 496 }
475 } 497 }
476 } 498 }
  499 +
  500 + enum class State {
  501 + // websocket' connected, but not offered yet
  502 + JOINING,
  503 +
  504 + // server received client offer
  505 + JOINED,
  506 +
  507 + // ICE connectivity established
  508 + ACTIVE,
  509 +
  510 + // WS disconnected
  511 + DISCONNECTED,
  512 +
  513 + UNKNOWN;
  514 +
  515 + companion object {
  516 + fun fromProto(proto: LivekitModels.ParticipantInfo.State): State {
  517 + return when (proto) {
  518 + LivekitModels.ParticipantInfo.State.JOINING -> JOINING
  519 + LivekitModels.ParticipantInfo.State.JOINED -> JOINED
  520 + LivekitModels.ParticipantInfo.State.ACTIVE -> ACTIVE
  521 + LivekitModels.ParticipantInfo.State.DISCONNECTED -> DISCONNECTED
  522 + LivekitModels.ParticipantInfo.State.UNRECOGNIZED -> UNKNOWN
  523 + }
  524 + }
  525 + }
  526 + }
477 } 527 }
478 528
479 /** 529 /**
  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.test.mock.room.datastream.outgoing
  18 +
  19 +import io.livekit.android.room.datastream.outgoing.DataChunker
  20 +import io.livekit.android.room.datastream.outgoing.StreamDestination
  21 +
  22 +class MockStreamDestination<T>(val chunkSize: Int) : StreamDestination<T> {
  23 + override var isOpen: Boolean = true
  24 +
  25 + val writtenChunks = mutableListOf<ByteArray>()
  26 + override suspend fun write(data: T, chunker: DataChunker<T>) {
  27 + val chunks = chunker.invoke(data, chunkSize)
  28 +
  29 + for (chunk in chunks) {
  30 + writtenChunks.add(chunk)
  31 + }
  32 + }
  33 +
  34 + override suspend fun close(reason: String?) {
  35 + isOpen = false
  36 + }
  37 +}
@@ -14,10 +14,10 @@ @@ -14,10 +14,10 @@
14 * limitations under the License. 14 * limitations under the License.
15 */ 15 */
16 16
17 -package io.livekit.android.room 17 +package io.livekit.android.room.datastream
18 18
19 import com.google.protobuf.ByteString 19 import com.google.protobuf.ByteString
20 -import io.livekit.android.room.datastream.StreamException 20 +import io.livekit.android.room.RTCEngine
21 import io.livekit.android.test.MockE2ETest 21 import io.livekit.android.test.MockE2ETest
22 import io.livekit.android.test.assert.assertIsClass 22 import io.livekit.android.test.assert.assertIsClass
23 import io.livekit.android.test.mock.MockDataChannel 23 import io.livekit.android.test.mock.MockDataChannel
@@ -38,7 +38,7 @@ import org.junit.Test @@ -38,7 +38,7 @@ import org.junit.Test
38 import java.nio.ByteBuffer 38 import java.nio.ByteBuffer
39 39
40 @OptIn(ExperimentalCoroutinesApi::class) 40 @OptIn(ExperimentalCoroutinesApi::class)
41 -class RoomDataStreamMockE2ETest : MockE2ETest() { 41 +class RoomIncomingDataStreamMockE2ETest : MockE2ETest() {
42 @Test 42 @Test
43 fun dataStream() = runTest { 43 fun dataStream() = runTest {
44 connect() 44 connect()
  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.datastream
  18 +
  19 +import com.google.protobuf.ByteString
  20 +import io.livekit.android.room.RTCEngine
  21 +import io.livekit.android.room.participant.Participant
  22 +import io.livekit.android.test.MockE2ETest
  23 +import io.livekit.android.test.mock.MockDataChannel
  24 +import io.livekit.android.test.mock.MockPeerConnection
  25 +import io.livekit.android.test.mock.TestData
  26 +import io.livekit.android.util.toOkioByteString
  27 +import kotlinx.coroutines.ExperimentalCoroutinesApi
  28 +import kotlinx.coroutines.launch
  29 +import livekit.LivekitModels
  30 +import livekit.LivekitRtc
  31 +import org.junit.Assert.assertEquals
  32 +import org.junit.Assert.assertTrue
  33 +import org.junit.Test
  34 +
  35 +@OptIn(ExperimentalCoroutinesApi::class)
  36 +class RoomOutgoingDataStreamMockE2ETest : MockE2ETest() {
  37 +
  38 + private lateinit var pubDataChannel: MockDataChannel
  39 +
  40 + override suspend fun connect(joinResponse: LivekitRtc.SignalResponse) {
  41 + super.connect(joinResponse)
  42 +
  43 + val pubPeerConnection = component.rtcEngine().getPublisherPeerConnection() as MockPeerConnection
  44 + pubDataChannel = pubPeerConnection.dataChannels[RTCEngine.RELIABLE_DATA_CHANNEL_LABEL] as MockDataChannel
  45 + }
  46 +
  47 + @Test
  48 + fun dataStream() = runTest {
  49 + connect()
  50 +
  51 + // Remote participant to send data to
  52 + wsFactory.listener.onMessage(
  53 + wsFactory.ws,
  54 + TestData.PARTICIPANT_JOIN.toOkioByteString(),
  55 + )
  56 +
  57 + val bytesToStream = ByteArray(100)
  58 + for (i in bytesToStream.indices) {
  59 + bytesToStream[i] = i.toByte()
  60 + }
  61 + val job = launch {
  62 + val sender = room.localParticipant.streamBytes(
  63 + StreamBytesOptions(
  64 + topic = "topic",
  65 + attributes = mapOf("hello" to "world"),
  66 + streamId = "stream_id",
  67 + destinationIdentities = listOf(Participant.Identity(TestData.REMOTE_PARTICIPANT.identity)),
  68 + name = "stream_name",
  69 + totalSize = bytesToStream.size.toLong(),
  70 + ),
  71 + )
  72 + sender.write(bytesToStream)
  73 + sender.close()
  74 + }
  75 +
  76 + job.join()
  77 +
  78 + val buffers = pubDataChannel.sentBuffers
  79 +
  80 + println(buffers)
  81 + assertEquals(3, buffers.size)
  82 +
  83 + val headerPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffers[0].data))
  84 + assertTrue(headerPacket.hasStreamHeader())
  85 +
  86 + with(headerPacket.streamHeader) {
  87 + assertTrue(hasByteHeader())
  88 + }
  89 +
  90 + val payloadPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffers[1].data))
  91 + assertTrue(payloadPacket.hasStreamChunk())
  92 + with(payloadPacket.streamChunk) {
  93 + assertEquals(100, content.size())
  94 + }
  95 +
  96 + val trailerPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(buffers[2].data))
  97 + assertTrue(trailerPacket.hasStreamTrailer())
  98 + with(trailerPacket.streamTrailer) {
  99 + assertTrue(reason.isNullOrEmpty())
  100 + }
  101 + }
  102 +}
  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 io.livekit.android.test.BaseTest
  21 +import io.livekit.android.test.mock.room.datastream.outgoing.MockStreamDestination
  22 +import kotlinx.coroutines.launch
  23 +import org.junit.Assert.assertEquals
  24 +import org.junit.Assert.assertFalse
  25 +import org.junit.Test
  26 +import kotlin.math.roundToInt
  27 +
  28 +class ByteStreamSenderTest : BaseTest() {
  29 +
  30 + companion object {
  31 + val CHUNK_SIZE = 2048
  32 + }
  33 +
  34 + @Test
  35 + fun sendsSmallBytes() = runTest {
  36 + val destination = MockStreamDestination<ByteArray>(CHUNK_SIZE)
  37 + val sender = ByteStreamSender(
  38 + info = createInfo(),
  39 + destination = destination,
  40 + )
  41 +
  42 + val job = launch {
  43 + sender.write(ByteArray(100))
  44 + sender.close()
  45 + }
  46 +
  47 + job.join()
  48 +
  49 + assertFalse(destination.isOpen)
  50 + assertEquals(1, destination.writtenChunks.size)
  51 + assertEquals(100, destination.writtenChunks[0].size)
  52 + }
  53 +
  54 + @Test
  55 + fun sendsLargeBytes() = runTest {
  56 + val destination = MockStreamDestination<ByteArray>(CHUNK_SIZE)
  57 + val sender = ByteStreamSender(
  58 + info = createInfo(),
  59 + destination = destination,
  60 + )
  61 +
  62 + val bytes = ByteArray((CHUNK_SIZE * 1.5).roundToInt())
  63 +
  64 + val job = launch {
  65 + sender.write(bytes)
  66 + sender.close()
  67 + }
  68 +
  69 + job.join()
  70 +
  71 + assertFalse(destination.isOpen)
  72 + assertEquals(2, destination.writtenChunks.size)
  73 + assertEquals(CHUNK_SIZE, destination.writtenChunks[0].size)
  74 + assertEquals(bytes.size - CHUNK_SIZE, destination.writtenChunks[1].size)
  75 + }
  76 +
  77 + fun createInfo(): ByteStreamInfo = ByteStreamInfo(id = "stream_id", topic = "topic", timestampMs = 0, totalSize = null, attributes = mapOf(), mimeType = "", name = null)
  78 +}
  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 io.livekit.android.test.BaseTest
  21 +import io.livekit.android.test.mock.room.datastream.outgoing.MockStreamDestination
  22 +import kotlinx.coroutines.launch
  23 +import org.junit.Assert.assertEquals
  24 +import org.junit.Assert.assertFalse
  25 +import org.junit.Assert.assertNotEquals
  26 +import org.junit.Test
  27 +
  28 +class TextStreamSenderTest : BaseTest() {
  29 +
  30 + companion object {
  31 + val CHUNK_SIZE = 20
  32 + }
  33 +
  34 + @Test
  35 + fun sendsSingle() = runTest {
  36 + val destination = MockStreamDestination<String>(CHUNK_SIZE)
  37 + val sender = TextStreamSender(
  38 + info = createInfo(),
  39 + destination = destination,
  40 + )
  41 +
  42 + val text = "abcdefghi"
  43 + val job = launch {
  44 + sender.write(text)
  45 + sender.close()
  46 + }
  47 +
  48 + job.join()
  49 +
  50 + assertFalse(destination.isOpen)
  51 + assertEquals(1, destination.writtenChunks.size)
  52 + assertEquals(text, destination.writtenChunks[0].decodeToString())
  53 + }
  54 +
  55 + @Test
  56 + fun sendsChunks() = runTest {
  57 + val destination = MockStreamDestination<String>(CHUNK_SIZE)
  58 + val sender = TextStreamSender(
  59 + info = createInfo(),
  60 + destination = destination,
  61 + )
  62 +
  63 + val text = with(StringBuilder()) {
  64 + for (i in 1..CHUNK_SIZE) {
  65 + append("abcdefghi")
  66 + }
  67 + toString()
  68 + }
  69 +
  70 + val job = launch {
  71 + sender.write(text)
  72 + sender.close()
  73 + }
  74 +
  75 + job.join()
  76 +
  77 + assertFalse(destination.isOpen)
  78 + assertNotEquals(1, destination.writtenChunks.size)
  79 +
  80 + val writtenString = with(StringBuilder()) {
  81 + for (chunk in destination.writtenChunks) {
  82 + append(chunk.decodeToString())
  83 + }
  84 + toString()
  85 + }
  86 +
  87 + assertEquals(text, writtenString)
  88 + }
  89 +
  90 + fun createInfo(): TextStreamInfo = TextStreamInfo(
  91 + id = "stream_id",
  92 + topic = "topic",
  93 + timestampMs = 0,
  94 + totalSize = null,
  95 + attributes = mapOf(),
  96 + operationType = TextStreamInfo.OperationType.CREATE,
  97 + version = 0,
  98 + replyToStreamId = null,
  99 + attachedStreamIds = listOf(),
  100 + generated = false,
  101 + )
  102 +}
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.
@@ -58,7 +58,7 @@ class ParticipantTest { @@ -58,7 +58,7 @@ class ParticipantTest {
58 assertEquals(INFO.name, participant.name) 58 assertEquals(INFO.name, participant.name)
59 assertEquals(Participant.Kind.fromProto(INFO.kind), participant.kind) 59 assertEquals(Participant.Kind.fromProto(INFO.kind), participant.kind)
60 assertEquals(INFO.attributesMap, participant.attributes) 60 assertEquals(INFO.attributesMap, participant.attributes)
61 - 61 + assertEquals(Participant.State.fromProto(INFO.state), participant.state)
62 assertEquals(INFO, participant.participantInfo) 62 assertEquals(INFO, participant.participantInfo)
63 } 63 }
64 64
@@ -151,6 +151,23 @@ class ParticipantTest { @@ -151,6 +151,23 @@ class ParticipantTest {
151 } 151 }
152 152
153 @Test 153 @Test
  154 + fun setStateChangedEvent() = runTest {
  155 + val eventCollector = EventCollector(participant.events, coroutineRule.scope)
  156 + participant.state = Participant.State.JOINED
  157 +
  158 + val events = eventCollector.stopCollecting()
  159 +
  160 + assertEquals(1, events.size)
  161 + assertEquals(true, events[0] is ParticipantEvent.StateChanged)
  162 +
  163 + val event = events[0] as ParticipantEvent.StateChanged
  164 +
  165 + assertEquals(participant, event.participant)
  166 + assertEquals(Participant.State.JOINED, event.newState)
  167 + assertEquals(Participant.State.UNKNOWN, event.oldState)
  168 + }
  169 +
  170 + @Test
154 fun addTrackPublication() = runTest { 171 fun addTrackPublication() = runTest {
155 val audioPublication = TrackPublication(TRACK_INFO, null, participant) 172 val audioPublication = TrackPublication(TRACK_INFO, null, participant)
156 participant.addTrackPublication(audioPublication) 173 participant.addTrackPublication(audioPublication)
@@ -197,6 +214,7 @@ class ParticipantTest { @@ -197,6 +214,7 @@ class ParticipantTest {
197 .setName("name") 214 .setName("name")
198 .setKind(LivekitModels.ParticipantInfo.Kind.STANDARD) 215 .setKind(LivekitModels.ParticipantInfo.Kind.STANDARD)
199 .putAttributes("attribute", "value") 216 .putAttributes("attribute", "value")
  217 + .setState(LivekitModels.ParticipantInfo.State.JOINED)
200 .build() 218 .build()
201 219
202 val TRACK_INFO = LivekitModels.TrackInfo.newBuilder() 220 val TRACK_INFO = LivekitModels.TrackInfo.newBuilder()
1 -Subproject commit 02ee5e6947593443d0dfc90cae0b27ce03b6c1fe 1 +Subproject commit 499c17c48063582ac2af0a021827fab18356cc29