From 7937a2b9050401bbc5ac87a5ec10cb378427fd2c Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 15:52:42 +0900 Subject: [PATCH 01/43] Rename RememberParticipantTracksReferencesTest file --- ...RememberParticipantTrackReferencesTest.kt} | 72 ++++++++----------- 1 file changed, 31 insertions(+), 41 deletions(-) rename livekit-compose-components/src/test/java/io/livekit/android/compose/state/{RememberParticipantTrackReferences.kt => RememberParticipantTrackReferencesTest.kt} (68%) diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferencesTest.kt similarity index 68% rename from livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt rename to livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferencesTest.kt index 4f16b4c..f9fddb0 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferencesTest.kt @@ -19,8 +19,7 @@ package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test -import io.livekit.android.room.SignalClient -import io.livekit.android.room.participant.RemoteParticipant +import io.livekit.android.compose.test.util.createFakeRemoteParticipant import io.livekit.android.room.participant.VideoTrackPublishOptions import io.livekit.android.room.track.LocalTrackPublication import io.livekit.android.room.track.LocalVideoTrack @@ -30,23 +29,24 @@ import io.livekit.android.test.MockE2ETest import io.livekit.android.test.mock.MockRtpReceiver import io.livekit.android.test.mock.MockVideoStreamTrack import io.livekit.android.test.mock.TestData -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch -import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.mockito.Mockito @OptIn(ExperimentalCoroutinesApi::class) -class RememberParticipantTrackReferences : MockE2ETest() { +class RememberParticipantTrackReferencesTest : MockE2ETest() { @Test fun getEmptyTrackReferences() = runTest { connect() moleculeFlow(RecompositionMode.Immediate) { - rememberParticipantTrackReferences(passedParticipant = room.localParticipant) + rememberParticipantTrackReferences(passedParticipant = room.localParticipant).value }.test { - Assert.assertTrue(awaitItem().isEmpty()) + assertTrue(awaitItem().isEmpty()) } } @@ -57,14 +57,14 @@ class RememberParticipantTrackReferences : MockE2ETest() { rememberParticipantTrackReferences( usePlaceholders = setOf(Track.Source.CAMERA), passedParticipant = room.localParticipant - ) + ).value }.test { val trackRefs = awaitItem() - Assert.assertEquals(1, trackRefs.size) + assertEquals(1, trackRefs.size) val trackRef = trackRefs.first() - Assert.assertTrue(trackRef.isPlaceholder()) - Assert.assertEquals(Track.Source.CAMERA, trackRef.source) - Assert.assertEquals(room.localParticipant, trackRef.participant) + assertTrue(trackRef.isPlaceholder()) + assertEquals(Track.Source.CAMERA, trackRef.source) + assertEquals(room.localParticipant, trackRef.participant) } } @@ -76,20 +76,20 @@ class RememberParticipantTrackReferences : MockE2ETest() { rememberParticipantTrackReferences( passedParticipant = room.localParticipant, onlySubscribed = false - ) + ).value }.test { // discard initial state. - Assert.assertTrue(awaitItem().isEmpty()) + assertTrue(awaitItem().isEmpty()) val trackRefs = awaitItem() - Assert.assertEquals(1, trackRefs.size) + assertEquals(1, trackRefs.size) val trackRef = trackRefs.first() val (trackPub) = room.localParticipant.videoTrackPublications.first() - Assert.assertFalse(trackRef.isPlaceholder()) - Assert.assertEquals(Track.Source.CAMERA, trackRef.source) - Assert.assertEquals(room.localParticipant, trackRef.participant) - Assert.assertEquals(trackPub, trackRef.publication) + assertFalse(trackRef.isPlaceholder()) + assertEquals(Track.Source.CAMERA, trackRef.source) + assertEquals(room.localParticipant, trackRef.participant) + assertEquals(trackPub, trackRef.publication) } } @@ -104,11 +104,11 @@ class RememberParticipantTrackReferences : MockE2ETest() { connect() val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { - rememberParticipantTrackReferences(passedParticipant = room.localParticipant) + rememberParticipantTrackReferences(passedParticipant = room.localParticipant).value }.test { - Assert.assertTrue(awaitItem().isEmpty()) // initial - Assert.assertTrue(awaitItem().isNotEmpty()) // add - Assert.assertTrue(awaitItem().isEmpty()) // disconnect + assertTrue(awaitItem().isEmpty()) // initial + assertTrue(awaitItem().isNotEmpty()) // add + assertTrue(awaitItem().isEmpty()) // disconnect } } val mockVideoTrack = Mockito.mock(LocalVideoTrack::class.java) @@ -121,25 +121,25 @@ class RememberParticipantTrackReferences : MockE2ETest() { @Test fun whenRemoteParticipantTrackSubscribed() = runTest { - val remoteParticipant = createFakeRemoteParticipant() + val remoteParticipant = createFakeRemoteParticipant(coroutineRule.dispatcher) val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberParticipantTrackReferences( passedParticipant = remoteParticipant, onlySubscribed = true - ) + ).value }.test { // discard initial state. - Assert.assertTrue(awaitItem().isEmpty()) + assertTrue(awaitItem().isEmpty()) val trackRefs = awaitItem() - Assert.assertEquals(1, trackRefs.size) + assertEquals(1, trackRefs.size) val trackRef = trackRefs.first() val (trackPub) = remoteParticipant.videoTrackPublications.first() - Assert.assertFalse(trackRef.isPlaceholder()) - Assert.assertEquals(Track.Source.CAMERA, trackRef.source) - Assert.assertEquals(remoteParticipant, trackRef.participant) - Assert.assertEquals(trackPub, trackRef.publication) + assertFalse(trackRef.isPlaceholder()) + assertEquals(Track.Source.CAMERA, trackRef.source) + assertEquals(remoteParticipant, trackRef.participant) + assertEquals(trackPub, trackRef.publication) } } @@ -155,14 +155,4 @@ class RememberParticipantTrackReferences : MockE2ETest() { job.join() } - private fun createFakeRemoteParticipant(): RemoteParticipant { - return RemoteParticipant( - TestData.REMOTE_PARTICIPANT, - Mockito.mock(SignalClient::class.java), - Dispatchers.IO, - Dispatchers.Default, - ).apply { - updateFromInfo(TestData.REMOTE_PARTICIPANT) - } - } } From 1d1f4d97d2279a3abca8a5ba40b0cc2afd24a601 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 15:59:02 +0900 Subject: [PATCH 02/43] test helpers --- .../test/util/FakeRemoteParticipant.kt | 53 ++++++++++++++ .../compose/test/util/LocalContextExt.kt | 65 +++++++++++++++++ .../compose/test/util/TextStreamTestUtils.kt | 70 +++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/FakeRemoteParticipant.kt create mode 100644 livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/LocalContextExt.kt create mode 100644 livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TextStreamTestUtils.kt diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/FakeRemoteParticipant.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/FakeRemoteParticipant.kt new file mode 100644 index 0000000..f313567 --- /dev/null +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/FakeRemoteParticipant.kt @@ -0,0 +1,53 @@ +package io.livekit.android.compose.test.util + +import io.livekit.android.room.SignalClient +import io.livekit.android.room.participant.RemoteParticipant +import io.livekit.android.room.track.RemoteAudioTrack +import io.livekit.android.room.track.RemoteVideoTrack +import io.livekit.android.test.mock.MockRTCThreadToken +import io.livekit.android.test.mock.TestData +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import livekit.org.webrtc.AudioTrack +import livekit.org.webrtc.RtpReceiver +import livekit.org.webrtc.VideoTrack +import org.mockito.Mockito + +fun createFakeRemoteParticipant(dispatcher: CoroutineDispatcher): RemoteParticipant { + + return RemoteParticipant( + info = TestData.REMOTE_PARTICIPANT, + signalClient = Mockito.mock(SignalClient::class.java), + ioDispatcher = Dispatchers.IO, + defaultDispatcher = Dispatchers.Default, + audioTrackFactory = object : RemoteAudioTrack.Factory { + override fun create( + name: String, + rtcTrack: AudioTrack, + receiver: RtpReceiver + ): RemoteAudioTrack { + return RemoteAudioTrack( + name = name, + rtcTrack = rtcTrack, + receiver = receiver, + rtcThreadToken = MockRTCThreadToken() + ) + } + + }, + videoTrackFactory = object : RemoteVideoTrack.Factory { + override fun create(name: String, rtcTrack: VideoTrack, autoManageVideo: Boolean, receiver: RtpReceiver): RemoteVideoTrack { + return RemoteVideoTrack( + name = name, + rtcTrack = rtcTrack, + autoManageVideo = autoManageVideo, + dispatcher = dispatcher, + receiver = receiver, + rtcThreadToken = MockRTCThreadToken() + ) + } + } + ).apply { + updateFromInfo(TestData.REMOTE_PARTICIPANT) + } +} \ No newline at end of file diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/LocalContextExt.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/LocalContextExt.kt new file mode 100644 index 0000000..fcd5208 --- /dev/null +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/LocalContextExt.kt @@ -0,0 +1,65 @@ +package io.livekit.android.compose.test.util + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.InternalComposeApi +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.currentComposer +import androidx.compose.ui.platform.LocalContext + +/** + * Helper function to get the value of [block] while + */ +@Composable +fun withLocalContext(context: Context, block: @Composable () -> R): R { + return withCompositionLocal(LocalContext provides context, block) +} + +// From https://android-review.googlesource.com/c/platform/frameworks/support/+/3679917 +// TODO: remove when upgrading compose and this is no longer needed.. +/** + * [withCompositionLocal] binds value to [androidx.compose.runtime.ProvidableCompositionLocal] key and returns the result + * produced by the [content] lambda. Use with non-unit returning [content] lambdas or else use + * [CompositionLocalProvider]. Reading the [androidx.compose.runtime.CompositionLocal] using [androidx.compose.runtime.CompositionLocal.current] will + * return the value provided in [CompositionLocalProvider]'s [value] parameter for all composable + * functions called directly or indirectly in the [content] lambda. + * + * @see CompositionLocalProvider + * @see androidx.compose.runtime.CompositionLocal + * @see androidx.compose.runtime.compositionLocalOf + * @see androidx.compose.runtime.staticCompositionLocalOf + */ +@Suppress("BanInlineOptIn") // b/430604046 - These APIs are stable so are ok to inline +@OptIn(InternalComposeApi::class) +@Composable +inline fun withCompositionLocal( + value: ProvidedValue<*>, + content: @Composable () -> T, +): T { + currentComposer.startProvider(value) + return content().also { currentComposer.endProvider() } +} + +/** + * [withCompositionLocals] binds values to [androidx.compose.runtime.ProvidableCompositionLocal] key and returns the result + * produced by the [content] lambda. Use with non-unit returning [content] lambdas or else use + * [CompositionLocalProvider]. Reading the [androidx.compose.runtime.CompositionLocal] using [androidx.compose.runtime.CompositionLocal.current] will + * return the values provided in [CompositionLocalProvider]'s [values] parameter for all composable + * functions called directly or indirectly in the [content] lambda. + * + * @see CompositionLocalProvider + * @see androidx.compose.runtime.CompositionLocal + * @see androidx.compose.runtime.compositionLocalOf + * @see androidx.compose.runtime.staticCompositionLocalOf + */ +@Suppress("BanInlineOptIn") // b/430604046 - These APIs are stable so are ok to inline +@OptIn(InternalComposeApi::class) +@Composable +inline fun withCompositionLocals( + vararg values: ProvidedValue<*>, + content: @Composable () -> T, +): T { + currentComposer.startProviders(values) + return content().also { currentComposer.endProvider() } +} \ No newline at end of file diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TextStreamTestUtils.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TextStreamTestUtils.kt new file mode 100644 index 0000000..8ec59c5 --- /dev/null +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TextStreamTestUtils.kt @@ -0,0 +1,70 @@ +package io.livekit.android.compose.test.util + +import com.google.protobuf.ByteString +import livekit.LivekitModels.DataPacket +import livekit.LivekitModels.DataStream +import livekit.LivekitModels.DataStream.OperationType +import livekit.LivekitModels.DataStream.TextHeader +import livekit.org.webrtc.DataChannel +import java.nio.ByteBuffer + + +fun DataPacket.wrap() = DataChannel.Buffer( + ByteBuffer.wrap(this.toByteArray()), + true, +) + +fun DataChannel.Observer.receiveTextStream(streamId: String = "streamId", chunk: String, topic: String = "topic") { + receiveTextStream(streamId, listOf(chunk), topic) +} + +fun DataChannel.Observer.receiveTextStream(streamId: String = "streamId", chunks: List, topic: String = "topic") { + onMessage(createStreamHeader(streamId, topic).wrap()) + + for (chunk in chunks) { + onMessage( + createStreamChunk( + index = 0, + bytes = chunk.toByteArray(), + id = streamId, + ).wrap(), + ) + } + onMessage(createStreamTrailer(streamId).wrap()) +} + +fun createStreamHeader(id: String = "streamId", headerTopic: String = "topic") = with(DataPacket.newBuilder()) { + streamHeader = with(DataStream.Header.newBuilder()) { + streamId = id + topic = headerTopic + timestamp = 0L + clearTotalLength() + mimeType = "mime" + + textHeader = with(TextHeader.newBuilder()) { + operationType = OperationType.CREATE + generated = false + build() + } + build() + } + build() +} + +fun createStreamChunk(index: Int, bytes: ByteArray, id: String = "streamId") = with(DataPacket.newBuilder()) { + streamChunk = with(DataStream.Chunk.newBuilder()) { + streamId = id + chunkIndex = index.toLong() + content = ByteString.copyFrom(bytes) + build() + } + build() +} + +fun createStreamTrailer(id: String = "streamId") = with(DataPacket.newBuilder()) { + streamTrailer = with(DataStream.Trailer.newBuilder()) { + streamId = id + build() + } + build() +} \ No newline at end of file From 8d6387827a4531ea622040b7129ecb6caaec5281 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 16:01:05 +0900 Subject: [PATCH 03/43] Ignore .idea --- .gitignore | 1 + .idea/compiler.xml | 6 ------ .idea/misc.xml | 10 ---------- .idea/vcs.xml | 8 -------- 4 files changed, 1 insertion(+), 24 deletions(-) delete mode 100644 .idea/compiler.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index fdeb5e8..11f76b8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ npm-debug.log yarn-debug.log yarn-error.log runConfigurations.xml +.idea diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index b86273d..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 74dd639..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 12e45cf..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file From e1ed3ee08203172bada176c96bc44ef195cdbc07 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 16:01:51 +0900 Subject: [PATCH 04/43] Upgrade gradle wrapper to 8.13 --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From d5cb87a963000b57c290848feb7603a36a2f66b3 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 16:02:38 +0900 Subject: [PATCH 05/43] Update to new data topics --- .../io/livekit/android/compose/flow/DataTopic.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataTopic.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataTopic.kt index 58b47cd..f42e174 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataTopic.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataTopic.kt @@ -20,6 +20,20 @@ package io.livekit.android.compose.flow * Standard topics for use with [rememberDataMessageHandler] */ enum class DataTopic(val value: String) { + /** Chat topic */ + CHAT("lk.chat"), + + /** Transcription topic */ + TRANSCRIPTION("lk.transcription"), +} + +/** + * Standard topics for use with [rememberDataMessageHandler] + * + * + */ +@Deprecated(message = "Use DataTopic") +enum class LegacyDataTopic(val value: String) { /** Chat topic */ CHAT("lk-chat-topic"), } From 92494f36bac07b5f084657d36048ec8996e3f66f Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 16:03:20 +0900 Subject: [PATCH 06/43] Use mutex.withLock for safety --- .../io/livekit/android/compose/flow/DataHandler.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt index 1786f79..33e1402 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock data class DataSendOptions(val reliability: DataPublishReliability, val identities: List? = null) @@ -49,11 +50,11 @@ class DataHandler( private val mutex = Mutex() suspend fun sendMessage(payload: ByteArray, options: DataSendOptions) { - mutex.lock() - isSending.value = true - send(payload, options) - isSending.value = false - mutex.unlock() + mutex.withLock { + isSending.value = true + send(payload, options) + isSending.value = false + } } } From af31ed7bad560443a3b819a56cd295f6ddebdc31 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 16:04:07 +0900 Subject: [PATCH 07/43] Data Streaming api --- .../io/livekit/android/compose/chat/Chat.kt | 223 +++++++++++++++--- .../android/compose/flow/DataHandler.kt | 2 +- .../android/compose/flow/TextStream.kt | 80 +++++++ .../compose/stream/RememberTextStream.kt | 26 ++ 4 files changed, 293 insertions(+), 38 deletions(-) create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/flow/TextStream.kt create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/stream/RememberTextStream.kt diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt index 9d18b03..313f5b5 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt @@ -16,96 +16,174 @@ package io.livekit.android.compose.chat -import android.util.Log -import androidx.compose.foundation.lazy.rememberLazyListState +import android.annotation.SuppressLint +import androidx.annotation.CheckResult import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.vdurmont.semver4j.Semver import io.livekit.android.compose.flow.DataHandler import io.livekit.android.compose.flow.DataSendOptions import io.livekit.android.compose.flow.DataTopic +import io.livekit.android.compose.flow.LegacyDataTopic import io.livekit.android.compose.flow.rememberDataMessageHandler import io.livekit.android.compose.local.RoomLocal +import io.livekit.android.compose.types.ReceivedChatMessage import io.livekit.android.room.Room +import io.livekit.android.room.ServerInfo +import io.livekit.android.room.datastream.StreamTextOptions +import io.livekit.android.room.datastream.incoming.TextStreamReceiver import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.room.participant.Participant import io.livekit.android.room.track.DataPublishReliability +import io.livekit.android.util.LKLog +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.util.Date +import java.util.UUID /** * Chat state for sending messages through LiveKit. */ -class Chat(private val localParticipant: LocalParticipant, private val dataHandler: DataHandler) { +class Chat( + private val localParticipant: LocalParticipant, + private val dataHandler: DataHandler, + private val serverSupportsDataStreams: () -> Boolean, // TODO +) { private val stateLock = Mutex() + private val sendLock = Mutex() /** * Send a message through LiveKit. * * @param message */ - suspend fun send(message: String) { - val timestamp = Date().time + @CheckResult + suspend fun send( + message: String, + streamTextOptions: StreamTextOptions = StreamTextOptions(topic = DataTopic.CHAT.value) + ): Result { - // participant is filled in server side. - val chatMessage = ChatMessage( - timestamp = timestamp, - message = message, - ) + val streamTextOptions = if (streamTextOptions.topic.isEmpty()) { + streamTextOptions.copy(topic = DataTopic.CHAT.value) + } else { + streamTextOptions + } + var retMessage: ReceivedChatMessage? - val encodedMessage = Json.encodeToString(chatMessage).toByteArray(Charsets.UTF_8) - dataHandler.sendMessage( - payload = encodedMessage, - options = DataSendOptions(reliability = DataPublishReliability.RELIABLE) - ) + sendLock.withLock { + _isSending.value = true + + val result = localParticipant.sendText(message, streamTextOptions) + val timestamp = Date().time + + if (result.isFailure) { + return Result.failure(result.exceptionOrNull() ?: Exception()) + } + + val streamInfo = result.getOrThrow() + val sentMessage = ReceivedChatMessage( + id = streamInfo.id, + message = message, + timestamp = timestamp, + fromParticipant = localParticipant + ) + retMessage = sentMessage + + // Legacy chat sending + // participant is filled in server side. + val chatMessage = LegacyChatMessage( + id = UUID.randomUUID().toString(), + timestamp = timestamp, + message = message, + ) + + val encodedMessage = Json.encodeToString(chatMessage).toByteArray(Charsets.UTF_8) + dataHandler.sendMessage( + payload = encodedMessage, + options = DataSendOptions(reliability = DataPublishReliability.RELIABLE) + ) + + // add the messages to the local log. + addMessage(sentMessage) + _isSending.value = false + } - // add the messages to the local log. - addMessage(chatMessage.copy(participant = localParticipant)) + + return retMessage?.let { + Result.success(it) + } ?: Result.failure(NullPointerException()) } /** * Add a message directly to the messages log. */ - internal suspend fun addMessage(chatMessage: ChatMessage) { - stateLock.lock() - messages.value = messages.value.plus(chatMessage) - mutableMessagesFlow.tryEmit(chatMessage) - stateLock.unlock() + internal suspend fun addMessage(chatMessage: ReceivedChatMessage) { + stateLock.withLock { + // If we have same id from same participant, replace it. + val messageList = messages.value + val existingIndex = + messageList.indexOfFirst { it.id == chatMessage.id && it.fromParticipant?.identity == chatMessage.fromParticipant?.identity } + + if (existingIndex >= 0) { + val mutatedList = messageList.toMutableList() + val original = messageList[existingIndex] + mutatedList[existingIndex] = chatMessage + .copy(timestamp = original.timestamp, editTimestamp = chatMessage.timestamp) + messages.value = mutatedList + } else { + messages.value = messages.value.plus(chatMessage) + } + + mutableMessagesFlow.tryEmit(chatMessage) + } } + private val _isSending = mutableStateOf(false) + /** * Indicates if currently sending a chat message. */ - val isSending: State - get() = dataHandler.isSending + val isSending: State = _isSending /** * The log of all messages sent and received. */ - val messages = mutableStateOf(emptyList()) + val messages = mutableStateOf(emptyList()) - private val mutableMessagesFlow = MutableSharedFlow(extraBufferCapacity = 1000) + private val mutableMessagesFlow = MutableSharedFlow(extraBufferCapacity = 1000) /** - * A hot flow emitting a [ChatMessage] for each individual message sent and received. + * A hot flow emitting a [ReceivedChatMessage] for each individual message sent and received. */ - val messagesFlow = mutableMessagesFlow as Flow + val messagesFlow = mutableMessagesFlow as Flow } /** * A chat message. */ +@SuppressLint("UnsafeOptInUsageError") +@Deprecated(message = "Deprecated in favor of ReceivedChatMessage") @Serializable -data class ChatMessage( +data class LegacyChatMessage( + val id: String? = null, /** Millis since UNIX epoch */ val timestamp: Long, /** The message */ @@ -116,7 +194,9 @@ data class ChatMessage( * Messages sent by the server will have a null participant. */ @Transient - val participant: Participant? = null + val participant: Participant? = null, + + internal val ignoreLegacy: Boolean? = false, ) /** @@ -126,26 +206,58 @@ data class ChatMessage( */ @Composable fun rememberChat(room: Room = RoomLocal.current): Chat { - rememberLazyListState() - val dataHandler = rememberDataMessageHandler(room = room, topic = DataTopic.CHAT) + + val serverSupportsDataStreams = remember(room) { + // lambda function + canSupport@{ + val version = room.serverInfo?.version + return@canSupport room.serverInfo?.edition == ServerInfo.Edition.CLOUD || + (version != null && version > Semver("1.8.2")) + } + } + val dataHandler = rememberDataMessageHandler(room = room, topic = LegacyDataTopic.CHAT) // Legacy data handler + val chatState = remember(dataHandler) { Chat( localParticipant = room.localParticipant, dataHandler = dataHandler, + serverSupportsDataStreams = serverSupportsDataStreams, ) } + // Data stream for chat. + val coroutineScope = rememberCoroutineScope() + val chatDataStream = remember(room) { setupChatDataStream(room, coroutineScope) } + DisposableEffect(room) { + onDispose { + cleanupChatDataStream(room) + } + } + + LaunchedEffect(chatDataStream, chatState) { + chatDataStream.collect { message -> + chatState.addMessage(message) + } + } + + // Legacy chat receiving LaunchedEffect(dataHandler, chatState) { dataHandler.messageFlow .collect { dataMessage -> val payloadString = dataMessage.payload.decodeToString() try { - val chatMessage = json.decodeFromString(payloadString) - .copy(participant = dataMessage.participant) - - chatState.addMessage(chatMessage) + val legacyChatMessage = json.decodeFromString(payloadString) + if (legacyChatMessage.ignoreLegacy == false) { + val chatMessage = ReceivedChatMessage( + id = legacyChatMessage.id ?: UUID.randomUUID().toString(), + message = legacyChatMessage.message, + timestamp = legacyChatMessage.timestamp, + fromParticipant = legacyChatMessage.participant, + ) + chatState.addMessage(chatMessage) + } } catch (e: Exception) { - Log.w("Chat", "malformed chat message: $payloadString") + LKLog.e(e) { "malformed chat message: $payloadString" } } } } @@ -153,6 +265,43 @@ fun rememberChat(room: Room = RoomLocal.current): Chat { return chatState } +private fun setupChatDataStream( + room: Room, + coroutineScope: CoroutineScope, + topic: String = DataTopic.CHAT.value +): SharedFlow { + + // The output flow + val outputFlow = MutableSharedFlow() + + room.registerTextStreamHandler(topic) { reader: TextStreamReceiver, fromIdentity: Participant.Identity -> + val participant = room.getParticipantByIdentity(fromIdentity) + coroutineScope.launch { + // Gather up the text + reader.flow + .scan("") { accumulator, value -> accumulator + value } + .drop(1) + .map { text -> + ReceivedChatMessage( + id = reader.info.id, + message = text, + timestamp = reader.info.timestampMs, + fromParticipant = participant, + ) + } + .collect { message -> + outputFlow.emit(message) + } + } + } + + return outputFlow +} + +private fun cleanupChatDataStream(room: Room, topic: String = DataTopic.CHAT.value) { + room.unregisterTextStreamHandler(topic) +} + private val json = Json { ignoreUnknownKeys = true } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt index 33e1402..d493bb9 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt @@ -103,7 +103,7 @@ data class DataMessage( * will be sent on the specified topic. */ @Composable -fun rememberDataMessageHandler(room: Room, topic: DataTopic): DataHandler { +fun rememberDataMessageHandler(room: Room, topic: LegacyDataTopic): DataHandler { return rememberDataMessageHandler(room, topic.value) } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/TextStream.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/TextStream.kt new file mode 100644 index 0000000..f11caa7 --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/TextStream.kt @@ -0,0 +1,80 @@ +package io.livekit.android.compose.flow + +import io.livekit.android.room.Room +import io.livekit.android.room.datastream.StreamInfo +import io.livekit.android.room.datastream.incoming.TextStreamReceiver +import io.livekit.android.room.participant.Participant +import io.livekit.android.room.types.TranscriptionAttributes +import io.livekit.android.room.types.fromMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.launch +import java.util.Collections + +data class TextStreamData( + val text: String, + val participantIdentity: Participant.Identity, + val streamInfo: StreamInfo +) + +/** + * Registers a text stream handler for [topic], and returns a flow of lists containing all the text stream data + * for each received stream. + */ +internal fun setupTextStream(room: Room, topic: String, coroutineScope: CoroutineScope): Flow> { + // The output flow + val textStreamFlow = MutableStateFlow>(emptyList()) + + val textStreams = Collections.synchronizedList(mutableListOf()) + room.registerTextStreamHandler(topic) { reader: TextStreamReceiver, fromIdentity: Participant.Identity -> + val transcriptionAttributes = TranscriptionAttributes.fromMap(reader.info.attributes) + val isTranscription = transcriptionAttributes.lkSegmentID != null + + var index = -1 + + coroutineScope.launch(Dispatchers.IO) { + // Gather up the text + reader.flow + .scan("") { accumulator, value -> accumulator + value } + .drop(1) + .collect { nextText -> + synchronized(textStreams) { + if (index == -1) { + index = textStreams.indexOfFirst { stream -> + val streamTranscriptionAttributes = if (isTranscription) { + TranscriptionAttributes.fromMap(stream.streamInfo.attributes) + } else { + null + } + return@indexOfFirst stream.streamInfo.id == reader.info.id || + (isTranscription && streamTranscriptionAttributes?.lkSegmentID == transcriptionAttributes.lkSegmentID) + } + } + + if (index == -1) { + // New stream, add it + textStreams.add( + TextStreamData( + text = nextText, + participantIdentity = fromIdentity, + streamInfo = reader.info, + ) + ) + } else { + val newData = textStreams[index].copy(text = nextText) + textStreams[index] = newData + } + + // Always make copy of list to prevent concurrent modification errors. + textStreamFlow.tryEmit(textStreams.toList()) + } + } + } + } + + return textStreamFlow +} diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/stream/RememberTextStream.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/stream/RememberTextStream.kt new file mode 100644 index 0000000..803049e --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/stream/RememberTextStream.kt @@ -0,0 +1,26 @@ +package io.livekit.android.compose.stream + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import io.livekit.android.compose.flow.TextStreamData +import io.livekit.android.compose.flow.setupTextStream +import io.livekit.android.compose.local.requireRoom +import io.livekit.android.room.Room + +@Composable +fun rememberTextStream(topic: String, room: Room?): State> { + val room = requireRoom(room) + + val coroutineScope = rememberCoroutineScope() + val textStreamDatas = remember { + setupTextStream( + room, topic, + coroutineScope = coroutineScope + ) + } + + return textStreamDatas.collectAsState(emptyList()) +} \ No newline at end of file From fc375a53d0b4bae0357ee1ec0d1dd691adbba388 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 16:04:47 +0900 Subject: [PATCH 08/43] Fix ParticipantLocal docs --- .../java/io/livekit/android/compose/local/ParticipantLocal.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/ParticipantLocal.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/ParticipantLocal.kt index 22acd5a..18e3e25 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/ParticipantLocal.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/ParticipantLocal.kt @@ -24,6 +24,8 @@ import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.room.participant.Participant /** + * CompositionLocal for the [Participant] currently provided by [ParticipantScope] + * * Not to be confused with [LocalParticipant]. */ @SuppressLint("CompositionLocalNaming") @@ -43,7 +45,7 @@ fun ParticipantScope( /** * Returns the [passedParticipant] or the currently provided [ParticipantLocal]. - * @throws IllegalStateException if passedParticipant is null and no ParticipantLocal is available (e.g. not inside a [RoomScope]). + * @throws IllegalStateException if passedParticipant is null and no ParticipantLocal is available (e.g. not inside a [ParticipantScope]). */ @Composable @Throws(IllegalStateException::class) From 6dd514da5b4886d8f3831943170610bff615ff3a Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 16:06:25 +0900 Subject: [PATCH 09/43] Don't require local context if room is passed to rememberLiveKitRoom --- .../java/io/livekit/android/compose/local/RoomLocal.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt index eab02e4..e86bb69 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt @@ -125,10 +125,14 @@ fun rememberLiveKitRoom( passedRoom: Room? = null, disconnectOnDispose: Boolean = true, ): Room { - val context = LocalContext.current + val context = if (passedRoom == null) { + LocalContext.current + } else { + null + } val room = remember(passedRoom) { passedRoom ?: LiveKit.create( - appContext = context.applicationContext, + appContext = context!!.applicationContext, options = roomOptions ?: RoomOptions(), overrides = liveKitOverrides ?: LiveKitOverrides(), ) From ec3fab6696652bc401802d4a60cf03e05c33f82a Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 16:07:36 +0900 Subject: [PATCH 10/43] rememberLiveKitRoom: Only disconnect Room if it has connected before to manage the connection --- .../livekit/android/compose/local/RoomLocal.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt index e86bb69..1b58123 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt @@ -22,7 +22,10 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import io.livekit.android.ConnectOptions import io.livekit.android.LiveKit @@ -30,6 +33,7 @@ import io.livekit.android.LiveKitOverrides import io.livekit.android.RoomOptions import io.livekit.android.room.Room import io.livekit.android.room.RoomException +import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.util.LKLog import io.livekit.android.util.flow import kotlinx.coroutines.CoroutineScope @@ -96,7 +100,7 @@ private val DEFAULT_ERROR_HANDLER: ((Room, Exception?) -> Unit) = { _, e -> * @param token the token to connect to livekit with. * @param audio enable or disable audio. Defaults to false. * @param video enable or disable video. Defaults to false. - * @param connect whether the room should automatically connect to the server. Defaults to true. + * @param connect whether the room should be automatically connected to the server. Defaults to true. * @param roomOptions options to pass to the [Room]. * @param liveKitOverrides overrides to pass to the [Room]. * @param connectOptions options to use when connecting. Will not reflect changes if already connected. @@ -200,8 +204,12 @@ fun rememberLiveKitRoom( HandleRoomState(Room.State.DISCONNECTED, room) { _, _ -> onDisconnected?.invoke(this, room) } + var hasConnected by remember { + mutableStateOf(false) + } LaunchedEffect(room, connect, url, token, connectOptions) { - if (!connect) { + // Only disconnect if we've connected before. + if (!connect && hasConnected) { room.disconnect() return@LaunchedEffect } @@ -211,6 +219,7 @@ fun rememberLiveKitRoom( } try { + hasConnected = true room.connect(url, token, connectOptions ?: ConnectOptions()) } catch (e: Exception) { onError?.invoke(room, RoomException.ConnectException(e.message, e)) @@ -305,6 +314,11 @@ fun requireRoom(passedRoom: Room? = null): Room { return passedRoom ?: RoomLocal.current } +/** + * CompositionLocal for the [Room] currently provided by [RoomScope] + * + * Not to be confused with [LocalParticipant]. + */ @SuppressLint("CompositionLocalNaming") val RoomLocal = compositionLocalOf { throw IllegalStateException("No Room object available. This should only be used within a RoomScope.") } From 6eb66e69458b0a9cc32390f5223e864641e93bc5 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 16:14:32 +0900 Subject: [PATCH 11/43] Fix RememberParticipantTrackReferences returning a new flow every recomposition --- .changeset/curly-lizards-teach.md | 5 +++++ .../state/RememberParticipantTrackReferences.kt | 15 +++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 .changeset/curly-lizards-teach.md diff --git a/.changeset/curly-lizards-teach.md b/.changeset/curly-lizards-teach.md new file mode 100644 index 0000000..b7891b5 --- /dev/null +++ b/.changeset/curly-lizards-teach.md @@ -0,0 +1,5 @@ +--- +"components-android": patch +--- + +Fix RememberParticipantTrackReferences returning a new flow every recomposition diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt index 93255cc..8dcfe55 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt @@ -87,12 +87,15 @@ fun rememberParticipantTrackReferences( ): List { val participant = requireParticipant(passedParticipant) - return participantTrackReferencesFlow( - participant = participant, - sources = sources, - usePlaceholders = usePlaceholders, - onlySubscribed = onlySubscribed - ) + val flow = remember(participant, sources, usePlaceholders, onlySubscribed) { + participantTrackReferencesFlow( + participant = participant, + sources = sources, + usePlaceholders = usePlaceholders, + onlySubscribed = onlySubscribed + ) + } + return flow .collectAsState(initial = participant.getTrackReferencesBySource(sources, usePlaceholders, onlySubscribed)) .value } From 3d147b814f0cacf76e37dbc70e93fb3330cc2d9c Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 16:16:37 +0900 Subject: [PATCH 12/43] rememberLiveKitRoom changesets --- .changeset/cyan-donuts-joke.md | 5 +++++ .changeset/nice-beers-worry.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/cyan-donuts-joke.md create mode 100644 .changeset/nice-beers-worry.md diff --git a/.changeset/cyan-donuts-joke.md b/.changeset/cyan-donuts-joke.md new file mode 100644 index 0000000..d098eab --- /dev/null +++ b/.changeset/cyan-donuts-joke.md @@ -0,0 +1,5 @@ +--- +"components-android": patch +--- + +rememberLiveKitRoom: Only disconnect Room if it has connected before to manage the connection diff --git a/.changeset/nice-beers-worry.md b/.changeset/nice-beers-worry.md new file mode 100644 index 0000000..da217d3 --- /dev/null +++ b/.changeset/nice-beers-worry.md @@ -0,0 +1,5 @@ +--- +"components-android": patch +--- + +rememberLiveKitRoom: Don't require local context if room is passed From 8f2cc4263b9c52d29adad21422d80fa6982bcf75 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 17:00:49 +0900 Subject: [PATCH 13/43] Switch to returning State objects instead of values --- .changeset/spicy-trains-know.md | 21 ++++ .../compose/state/RememberConnectionState.kt | 5 +- .../compose/state/RememberParticipantInfo.kt | 34 +++--- .../RememberParticipantTrackReferences.kt | 17 +-- .../compose/state/RememberParticipants.kt | 10 +- .../android/compose/state/RememberRoomInfo.kt | 25 +++-- .../android/compose/state/RememberTrack.kt | 7 +- .../compose/state/RememberTrackMuted.kt | 20 ++-- .../compose/state/RememberTrackReferences.kt | 4 +- .../transcriptions/RememberTranscriptions.kt | 104 +++++++++--------- .../android/compose/types/TrackIdentifier.kt | 2 + .../android/compose/ui/VideoTrackView.kt | 11 +- .../compose/util/RememberStateOrDefault.kt | 22 ++++ .../state/RememberConnectionStateTest.kt | 4 +- .../compose/state/RememberParticipantsTest.kt | 6 +- .../compose/state/RememberTrackMutedTest.kt | 6 +- .../state/RememberTrackReferencesTest.kt | 25 ++--- .../compose/state/RememberTrackTest.kt | 4 +- 18 files changed, 196 insertions(+), 131 deletions(-) create mode 100644 .changeset/spicy-trains-know.md create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt diff --git a/.changeset/spicy-trains-know.md b/.changeset/spicy-trains-know.md new file mode 100644 index 0000000..ea5600c --- /dev/null +++ b/.changeset/spicy-trains-know.md @@ -0,0 +1,21 @@ +--- +"components-android": major +--- + +Compose depends on the timing of reads of `State` objects to determine whether it is a dependency for certain +use cases, such as when using `derivedStateOf` or `snapshotFlow`. When we pass back state values, these timings +can be disassociated from their usage, causing Compose to not register the states appropriately and not update +when the state value changed. + +To address this, we've changed the return values of simple functions like `rememberConnectionState` to return +`State` objects instead of the values directly. This means that their reads will be more closely aligned with +their usages and prevent issues with Compose not updating appropriately. + +To migrate, switch to using the `by` delegate syntax when declaring an object to hold the state: + +``` +val connectionState by rememberConnectionState() +``` + +In places where we return data objects to hold multiple values (such as `rememberRoomInfo`), we've kept the API +to return values, as these have been converted to be delegates to the state objects backing them. \ No newline at end of file diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberConnectionState.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberConnectionState.kt index ca0e0d1..d2fd793 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberConnectionState.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberConnectionState.kt @@ -17,6 +17,7 @@ package io.livekit.android.compose.state import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import io.livekit.android.compose.local.RoomScope import io.livekit.android.compose.local.requireRoom @@ -27,7 +28,7 @@ import io.livekit.android.util.flow * Returns the [Room.State] from [passedRoom] or the local [RoomScope] if null. */ @Composable -fun rememberConnectionState(passedRoom: Room? = null): Room.State { +fun rememberConnectionState(passedRoom: Room? = null): State { val room = requireRoom(passedRoom) - return room::state.flow.collectAsState().value + return room::state.flow.collectAsState() } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantInfo.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantInfo.kt index 2569b04..d44e346 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantInfo.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantInfo.kt @@ -17,8 +17,10 @@ package io.livekit.android.compose.state import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import io.livekit.android.compose.local.requireParticipant import io.livekit.android.room.participant.Participant @@ -27,12 +29,16 @@ import io.livekit.android.util.flow /** * Holder for basic [Participant] information. */ -@Immutable -data class ParticipantInfo( - val name: String?, - val identity: Participant.Identity?, - val metadata: String?, -) +@Stable +class ParticipantInfo( + nameState: State, + identityState: State, + metadataState: State, +) { + val name: String? by nameState + val identity: Participant.Identity? by identityState + val metadata: String? by metadataState +} /** * Remembers the participant info and updates whenever it is changed. @@ -41,15 +47,15 @@ data class ParticipantInfo( fun rememberParticipantInfo(passedParticipant: Participant? = null): ParticipantInfo { val participant = requireParticipant(passedParticipant) - val name = participant::name.flow.collectAsState().value - val identity = participant::identity.flow.collectAsState().value - val metadata = participant::metadata.flow.collectAsState().value + val nameState = participant::name.flow.collectAsState() + val identityState = participant::identity.flow.collectAsState() + val metadataState = participant::metadata.flow.collectAsState() - val participantInfo = remember(name, identity, metadata) { + val participantInfo = remember(nameState, identityState, metadataState) { ParticipantInfo( - name = name, - identity = identity, - metadata = metadata, + nameState = nameState, + identityState = identityState, + metadataState = metadataState, ) } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt index 8dcfe55..9b34d04 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt @@ -17,7 +17,9 @@ package io.livekit.android.compose.state import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import io.livekit.android.compose.local.ParticipantLocal import io.livekit.android.compose.local.RoomLocal import io.livekit.android.compose.local.requireParticipant @@ -49,12 +51,14 @@ fun rememberParticipantTrackReferences( passedRoom: Room? = null, usePlaceholders: Set = emptySet(), onlySubscribed: Boolean = true, -): List { +): State> { val room = requireRoom(passedRoom) - val participant = if (participantIdentity != null) { - room.getParticipantByIdentity(participantIdentity) - } else { - null + val participant = remember(participantIdentity) { + if (participantIdentity != null) { + room.getParticipantByIdentity(participantIdentity) + } else { + null + } } return rememberParticipantTrackReferences( @@ -84,7 +88,7 @@ fun rememberParticipantTrackReferences( usePlaceholders: Set = emptySet(), passedParticipant: Participant? = null, onlySubscribed: Boolean = true, -): List { +): State> { val participant = requireParticipant(passedParticipant) val flow = remember(participant, sources, usePlaceholders, onlySubscribed) { @@ -97,7 +101,6 @@ fun rememberParticipantTrackReferences( } return flow .collectAsState(initial = participant.getTrackReferencesBySource(sources, usePlaceholders, onlySubscribed)) - .value } /** diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipants.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipants.kt index 77c61da..80e26f1 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipants.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipants.kt @@ -17,7 +17,9 @@ package io.livekit.android.compose.state import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import io.livekit.android.compose.local.requireRoom @@ -32,13 +34,15 @@ import io.livekit.android.util.flow * Updates automatically whenever the participant list changes. */ @Composable -fun rememberParticipants(passedRoom: Room? = null): List { +fun rememberParticipants(passedRoom: Room? = null): State> { val room = requireRoom(passedRoom = passedRoom) val localParticipant = room.localParticipant val remoteParticipants by room::remoteParticipants.flow.collectAsState() - return remember(localParticipant, remoteParticipants) { - return@remember listOf(localParticipant).plus(remoteParticipants.values) + return remember { + derivedStateOf { + listOf(localParticipant).plus(remoteParticipants.values) + } } } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberRoomInfo.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberRoomInfo.kt index ad87eb7..ee6539f 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberRoomInfo.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberRoomInfo.kt @@ -17,8 +17,10 @@ package io.livekit.android.compose.state import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import io.livekit.android.compose.local.requireRoom import io.livekit.android.room.Room @@ -27,11 +29,14 @@ import io.livekit.android.util.flow /** * Holder for basic [Room] information. */ -@Immutable -data class RoomInfo( - val name: String?, - val metadata: String?, -) +@Stable +class RoomInfo( + nameState: State, + metadataState: State, +) { + val name: String? by nameState + val metadata: String? by metadataState +} /** * Remembers the room info and updates whenever it is changed. @@ -40,13 +45,13 @@ data class RoomInfo( fun rememberRoomInfo(passedRoom: Room? = null): RoomInfo { val room = requireRoom(passedRoom = passedRoom) - val name = room::name.flow.collectAsState().value - val metadata = room::metadata.flow.collectAsState().value + val name = room::name.flow.collectAsState() + val metadata = room::metadata.flow.collectAsState() val roomInfo = remember(name, metadata) { RoomInfo( - name = name, - metadata = metadata, + nameState = name, + metadataState = metadata, ) } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrack.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrack.kt index aad6d7d..e76a067 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrack.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrack.kt @@ -18,6 +18,7 @@ package io.livekit.android.compose.state import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import io.livekit.android.compose.types.TrackIdentifier @@ -36,7 +37,7 @@ import kotlinx.coroutines.flow.collectLatest * as needed. */ @Composable -internal fun rememberTrack(trackPublication: TrackPublication?): T? { +internal fun rememberTrack(trackPublication: TrackPublication?): State { val trackState = remember { mutableStateOf(null) } LaunchedEffect(trackPublication) { @@ -50,7 +51,7 @@ internal fun rememberTrack(trackPublication: TrackPublication?): T? } } - return trackState.value + return trackState } /** @@ -64,6 +65,6 @@ internal fun rememberTrack(trackPublication: TrackPublication?): T? * @see TrackReference */ @Composable -fun rememberTrack(trackIdentifier: TrackIdentifier): T? { +fun rememberTrack(trackIdentifier: TrackIdentifier): State { return rememberTrack(trackIdentifier.publication) } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt index 3bc408d..6d48c65 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt @@ -17,23 +17,27 @@ package io.livekit.android.compose.state import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import io.livekit.android.compose.types.TrackReference +import io.livekit.android.compose.util.rememberStateOrDefault import io.livekit.android.util.flow /** * @return true if the referenced track is muted or is a placeholder */ @Composable -fun rememberTrackMuted(trackRef: TrackReference): Boolean { - return if (trackRef.isPlaceholder()) { - true - } else { - val publication = trackRef.publication - if (publication != null) { - publication::muted.flow.collectAsState().value +fun rememberTrackMuted(trackRef: TrackReference): State { + return rememberStateOrDefault(true) { + if (trackRef.isPlaceholder()) { + null } else { - true + val publication = trackRef.publication + if (publication != null) { + publication::muted.flow.collectAsState() + } else { + null + } } } } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt index 687c80a..c12261a 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt @@ -17,6 +17,7 @@ package io.livekit.android.compose.state import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import io.livekit.android.compose.local.RoomLocal import io.livekit.android.compose.local.requireRoom @@ -53,7 +54,7 @@ fun rememberTracks( usePlaceholders: Set = emptySet(), passedRoom: Room? = null, onlySubscribed: Boolean = true, -): List { +): State> { val room = requireRoom(passedRoom) return trackReferencesFlow( @@ -63,7 +64,6 @@ fun rememberTracks( onlySubscribed = onlySubscribed ) .collectAsState(initial = room.getTrackReferences(sources, usePlaceholders, onlySubscribed)) - .value } /** diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptions.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptions.kt index 1037c58..2fcb161 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptions.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptions.kt @@ -17,96 +17,96 @@ package io.livekit.android.compose.state.transcriptions import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import io.livekit.android.annotations.Beta -import io.livekit.android.compose.flow.rememberEventSelector +import io.livekit.android.compose.flow.DataTopic +import io.livekit.android.compose.flow.TextStreamData import io.livekit.android.compose.local.requireParticipant import io.livekit.android.compose.local.requireRoom +import io.livekit.android.compose.stream.rememberTextStream import io.livekit.android.compose.types.TrackReference -import io.livekit.android.events.ParticipantEvent -import io.livekit.android.events.RoomEvent -import io.livekit.android.events.TrackPublicationEvent +import io.livekit.android.compose.util.rememberStateOrDefault import io.livekit.android.room.Room import io.livekit.android.room.participant.Participant -import io.livekit.android.room.types.TranscriptionSegment -import io.livekit.android.room.types.mergeNewSegments -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import io.livekit.android.util.flow /** * Collect all the transcriptions for the room. * - * @return Returns the collected transcriptions, ordered by [TranscriptionSegment.firstReceivedTime]. + * @return Returns the collected transcriptions, ordered by timestamp. */ @Beta @Composable -fun rememberTranscriptions(passedRoom: Room? = null): List { - val room = requireRoom(passedRoom) - val events = rememberEventSelector(room) - val flow by remember(events) { +fun rememberTranscriptions( + room: Room? = null, + participantIdentities: List? = null, + trackSids: List? = null, +): State> { + val room = requireRoom(room) + val textStreams by rememberTextStream(room = room, topic = DataTopic.TRANSCRIPTION.value) + + val filteredTextStreams = remember { derivedStateOf { - events.map { it.transcriptionSegments } + textStreams + .filter { streamData -> + participantIdentities?.contains(streamData.participantIdentity) + ?: true + } + .filter { streamData -> + trackSids?.contains(streamData.streamInfo.attributes["lk.transcribed_track_id"]) + ?: true + } } } - return rememberTranscriptionsImpl(transcriptionsFlow = flow) + return filteredTextStreams } /** * Collect all the transcriptions for a track reference. * - * @return Returns the collected transcriptions, ordered by [TranscriptionSegment.firstReceivedTime]. + * @return Returns the collected transcriptions, ordered by timestamp. */ @Beta @Composable -fun rememberTrackTranscriptions(trackReference: TrackReference): List { - val publication = trackReference.publication ?: return emptyList() - val events = rememberEventSelector(publication) - val flow by remember(events) { - derivedStateOf { - events.map { it.transcriptions } +fun rememberTrackTranscriptions(trackReference: TrackReference, room: Room? = null): State> { + val publication = trackReference.publication + return rememberStateOrDefault(emptyList()) { + if (publication == null) { + null + } else { + rememberTranscriptions( + trackSids = listOf(publication.sid), + room = room + ) } } - - return rememberTranscriptionsImpl(transcriptionsFlow = flow) } /** * Collect all the transcriptions for a participant. * - * @return Returns the collected transcriptions, ordered by [TranscriptionSegment.firstReceivedTime]. + * @return Returns the collected transcriptions, ordered by timestamp. */ @Beta @Composable -fun rememberParticipantTranscriptions(passedParticipant: Participant? = null): List { +fun rememberParticipantTranscriptions(passedParticipant: Participant? = null, room: Room? = null): State> { val participant = requireParticipant(passedParticipant) - val events = rememberEventSelector(participant) - val flow by remember(events) { - derivedStateOf { - events.map { it.transcriptions } - } - } - - return rememberTranscriptionsImpl(transcriptionsFlow = flow) -} + val identity = participant::identity.flow.collectAsState().value -@Composable -internal fun rememberTranscriptionsImpl(transcriptionsFlow: Flow>): List { - val segments = remember(transcriptionsFlow) { mutableStateMapOf() } - val orderedSegments = remember(segments) { - derivedStateOf { - segments.values.sortedBy { segment -> segment.firstReceivedTime } - } - } - LaunchedEffect(transcriptionsFlow) { - transcriptionsFlow.collect { - segments.mergeNewSegments(it) + return rememberUpdatedState( + if (identity == null) { + emptyList() + } else { + rememberTranscriptions( + participantIdentities = listOf(identity), + room = room, + ).value } - } - - return orderedSegments.value -} + ) +} \ No newline at end of file diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/TrackIdentifier.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/TrackIdentifier.kt index 661a022..74e4f95 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/TrackIdentifier.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/TrackIdentifier.kt @@ -16,6 +16,7 @@ package io.livekit.android.compose.types +import androidx.compose.runtime.Immutable import io.livekit.android.room.participant.Participant import io.livekit.android.room.track.Track import io.livekit.android.room.track.TrackPublication @@ -59,6 +60,7 @@ data class TrackSource( /** * A reference to a [Track], or a placeholder. */ +@Immutable data class TrackReference( override val participant: Participant, override val publication: TrackPublication?, diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/ui/VideoTrackView.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/ui/VideoTrackView.kt index e4a1cc3..101a694 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/ui/VideoTrackView.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/ui/VideoTrackView.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.viewinterop.AndroidView import io.livekit.android.compose.local.requireRoom import io.livekit.android.compose.state.rememberTrack import io.livekit.android.compose.types.TrackReference +import io.livekit.android.compose.util.rememberStateOrDefault import io.livekit.android.renderer.SurfaceViewRenderer import io.livekit.android.renderer.TextureViewRenderer import io.livekit.android.room.Room @@ -69,10 +70,12 @@ fun VideoTrackView( rendererType: RendererType = RendererType.Texture, onFirstFrameRendered: () -> Unit = {} ) { - val track = if (trackReference != null) { - rememberTrack(trackIdentifier = trackReference) - } else { - null + val track by rememberStateOrDefault(null) { + if (trackReference != null) { + rememberTrack(trackIdentifier = trackReference) + } else { + null + } } VideoTrackView( diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt new file mode 100644 index 0000000..92b108b --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt @@ -0,0 +1,22 @@ +package io.livekit.android.compose.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.map + +/** + * A utility state that either collects from the state provided from [block], + * or emits the [default] value if the state is null. + * + * The returned state will always refer to the same [State] object. + */ +@Composable +internal inline fun rememberStateOrDefault(default: T, block: @Composable () -> State?): State { + val state = block() + + return snapshotFlow { state?.value } + .map { value -> value ?: default } + .collectAsState(state?.value ?: default) +} \ No newline at end of file diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberConnectionStateTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberConnectionStateTest.kt index 40c4804..f62c6d9 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberConnectionStateTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberConnectionStateTest.kt @@ -32,7 +32,7 @@ class RememberConnectionStateTest : MockE2ETest() { @Test fun initialState() = runTest { moleculeFlow(RecompositionMode.Immediate) { - rememberConnectionState(room) + rememberConnectionState(room).value }.test { assertEquals(awaitItem(), Room.State.DISCONNECTED) } @@ -42,7 +42,7 @@ class RememberConnectionStateTest : MockE2ETest() { fun connectAndDisconnectState() = runTest { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { - rememberConnectionState(room) + rememberConnectionState(room).value }.test { assertEquals(awaitItem(), Room.State.DISCONNECTED) assertEquals(awaitItem(), Room.State.CONNECTING) diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantsTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantsTest.kt index c6e2a2a..cba73b1 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantsTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantsTest.kt @@ -36,7 +36,7 @@ class RememberParticipantsTest : MockE2ETest() { @Test fun initialState() = runTest { moleculeFlow(RecompositionMode.Immediate) { - rememberParticipants(room) + rememberParticipants(room).value }.test { val participants = awaitItem() assertEquals(participants.size, 1) @@ -48,7 +48,7 @@ class RememberParticipantsTest : MockE2ETest() { fun participantJoin() = runTest { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { - rememberParticipants(room) + rememberParticipants(room).value }.test { delay(10) val participants = expectMostRecentItem() @@ -70,7 +70,7 @@ class RememberParticipantsTest : MockE2ETest() { fun participantLeave() = runTest { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { - rememberParticipants(room) + rememberParticipants(room).value }.test { delay(10) val participants = expectMostRecentItem() diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackMutedTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackMutedTest.kt index c2ff1a8..e4cd1f5 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackMutedTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackMutedTest.kt @@ -46,7 +46,7 @@ class RememberTrackMutedTest : MockE2ETest() { publication = publication, ) moleculeFlow(RecompositionMode.Immediate) { - rememberTrackMuted(trackRef = trackReference) + rememberTrackMuted(trackRef = trackReference).value }.test { assertFalse(awaitItem()) } @@ -65,7 +65,7 @@ class RememberTrackMutedTest : MockE2ETest() { ) moleculeFlow(RecompositionMode.Immediate) { - rememberTrackMuted(trackReference) + rememberTrackMuted(trackReference).value }.test { delay(10) val isMuted = expectMostRecentItem() @@ -88,7 +88,7 @@ class RememberTrackMutedTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { - rememberTrackMuted(trackReference) + rememberTrackMuted(trackReference).value }.test { assertFalse(awaitItem()) assertTrue(awaitItem()) diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackReferencesTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackReferencesTest.kt index 369b035..b8a9be1 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackReferencesTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackReferencesTest.kt @@ -19,7 +19,6 @@ package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test -import io.livekit.android.room.SignalClient import io.livekit.android.room.participant.RemoteParticipant import io.livekit.android.room.track.Track import io.livekit.android.test.MockE2ETest @@ -28,14 +27,12 @@ import io.livekit.android.test.mock.MockRtpReceiver import io.livekit.android.test.mock.MockVideoStreamTrack import io.livekit.android.test.mock.TestData import io.livekit.android.test.mock.createMediaStreamId -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import org.mockito.Mockito @OptIn(ExperimentalCoroutinesApi::class) class RememberTrackReferencesTest : MockE2ETest() { @@ -44,7 +41,7 @@ class RememberTrackReferencesTest : MockE2ETest() { fun getEmptyTrackReferences() = runTest { connect() moleculeFlow(RecompositionMode.Immediate) { - rememberTracks(passedRoom = room) + rememberTracks(passedRoom = room).value }.test { assertTrue(awaitItem().isEmpty()) } @@ -54,7 +51,7 @@ class RememberTrackReferencesTest : MockE2ETest() { fun getPlaceholderTrackReferences() = runTest { connect() moleculeFlow(RecompositionMode.Immediate) { - rememberTracks(usePlaceholders = setOf(Track.Source.CAMERA), passedRoom = room) + rememberTracks(usePlaceholders = setOf(Track.Source.CAMERA), passedRoom = room).value }.test { val trackRefs = awaitItem() assertEquals(1, trackRefs.size) @@ -70,7 +67,7 @@ class RememberTrackReferencesTest : MockE2ETest() { connect() val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { - rememberTracks(passedRoom = room, onlySubscribed = false) + rememberTracks(passedRoom = room, onlySubscribed = false).value }.test { // discard initial state. assertTrue(awaitItem().isEmpty()) @@ -96,7 +93,7 @@ class RememberTrackReferencesTest : MockE2ETest() { connect() val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { - rememberTracks(passedRoom = room, onlySubscribed = false) + rememberTracks(passedRoom = room, onlySubscribed = false).value }.test { assertTrue(awaitItem().isEmpty()) // initial assertTrue(awaitItem().isNotEmpty()) // join @@ -113,7 +110,7 @@ class RememberTrackReferencesTest : MockE2ETest() { connect() val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { - rememberTracks(passedRoom = room, onlySubscribed = true) + rememberTracks(passedRoom = room, onlySubscribed = true).value }.test { // discard initial state. assertTrue(awaitItem().isEmpty()) @@ -147,13 +144,9 @@ class RememberTrackReferencesTest : MockE2ETest() { } private fun createFakeRemoteParticipant(): RemoteParticipant { - return RemoteParticipant( - TestData.REMOTE_PARTICIPANT, - Mockito.mock(SignalClient::class.java), - Dispatchers.IO, - Dispatchers.Default, - ).apply { - updateFromInfo(TestData.REMOTE_PARTICIPANT) - } + return io.livekit.android.compose.test.util.createFakeRemoteParticipant(coroutineRule.dispatcher) + .apply { + updateFromInfo(TestData.REMOTE_PARTICIPANT) + } } } diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackTest.kt index 1b801b3..f847989 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackTest.kt @@ -52,7 +52,7 @@ class RememberTrackTest : MockE2ETest() { publication = publication, ) moleculeFlow(RecompositionMode.Immediate) { - rememberTrack(trackReference) + rememberTrack(trackReference).value }.test { val track = awaitItem() assertNull(track) @@ -76,7 +76,7 @@ class RememberTrackTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { - rememberTrack(trackReference) + rememberTrack(trackReference).value }.test { delay(10) val track = expectMostRecentItem() From c4d027fb6f59cb670026d9e53ac033b4bf4d778c Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 17:59:52 +0900 Subject: [PATCH 14/43] RememberStateOrDefault utility function --- .../compose/util/RememberStateOrDefault.kt | 27 ++- .../util/RememberStateOrDefaultTest.kt | 167 ++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt index 92b108b..7fb0364 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt @@ -3,20 +3,43 @@ package io.livekit.android.compose.util import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.flow.map /** * A utility state that either collects from the state provided from [block], - * or emits the [default] value if the state is null. + * or emits the [default] value if null. * * The returned state will always refer to the same [State] object. */ @Composable -internal inline fun rememberStateOrDefault(default: T, block: @Composable () -> State?): State { +internal inline fun oldrememberStateOrDefault(default: T, block: @Composable () -> State?): State { val state = block() + val flow = snapshotFlow { state?.value } + println("hashcode = " + flow.hashCode()) return snapshotFlow { state?.value } .map { value -> value ?: default } .collectAsState(state?.value ?: default) +} + +/** + * A utility state that either collects from the state provided from [block], + * or emits the [default] value if the state is null. + * + * The returned state will always refer to the same [State] object. + */ +@Composable +internal inline fun rememberStateOrDefault(default: T, block: @Composable () -> State?): State { + val blockState = rememberUpdatedState(block()) + + return remember { + derivedStateOf { + blockState.value?.value + ?: default + } + } } \ No newline at end of file diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt new file mode 100644 index 0000000..3ad5942 --- /dev/null +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt @@ -0,0 +1,167 @@ +package io.livekit.android.compose.util + +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import io.livekit.android.test.BaseTest +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RememberStateOrDefaultTest : BaseTest() { + + @Test + fun emitsDefaultWhenNull() = runTest { + + val emitNull = mutableStateOf(false) + val state = mutableStateOf(1) + moleculeFlow(RecompositionMode.Immediate) { + rememberStateOrDefault(-1) { + if (emitNull.value) { + null + } else { + state + } + } + }.test { + val first = awaitItem() + assertEquals(1, first.value) + + emitNull.value = true + val second = awaitItem() + assertTrue(first === second) + assertEquals(-1, second.value) + + expectNoEvents() + } + } + + @Test + fun emitsStateWhenGoingToNotNull() = runTest { + val emitNull = mutableStateOf(true) + val state = mutableStateOf(1) + moleculeFlow(RecompositionMode.Immediate) { + rememberStateOrDefault(-1) { + if (emitNull.value) { + null + } else { + state + } + } + }.test { + val first = awaitItem() + assertEquals(-1, first.value) + + emitNull.value = false + val second = awaitItem() + assertTrue(first === second) + assertEquals(1, second.value) + + expectNoEvents() + } + } + + @Test + fun emitsStateWhenReadIsSeparated() = runTest { + val emitNull = mutableStateOf(true) + val state = mutableStateOf(1) + moleculeFlow(RecompositionMode.Immediate) { + val currentEmitNull = emitNull.value + rememberStateOrDefault(-1) { + if (currentEmitNull) { + null + } else { + state + } + }.value + }.test { + + val first = awaitItem() + assertEquals(-1, first) + + emitNull.value = false + val second = awaitItem() + assertEquals(1, second) + + expectNoEvents() + } + } + + @Test + fun handlesCollectAsState() = runTest { + + val emitNull = mutableStateOf(true) + val flow = MutableStateFlow(1) + moleculeFlow(RecompositionMode.Immediate) { + val currentEmitNull = emitNull.value + rememberStateOrDefault(-1) { + if (currentEmitNull) { + null + } else { + flow.collectAsState() + } + } + }.test { + val first = awaitItem() + assertEquals(-1, first.value) + + emitNull.value = false + val second = awaitItem() + assertTrue(first === second) + assertEquals(1, second.value) + + expectNoEvents() + } + } + + // Verification on derivedStateOf behavior + @Test + fun derivedStateTest() = runTest { + val state = mutableStateOf(1) + moleculeFlow(RecompositionMode.Immediate) { + remember { + derivedStateOf { + state.value + 1 + } + }.value + }.test { + val first = awaitItem() + assertEquals(2, first) + + state.value = 2 + val second = awaitItem() + assertEquals(3, second) + + expectNoEvents() + } + } + + @Test + fun derivedStateDelegatedTest() = runTest { + val state = mutableStateOf(1) + moleculeFlow(RecompositionMode.Immediate) { + val currentState by state + remember { + derivedStateOf { + currentState + 1 + } + }.value + }.test { + val first = awaitItem() + assertEquals(2, first) + + state.value = 2 + val second = awaitItem() + assertEquals(3, second) + + expectNoEvents() + } + } + +} \ No newline at end of file From 60a83c63a25d0604e71fbd6d59353c8713d74f4f Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:25:55 +0900 Subject: [PATCH 15/43] composeTest utility function to verify unconsumed events --- .../compose/state/RememberAgentState.kt | 2 + .../compose/util/RememberStateOrDefault.kt | 1 + .../android/compose/flows/DataHandlerTest.kt | 6 +- .../flows/RememberEventSelectorTest.kt | 6 +- .../android/compose/local/RoomScopeTest.kt | 15 +- .../state/RememberConnectionStateTest.kt | 6 +- .../state/RememberParticipantInfoTest.kt | 6 +- .../RememberParticipantTrackReferencesTest.kt | 12 +- .../compose/state/RememberParticipantsTest.kt | 12 +- .../compose/state/RememberRoomInfoTest.kt | 21 +-- .../compose/state/RememberTrackMutedTest.kt | 8 +- .../state/RememberTrackReferencesTest.kt | 12 +- .../compose/state/RememberTrackTest.kt | 6 +- .../state/RememberVoiceAssistantTest.kt | 169 ++++++++++++------ .../android/compose/test/util/TurbineExt.kt | 35 ++++ .../util/RememberStateOrDefaultTest.kt | 68 +++---- 16 files changed, 241 insertions(+), 144 deletions(-) create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt create mode 100644 livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt new file mode 100644 index 0000000..6c80011 --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt @@ -0,0 +1,2 @@ +package io.livekit.android.compose.state + diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt index 7fb0364..c31de61 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map /** diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/flows/DataHandlerTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/flows/DataHandlerTest.kt index fe86c88..ea55af7 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/flows/DataHandlerTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/flows/DataHandlerTest.kt @@ -19,10 +19,10 @@ package io.livekit.android.compose.flows import androidx.compose.runtime.collectAsState import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test import com.google.protobuf.ByteString import io.livekit.android.compose.flow.DataSendOptions import io.livekit.android.compose.flow.rememberDataMessageHandler +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.room.RTCEngine import io.livekit.android.room.track.DataPublishReliability import io.livekit.android.test.MockE2ETest @@ -44,7 +44,7 @@ class DataHandlerTest : MockE2ETest() { val messageString = "message" moleculeFlow(RecompositionMode.Immediate) { rememberDataMessageHandler(room) - }.test { + }.composeTest { val dataHandler = awaitItem() Assert.assertNotNull(dataHandler) @@ -77,7 +77,7 @@ class DataHandlerTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberDataMessageHandler(room).messageFlow.collectAsState(initial = null).value - }.test { + }.composeTest { // discard initial state. awaitItem() diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/flows/RememberEventSelectorTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/flows/RememberEventSelectorTest.kt index edf1b8e..33f38a8 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/flows/RememberEventSelectorTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/flows/RememberEventSelectorTest.kt @@ -19,8 +19,8 @@ package io.livekit.android.compose.flows import androidx.compose.runtime.collectAsState import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test import io.livekit.android.compose.flow.rememberEventSelector +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.events.RoomEvent import io.livekit.android.test.MockE2ETest import io.livekit.android.test.assert.assertIsClass @@ -37,7 +37,7 @@ class RememberEventSelectorTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberEventSelector(room = room).collectAsState(initial = null).value - }.test { + }.composeTest { // discard initial state. awaitItem() val event = awaitItem() @@ -54,7 +54,7 @@ class RememberEventSelectorTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberEventSelector(room = room).collectAsState(initial = null).value - }.test { + }.composeTest { // discard initial state. awaitItem() diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/local/RoomScopeTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/local/RoomScopeTest.kt index d9cd04a..b5d2ba3 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/local/RoomScopeTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/local/RoomScopeTest.kt @@ -26,7 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.room.Room import io.livekit.android.test.MockE2ETest import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -48,7 +48,7 @@ class RoomScopeTest : MockE2ETest() { } } testRoom - }.test { + }.composeTest { val retRoom = awaitItem() assertNotNull(retRoom) } @@ -64,7 +64,7 @@ class RoomScopeTest : MockE2ETest() { } } testRoom - }.test { + }.composeTest { awaitError() // real rooms can't be created in test env. } } @@ -88,8 +88,8 @@ class RoomScopeTest : MockE2ETest() { ) {} } } - 1 - }.test { + useScope + }.composeTest { awaitItem() assertEquals(Room.State.CONNECTED, room.state) delay(1000) @@ -119,10 +119,7 @@ class RoomScopeTest : MockE2ETest() { } } 1 - }.test { - awaitItem() - assertEquals(Room.State.CONNECTED, room.state) - delay(1000) + }.composeTest { awaitItem() assertEquals(Room.State.CONNECTED, room.state) } diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberConnectionStateTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberConnectionStateTest.kt index f62c6d9..2c94db7 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberConnectionStateTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberConnectionStateTest.kt @@ -18,7 +18,7 @@ package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.room.Room import io.livekit.android.test.MockE2ETest import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -33,7 +33,7 @@ class RememberConnectionStateTest : MockE2ETest() { fun initialState() = runTest { moleculeFlow(RecompositionMode.Immediate) { rememberConnectionState(room).value - }.test { + }.composeTest { assertEquals(awaitItem(), Room.State.DISCONNECTED) } } @@ -43,7 +43,7 @@ class RememberConnectionStateTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberConnectionState(room).value - }.test { + }.composeTest { assertEquals(awaitItem(), Room.State.DISCONNECTED) assertEquals(awaitItem(), Room.State.CONNECTING) assertEquals(awaitItem(), Room.State.CONNECTED) diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantInfoTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantInfoTest.kt index 18f2f43..90d1f5c 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantInfoTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantInfoTest.kt @@ -18,7 +18,7 @@ package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.room.participant.Participant import io.livekit.android.test.BaseTest import io.livekit.android.test.mock.TestData @@ -41,7 +41,7 @@ class RememberParticipantInfoTest : BaseTest() { fun getsParticipantInfo() = runTest { moleculeFlow(RecompositionMode.Immediate) { rememberParticipantInfo(participant) - }.test { + }.composeTest { val info = awaitItem() assertEquals(participant.identity, info.identity) assertEquals(participant.name, info.name) @@ -54,7 +54,7 @@ class RememberParticipantInfoTest : BaseTest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberParticipantInfo(participant) - }.test { + }.composeTest { delay(10) val info = expectMostRecentItem() diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferencesTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferencesTest.kt index f9fddb0..e18d251 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferencesTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferencesTest.kt @@ -18,8 +18,8 @@ package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test import io.livekit.android.compose.test.util.createFakeRemoteParticipant +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.room.participant.VideoTrackPublishOptions import io.livekit.android.room.track.LocalTrackPublication import io.livekit.android.room.track.LocalVideoTrack @@ -45,7 +45,7 @@ class RememberParticipantTrackReferencesTest : MockE2ETest() { connect() moleculeFlow(RecompositionMode.Immediate) { rememberParticipantTrackReferences(passedParticipant = room.localParticipant).value - }.test { + }.composeTest { assertTrue(awaitItem().isEmpty()) } } @@ -58,7 +58,7 @@ class RememberParticipantTrackReferencesTest : MockE2ETest() { usePlaceholders = setOf(Track.Source.CAMERA), passedParticipant = room.localParticipant ).value - }.test { + }.composeTest { val trackRefs = awaitItem() assertEquals(1, trackRefs.size) val trackRef = trackRefs.first() @@ -77,7 +77,7 @@ class RememberParticipantTrackReferencesTest : MockE2ETest() { passedParticipant = room.localParticipant, onlySubscribed = false ).value - }.test { + }.composeTest { // discard initial state. assertTrue(awaitItem().isEmpty()) @@ -105,7 +105,7 @@ class RememberParticipantTrackReferencesTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberParticipantTrackReferences(passedParticipant = room.localParticipant).value - }.test { + }.composeTest { assertTrue(awaitItem().isEmpty()) // initial assertTrue(awaitItem().isNotEmpty()) // add assertTrue(awaitItem().isEmpty()) // disconnect @@ -128,7 +128,7 @@ class RememberParticipantTrackReferencesTest : MockE2ETest() { passedParticipant = remoteParticipant, onlySubscribed = true ).value - }.test { + }.composeTest { // discard initial state. assertTrue(awaitItem().isEmpty()) diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantsTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantsTest.kt index cba73b1..3604f89 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantsTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantsTest.kt @@ -18,7 +18,7 @@ package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.room.participant.Participant import io.livekit.android.test.MockE2ETest @@ -37,7 +37,7 @@ class RememberParticipantsTest : MockE2ETest() { fun initialState() = runTest { moleculeFlow(RecompositionMode.Immediate) { rememberParticipants(room).value - }.test { + }.composeTest { val participants = awaitItem() assertEquals(participants.size, 1) assertEquals(participants.first(), room.localParticipant) @@ -49,9 +49,9 @@ class RememberParticipantsTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberParticipants(room).value - }.test { - delay(10) - val participants = expectMostRecentItem() + }.composeTest { + assertEquals(1, awaitItem().size) + val participants = awaitItem() assertEquals(participants.size, 2) assertTrue(participants.contains(room.localParticipant)) @@ -71,7 +71,7 @@ class RememberParticipantsTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberParticipants(room).value - }.test { + }.composeTest { delay(10) val participants = expectMostRecentItem() assertEquals(participants.size, 1) diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberRoomInfoTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberRoomInfoTest.kt index 29afdc7..d2a4c56 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberRoomInfoTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberRoomInfoTest.kt @@ -18,11 +18,10 @@ package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.test.MockE2ETest import io.livekit.android.test.mock.TestData import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.junit.Assert.assertEquals import org.junit.Test @@ -34,7 +33,7 @@ class RememberRoomInfoTest : MockE2ETest() { fun initialState() = runTest { moleculeFlow(RecompositionMode.Immediate) { rememberRoomInfo(room) - }.test { + }.composeTest { val info = awaitItem() assertEquals(room.name, info.name) assertEquals(room.metadata, info.metadata) @@ -45,14 +44,16 @@ class RememberRoomInfoTest : MockE2ETest() { fun getRoomInfoChanges() = runTest { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { - rememberRoomInfo(room) - }.test { - delay(100) - - val info = expectMostRecentItem() + val info = rememberRoomInfo(room) + info.name to info.metadata + }.composeTest { + awaitItem() + val (name, metadata) = awaitItem() val expectedRoom = TestData.JOIN.join.room - assertEquals(expectedRoom.name, info.name) - assertEquals(expectedRoom.metadata, info.metadata) + assertEquals(expectedRoom.name, name) + assertEquals(expectedRoom.metadata, metadata) + + expectNoEvents() } } connect() diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackMutedTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackMutedTest.kt index e4cd1f5..80697c7 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackMutedTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackMutedTest.kt @@ -18,7 +18,7 @@ package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.compose.types.TrackReference import io.livekit.android.room.track.Track import io.livekit.android.test.MockE2ETest @@ -47,7 +47,7 @@ class RememberTrackMutedTest : MockE2ETest() { ) moleculeFlow(RecompositionMode.Immediate) { rememberTrackMuted(trackRef = trackReference).value - }.test { + }.composeTest { assertFalse(awaitItem()) } } @@ -66,7 +66,7 @@ class RememberTrackMutedTest : MockE2ETest() { moleculeFlow(RecompositionMode.Immediate) { rememberTrackMuted(trackReference).value - }.test { + }.composeTest { delay(10) val isMuted = expectMostRecentItem() assertTrue(isMuted) @@ -89,7 +89,7 @@ class RememberTrackMutedTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberTrackMuted(trackReference).value - }.test { + }.composeTest { assertFalse(awaitItem()) assertTrue(awaitItem()) } diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackReferencesTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackReferencesTest.kt index b8a9be1..e4944aa 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackReferencesTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackReferencesTest.kt @@ -18,7 +18,7 @@ package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.room.participant.RemoteParticipant import io.livekit.android.room.track.Track import io.livekit.android.test.MockE2ETest @@ -42,7 +42,7 @@ class RememberTrackReferencesTest : MockE2ETest() { connect() moleculeFlow(RecompositionMode.Immediate) { rememberTracks(passedRoom = room).value - }.test { + }.composeTest { assertTrue(awaitItem().isEmpty()) } } @@ -52,7 +52,7 @@ class RememberTrackReferencesTest : MockE2ETest() { connect() moleculeFlow(RecompositionMode.Immediate) { rememberTracks(usePlaceholders = setOf(Track.Source.CAMERA), passedRoom = room).value - }.test { + }.composeTest { val trackRefs = awaitItem() assertEquals(1, trackRefs.size) val trackRef = trackRefs.first() @@ -68,7 +68,7 @@ class RememberTrackReferencesTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberTracks(passedRoom = room, onlySubscribed = false).value - }.test { + }.composeTest { // discard initial state. assertTrue(awaitItem().isEmpty()) @@ -94,7 +94,7 @@ class RememberTrackReferencesTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberTracks(passedRoom = room, onlySubscribed = false).value - }.test { + }.composeTest { assertTrue(awaitItem().isEmpty()) // initial assertTrue(awaitItem().isNotEmpty()) // join assertTrue(awaitItem().isEmpty()) // disconnect @@ -111,7 +111,7 @@ class RememberTrackReferencesTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberTracks(passedRoom = room, onlySubscribed = true).value - }.test { + }.composeTest { // discard initial state. assertTrue(awaitItem().isEmpty()) diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackTest.kt index f847989..a120824 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberTrackTest.kt @@ -18,7 +18,7 @@ package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.compose.types.TrackReference import io.livekit.android.room.track.RemoteVideoTrack import io.livekit.android.room.track.Track @@ -53,7 +53,7 @@ class RememberTrackTest : MockE2ETest() { ) moleculeFlow(RecompositionMode.Immediate) { rememberTrack(trackReference).value - }.test { + }.composeTest { val track = awaitItem() assertNull(track) } @@ -77,7 +77,7 @@ class RememberTrackTest : MockE2ETest() { val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberTrack(trackReference).value - }.test { + }.composeTest { delay(10) val track = expectMostRecentItem() assertIsClass(RemoteVideoTrack::class.java, track!!) diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberVoiceAssistantTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberVoiceAssistantTest.kt index 30685b0..ffc7834 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberVoiceAssistantTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberVoiceAssistantTest.kt @@ -16,10 +16,14 @@ package io.livekit.android.compose.state +import androidx.compose.runtime.Composable import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow +import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import io.livekit.android.annotations.Beta +import io.livekit.android.compose.flow.TextStreamData +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.compose.types.TrackReference import io.livekit.android.room.track.Track import io.livekit.android.test.MockE2ETest @@ -27,9 +31,11 @@ import io.livekit.android.test.mock.MockAudioStreamTrack import io.livekit.android.test.mock.MockRtpReceiver import io.livekit.android.test.mock.TestData import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import livekit.LivekitModels.ParticipantInfo.Kind import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class, Beta::class) @@ -39,66 +45,31 @@ class RememberVoiceAssistantTest : MockE2ETest() { fun initialState() = runTest { moleculeFlow(RecompositionMode.Immediate) { rememberVoiceAssistant(room) - }.test { - assertEquals( - VoiceAssistant( - agent = null, - state = AgentState.DISCONNECTED, - audioTrack = null, - agentTranscriptions = listOf(), - agentAttributes = mapOf(), - ), - awaitItem() - ) + }.composeTest { + val voiceAssistant = awaitItem() + + assertEquals(null, voiceAssistant.agent) + assertEquals(AgentState.DISCONNECTED, voiceAssistant.state) + assertEquals(null, voiceAssistant.audioTrack) + assertEquals(emptyList(), voiceAssistant.agentTranscriptions) + assertEquals(emptyMap(), voiceAssistant.agentAttributes) } } - @Test - fun agentJoin() = runTest { - val job = coroutineRule.scope.launch { - moleculeFlow(RecompositionMode.Immediate) { - rememberVoiceAssistant(room) - }.test { - awaitItem() // intermediate flow emissions, not under test. - awaitItem() - awaitItem() - awaitItem() - awaitItem() + suspend fun agentJoinTest(body: @Composable () -> T, validate: suspend TurbineTestContext.() -> Unit) { - val agent = room.remoteParticipants.values.first() - assertEquals( - VoiceAssistant( - agent = agent, - state = AgentState.LISTENING, - audioTrack = TrackReference( - participant = agent, - publication = agent.audioTrackPublications.first().first, - source = Track.Source.MICROPHONE, - ), - agentTranscriptions = listOf(), - agentAttributes = mapOf(PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_KEY to PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_LISTENING), - ), - awaitItem() - ) - } + val testJob = coroutineRule.scope.launch { + moleculeFlow(RecompositionMode.Immediate) { body() } + .distinctUntilChanged() + .test { + validate() + delay(1) + } + println("job done") } connect() - - val agentJoin = with(TestData.PARTICIPANT_JOIN.toBuilder()) { - update = with(update.toBuilder()) { - clearParticipants() - val agent = with(TestData.REMOTE_PARTICIPANT.toBuilder()) { - kind = Kind.AGENT - clearAttributes() - putAttributes(PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_KEY, PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_LISTENING) - build() - } - addParticipants(agent) - build() - } - build() - } + val agentJoin = TestData.AGENT_JOIN simulateMessageFromServer(agentJoin) room.remoteParticipants.values.first().addSubscribedMediaTrack( @@ -110,6 +81,92 @@ class RememberVoiceAssistantTest : MockE2ETest() { triesLeft = 1 ) - job.join() + println("await job") + testJob.join() + + println("finish await job") + } + + @Test + fun agentJoin() = runTest { + agentJoinTest( + body = { + rememberVoiceAssistant(room).agent + }, + validate = { + assertNull(awaitItem()) + val agent = awaitItem() + val remoteParticipant = room.remoteParticipants.values.first() + + assertEquals( + remoteParticipant, + agent, + ) + } + ) + } + + @Test + fun agentJoinAudioTrack() = runTest { + agentJoinTest( + body = { + rememberVoiceAssistant(room).audioTrack + }, + validate = { + assertNull(awaitItem()) + + val audioTrack = awaitItem() + val agent = room.remoteParticipants.values.first() + + assertEquals( + TrackReference( + participant = agent, + publication = agent.audioTrackPublications.first().first, + source = Track.Source.MICROPHONE, + ), + audioTrack, + ) + } + ) + } + + @Test + fun agentJoinState() = runTest { + agentJoinTest( + body = { + rememberVoiceAssistant(room).state + }, + validate = { + assertEquals(AgentState.DISCONNECTED, awaitItem()) + assertEquals(AgentState.CONNECTING, awaitItem()) + assertEquals(AgentState.INITIALIZING, awaitItem()) + assertEquals(AgentState.LISTENING, awaitItem()) + } + ) + } + + @Test + fun agentJoinTranscriptions() = runTest { + agentJoinTest( + body = { + rememberVoiceAssistant(room).agentTranscriptions + }, + validate = { + assertEquals(emptyList(), awaitItem()) + } + ) + } + + @Test + fun agentJoinAttributes() = runTest { + agentJoinTest( + body = { + rememberVoiceAssistant(room).agentAttributes + }, + validate = { + assertEquals(emptyMap(), awaitItem()) + assertEquals(mapOf(PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_KEY to PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_LISTENING), awaitItem()) + } + ) } -} +} \ No newline at end of file diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt new file mode 100644 index 0000000..6fa6a1d --- /dev/null +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt @@ -0,0 +1,35 @@ +package io.livekit.android.compose.test.util + +import app.cash.turbine.TurbineTestContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlin.time.Duration +import app.cash.turbine.test as turbineTest + +/** + * Due to the use of [kotlinx.coroutines.test.UnconfinedTestDispatcher], + * Turbine fails to validate unconsumed events. This adds a small delay + * at the end of the validation block to release the coroutine to finish + * any extra work. + */ +suspend fun Flow.composeTest( + distinctUntilChanged: Boolean = true, + timeout: Duration? = null, + name: String? = null, + validate: suspend TurbineTestContext.() -> Unit +) { + val flow = if (distinctUntilChanged) { + this.distinctUntilChanged() + } else { + this + } + flow.turbineTest( + timeout, + name, + { + validate() + delay(1) + } + ) +} \ No newline at end of file diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt index 3ad5942..7073d96 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import io.livekit.android.compose.test.util.composeTest import io.livekit.android.test.BaseTest import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals @@ -17,7 +17,7 @@ import org.junit.Test class RememberStateOrDefaultTest : BaseTest() { @Test - fun emitsDefaultWhenNull() = runTest { + fun emitsTheSameState() = runTest { val emitNull = mutableStateOf(false) val state = mutableStateOf(1) @@ -29,16 +29,34 @@ class RememberStateOrDefaultTest : BaseTest() { state } } - }.test { + }.composeTest(distinctUntilChanged = false) { val first = awaitItem() - assertEquals(1, first.value) - emitNull.value = true val second = awaitItem() + assertTrue(first === second) - assertEquals(-1, second.value) + } + } + @Test + fun emitsDefaultWhenNull() = runTest { + + val emitNull = mutableStateOf(false) + val state = mutableStateOf(1) + moleculeFlow(RecompositionMode.Immediate) { + rememberStateOrDefault(-1) { + if (emitNull.value) { + null + } else { + state + } + }.value + }.composeTest(distinctUntilChanged = false) { + val first = awaitItem() + assertEquals(1, first) - expectNoEvents() + emitNull.value = true + val second = awaitItem() + assertEquals(-1, second) } } @@ -53,17 +71,14 @@ class RememberStateOrDefaultTest : BaseTest() { } else { state } - } - }.test { + }.value + }.composeTest(distinctUntilChanged = false) { val first = awaitItem() - assertEquals(-1, first.value) + assertEquals(-1, first) emitNull.value = false val second = awaitItem() - assertTrue(first === second) - assertEquals(1, second.value) - - expectNoEvents() + assertEquals(1, second) } } @@ -80,16 +95,13 @@ class RememberStateOrDefaultTest : BaseTest() { state } }.value - }.test { - + }.composeTest(distinctUntilChanged = false) { val first = awaitItem() assertEquals(-1, first) emitNull.value = false val second = awaitItem() assertEquals(1, second) - - expectNoEvents() } } @@ -106,17 +118,14 @@ class RememberStateOrDefaultTest : BaseTest() { } else { flow.collectAsState() } - } - }.test { + }.value + }.composeTest(distinctUntilChanged = false) { val first = awaitItem() - assertEquals(-1, first.value) + assertEquals(-1, first) emitNull.value = false val second = awaitItem() - assertTrue(first === second) - assertEquals(1, second.value) - - expectNoEvents() + assertEquals(1, second) } } @@ -130,15 +139,13 @@ class RememberStateOrDefaultTest : BaseTest() { state.value + 1 } }.value - }.test { + }.composeTest(distinctUntilChanged = false) { val first = awaitItem() assertEquals(2, first) state.value = 2 val second = awaitItem() assertEquals(3, second) - - expectNoEvents() } } @@ -152,16 +159,13 @@ class RememberStateOrDefaultTest : BaseTest() { currentState + 1 } }.value - }.test { + }.composeTest(distinctUntilChanged = false) { val first = awaitItem() assertEquals(2, first) state.value = 2 val second = awaitItem() assertEquals(3, second) - - expectNoEvents() } } - } \ No newline at end of file From 457c9714083aa5110e7b97bdb01c07bfeb222957 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:26:34 +0900 Subject: [PATCH 16/43] Move rememberAgentState to separate file --- .../compose/state/RememberAgentState.kt | 25 +++++++++++++++++++ .../compose/state/RememberVoiceAssistant.kt | 13 +++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt index 6c80011..4cbc700 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt @@ -1,2 +1,27 @@ package io.livekit.android.compose.state +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import io.livekit.android.annotations.Beta +import io.livekit.android.compose.util.rememberStateOrDefault +import io.livekit.android.room.participant.Participant +import io.livekit.android.util.flow + +/** + * Keeps track of the agent state for a participant. + */ +@Beta +@Composable +fun rememberAgentState(participant: Participant?): State { + return rememberStateOrDefault(AgentState.DISCONNECTED) { + if (participant != null) { + val agentSdkState by participant::agentAttributes.flow.collectAsState() + rememberUpdatedState(AgentState.fromAgentSdkState(agentSdkState.lkAgentState)) + } else { + null + } + } +} \ No newline at end of file diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt index 0c2df9f..19ada00 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt @@ -144,6 +144,7 @@ enum class AgentState { DISCONNECTED, CONNECTING, INITIALIZING, + IDLE, LISTENING, THINKING, SPEAKING, @@ -159,5 +160,15 @@ enum class AgentState { else -> UNKNOWN } } + fun fromAgentSdkState(agentSdkState: AgentSdkState?): AgentState { + return when (agentSdkState) { + AgentSdkState.Idle -> IDLE + AgentSdkState.Initializing -> INITIALIZING + AgentSdkState.Listening -> LISTENING + AgentSdkState.Speaking -> SPEAKING + AgentSdkState.Thinking -> THINKING + null -> UNKNOWN + } + } } -} +} \ No newline at end of file From b3894432281da4511273f3ff169d19a578351b7e Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:27:28 +0900 Subject: [PATCH 17/43] Convert VoiceAssistant class to be state-backed --- .../compose/state/RememberVoiceAssistant.kt | 130 +++++++++--------- 1 file changed, 66 insertions(+), 64 deletions(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt index 19ada00..7441420 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt @@ -17,22 +17,25 @@ package io.livekit.android.compose.state import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import io.livekit.android.annotations.Beta +import io.livekit.android.compose.flow.TextStreamData import io.livekit.android.compose.local.requireRoom import io.livekit.android.compose.state.transcriptions.rememberTrackTranscriptions import io.livekit.android.compose.types.TrackReference +import io.livekit.android.compose.util.rememberStateOrDefault import io.livekit.android.room.Room import io.livekit.android.room.participant.Participant import io.livekit.android.room.participant.RemoteParticipant import io.livekit.android.room.track.Track -import io.livekit.android.room.types.TranscriptionSegment +import io.livekit.android.room.types.AgentSdkState import io.livekit.android.util.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map /** * This looks for the first agent-participant in the room. @@ -45,59 +48,69 @@ fun rememberVoiceAssistant(passedRoom: Room? = null): VoiceAssistant { val room = requireRoom(passedRoom) val connectionState = rememberConnectionState(room) val remoteParticipants by room::remoteParticipants.flow.collectAsState() - val agent by remember { + val agent = remember { derivedStateOf { remoteParticipants.values .firstOrNull { p -> p.kind == Participant.Kind.AGENT } } } - // For nullability checks - val curAgent = agent - val audioTrack = if (curAgent != null) { - rememberParticipantTrackReferences( - sources = listOf(Track.Source.MICROPHONE), - participantIdentity = curAgent.identity, - passedRoom = room, - ).firstOrNull() - } else { - null + val audioTracks by rememberStateOrDefault(emptyList()) { + val curAgent = agent.value + if (curAgent != null) { + rememberParticipantTrackReferences( + sources = listOf(Track.Source.MICROPHONE), + participantIdentity = curAgent.identity, + passedRoom = room, + ) + } else { + null + } } + val audioTrack = rememberUpdatedState(audioTracks.firstOrNull()) - val agentTranscriptions = if (audioTrack != null) { - rememberTrackTranscriptions(trackReference = audioTrack) - } else { - emptyList() + val agentTranscriptions = rememberStateOrDefault(emptyList()) { + val curAudioTrack = audioTrack.value + if (curAudioTrack != null) { + rememberTrackTranscriptions(trackReference = curAudioTrack, room = room) + } else { + null + } } - val agentState = rememberAgentState(participant = curAgent) - val agentAttributes = if (curAgent != null) { - curAgent::attributes.flow.collectAsState().value - } else { - emptyMap() + val agentState = rememberAgentState(participant = agent.value) + val agentAttributes = rememberStateOrDefault(emptyMap()) { + val curAgent = agent.value + if (curAgent != null) { + curAgent::attributes.flow.collectAsState() + } else { + null + } } - val combinedAgentState = remember(agentState, connectionState) { - when { - connectionState == Room.State.DISCONNECTED -> { - AgentState.DISCONNECTED - } - - connectionState == Room.State.CONNECTING -> { - AgentState.CONNECTING - } - - agent == null -> { - AgentState.INITIALIZING - } - - else -> { - agentState + val combinedAgentState = remember { + derivedStateOf { + when { + connectionState.value == Room.State.DISCONNECTED -> { + AgentState.DISCONNECTED + } + + connectionState.value == Room.State.CONNECTING -> { + AgentState.CONNECTING + } + + agent.value == null -> { + AgentState.INITIALIZING + } + + else -> { + agentState.value + } } } } - return remember(agent, combinedAgentState, audioTrack, agentTranscriptions, agentAttributes) { + return remember { VoiceAssistant( agent = agent, state = combinedAgentState, @@ -108,30 +121,19 @@ fun rememberVoiceAssistant(passedRoom: Room? = null): VoiceAssistant { } } -data class VoiceAssistant( - val agent: RemoteParticipant?, - val state: AgentState, - val audioTrack: TrackReference?, - val agentTranscriptions: List, - val agentAttributes: Map?, -) - -/** - * Keeps track of the agent state for a participant. - */ -@Composable -fun rememberAgentState(participant: Participant?): AgentState { - val flow = remember(participant) { - if (participant != null) { - return@remember participant::attributes.flow - .map { attributes -> attributes[PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_KEY] } - .map { stateString -> AgentState.fromAttribute(stateString) } - } else { - return@remember flowOf(AgentState.UNKNOWN) - } - } - - return flow.collectAsState(initial = AgentState.UNKNOWN).value +@Stable +class VoiceAssistant( + agent: State, + state: State, + audioTrack: State, + agentTranscriptions: State>, + agentAttributes: State>, +) { + val agent by agent + val state by state + val audioTrack by audioTrack + val agentTranscriptions by agentTranscriptions + val agentAttributes by agentAttributes } const val PARTICIPANT_ATTRIBUTE_LK_AGENT_STATE_KEY = "lk.agent.state" From 7008b1a8d04ef23dcf093674ea41e560788eacc0 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:27:59 +0900 Subject: [PATCH 18/43] cleanup code for rememberTrackMuted --- .../android/compose/state/RememberTrackMuted.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt index 6d48c65..dcdd619 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt @@ -29,15 +29,11 @@ import io.livekit.android.util.flow @Composable fun rememberTrackMuted(trackRef: TrackReference): State { return rememberStateOrDefault(true) { - if (trackRef.isPlaceholder()) { - null + val publication = trackRef.publication + if (publication != null) { + publication::muted.flow.collectAsState() } else { - val publication = trackRef.publication - if (publication != null) { - publication::muted.flow.collectAsState() - } else { - null - } + null } } } From ecfb7d96ad6e54c0ae9191e50c52f4a45ed2d62c Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:29:06 +0900 Subject: [PATCH 19/43] Add rememberSpeakingParticipants --- .changeset/dirty-news-teach.md | 5 +++++ .../state/RememberSpeakingParticipants.kt | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 .changeset/dirty-news-teach.md create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSpeakingParticipants.kt diff --git a/.changeset/dirty-news-teach.md b/.changeset/dirty-news-teach.md new file mode 100644 index 0000000..e95f75f --- /dev/null +++ b/.changeset/dirty-news-teach.md @@ -0,0 +1,5 @@ +--- +"components-android": minor +--- + +Add rememberSpeakingParticipants diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSpeakingParticipants.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSpeakingParticipants.kt new file mode 100644 index 0000000..92f8459 --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSpeakingParticipants.kt @@ -0,0 +1,16 @@ +package io.livekit.android.compose.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import io.livekit.android.compose.local.requireRoom +import io.livekit.android.room.Room +import io.livekit.android.room.participant.Participant +import io.livekit.android.util.flow + +@Composable +fun rememberSpeakingParticipants(room: Room? = null): State> { + val room = requireRoom(room) + + return room::activeSpeakers.flow.collectAsState() +} \ No newline at end of file From c42c191c0abbe8e40258259c5fa5a2ad252a729f Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:29:48 +0900 Subject: [PATCH 20/43] tests for rememberTranscriptions and rememberTextStream --- .../RememberTranscriptionsTest.kt | 107 ++++++++++++++++++ .../compose/stream/RememberTextStreamTest.kt | 104 +++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 livekit-compose-components/src/test/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptionsTest.kt create mode 100644 livekit-compose-components/src/test/java/io/livekit/android/compose/stream/RememberTextStreamTest.kt diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptionsTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptionsTest.kt new file mode 100644 index 0000000..1549605 --- /dev/null +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptionsTest.kt @@ -0,0 +1,107 @@ +package io.livekit.android.compose.state.transcriptions + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import io.livekit.android.annotations.Beta +import io.livekit.android.compose.flow.DataTopic +import io.livekit.android.compose.test.util.composeTest +import io.livekit.android.compose.test.util.receiveTextStream +import io.livekit.android.room.RTCEngine +import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.mock.MockDataChannel +import io.livekit.android.test.mock.MockPeerConnection +import kotlinx.coroutines.launch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(Beta::class) +class RememberTranscriptionsTest : MockE2ETest() { + + + @Test + fun textStreamUpdates() = runTest { + connect() + val subPeerConnection = component.rtcEngine().getSubscriberPeerConnection() as MockPeerConnection + val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL) + subPeerConnection.observer?.onDataChannel(subDataChannel) + + val job = coroutineRule.scope.launch { + moleculeFlow(RecompositionMode.Immediate) { + rememberTranscriptions(room).value + }.composeTest { + run { + val initial = awaitItem() + println("initial: $initial") + assertTrue(initial.isEmpty()) + + println("first") + val first = awaitItem() + println("first: $first") + assertEquals(1, first.size) + assertEquals("hello", first[0].text) + + println("second") + val second = awaitItem() + println("second: $second") + assertEquals(1, second.size) + assertEquals("hello world", second[0].text) + + println("third") + val third = awaitItem() + println("third: $third") + assertEquals(1, third.size) + assertEquals("hello world!", third[0].text) + } + } + } + + subDataChannel.observer?.receiveTextStream(chunks = listOf("hello", " world", "!"), topic = DataTopic.TRANSCRIPTION.value) + + job.join() + + } + + @Test + fun multipleTextStreams() = runTest { + connect() + val subPeerConnection = component.rtcEngine().getSubscriberPeerConnection() as MockPeerConnection + val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL) + subPeerConnection.observer?.onDataChannel(subDataChannel) + + + val job = coroutineRule.scope.launch { + moleculeFlow(RecompositionMode.Immediate) { + rememberTranscriptions(room).value + }.composeTest { + run { + val initial = awaitItem() + println("initial: $initial") + assertTrue(initial.isEmpty()) + + println("first") + val first = awaitItem() + println("first: $first") +// assertEquals(1, first.size) +// assertEquals("hello", first[0].text) + + println("second") + val second = awaitItem() + println("second: $second") +// assertEquals(2, second.size) +// assertEquals("hello", second[0].text) +// assertEquals("world", second[1].text) + + expectNoEvents() + + } + } + } + + subDataChannel.observer?.receiveTextStream(streamId = "streamId1", chunk = "hello", topic = DataTopic.TRANSCRIPTION.value) + subDataChannel.observer?.receiveTextStream(streamId = "streamId2", chunk = "world", topic = DataTopic.TRANSCRIPTION.value) + + job.join() + + } +} \ No newline at end of file diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/stream/RememberTextStreamTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/stream/RememberTextStreamTest.kt new file mode 100644 index 0000000..d8a7203 --- /dev/null +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/stream/RememberTextStreamTest.kt @@ -0,0 +1,104 @@ +package io.livekit.android.compose.stream + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import io.livekit.android.compose.test.util.composeTest +import io.livekit.android.compose.test.util.receiveTextStream +import io.livekit.android.room.RTCEngine +import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.mock.MockDataChannel +import io.livekit.android.test.mock.MockPeerConnection +import kotlinx.coroutines.launch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RememberTextStreamTest : MockE2ETest() { + + + @Test + fun textStreamUpdates() = runTest { + connect() + val subPeerConnection = component.rtcEngine().getSubscriberPeerConnection() as MockPeerConnection + val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL) + subPeerConnection.observer?.onDataChannel(subDataChannel) + + + val job = coroutineRule.scope.launch { + moleculeFlow(RecompositionMode.Immediate) { + rememberTextStream("topic", room).value + }.composeTest { + run { + val initial = awaitItem() + println("initial: $initial") + assertTrue(initial.isEmpty()) + + println("first") + val first = awaitItem() + println("first: $first") + assertEquals(1, first.size) + assertEquals("hello", first[0].text) + + println("second") + val second = awaitItem() + println("second: $second") + assertEquals(1, second.size) + assertEquals("hello world", second[0].text) + + println("third") + val third = awaitItem() + println("third: $third") + assertEquals(1, third.size) + assertEquals("hello world!", third[0].text) + } + } + } + + subDataChannel.observer?.receiveTextStream(chunks = listOf("hello", " world", "!")) + + job.join() + + } + + @Test + fun multipleTextStreams() = runTest { + connect() + val subPeerConnection = component.rtcEngine().getSubscriberPeerConnection() as MockPeerConnection + val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL) + subPeerConnection.observer?.onDataChannel(subDataChannel) + + + val job = coroutineRule.scope.launch { + moleculeFlow(RecompositionMode.Immediate) { + rememberTextStream("topic", room).value + }.composeTest { + run { + val initial = awaitItem() + println("initial: $initial") + assertTrue(initial.isEmpty()) + + println("first") + val first = awaitItem() + println("first: $first") + assertEquals(1, first.size) + assertEquals("hello", first[0].text) + + println("second") + val second = awaitItem() + println("second: $second") + assertEquals(2, second.size) + assertEquals("hello", second[0].text) + assertEquals("world", second[1].text) + + } + } + } + + subDataChannel.observer?.receiveTextStream(streamId = "streamId1", chunk = "hello") + subDataChannel.observer?.receiveTextStream(streamId = "streamId2", chunk = "world") + + job.join() + + } + +} \ No newline at end of file From a069e51d0098bed84c1adbef4da57f181e812d7d Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:30:11 +0900 Subject: [PATCH 21/43] rememberLocalMedia implementation --- .../compose/state/RememberLocalMedia.kt | 249 ++++++++++++++++++ .../android/compose/types/LocalMedia.kt | 34 +++ 2 files changed, 283 insertions(+) create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt new file mode 100644 index 0000000..30fd303 --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt @@ -0,0 +1,249 @@ +package io.livekit.android.compose.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.platform.LocalContext +import com.twilio.audioswitch.AudioDevice +import com.twilio.audioswitch.AudioDeviceChangeListener +import io.livekit.android.compose.local.requireRoom +import io.livekit.android.compose.types.LocalMedia +import io.livekit.android.compose.types.TrackReference +import io.livekit.android.compose.ui.flipped +import io.livekit.android.room.Room +import io.livekit.android.room.track.LocalVideoTrack +import io.livekit.android.room.track.Track +import io.livekit.android.room.track.screencapture.ScreenCaptureParams +import io.livekit.android.room.track.video.CameraCapturerUtils +import io.livekit.android.util.LKLog +import livekit.org.webrtc.CameraEnumerator + +internal class LocalMediaImpl( + microphoneTrackState: State, + cameraTrackState: State, + screenShareTrackState: State, + audioDevicesState: SnapshotStateList, + cameraDevicesState: SnapshotStateList, + selectedAudioDeviceState: State, + selectedCameraState: State, + canSwitchPositionState: State, + private val setMicrophoneFn: suspend (Boolean) -> Boolean, + private val setCameraFn: suspend (Boolean) -> Boolean, + private val setScreenShareFn: suspend (Boolean, ScreenCaptureParams?) -> Boolean, + private val selectAudioDeviceFn: (AudioDevice) -> Unit?, + private val selectCameraFn: (String) -> Unit?, + private val switchCameraFn: () -> Unit?, + override val cameraEnumerator: CameraEnumerator, +) : LocalMedia { + override val microphoneTrack by microphoneTrackState + override val cameraTrack by cameraTrackState + override val screenShareTrack by screenShareTrackState + + override val isMicrophoneEnabled by derivedStateOf { + microphoneTrack?.isSubscribed() == true + } + override val isCameraEnabled by derivedStateOf { + cameraTrack?.isSubscribed() == true + } + override val isScreenShareEnabled by derivedStateOf { + screenShareTrack?.isSubscribed() == true + } + + override val audioDevices = audioDevicesState + override val cameraDevices = cameraDevicesState + + override val selectedAudioDevice by selectedAudioDeviceState + override val selectedCameraId by selectedCameraState + override val canSwitchPosition by canSwitchPositionState + + override suspend fun startMicrophone() { + setMicrophoneFn(true) + } + + override suspend fun stopMicrophone() { + setMicrophoneFn(false) + } + + override suspend fun startCamera() { + setCameraFn(true) + } + + override suspend fun stopCamera() { + setCameraFn(false) + } + + override suspend fun startScreenShare(params: ScreenCaptureParams) { + setScreenShareFn(true, params) + } + + override suspend fun stopScreenShare() { + setScreenShareFn(false, null) + } + + override fun selectAudioDevice(audioDevice: AudioDevice) { + selectAudioDeviceFn(audioDevice) + } + + override fun selectCamera(deviceName: String) { + selectCameraFn(deviceName) + } + + override fun switchCamera() { + switchCameraFn() + } +} + +@Composable +fun rememberLocalMedia(room: Room? = null): LocalMedia { + val room = requireRoom(room) + + val localMicTrack by rememberParticipantTrackReferences( + sources = listOf(Track.Source.MICROPHONE), + passedParticipant = room.localParticipant, + ) + val micState = remember { + derivedStateOf { localMicTrack.firstOrNull() } + } + + val localCameraTrack by rememberParticipantTrackReferences( + sources = listOf(Track.Source.CAMERA), + passedParticipant = room.localParticipant, + ) + val cameraState = remember { + derivedStateOf { localCameraTrack.firstOrNull() } + } + + val localScreenShareTrack by rememberParticipantTrackReferences( + sources = listOf(Track.Source.SCREEN_SHARE), + passedParticipant = room.localParticipant, + ) + val screenShareState = remember { + derivedStateOf { localScreenShareTrack.firstOrNull() } + } + + // Audio + val selectedAudioDeviceState = remember { + mutableStateOf(room.audioSwitchHandler?.selectedAudioDevice) + } + val audioDevicesStateList = remember { + mutableStateListOf(*(room.audioSwitchHandler?.availableAudioDevices?.toTypedArray() ?: emptyArray())) + } + + DisposableEffect(room) { + val audioSwitchHandler = room.audioSwitchHandler + val audioDeviceChangeListener: AudioDeviceChangeListener = { audioDevices, selectedDevice -> + audioDevicesStateList.clear() + audioDevicesStateList.addAll(audioDevices) + + selectedAudioDeviceState.value = selectedDevice + } + if (audioSwitchHandler != null) { + audioSwitchHandler.registerAudioDeviceChangeListener(audioDeviceChangeListener) + } else { + LKLog.w { "Room.audioSwitchHandler is null. Audio management is unavailable." } + } + + onDispose { + audioSwitchHandler?.unregisterAudioDeviceChangeListener(audioDeviceChangeListener) + } + } + + val selectAudioDeviceFn = remember(room) { + { audioDevice: AudioDevice -> + val audioSwitchHandler = room.audioSwitchHandler + audioSwitchHandler?.selectDevice(audioDevice) + } + } + + // Cameras + val context = LocalContext.current + val enumerator = remember { + CameraCapturerUtils.createCameraEnumerator(context) + } + val availableCameras = remember { + val devices = enumerator.deviceNames + mutableStateListOf(*devices) + } + val selectedCameraState = remember { + derivedStateOf { + val cameraTrack = cameraState.value?.publication?.track as? LocalVideoTrack + cameraTrack?.options?.deviceId + } + } + + val canSwitchPositionState = remember { + derivedStateOf { + val deviceName = selectedCameraState.value + return@derivedStateOf if (deviceName == null) { + false + } else { + enumerator.isBackFacing(deviceName) || enumerator.isFrontFacing(deviceName) + } + } + } + val selectCameraFn = remember(room) { + { deviceName: String -> + val cameraTrack = cameraState.value?.publication?.track as? LocalVideoTrack + cameraTrack?.switchCamera(deviceId = deviceName) + } + } + val switchCameraFn = remember(room) { + { + val cameraTrack = cameraState.value?.publication?.track as? LocalVideoTrack + if (cameraTrack != null) { + val newPosition = cameraTrack.options.position?.flipped() + if (newPosition != null) { + cameraTrack.switchCamera(position = newPosition) + } + } + } + } + + // Enable/disable devices + val setMicrophoneFn = remember(room) { + val fn: suspend (Boolean) -> Boolean = { enabled: Boolean -> + room.localParticipant.setMicrophoneEnabled(enabled) + } + return@remember fn + } + val setCameraFn = remember(room) { + val fn: suspend (Boolean) -> Boolean = { enabled: Boolean -> + room.localParticipant.setCameraEnabled(enabled) + } + return@remember fn + } + val setScreenShareFn = remember(room) { + val fn: suspend (Boolean, ScreenCaptureParams?) -> Boolean = { enabled, params -> + room.localParticipant.setScreenShareEnabled(enabled, params) + } + return@remember fn + } + + val localMedia = remember(room) { + LocalMediaImpl( + microphoneTrackState = micState, + cameraTrackState = cameraState, + screenShareTrackState = screenShareState, + audioDevicesState = audioDevicesStateList, + cameraDevicesState = availableCameras, + selectedAudioDeviceState = selectedAudioDeviceState, + selectedCameraState = selectedCameraState, + canSwitchPositionState = canSwitchPositionState, + setMicrophoneFn = setMicrophoneFn, + setCameraFn = setCameraFn, + setScreenShareFn = setScreenShareFn, + selectAudioDeviceFn = selectAudioDeviceFn, + selectCameraFn = selectCameraFn, + switchCameraFn = switchCameraFn, + cameraEnumerator = enumerator, + ) + } + + return localMedia +} diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt new file mode 100644 index 0000000..ef3db84 --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt @@ -0,0 +1,34 @@ +package io.livekit.android.compose.types + +import androidx.compose.runtime.snapshots.SnapshotStateList +import com.twilio.audioswitch.AudioDevice +import io.livekit.android.room.track.screencapture.ScreenCaptureParams +import livekit.org.webrtc.CameraEnumerator + +interface LocalMedia { + val microphoneTrack: TrackReference? + val cameraTrack: TrackReference? + val screenShareTrack: TrackReference? + + val isMicrophoneEnabled: Boolean + val isCameraEnabled: Boolean + val isScreenShareEnabled: Boolean + + val audioDevices: List + val cameraDevices: SnapshotStateList + val selectedAudioDevice: AudioDevice? + val selectedCameraId: String? + val canSwitchPosition: Boolean + + val cameraEnumerator: CameraEnumerator + + suspend fun startMicrophone() + suspend fun stopMicrophone() + suspend fun startCamera() + suspend fun stopCamera() + suspend fun startScreenShare(params: ScreenCaptureParams) + suspend fun stopScreenShare() + fun selectAudioDevice(audioDevice: AudioDevice) + fun selectCamera(deviceName: String) + fun switchCamera() +} From be10562b71be4724b7e224707b784ea6fe12882a Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:30:46 +0900 Subject: [PATCH 22/43] rememberSessionMessages implementation --- .../compose/state/RememberSessionMessages.kt | 152 ++++++++++++++++++ .../livekit/android/compose/chat/ChatTest.kt | 92 ++++++++--- 2 files changed, 220 insertions(+), 24 deletions(-) create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt new file mode 100644 index 0000000..5254beb --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt @@ -0,0 +1,152 @@ +package io.livekit.android.compose.state + +import android.os.SystemClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import io.livekit.android.annotations.Beta +import io.livekit.android.compose.chat.rememberChat +import io.livekit.android.compose.local.requireSession +import io.livekit.android.compose.state.transcriptions.rememberTranscriptions +import io.livekit.android.compose.types.ReceivedAgentTranscriptionMessage +import io.livekit.android.compose.types.ReceivedChatMessage +import io.livekit.android.compose.types.ReceivedMessage +import io.livekit.android.compose.types.ReceivedUserTranscriptionMessage +import io.livekit.android.room.datastream.StreamTextOptions +import kotlinx.coroutines.launch + +interface SessionMessages { + /** + * The log of all messages sent and received. + */ + val messages: List + + /** + * A hot flow emitting a [ReceivedMessage] for each individual message sent and received. + */ + //TODO val messagesFlow: Flow + + val isSending: Boolean + + suspend fun send(message: String, options: StreamTextOptions = StreamTextOptions()): Result +} + +internal class SessionMessagesImpl( + private val messagesState: State>, + val sendImpl: suspend (message: String, options: StreamTextOptions) -> Result +) : SessionMessages { + override val messages + get() = messagesState.value + override var isSending by mutableStateOf(false) // TOD + internal set + + override suspend fun send( + message: String, + options: StreamTextOptions, + ): Result { + return sendImpl(message, options) + } +} + +@Beta +@Composable +fun rememberSessionMessages(session: Session? = null): SessionMessages { + val session = requireSession(session) + val room = session.room + val agent = rememberAgent(session) + + val transcriptions by rememberTranscriptions(room) + val chat = rememberChat(room = room) + val transcriptionMessages by remember(room) { + derivedStateOf { + transcriptions.map { transcription -> + when (transcription.participantIdentity) { + room.localParticipant.identity -> { + // User transcription + ReceivedUserTranscriptionMessage( + id = transcription.streamInfo.id, + message = transcription.text, + timestamp = transcription.streamInfo.timestampMs, + fromParticipant = room.localParticipant, + ) + } + + agent.agentParticipant?.identity, + agent.workerParticipant?.identity -> { + ReceivedAgentTranscriptionMessage( + id = transcription.streamInfo.id, + message = transcription.text, + timestamp = transcription.streamInfo.timestampMs, + fromParticipant = if (agent.agentParticipant?.identity == transcription.participantIdentity) { + agent.agentParticipant!! + } else { + agent.workerParticipant!! + }, + ) + } + + else -> { + // FIXME: what should happen if an associated participant is not found? + // + // For now, just assume it is an agent transcription, since maybe it is from an agent + // which disconnected from the room or something like that. + ReceivedAgentTranscriptionMessage( + id = transcription.streamInfo.id, + message = transcription.text, + timestamp = transcription.streamInfo.timestampMs, + fromParticipant = room.remoteParticipants.values.firstOrNull { p -> p.identity == transcription.participantIdentity } + ) + } + } + } + } + } + + val receivedMessagesTimeMap = remember { + mutableStateMapOf() + } + val receivedMessages = remember { + derivedStateOf { + (transcriptionMessages.plus(elements = chat.messages.value)) + .sortedBy { receivedMessagesTimeMap[it.id] } + } + } + + LaunchedEffect(Unit) { + launch { + snapshotFlow { transcriptionMessages } + .collect { messages -> + for (message in messages) { + val original = receivedMessagesTimeMap[message.id] + if (original == null) { + receivedMessagesTimeMap[message.id] = SystemClock.elapsedRealtime() + } + } + } + } + launch { + chat.messagesFlow + .collect { message -> receivedMessagesTimeMap[message.id] = SystemClock.elapsedRealtime() } + } + } + + + val sessionMessages = remember(chat) { + SessionMessagesImpl( + messagesState = receivedMessages, + { message, options -> + chat.send(message, options) + } + ) + } + + return sessionMessages +} + diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/chat/ChatTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/chat/ChatTest.kt index 4194191..ba9202e 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/chat/ChatTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/chat/ChatTest.kt @@ -19,9 +19,11 @@ package io.livekit.android.compose.chat import androidx.compose.runtime.collectAsState import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import app.cash.turbine.test import com.google.protobuf.ByteString import io.livekit.android.compose.flow.DataTopic +import io.livekit.android.compose.flow.LegacyDataTopic +import io.livekit.android.compose.test.util.composeTest +import io.livekit.android.compose.test.util.receiveTextStream import io.livekit.android.room.RTCEngine import io.livekit.android.test.MockE2ETest import io.livekit.android.test.mock.MockDataChannel @@ -35,6 +37,7 @@ import livekit.LivekitModels import livekit.org.webrtc.DataChannel import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Test import java.nio.ByteBuffer @@ -47,32 +50,73 @@ class ChatTest : MockE2ETest() { val messageString = "message" moleculeFlow(RecompositionMode.Immediate) { rememberChat(room) - }.test { + }.composeTest { val chat = awaitItem() assertNotNull(chat) - chat.send(messageString) + val result = chat.send(messageString) + assertTrue(result.isSuccess) val pubPeerConnection = component.rtcEngine().getPublisherPeerConnection() as MockPeerConnection val dataChannel = pubPeerConnection.dataChannels[RTCEngine.RELIABLE_DATA_CHANNEL_LABEL] as MockDataChannel - assertEquals(1, dataChannel.sentBuffers.size) + assertEquals(4, dataChannel.sentBuffers.size) - val data = dataChannel.sentBuffers.first()!!.data - val dataPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(data)) - val chatMessage = dataPacket.user.payload!! - .toByteArray() - .decodeToString() - .run { - val json = Json { ignoreUnknownKeys = true } - json.decodeFromString(this) - } + // Data stream send + run { + val data = dataChannel.sentBuffers[1].data + val dataPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(data)) + val chatMessage = dataPacket.streamChunk.content + .toStringUtf8() - assertEquals(messageString, chatMessage.message) + assertEquals(messageString, chatMessage) + } + // Legacy chat send + run { + val data = dataChannel.sentBuffers[3].data + val dataPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(data)) + val chatMessage = dataPacket.user.payload!! + .toByteArray() + .decodeToString() + .run { + val json = Json { ignoreUnknownKeys = true } + json.decodeFromString(this) + } + + assertEquals(messageString, chatMessage.message) + } } } @Test - fun receiveMessage() = runTest { + fun receiveDataStreamMessage() = runTest { + connect() + + // Setup data channels + val subPeerConnection = component.rtcEngine().getSubscriberPeerConnection() as MockPeerConnection + val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL) + subPeerConnection.observer?.onDataChannel(subDataChannel) + + val job = coroutineRule.scope.launch { + moleculeFlow(RecompositionMode.Immediate) { + rememberChat(room).messages.value + }.composeTest { + // Discard initial state + val first = awaitItem() + println("first: $first") + val receivedMsgs = awaitItem() + println("receivedMsgs: $receivedMsgs") + + assertEquals(1, receivedMsgs.size) + assertEquals("message", receivedMsgs.first().message) + } + } + + subDataChannel.observer?.receiveTextStream(chunk = "message", topic = DataTopic.CHAT.value) + job.join() + } + + @Test + fun receiveLegacyMessage() = runTest { connect() // Setup data channels @@ -80,23 +124,23 @@ class ChatTest : MockE2ETest() { val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL) subPeerConnection.observer?.onDataChannel(subDataChannel) - val chatMessage = ChatMessage(timestamp = 0L, message = "message") + val chatMessage = LegacyChatMessage(timestamp = 0L, message = "message") val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberChat(room).messages.value - }.test { + }.composeTest { // Discard initial state awaitItem() val receivedMsgs = awaitItem() assertEquals(1, receivedMsgs.size) - assertEquals(chatMessage, receivedMsgs.first()) + assertEquals(chatMessage.message, receivedMsgs.first().message) } } val dataPacket = with(LivekitModels.DataPacket.newBuilder()) { user = with(LivekitModels.UserPacket.newBuilder()) { - topic = DataTopic.CHAT.value + topic = LegacyDataTopic.CHAT.value payload = ByteString.copyFrom(Json.encodeToString(chatMessage).toByteArray()) build() } @@ -112,7 +156,7 @@ class ChatTest : MockE2ETest() { } @Test - fun receiveMessageFlow() = runTest { + fun receiveLegacyMessageFlow() = runTest { connect() // Setup data channels @@ -120,22 +164,22 @@ class ChatTest : MockE2ETest() { val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL) subPeerConnection.observer?.onDataChannel(subDataChannel) - val chatMessage = ChatMessage(timestamp = 0L, message = "message") + val chatMessage = LegacyChatMessage(timestamp = 0L, message = "message") val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberChat(room).messagesFlow.collectAsState(initial = null).value - }.test { + }.composeTest { // Discard initial state awaitItem() val receivedMsg = awaitItem() - assertEquals(chatMessage, receivedMsg) + assertEquals(chatMessage.message, receivedMsg?.message) } } val dataPacket = with(LivekitModels.DataPacket.newBuilder()) { user = with(LivekitModels.UserPacket.newBuilder()) { - topic = DataTopic.CHAT.value + topic = LegacyDataTopic.CHAT.value payload = ByteString.copyFrom(Json.encodeToString(chatMessage).toByteArray()) build() } From 79e816b743d0027027ca12d7139eb6b5937c8451 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:31:09 +0900 Subject: [PATCH 23/43] ReceivedMessage --- .../android/compose/types/ReceivedMessage.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/types/ReceivedMessage.kt diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/ReceivedMessage.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/ReceivedMessage.kt new file mode 100644 index 0000000..2afebb7 --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/ReceivedMessage.kt @@ -0,0 +1,36 @@ +package io.livekit.android.compose.types + +import io.livekit.android.room.participant.Participant + +sealed class ReceivedMessage { + abstract val id: String + abstract val message: String + abstract val timestamp: Long + abstract val fromParticipant: Participant? + abstract val attributes: Map +} + +data class ReceivedChatMessage( + override val id: String, + override val message: String, + override val timestamp: Long, + override val fromParticipant: Participant?, + override val attributes: Map = emptyMap(), + val editTimestamp: Long? = null, +) : ReceivedMessage() + +data class ReceivedUserTranscriptionMessage( + override val id: String, + override val message: String, + override val timestamp: Long, + override val fromParticipant: Participant?, + override val attributes: Map = emptyMap(), +) : ReceivedMessage() + +data class ReceivedAgentTranscriptionMessage( + override val id: String, + override val message: String, + override val timestamp: Long, + override val fromParticipant: Participant?, + override val attributes: Map = emptyMap(), +) : ReceivedMessage() \ No newline at end of file From a3d178ee1132cf3f2a5d4433c7b1f95e11f1bf21 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:31:41 +0900 Subject: [PATCH 24/43] RememberAgent implementation --- .../android/compose/state/RememberAgent.kt | 293 ++++++++++++++++++ .../compose/state/RememberAgentTest.kt | 91 ++++++ .../compose/state/RememberLocalMediaTest.kt | 42 +++ 3 files changed, 426 insertions(+) create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt create mode 100644 livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt create mode 100644 livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberLocalMediaTest.kt diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt new file mode 100644 index 0000000..1245832 --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt @@ -0,0 +1,293 @@ +package io.livekit.android.compose.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateList +import io.livekit.android.annotations.Beta +import io.livekit.android.compose.local.requireSession +import io.livekit.android.compose.types.TrackReference +import io.livekit.android.compose.util.rememberStateOrDefault +import io.livekit.android.room.ConnectionState +import io.livekit.android.room.participant.RemoteParticipant +import io.livekit.android.room.participant.isAgent +import io.livekit.android.room.track.Track +import io.livekit.android.room.types.AgentAttributes +import io.livekit.android.util.flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.takeWhile +import kotlin.time.Duration.Companion.seconds + +// TODO: make this 10 seconds once room dispatch booting info is discoverable +private val DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS = 20.seconds; + +interface Agent { + val agentParticipant: RemoteParticipant? + + /** + * @suppress + */ + val workerParticipant: RemoteParticipant? + + val attributes: AgentAttributes? + + val failureReasons: List + + val agentState: AgentState + + val audioTrack: TrackReference? + + val videoTrack: TrackReference? + + val isAvailable: Boolean + + val isBufferingSpeech: Boolean + + suspend fun waitUntilAvailable() + suspend fun waitUntilCamera() + suspend fun waitUntilMicrophone() +} + +@Stable +internal class AgentImpl( + agentParticipantState: State, + workerParticipantState: State, + failureReasons: SnapshotStateList, + audioTrackState: State, + videoTrackState: State, + agentStateState: State, + isAvailableState: State, + isBufferingSpeechState: State, + attributesState: State, + private val waitUntilAvailableFn: suspend () -> Unit, + private val waitUntilCameraFn: suspend () -> Unit, + private val waitUntilMicrophoneFn: suspend () -> Unit, +) : Agent { + override val agentParticipant by agentParticipantState + + override val workerParticipant by workerParticipantState + + override val attributes by attributesState + + override val failureReasons: List = failureReasons + + override val agentState by agentStateState + + override val audioTrack by audioTrackState + + override val videoTrack by videoTrackState + + override val isAvailable by isAvailableState + + override val isBufferingSpeech by isBufferingSpeechState + + override suspend fun waitUntilAvailable() { + waitUntilAvailableFn() + } + + override suspend fun waitUntilCamera() { + waitUntilCameraFn() + } + + override suspend fun waitUntilMicrophone() { + waitUntilMicrophoneFn() + } + +} + +/** + * This looks for the first agent-participant in the room. + * + * Requires an agent running with livekit-agents \>= 0.9.0. + */ +@Beta +@Composable +fun rememberAgent(session: Session? = null): Agent { + + val session = requireSession(session) + val room = session.room + + // Gather participant info + val remoteParticipants by room::remoteParticipants.flow.collectAsState() + val agentParticipantState = remember { + derivedStateOf { + remoteParticipants.values + .filter { p -> p.agentAttributes.lkPublishOnBehalf == null } + .firstOrNull { p -> p.isAgent } + } + } + + val curAgentParticipant = agentParticipantState.value // For nullability checks + val workerParticipantState = remember { + derivedStateOf { + if (curAgentParticipant == null) { + return@derivedStateOf null + } + remoteParticipants.values + .filter { p -> p.agentAttributes.lkPublishOnBehalf != null && p.agentAttributes.lkPublishOnBehalf == curAgentParticipant.identity?.value } + .firstOrNull { p -> p.isAgent } + } + } + val curWorkerParticipant = workerParticipantState.value // For nullability checks + + // Track handling + val agentTracks by rememberStateOrDefault(emptyList()) { + if (curAgentParticipant != null) { + rememberParticipantTrackReferences( + sources = listOf(Track.Source.MICROPHONE, Track.Source.CAMERA), + participantIdentity = curAgentParticipant.identity, + passedRoom = room, + ) + } else { + null + } + } + + val workerTracks by rememberStateOrDefault(emptyList()) { + if (curWorkerParticipant != null) { + rememberParticipantTrackReferences( + sources = listOf(Track.Source.MICROPHONE, Track.Source.CAMERA), + participantIdentity = curWorkerParticipant.identity, + passedRoom = room, + ) + } else { + null + } + } + + val videoTrackState = remember { + derivedStateOf { + agentTracks.firstOrNull { trackReference -> trackReference.source == Track.Source.CAMERA } + ?: workerTracks.firstOrNull { trackReference -> trackReference.source == Track.Source.CAMERA } + } + } + + val audioTrackState = remember { + derivedStateOf { + agentTracks.firstOrNull { trackReference -> trackReference.source == Track.Source.MICROPHONE } + ?: workerTracks.firstOrNull { trackReference -> trackReference.source == Track.Source.MICROPHONE } + } + } + + val localMicTrack by rememberParticipantTrackReferences( + sources = listOf(Track.Source.MICROPHONE), + passedParticipant = room.localParticipant, + ) + + // Attributes and states + val agentState by rememberAgentState(participant = curAgentParticipant) + val connectionState = session.connectionState + + val combinedAgentState = remember { + derivedStateOf { + + var state = AgentState.DISCONNECTED + if (connectionState != ConnectionState.DISCONNECTED) { + state = AgentState.CONNECTING + } + + if (localMicTrack.isNotEmpty()) { + state = AgentState.LISTENING + } + + val agentParticipant = agentParticipantState.value + if (agentParticipant != null) { + state = agentState + } + + return@derivedStateOf state + } + } + + val isAvailable = remember { + derivedStateOf { + calculateIsAvailable(agentState) + } + } + + val isBufferingSpeech = remember { + derivedStateOf { + !(connectionState == ConnectionState.DISCONNECTED + || isAvailable.value + || localMicTrack.isNotEmpty()) + } + } + + val attributesState = rememberStateOrDefault(null) { + if (curAgentParticipant != null) { + curAgentParticipant::agentAttributes.flow + .collectAsState() + } else { + null + } + } + + // Agent actions + val waitUntilAvailableFn = remember(room) { + suspend waitUntilAvailable@{ + snapshotFlow { combinedAgentState.value } + .takeWhile { !calculateIsAvailable(it) } + .collect() + } + } + + val waitUntilCameraFn = remember(room) { + suspend waitUntilCamera@{ + snapshotFlow { videoTrackState.value } + .takeWhile { it == null } + .collect() + } + } + + val waitUntilMicrophoneFn = remember(room) { + suspend waitUntilMicrophone@{ + snapshotFlow { audioTrackState.value } + .takeWhile { it == null } + .collect() + } + } + + val failureReasons = remember(room) { + SnapshotStateList() + } + // Assemble the agent + val agent = remember { + derivedStateOf { + AgentImpl( + agentParticipantState = agentParticipantState, + workerParticipantState = workerParticipantState, + audioTrackState = audioTrackState, + videoTrackState = videoTrackState, + agentStateState = combinedAgentState, + isAvailableState = isAvailable, + isBufferingSpeechState = isBufferingSpeech, + failureReasons = failureReasons, + waitUntilAvailableFn = waitUntilAvailableFn, + waitUntilCameraFn = waitUntilCameraFn, + waitUntilMicrophoneFn = waitUntilMicrophoneFn, + attributesState = attributesState, + ) + } + } + + return agent.value +} + +private fun calculateIsAvailable(agentState: AgentState): Boolean { + return when (agentState) { + AgentState.IDLE, + AgentState.LISTENING, + AgentState.THINKING, + AgentState.SPEAKING -> true + + AgentState.CONNECTING, + AgentState.INITIALIZING, + AgentState.DISCONNECTED, + AgentState.UNKNOWN -> false + } +} \ No newline at end of file diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt new file mode 100644 index 0000000..00ea8d0 --- /dev/null +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt @@ -0,0 +1,91 @@ +package io.livekit.android.compose.state + +import androidx.compose.runtime.LaunchedEffect +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import io.livekit.android.annotations.Beta +import io.livekit.android.compose.test.util.composeTest +import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.mock.TestData +import io.livekit.android.token.TokenSource +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(Beta::class) +class RememberAgentTest : MockE2ETest() { + @Test + fun basicSession() = runTest { + val job = launch { + moleculeFlow(RecompositionMode.Immediate) { + val session = rememberSession( + tokenSource = TokenSource.fromLiteral(TestData.EXAMPLE_URL, "token"), + options = SessionOptions( + room = room + ) + ) + + val agent = rememberAgent(session) + + LaunchedEffect(Unit) { + val result = session.start() + assertTrue(result.isSuccess) + + val agentJoin = TestData.AGENT_JOIN + simulateMessageFromServer(agentJoin) + + session.end() + } + agent.agentParticipant + }.distinctUntilChanged().composeTest { + assertEquals(null, awaitItem()) + assertEquals(TestData.REMOTE_PARTICIPANT.identity, awaitItem()?.identity?.value) + assertEquals(null, awaitItem()) + } + } + + sessionConnect() + + job.join() + } + + + @Test + fun agentStateWithPreconnect() = runTest { + val job = launch { + moleculeFlow(RecompositionMode.Immediate) { + val session = rememberSession( + tokenSource = TokenSource.fromLiteral(TestData.EXAMPLE_URL, "token"), + options = SessionOptions( + room = room + ) + ) + + val agent = rememberAgent(session) + + LaunchedEffect(Unit) { + assertTrue(session.start().isSuccess) + + val agentJoin = TestData.AGENT_JOIN + simulateMessageFromServer(agentJoin) + + session.end() + } + agent.agentState + } + .distinctUntilChanged() + .composeTest { + + assertEquals(AgentState.DISCONNECTED, awaitItem()) + assertEquals(AgentState.LISTENING, awaitItem()) + assertEquals(AgentState.DISCONNECTED, awaitItem()) + } + } + + sessionConnect() + + job.join() + } +} \ No newline at end of file diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberLocalMediaTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberLocalMediaTest.kt new file mode 100644 index 0000000..4c9949c --- /dev/null +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberLocalMediaTest.kt @@ -0,0 +1,42 @@ +package io.livekit.android.compose.state + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import io.livekit.android.compose.test.util.composeTest +import io.livekit.android.compose.test.util.withLocalContext +import io.livekit.android.room.track.TrackPublication +import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.mock.TestData +import kotlinx.coroutines.launch +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock + +class RememberLocalMediaTest : MockE2ETest() { + @Test + fun basicLocalMedia() = runTest { + connect() + + val trackPublication = TrackPublication(TestData.LOCAL_VIDEO_TRACK, mock(), room.localParticipant) + room.localParticipant.addTrackPublication(trackPublication) + + val job = coroutineRule.scope.launch { + moleculeFlow(RecompositionMode.Immediate) { + withLocalContext(context) { + rememberLocalMedia(room) + } + }.composeTest { + val localMedia = awaitItem() + + assertTrue(localMedia.microphoneTrack == null) + assertFalse(localMedia.isMicrophoneEnabled) + + assertTrue(localMedia.cameraTrack != null) + assertTrue(localMedia.isCameraEnabled) + } + } + + job.join() + } +} \ No newline at end of file From 29b33069f69060880e2895aa9398f420764d65dd Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:31:58 +0900 Subject: [PATCH 25/43] RememberSession implementation --- .../android/compose/local/SessionLocal.kt | 57 ++++ .../android/compose/state/RememberSession.kt | 292 ++++++++++++++++++ .../compose/state/RememberSessionTest.kt | 100 ++++++ 3 files changed, 449 insertions(+) create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/local/SessionLocal.kt create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt create mode 100644 livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberSessionTest.kt diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/SessionLocal.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/SessionLocal.kt new file mode 100644 index 0000000..d507004 --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/SessionLocal.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2023 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.compose.local + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import io.livekit.android.compose.state.Session +import io.livekit.android.room.participant.LocalParticipant + +/** + * Not to be confused with [LocalParticipant]. + */ +@SuppressLint("CompositionLocalNaming") +val SessionLocal = + compositionLocalOf { throw IllegalStateException("No Session object available. This should only be used within a SessionScope.") } + +/** + * Establishes a session scope which allows the current [Session] that can be accessed + * through the [SessionLocal] composition local, as well as the session's room object + * through the [RoomLocal] composition local. + */ +@Composable +fun SessionScope( + session: Session, + content: @Composable (session: Session) -> Unit +) { + CompositionLocalProvider( + SessionLocal provides session, RoomLocal provides session.room, + content = { content(session) }, + ) +} + +/** + * Returns the [session], or if null/no-arg, the currently provided [SessionLocal]. + * @throws IllegalStateException if [session] is null and no [SessionLocal] is available (e.g. not inside a [SessionScope]). + */ +@Composable +@Throws(IllegalStateException::class) +fun requireSession(session: Session? = null): Session { + return session ?: SessionLocal.current +} diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt new file mode 100644 index 0000000..3fe2ecb --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt @@ -0,0 +1,292 @@ +package io.livekit.android.compose.state + +import androidx.annotation.CheckResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import io.livekit.android.ConnectOptions +import io.livekit.android.annotations.Beta +import io.livekit.android.compose.local.rememberLiveKitRoom +import io.livekit.android.room.ConnectionState +import io.livekit.android.room.Room +import io.livekit.android.room.participant.AudioTrackPublishOptions +import io.livekit.android.room.track.LocalAudioTrackOptions +import io.livekit.android.token.ConfigurableTokenSource +import io.livekit.android.token.FixedTokenSource +import io.livekit.android.token.TokenRequestOptions +import io.livekit.android.token.TokenSource +import io.livekit.android.util.flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.takeWhile +import kotlin.time.Duration + +data class SessionOptions( + /** + * The room to use. If null is passed, one will be created for you. + */ + val room: Room? = null, + + /** + * Amount of time to wait for an agent to join the room, before transitioning + * to the failure state. + */ + val agentConnectTimeout: Duration? = null, + + /** + * The options to use when fetching the token, if it is fetching from + * a [ConfigurableTokenSource]. + * + * These options will be ignored for a [FixedTokenSource]. + */ + val tokenRequestOptions: TokenRequestOptions? = null +) + +data class SessionConnectOptions( + val tracks: SessionConnectTrackOptions = SessionConnectTrackOptions(), + val roomConnectOptions: ConnectOptions = ConnectOptions() +) + +/** + * Track options for connection + */ +data class SessionConnectTrackOptions( + /** Whether to enable microphone on connect. */ + val microphoneEnabled: Boolean = true, + /** Whether to enable the preconnect audio buffer for faster perceived connection times */ + val usePreconnectBuffer: Boolean = true, + /** @see LocalAudioTrackOptions */ + val microphoneCaptureOptions: LocalAudioTrackOptions = LocalAudioTrackOptions(), + /** @see AudioTrackPublishOptions */ + val microphonePublishOptions: AudioTrackPublishOptions = AudioTrackPublishOptions(), +) + +interface Session { + + /** The [Room] object used for this session. */ + val room: Room + + /** The [ConnectionState] of the session. */ + val connectionState: ConnectionState + + /** Whether the session is connected or not. */ + val isConnected: Boolean + + /** Whether the session is reconnecting or not. */ + val isReconnecting: Boolean + + /** + * A function that suspends until the session is connected. + */ + suspend fun waitUntilConnected() + + /** + * A function that suspends until the session is disconnected. + */ + suspend fun waitUntilDisconnected() + + /** + * Prepares the connection to speed up initial connection time. + * + * @see Room.prepareConnection + */ + suspend fun prepareConnection() + + /** + * Connect to the session. + */ + @CheckResult + suspend fun start(options: SessionConnectOptions = SessionConnectOptions()): Result + + /** + * Disconnect from the session. + */ + fun end() +} + +@Stable +internal class SessionImpl( + override val room: Room, + val connectionStateState: State, + val waitUntilConnectedFn: suspend () -> Unit, + val waitUntilDisconnectedFn: suspend () -> Unit, + val prepareConnectionFn: suspend () -> Unit, + val startFn: suspend (options: SessionConnectOptions) -> Result, + val endFn: () -> Unit +) : Session { + override val connectionState by connectionStateState + + override val isConnected by derivedStateOf { + when (connectionState) { + ConnectionState.CONNECTED, + ConnectionState.RECONNECTING, + ConnectionState.RESUMING -> true + + ConnectionState.CONNECTING, + ConnectionState.DISCONNECTED -> false + } + } + + override val isReconnecting by derivedStateOf { + when (connectionState) { + ConnectionState.RECONNECTING, + ConnectionState.RESUMING -> true + + ConnectionState.CONNECTING, + ConnectionState.CONNECTED, + ConnectionState.DISCONNECTED -> false + } + } + + override suspend fun waitUntilConnected() { + waitUntilConnectedFn() + } + + override suspend fun waitUntilDisconnected() { + waitUntilDisconnectedFn() + } + + override suspend fun prepareConnection() { + prepareConnectionFn() + } + + override suspend fun start(options: SessionConnectOptions): Result { + return startFn(options) + } + + override fun end() { + endFn() + } + +} + +@Beta +@Composable +fun rememberSession(tokenSource: TokenSource, options: SessionOptions = SessionOptions()): Session { + val room = rememberLiveKitRoom(passedRoom = options.room, connect = false) + val connectionState = room::state.flow + .map { state -> + when (state) { + Room.State.CONNECTING -> ConnectionState.CONNECTING + Room.State.CONNECTED -> ConnectionState.CONNECTED + Room.State.DISCONNECTED -> ConnectionState.DISCONNECTED + Room.State.RECONNECTING -> ConnectionState.RECONNECTING + } + } + .collectAsState(ConnectionState.DISCONNECTED) + + val waitUntilConnected = remember(room) { + suspend { + room::state.flow + .takeWhile { it != Room.State.CONNECTED } + .collect() + } + } + + val waitUntilDisconnected = remember(room) { + suspend { + room::state.flow + .takeWhile { it != Room.State.DISCONNECTED } + .collect() + } + } + + val tokenSourceFetch = remember(tokenSource, options.tokenRequestOptions) { + suspend fetch@{ + return@fetch when (tokenSource) { + is FixedTokenSource -> { + tokenSource.fetch() + } + + is ConfigurableTokenSource -> { + tokenSource.fetch(options.tokenRequestOptions ?: TokenRequestOptions()) + } + + else -> { + throw IllegalArgumentException("tokenSource must either be a FixedTokenSource or ConfigurableTokenSource") + } + } + } + } + + val start = remember(room, waitUntilDisconnected, waitUntilConnected, tokenSourceFetch) { + val startImpl: suspend (SessionConnectOptions) -> Result = { sessionConnectOptions -> + + waitUntilDisconnected() + + @CheckResult + suspend fun connect(): Result { + val fetchResult = tokenSourceFetch() + if (fetchResult.isFailure) { + return Result.failure(fetchResult.exceptionOrNull() ?: NullPointerException()) + } + + val credentials = fetchResult.getOrThrow() + val connectOptions = sessionConnectOptions.roomConnectOptions + .copy(audio = sessionConnectOptions.tracks.microphoneEnabled) + + try { + room.connect( + url = credentials.serverUrl, + token = credentials.participantToken, + options = connectOptions, + ) + } catch (e: Exception) { + return Result.failure(e) + } + + return Result.success(Unit) + } + + val result = connect() + + if (result.isSuccess) { + waitUntilConnected() + } + + result + } + + startImpl + } + val end = remember(room) { + { + room.disconnect() + } + } + + val prepareConnection = remember(room, tokenSourceFetch) { + suspend { + val fetchResult = tokenSourceFetch() + if (fetchResult.isSuccess) { + val credentials = fetchResult.getOrThrow() + room.prepareConnection(credentials.serverUrl, credentials.participantToken) + } + } + } + + // Only prepare connection once ever. + LaunchedEffect(Unit) { + prepareConnection() + } + + val session = remember(room) { + SessionImpl( + room = room, + connectionStateState = connectionState, + waitUntilConnectedFn = waitUntilConnected, + waitUntilDisconnectedFn = waitUntilDisconnected, + prepareConnectionFn = prepareConnection, + startFn = start, + endFn = end, + ) + } + + return session +} + diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberSessionTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberSessionTest.kt new file mode 100644 index 0000000..bd19cf2 --- /dev/null +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberSessionTest.kt @@ -0,0 +1,100 @@ +package io.livekit.android.compose.state + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import io.livekit.android.annotations.Beta +import io.livekit.android.compose.test.util.composeTest +import io.livekit.android.test.MockE2ETest +import io.livekit.android.test.mock.TestData +import io.livekit.android.token.TokenSource +import kotlinx.coroutines.launch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(Beta::class) +class RememberSessionTest : MockE2ETest() { + + @Test + fun basicSession() = runTest { + + val job = coroutineRule.scope.launch { + moleculeFlow(RecompositionMode.Immediate) { + val session = rememberSession( + tokenSource = TokenSource.fromLiteral(TestData.EXAMPLE_URL, "token"), + options = SessionOptions( + room = room, + ) + ) + + LaunchedEffect(Unit) { + session.start() + session.end() + } + session.isConnected + }.composeTest { + assertFalse(awaitItem()) + assertTrue(awaitItem()) + assertFalse(awaitItem()) + } + } + + sessionConnect() + job.join() + } + + @Test + fun waitFunctions() = runTest { + + val job = launch { + moleculeFlow(RecompositionMode.Immediate) { + val session = rememberSession( + tokenSource = TokenSource.fromLiteral(TestData.EXAMPLE_URL, "token"), + options = SessionOptions( + room = room, + ) + ) + + var state by remember { + mutableStateOf(0) + } + LaunchedEffect(Unit) { + + val waitUntilConnectedJob = launch { + session.waitUntilConnected() + state = 1 + } + session.start() + waitUntilConnectedJob.join() + + val waitUntilDisconnectedJob = launch { + session.waitUntilDisconnected() + state = 2 + } + session.end() + waitUntilDisconnectedJob.join() + } + + state + }.composeTest { + assertEquals(0, awaitItem()) + assertEquals(1, awaitItem()) + assertEquals(2, awaitItem()) + } + } + + sessionConnect() + job.join() + } +} + +suspend fun MockE2ETest.sessionConnect() { + prepareSignal() + connectPeerConnection() +} \ No newline at end of file From 31604184a829e2b07d46c002bf7e5febe28ff9c8 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:33:14 +0900 Subject: [PATCH 26/43] spotless --- .../io/livekit/android/compose/chat/Chat.kt | 9 ++---- .../android/compose/flow/DataHandler.kt | 2 +- .../livekit/android/compose/flow/DataTopic.kt | 2 +- .../android/compose/flow/TextStream.kt | 18 +++++++++++- .../android/compose/local/ParticipantLocal.kt | 5 ++-- .../android/compose/local/SessionLocal.kt | 5 ++-- .../android/compose/state/RememberAgent.kt | 29 ++++++++++++++----- .../compose/state/RememberAgentState.kt | 18 +++++++++++- .../compose/state/RememberConnectionState.kt | 2 +- .../compose/state/RememberLocalMedia.kt | 16 ++++++++++ .../compose/state/RememberParticipantInfo.kt | 2 +- .../RememberParticipantTrackReferences.kt | 2 +- .../compose/state/RememberParticipants.kt | 2 +- .../android/compose/state/RememberRoomInfo.kt | 2 +- .../android/compose/state/RememberSession.kt | 18 ++++++++++-- .../compose/state/RememberSessionMessages.kt | 20 +++++++++++-- .../state/RememberSpeakingParticipants.kt | 18 +++++++++++- .../android/compose/state/RememberTrack.kt | 2 +- .../compose/state/RememberTrackMuted.kt | 2 +- .../compose/state/RememberTrackReferences.kt | 2 +- .../compose/state/RememberVoiceAssistant.kt | 4 +-- .../transcriptions/RememberTranscriptions.kt | 4 +-- .../compose/stream/RememberTextStream.kt | 21 ++++++++++++-- .../android/compose/types/LocalMedia.kt | 16 ++++++++++ .../android/compose/types/ReceivedMessage.kt | 18 +++++++++++- .../android/compose/types/TrackIdentifier.kt | 2 +- .../compose/util/RememberStateOrDefault.kt | 19 ++++++++++-- .../compose/state/RememberAgentTest.kt | 20 +++++++++++-- .../compose/state/RememberLocalMediaTest.kt | 18 +++++++++++- .../RememberParticipantTrackReferencesTest.kt | 3 +- .../compose/state/RememberSessionTest.kt | 21 +++++++++++--- .../state/RememberVoiceAssistantTest.kt | 3 +- .../RememberTranscriptionsTest.kt | 23 +++++++++++---- .../compose/stream/RememberTextStreamTest.kt | 25 +++++++++++----- .../test/util/FakeRemoteParticipant.kt | 20 +++++++++++-- .../compose/test/util/LocalContextExt.kt | 18 +++++++++++- .../compose/test/util/TextStreamTestUtils.kt | 19 ++++++++++-- .../android/compose/test/util/TurbineExt.kt | 18 +++++++++++- .../util/RememberStateOrDefaultTest.kt | 22 +++++++++++--- 39 files changed, 386 insertions(+), 84 deletions(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt index 313f5b5..730b0cc 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -70,6 +70,7 @@ class Chat( private val stateLock = Mutex() private val sendLock = Mutex() + /** * Send a message through LiveKit. * @@ -80,7 +81,6 @@ class Chat( message: String, streamTextOptions: StreamTextOptions = StreamTextOptions(topic = DataTopic.CHAT.value) ): Result { - val streamTextOptions = if (streamTextOptions.topic.isEmpty()) { streamTextOptions.copy(topic = DataTopic.CHAT.value) } else { @@ -126,7 +126,6 @@ class Chat( _isSending.value = false } - return retMessage?.let { Result.success(it) } ?: Result.failure(NullPointerException()) @@ -206,13 +205,12 @@ data class LegacyChatMessage( */ @Composable fun rememberChat(room: Room = RoomLocal.current): Chat { - val serverSupportsDataStreams = remember(room) { // lambda function canSupport@{ val version = room.serverInfo?.version return@canSupport room.serverInfo?.edition == ServerInfo.Edition.CLOUD || - (version != null && version > Semver("1.8.2")) + (version != null && version > Semver("1.8.2")) } } val dataHandler = rememberDataMessageHandler(room = room, topic = LegacyDataTopic.CHAT) // Legacy data handler @@ -270,7 +268,6 @@ private fun setupChatDataStream( coroutineScope: CoroutineScope, topic: String = DataTopic.CHAT.value ): SharedFlow { - // The output flow val outputFlow = MutableSharedFlow() diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt index d493bb9..894c029 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataHandler.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataTopic.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataTopic.kt index f42e174..f7bd5e0 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataTopic.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/DataTopic.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/TextStream.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/TextStream.kt index f11caa7..ce65821 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/TextStream.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/flow/TextStream.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.flow import io.livekit.android.room.Room @@ -51,7 +67,7 @@ internal fun setupTextStream(room: Room, topic: String, coroutineScope: Coroutin null } return@indexOfFirst stream.streamInfo.id == reader.info.id || - (isTranscription && streamTranscriptionAttributes?.lkSegmentID == transcriptionAttributes.lkSegmentID) + (isTranscription && streamTranscriptionAttributes?.lkSegmentID == transcriptionAttributes.lkSegmentID) } } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/ParticipantLocal.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/ParticipantLocal.kt index 18e3e25..0476c76 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/ParticipantLocal.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/ParticipantLocal.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -53,7 +53,6 @@ fun requireParticipant(passedParticipant: Participant? = null): Participant { return passedParticipant ?: ParticipantLocal.current } - /** * A simple way to loop over participants that creates a [ParticipantScope] for each participant and calls [content]. */ @@ -67,4 +66,4 @@ fun ForEachParticipant( content(participant) } } -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/SessionLocal.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/SessionLocal.kt index d507004..dfe6c9d 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/SessionLocal.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/SessionLocal.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,7 +41,8 @@ fun SessionScope( content: @Composable (session: Session) -> Unit ) { CompositionLocalProvider( - SessionLocal provides session, RoomLocal provides session.room, + SessionLocal provides session, + RoomLocal provides session.room, content = { content(session) }, ) } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt index 1245832..4a35460 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.state import androidx.compose.runtime.Composable @@ -24,7 +40,7 @@ import kotlinx.coroutines.flow.takeWhile import kotlin.time.Duration.Companion.seconds // TODO: make this 10 seconds once room dispatch booting info is discoverable -private val DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS = 20.seconds; +private val DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS = 20.seconds interface Agent { val agentParticipant: RemoteParticipant? @@ -97,7 +113,6 @@ internal class AgentImpl( override suspend fun waitUntilMicrophone() { waitUntilMicrophoneFn() } - } /** @@ -108,7 +123,6 @@ internal class AgentImpl( @Beta @Composable fun rememberAgent(session: Session? = null): Agent { - val session = requireSession(session) val room = session.room @@ -185,7 +199,6 @@ fun rememberAgent(session: Session? = null): Agent { val combinedAgentState = remember { derivedStateOf { - var state = AgentState.DISCONNECTED if (connectionState != ConnectionState.DISCONNECTED) { state = AgentState.CONNECTING @@ -212,9 +225,9 @@ fun rememberAgent(session: Session? = null): Agent { val isBufferingSpeech = remember { derivedStateOf { - !(connectionState == ConnectionState.DISCONNECTED - || isAvailable.value - || localMicTrack.isNotEmpty()) + !(connectionState == ConnectionState.DISCONNECTED || + isAvailable.value || + localMicTrack.isNotEmpty()) } } @@ -290,4 +303,4 @@ private fun calculateIsAvailable(agentState: AgentState): Boolean { AgentState.DISCONNECTED, AgentState.UNKNOWN -> false } -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt index 4cbc700..6f83f14 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgentState.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.state import androidx.compose.runtime.Composable @@ -24,4 +40,4 @@ fun rememberAgentState(participant: Participant?): State { null } } -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberConnectionState.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberConnectionState.kt index d2fd793..c6273f8 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberConnectionState.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberConnectionState.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 LiveKit, Inc. + * Copyright 2024-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt index 30fd303..a294996 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.state import androidx.compose.runtime.Composable diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantInfo.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantInfo.kt index d44e346..b42039d 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantInfo.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantInfo.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt index 9b34d04..fbf7689 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipantTrackReferences.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 LiveKit, Inc. + * Copyright 2024-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipants.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipants.kt index 80e26f1..90a5cce 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipants.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberParticipants.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberRoomInfo.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberRoomInfo.kt index ee6539f..9b47634 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberRoomInfo.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberRoomInfo.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt index 3fe2ecb..5ad6c0c 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.state import androidx.annotation.CheckResult @@ -162,7 +178,6 @@ internal class SessionImpl( override fun end() { endFn() } - } @Beta @@ -289,4 +304,3 @@ fun rememberSession(tokenSource: TokenSource, options: SessionOptions = SessionO return session } - diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt index 5254beb..58712b5 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.state import android.os.SystemClock @@ -31,7 +47,7 @@ interface SessionMessages { /** * A hot flow emitting a [ReceivedMessage] for each individual message sent and received. */ - //TODO val messagesFlow: Flow + // TODO val messagesFlow: Flow val isSending: Boolean @@ -137,7 +153,6 @@ fun rememberSessionMessages(session: Session? = null): SessionMessages { } } - val sessionMessages = remember(chat) { SessionMessagesImpl( messagesState = receivedMessages, @@ -149,4 +164,3 @@ fun rememberSessionMessages(session: Session? = null): SessionMessages { return sessionMessages } - diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSpeakingParticipants.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSpeakingParticipants.kt index 92f8459..8b7f3d9 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSpeakingParticipants.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSpeakingParticipants.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.state import androidx.compose.runtime.Composable @@ -13,4 +29,4 @@ fun rememberSpeakingParticipants(room: Room? = null): State> { val room = requireRoom(room) return room::activeSpeakers.flow.collectAsState() -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrack.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrack.kt index e76a067..6211ab2 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrack.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrack.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 LiveKit, Inc. + * Copyright 2024-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt index dcdd619..11523fa 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackMuted.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 LiveKit, Inc. + * Copyright 2024-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt index c12261a..b9a2511 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt index 7441420..4dc1a71 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 LiveKit, Inc. + * Copyright 2024-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -173,4 +173,4 @@ enum class AgentState { } } } -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptions.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptions.kt index 2fcb161..535bcd7 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptions.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptions.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 LiveKit, Inc. + * Copyright 2024-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -109,4 +109,4 @@ fun rememberParticipantTranscriptions(passedParticipant: Participant? = null, ro ).value } ) -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/stream/RememberTextStream.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/stream/RememberTextStream.kt index 803049e..a9d0cd2 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/stream/RememberTextStream.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/stream/RememberTextStream.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.stream import androidx.compose.runtime.Composable @@ -17,10 +33,11 @@ fun rememberTextStream(topic: String, room: Room?): State> val coroutineScope = rememberCoroutineScope() val textStreamDatas = remember { setupTextStream( - room, topic, + room, + topic, coroutineScope = coroutineScope ) } return textStreamDatas.collectAsState(emptyList()) -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt index ef3db84..8483e4a 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.types import androidx.compose.runtime.snapshots.SnapshotStateList diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/ReceivedMessage.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/ReceivedMessage.kt index 2afebb7..a3f4e7d 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/ReceivedMessage.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/ReceivedMessage.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.types import io.livekit.android.room.participant.Participant @@ -33,4 +49,4 @@ data class ReceivedAgentTranscriptionMessage( override val timestamp: Long, override val fromParticipant: Participant?, override val attributes: Map = emptyMap(), -) : ReceivedMessage() \ No newline at end of file +) : ReceivedMessage() diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/TrackIdentifier.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/TrackIdentifier.kt index 74e4f95..0b9c2fc 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/TrackIdentifier.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/TrackIdentifier.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-2025 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt index c31de61..eee226f 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.util import androidx.compose.runtime.Composable @@ -7,7 +23,6 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map /** @@ -43,4 +58,4 @@ internal inline fun rememberStateOrDefault(default: T, block: @Composable () ?: default } } -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt index 00ea8d0..e8f2737 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.state import androidx.compose.runtime.LaunchedEffect @@ -51,7 +67,6 @@ class RememberAgentTest : MockE2ETest() { job.join() } - @Test fun agentStateWithPreconnect() = runTest { val job = launch { @@ -77,7 +92,6 @@ class RememberAgentTest : MockE2ETest() { } .distinctUntilChanged() .composeTest { - assertEquals(AgentState.DISCONNECTED, awaitItem()) assertEquals(AgentState.LISTENING, awaitItem()) assertEquals(AgentState.DISCONNECTED, awaitItem()) @@ -88,4 +102,4 @@ class RememberAgentTest : MockE2ETest() { job.join() } -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberLocalMediaTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberLocalMediaTest.kt index 4c9949c..03225eb 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberLocalMediaTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberLocalMediaTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode @@ -39,4 +55,4 @@ class RememberLocalMediaTest : MockE2ETest() { job.join() } -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferencesTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferencesTest.kt index e18d251..5c6fdba 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferencesTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberParticipantTrackReferencesTest.kt @@ -18,8 +18,8 @@ package io.livekit.android.compose.state import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow -import io.livekit.android.compose.test.util.createFakeRemoteParticipant import io.livekit.android.compose.test.util.composeTest +import io.livekit.android.compose.test.util.createFakeRemoteParticipant import io.livekit.android.room.participant.VideoTrackPublishOptions import io.livekit.android.room.track.LocalTrackPublication import io.livekit.android.room.track.LocalVideoTrack @@ -154,5 +154,4 @@ class RememberParticipantTrackReferencesTest : MockE2ETest() { job.join() } - } diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberSessionTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberSessionTest.kt index bd19cf2..46b7710 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberSessionTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberSessionTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.state import androidx.compose.runtime.LaunchedEffect @@ -23,7 +39,6 @@ class RememberSessionTest : MockE2ETest() { @Test fun basicSession() = runTest { - val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { val session = rememberSession( @@ -51,7 +66,6 @@ class RememberSessionTest : MockE2ETest() { @Test fun waitFunctions() = runTest { - val job = launch { moleculeFlow(RecompositionMode.Immediate) { val session = rememberSession( @@ -65,7 +79,6 @@ class RememberSessionTest : MockE2ETest() { mutableStateOf(0) } LaunchedEffect(Unit) { - val waitUntilConnectedJob = launch { session.waitUntilConnected() state = 1 @@ -97,4 +110,4 @@ class RememberSessionTest : MockE2ETest() { suspend fun MockE2ETest.sessionConnect() { prepareSignal() connectPeerConnection() -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberVoiceAssistantTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberVoiceAssistantTest.kt index ffc7834..0aa58b8 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberVoiceAssistantTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberVoiceAssistantTest.kt @@ -57,7 +57,6 @@ class RememberVoiceAssistantTest : MockE2ETest() { } suspend fun agentJoinTest(body: @Composable () -> T, validate: suspend TurbineTestContext.() -> Unit) { - val testJob = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { body() } .distinctUntilChanged() @@ -169,4 +168,4 @@ class RememberVoiceAssistantTest : MockE2ETest() { } ) } -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptionsTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptionsTest.kt index 1549605..08b03ff 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptionsTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/transcriptions/RememberTranscriptionsTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.state.transcriptions import app.cash.molecule.RecompositionMode @@ -18,7 +34,6 @@ import org.junit.Test @OptIn(Beta::class) class RememberTranscriptionsTest : MockE2ETest() { - @Test fun textStreamUpdates() = runTest { connect() @@ -59,7 +74,6 @@ class RememberTranscriptionsTest : MockE2ETest() { subDataChannel.observer?.receiveTextStream(chunks = listOf("hello", " world", "!"), topic = DataTopic.TRANSCRIPTION.value) job.join() - } @Test @@ -69,7 +83,6 @@ class RememberTranscriptionsTest : MockE2ETest() { val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL) subPeerConnection.observer?.onDataChannel(subDataChannel) - val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberTranscriptions(room).value @@ -93,7 +106,6 @@ class RememberTranscriptionsTest : MockE2ETest() { // assertEquals("world", second[1].text) expectNoEvents() - } } } @@ -102,6 +114,5 @@ class RememberTranscriptionsTest : MockE2ETest() { subDataChannel.observer?.receiveTextStream(streamId = "streamId2", chunk = "world", topic = DataTopic.TRANSCRIPTION.value) job.join() - } -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/stream/RememberTextStreamTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/stream/RememberTextStreamTest.kt index d8a7203..fa4e824 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/stream/RememberTextStreamTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/stream/RememberTextStreamTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.stream import app.cash.molecule.RecompositionMode @@ -15,7 +31,6 @@ import org.junit.Test class RememberTextStreamTest : MockE2ETest() { - @Test fun textStreamUpdates() = runTest { connect() @@ -23,7 +38,6 @@ class RememberTextStreamTest : MockE2ETest() { val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL) subPeerConnection.observer?.onDataChannel(subDataChannel) - val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberTextStream("topic", room).value @@ -57,7 +71,6 @@ class RememberTextStreamTest : MockE2ETest() { subDataChannel.observer?.receiveTextStream(chunks = listOf("hello", " world", "!")) job.join() - } @Test @@ -67,7 +80,6 @@ class RememberTextStreamTest : MockE2ETest() { val subDataChannel = MockDataChannel(RTCEngine.RELIABLE_DATA_CHANNEL_LABEL) subPeerConnection.observer?.onDataChannel(subDataChannel) - val job = coroutineRule.scope.launch { moleculeFlow(RecompositionMode.Immediate) { rememberTextStream("topic", room).value @@ -89,7 +101,6 @@ class RememberTextStreamTest : MockE2ETest() { assertEquals(2, second.size) assertEquals("hello", second[0].text) assertEquals("world", second[1].text) - } } } @@ -98,7 +109,5 @@ class RememberTextStreamTest : MockE2ETest() { subDataChannel.observer?.receiveTextStream(streamId = "streamId2", chunk = "world") job.join() - } - -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/FakeRemoteParticipant.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/FakeRemoteParticipant.kt index f313567..6e2d8ea 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/FakeRemoteParticipant.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/FakeRemoteParticipant.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.test.util import io.livekit.android.room.SignalClient @@ -14,7 +30,6 @@ import livekit.org.webrtc.VideoTrack import org.mockito.Mockito fun createFakeRemoteParticipant(dispatcher: CoroutineDispatcher): RemoteParticipant { - return RemoteParticipant( info = TestData.REMOTE_PARTICIPANT, signalClient = Mockito.mock(SignalClient::class.java), @@ -33,7 +48,6 @@ fun createFakeRemoteParticipant(dispatcher: CoroutineDispatcher): RemoteParticip rtcThreadToken = MockRTCThreadToken() ) } - }, videoTrackFactory = object : RemoteVideoTrack.Factory { override fun create(name: String, rtcTrack: VideoTrack, autoManageVideo: Boolean, receiver: RtpReceiver): RemoteVideoTrack { @@ -50,4 +64,4 @@ fun createFakeRemoteParticipant(dispatcher: CoroutineDispatcher): RemoteParticip ).apply { updateFromInfo(TestData.REMOTE_PARTICIPANT) } -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/LocalContextExt.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/LocalContextExt.kt index fcd5208..df5f26d 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/LocalContextExt.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/LocalContextExt.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.test.util import android.content.Context @@ -62,4 +78,4 @@ inline fun withCompositionLocals( ): T { currentComposer.startProviders(values) return content().also { currentComposer.endProvider() } -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TextStreamTestUtils.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TextStreamTestUtils.kt index 8ec59c5..c70b3d3 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TextStreamTestUtils.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TextStreamTestUtils.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.test.util import com.google.protobuf.ByteString @@ -8,7 +24,6 @@ import livekit.LivekitModels.DataStream.TextHeader import livekit.org.webrtc.DataChannel import java.nio.ByteBuffer - fun DataPacket.wrap() = DataChannel.Buffer( ByteBuffer.wrap(this.toByteArray()), true, @@ -67,4 +82,4 @@ fun createStreamTrailer(id: String = "streamId") = with(DataPacket.newBuilder()) build() } build() -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt index 6fa6a1d..e8fa946 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.test.util import app.cash.turbine.TurbineTestContext @@ -32,4 +48,4 @@ suspend fun Flow.composeTest( delay(1) } ) -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt index 7073d96..5f47244 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/util/RememberStateOrDefaultTest.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.livekit.android.compose.util import androidx.compose.runtime.collectAsState @@ -18,7 +34,6 @@ class RememberStateOrDefaultTest : BaseTest() { @Test fun emitsTheSameState() = runTest { - val emitNull = mutableStateOf(false) val state = mutableStateOf(1) moleculeFlow(RecompositionMode.Immediate) { @@ -37,9 +52,9 @@ class RememberStateOrDefaultTest : BaseTest() { assertTrue(first === second) } } + @Test fun emitsDefaultWhenNull() = runTest { - val emitNull = mutableStateOf(false) val state = mutableStateOf(1) moleculeFlow(RecompositionMode.Immediate) { @@ -107,7 +122,6 @@ class RememberStateOrDefaultTest : BaseTest() { @Test fun handlesCollectAsState() = runTest { - val emitNull = mutableStateOf(true) val flow = MutableStateFlow(1) moleculeFlow(RecompositionMode.Immediate) { @@ -168,4 +182,4 @@ class RememberStateOrDefaultTest : BaseTest() { assertEquals(3, second) } } -} \ No newline at end of file +} From e0eabbdf85c1b6dde8b019a7f7662a8ca9ac3b90 Mon Sep 17 00:00:00 2001 From: davidliu Date: Sun, 16 Nov 2025 20:56:06 +0900 Subject: [PATCH 27/43] cleanup --- .../compose/util/RememberStateOrDefault.kt | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt index eee226f..dc95527 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/util/RememberStateOrDefault.kt @@ -18,29 +18,9 @@ package io.livekit.android.compose.util import androidx.compose.runtime.Composable import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.flow.map - -/** - * A utility state that either collects from the state provided from [block], - * or emits the [default] value if null. - * - * The returned state will always refer to the same [State] object. - */ -@Composable -internal inline fun oldrememberStateOrDefault(default: T, block: @Composable () -> State?): State { - val state = block() - - val flow = snapshotFlow { state?.value } - println("hashcode = " + flow.hashCode()) - return snapshotFlow { state?.value } - .map { value -> value ?: default } - .collectAsState(state?.value ?: default) -} /** * A utility state that either collects from the state provided from [block], From 6667d522a64865cfb23f197fdea892282a04cda1 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 17 Nov 2025 01:46:55 +0900 Subject: [PATCH 28/43] fix hasConnected in rememberLiveKitRoom --- .../src/main/java/io/livekit/android/compose/local/RoomLocal.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt index 1b58123..dfd3034 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/local/RoomLocal.kt @@ -204,7 +204,7 @@ fun rememberLiveKitRoom( HandleRoomState(Room.State.DISCONNECTED, room) { _, _ -> onDisconnected?.invoke(this, room) } - var hasConnected by remember { + var hasConnected by remember(room) { mutableStateOf(false) } LaunchedEffect(room, connect, url, token, connectOptions) { From 4ab690a5ba2f875764adb8ad04634297efd0321f Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 17 Nov 2025 01:47:50 +0900 Subject: [PATCH 29/43] Agent timeout --- .../android/compose/state/RememberAgent.kt | 59 +++++---- .../android/compose/state/RememberSession.kt | 124 +++++++++++++----- .../compose/state/RememberTrackReferences.kt | 1 + .../compose/state/RememberVoiceAssistant.kt | 1 + 4 files changed, 129 insertions(+), 56 deletions(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt index 4a35460..9b0eecc 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.SnapshotStateList @@ -37,10 +38,6 @@ import io.livekit.android.room.types.AgentAttributes import io.livekit.android.util.flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.takeWhile -import kotlin.time.Duration.Companion.seconds - -// TODO: make this 10 seconds once room dispatch booting info is discoverable -private val DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS = 20.seconds interface Agent { val agentParticipant: RemoteParticipant? @@ -125,6 +122,7 @@ internal class AgentImpl( fun rememberAgent(session: Session? = null): Agent { val session = requireSession(session) val room = session.room + val connectionState by session::connectionState // Gather participant info val remoteParticipants by room::remoteParticipants.flow.collectAsState() @@ -136,9 +134,9 @@ fun rememberAgent(session: Session? = null): Agent { } } - val curAgentParticipant = agentParticipantState.value // For nullability checks val workerParticipantState = remember { derivedStateOf { + val curAgentParticipant = agentParticipantState.value if (curAgentParticipant == null) { return@derivedStateOf null } @@ -147,10 +145,10 @@ fun rememberAgent(session: Session? = null): Agent { .firstOrNull { p -> p.isAgent } } } - val curWorkerParticipant = workerParticipantState.value // For nullability checks // Track handling val agentTracks by rememberStateOrDefault(emptyList()) { + val curAgentParticipant = agentParticipantState.value if (curAgentParticipant != null) { rememberParticipantTrackReferences( sources = listOf(Track.Source.MICROPHONE, Track.Source.CAMERA), @@ -163,6 +161,7 @@ fun rememberAgent(session: Session? = null): Agent { } val workerTracks by rememberStateOrDefault(emptyList()) { + val curWorkerParticipant = workerParticipantState.value if (curWorkerParticipant != null) { rememberParticipantTrackReferences( sources = listOf(Track.Source.MICROPHONE, Track.Source.CAMERA), @@ -188,50 +187,63 @@ fun rememberAgent(session: Session? = null): Agent { } } - val localMicTrack by rememberParticipantTrackReferences( + val localMicTracks by rememberParticipantTrackReferences( sources = listOf(Track.Source.MICROPHONE), passedParticipant = room.localParticipant, ) // Attributes and states - val agentState by rememberAgentState(participant = curAgentParticipant) - val connectionState = session.connectionState + val agentState by rememberAgentState(participant = agentParticipantState.value) + val isAvailableState = remember { + derivedStateOf { + calculateIsAvailable(agentState) + } + } + val hasAgentConnectedOnce by produceState(false, isAvailableState.value, connectionState) { + if (connectionState == ConnectionState.DISCONNECTED) { + value = false + } else { + value = value || isAvailableState.value + } + } val combinedAgentState = remember { derivedStateOf { - var state = AgentState.DISCONNECTED - if (connectionState != ConnectionState.DISCONNECTED) { - state = AgentState.CONNECTING + if (connectionState == ConnectionState.DISCONNECTED) { + return@derivedStateOf AgentState.DISCONNECTED } - if (localMicTrack.isNotEmpty()) { + if (session.agentFailure != null) { + return@derivedStateOf AgentState.FAILED + } + var state = AgentState.CONNECTING + + if (localMicTracks.isNotEmpty()) { state = AgentState.LISTENING } val agentParticipant = agentParticipantState.value if (agentParticipant != null) { state = agentState + } else if (hasAgentConnectedOnce) { + // means agent disconnected mid session. + state = AgentState.DISCONNECTED } return@derivedStateOf state } } - val isAvailable = remember { - derivedStateOf { - calculateIsAvailable(agentState) - } - } - val isBufferingSpeech = remember { derivedStateOf { !(connectionState == ConnectionState.DISCONNECTED || - isAvailable.value || - localMicTrack.isNotEmpty()) + isAvailableState.value || + localMicTracks.isNotEmpty()) } } val attributesState = rememberStateOrDefault(null) { + val curAgentParticipant = agentParticipantState.value if (curAgentParticipant != null) { curAgentParticipant::agentAttributes.flow .collectAsState() @@ -277,7 +289,7 @@ fun rememberAgent(session: Session? = null): Agent { audioTrackState = audioTrackState, videoTrackState = videoTrackState, agentStateState = combinedAgentState, - isAvailableState = isAvailable, + isAvailableState = isAvailableState, isBufferingSpeechState = isBufferingSpeech, failureReasons = failureReasons, waitUntilAvailableFn = waitUntilAvailableFn, @@ -291,7 +303,7 @@ fun rememberAgent(session: Session? = null): Agent { return agent.value } -private fun calculateIsAvailable(agentState: AgentState): Boolean { +internal fun calculateIsAvailable(agentState: AgentState): Boolean { return when (agentState) { AgentState.IDLE, AgentState.LISTENING, @@ -301,6 +313,7 @@ private fun calculateIsAvailable(agentState: AgentState): Boolean { AgentState.CONNECTING, AgentState.INITIALIZING, AgentState.DISCONNECTED, + AgentState.FAILED, AgentState.UNKNOWN -> false } } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt index 5ad6c0c..72924f5 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSession.kt @@ -21,26 +21,37 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import io.livekit.android.ConnectOptions import io.livekit.android.annotations.Beta import io.livekit.android.compose.local.rememberLiveKitRoom +import io.livekit.android.compose.types.AgentFailure import io.livekit.android.room.ConnectionState import io.livekit.android.room.Room import io.livekit.android.room.participant.AudioTrackPublishOptions +import io.livekit.android.room.participant.isAgent import io.livekit.android.room.track.LocalAudioTrackOptions import io.livekit.android.token.ConfigurableTokenSource import io.livekit.android.token.FixedTokenSource import io.livekit.android.token.TokenRequestOptions import io.livekit.android.token.TokenSource import io.livekit.android.util.flow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds data class SessionOptions( /** @@ -52,7 +63,8 @@ data class SessionOptions( * Amount of time to wait for an agent to join the room, before transitioning * to the failure state. */ - val agentConnectTimeout: Duration? = null, + // TODO: make this 10 seconds once room dispatch booting info is discoverable + val agentConnectTimeout: Duration = 20.seconds, /** * The options to use when fetching the token, if it is fetching from @@ -60,7 +72,7 @@ data class SessionOptions( * * These options will be ignored for a [FixedTokenSource]. */ - val tokenRequestOptions: TokenRequestOptions? = null + val tokenRequestOptions: TokenRequestOptions = TokenRequestOptions() ) data class SessionConnectOptions( @@ -82,59 +94,62 @@ data class SessionConnectTrackOptions( val microphonePublishOptions: AudioTrackPublishOptions = AudioTrackPublishOptions(), ) -interface Session { +abstract class Session { /** The [Room] object used for this session. */ - val room: Room + abstract val room: Room /** The [ConnectionState] of the session. */ - val connectionState: ConnectionState + abstract val connectionState: ConnectionState /** Whether the session is connected or not. */ - val isConnected: Boolean + abstract val isConnected: Boolean /** Whether the session is reconnecting or not. */ - val isReconnecting: Boolean + abstract val isReconnecting: Boolean /** * A function that suspends until the session is connected. */ - suspend fun waitUntilConnected() + abstract suspend fun waitUntilConnected() /** * A function that suspends until the session is disconnected. */ - suspend fun waitUntilDisconnected() + abstract suspend fun waitUntilDisconnected() /** * Prepares the connection to speed up initial connection time. * * @see Room.prepareConnection */ - suspend fun prepareConnection() + abstract suspend fun prepareConnection() /** * Connect to the session. */ @CheckResult - suspend fun start(options: SessionConnectOptions = SessionConnectOptions()): Result + abstract suspend fun start(options: SessionConnectOptions = SessionConnectOptions()): Result /** * Disconnect from the session. */ - fun end() + abstract fun end() + + internal abstract val agentFailure: AgentFailure? } @Stable internal class SessionImpl( override val room: Room, - val connectionStateState: State, - val waitUntilConnectedFn: suspend () -> Unit, - val waitUntilDisconnectedFn: suspend () -> Unit, - val prepareConnectionFn: suspend () -> Unit, - val startFn: suspend (options: SessionConnectOptions) -> Result, - val endFn: () -> Unit -) : Session { + connectionStateState: State, + agentFailureState: State, + private val waitUntilConnectedFn: suspend () -> Unit, + private val waitUntilDisconnectedFn: suspend () -> Unit, + private val prepareConnectionFn: suspend () -> Unit, + private val startFn: suspend (options: SessionConnectOptions) -> Result, + private val endFn: () -> Unit, +) : Session() { override val connectionState by connectionStateState override val isConnected by derivedStateOf { @@ -178,22 +193,30 @@ internal class SessionImpl( override fun end() { endFn() } + + override val agentFailure: AgentFailure? by agentFailureState } +@OptIn(ExperimentalCoroutinesApi::class) @Beta @Composable fun rememberSession(tokenSource: TokenSource, options: SessionOptions = SessionOptions()): Session { val room = rememberLiveKitRoom(passedRoom = options.room, connect = false) - val connectionState = room::state.flow - .map { state -> - when (state) { - Room.State.CONNECTING -> ConnectionState.CONNECTING - Room.State.CONNECTED -> ConnectionState.CONNECTED - Room.State.DISCONNECTED -> ConnectionState.DISCONNECTED - Room.State.RECONNECTING -> ConnectionState.RECONNECTING + val connectionState = produceState(ConnectionState.DISCONNECTED, room) { + room::state.flow + .map { state -> + when (state) { + Room.State.CONNECTING -> ConnectionState.CONNECTING + Room.State.CONNECTED -> ConnectionState.CONNECTED + Room.State.DISCONNECTED -> ConnectionState.DISCONNECTED + Room.State.RECONNECTING -> ConnectionState.RECONNECTING + } } - } - .collectAsState(ConnectionState.DISCONNECTED) + .collect { + println("emitting connstate: $it") + value = it + } + } val waitUntilConnected = remember(room) { suspend { @@ -211,15 +234,18 @@ fun rememberSession(tokenSource: TokenSource, options: SessionOptions = SessionO } } - val tokenSourceFetch = remember(tokenSource, options.tokenRequestOptions) { + val tokenSource by rememberUpdatedState(tokenSource) + val tokenRequestOptions by rememberUpdatedState(options.tokenRequestOptions) + val tokenSourceFetch = remember { suspend fetch@{ - return@fetch when (tokenSource) { + val source = tokenSource + return@fetch when (source) { is FixedTokenSource -> { - tokenSource.fetch() + source.fetch() } is ConfigurableTokenSource -> { - tokenSource.fetch(options.tokenRequestOptions ?: TokenRequestOptions()) + source.fetch(tokenRequestOptions) } else -> { @@ -229,6 +255,36 @@ fun rememberSession(tokenSource: TokenSource, options: SessionOptions = SessionO } } + val agentTimeoutDuration by rememberUpdatedState(options.agentConnectTimeout) + val isSessionDisconnected by rememberUpdatedState(connectionState.value == ConnectionState.DISCONNECTED) + val agentFailureState = produceState(null, isSessionDisconnected) { + value = null + if (isSessionDisconnected) { + return@produceState + } + + val participant = withContext(Dispatchers.IO) { + withTimeoutOrNull(agentTimeoutDuration) { + // Take until we get an agent participant. + room::remoteParticipants.flow + .map { it -> it.values } + .map { remoteParticipants -> + remoteParticipants + .filter { p -> p.agentAttributes.lkPublishOnBehalf == null } + .firstOrNull { p -> p.isAgent } + } + .mapNotNull { it } + .first() + } + } + + ensureActive() + value = if (participant != null) { + null + } else { + AgentFailure.TIMEOUT + } + } val start = remember(room, waitUntilDisconnected, waitUntilConnected, tokenSourceFetch) { val startImpl: suspend (SessionConnectOptions) -> Result = { sessionConnectOptions -> @@ -269,6 +325,7 @@ fun rememberSession(tokenSource: TokenSource, options: SessionOptions = SessionO startImpl } + val end = remember(room) { { room.disconnect() @@ -299,6 +356,7 @@ fun rememberSession(tokenSource: TokenSource, options: SessionOptions = SessionO prepareConnectionFn = prepareConnection, startFn = start, endFn = end, + agentFailureState = agentFailureState ) } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt index b9a2511..f457262 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberTrackReferences.kt @@ -57,6 +57,7 @@ fun rememberTracks( ): State> { val room = requireRoom(passedRoom) + // TODO: check for flow operator correctness return trackReferencesFlow( room = room, sources = sources, diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt index 4dc1a71..1e6f0b8 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberVoiceAssistant.kt @@ -150,6 +150,7 @@ enum class AgentState { LISTENING, THINKING, SPEAKING, + FAILED, UNKNOWN; companion object { From fac0d219ad8fe8309019d3042c4dc3eacbc52ee5 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 17 Nov 2025 01:48:10 +0900 Subject: [PATCH 30/43] agent state testing --- .../compose/state/RememberAgentTest.kt | 124 +++++++++++++++++- .../android/compose/test/util/TurbineExt.kt | 9 +- 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt index e8f2737..a0d00bc 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.launch import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds @OptIn(Beta::class) class RememberAgentTest : MockE2ETest() { @@ -67,6 +68,50 @@ class RememberAgentTest : MockE2ETest() { job.join() } + @Test + fun agentStateWithoutPreconnect() = runTest { + val job = launch { + moleculeFlow(RecompositionMode.Immediate) { + val session = rememberSession( + tokenSource = TokenSource.fromLiteral(TestData.EXAMPLE_URL, "token"), + options = SessionOptions( + room = room, + ) + ) + + val agent = rememberAgent(session) + + LaunchedEffect(Unit) { + val startResult = session.start( + options = SessionConnectOptions( + tracks = SessionConnectTrackOptions( + microphoneEnabled = false, + usePreconnectBuffer = false, + ) + ) + ) + assertTrue(startResult.isSuccess) + + val agentJoin = TestData.AGENT_JOIN + simulateMessageFromServer(agentJoin) + + session.end() + } + agent.agentState + } + .distinctUntilChanged() + .composeTest { + assertEquals(AgentState.DISCONNECTED, awaitItem()) + assertEquals(AgentState.CONNECTING, awaitItem()) + assertEquals(AgentState.LISTENING, awaitItem()) + assertEquals(AgentState.DISCONNECTED, awaitItem()) + } + } + + sessionConnect() + + job.join() + } @Test fun agentStateWithPreconnect() = runTest { val job = launch { @@ -74,7 +119,7 @@ class RememberAgentTest : MockE2ETest() { val session = rememberSession( tokenSource = TokenSource.fromLiteral(TestData.EXAMPLE_URL, "token"), options = SessionOptions( - room = room + room = room, ) ) @@ -93,8 +138,85 @@ class RememberAgentTest : MockE2ETest() { .distinctUntilChanged() .composeTest { assertEquals(AgentState.DISCONNECTED, awaitItem()) + assertEquals(AgentState.CONNECTING, awaitItem()) + assertEquals(AgentState.LISTENING, awaitItem()) + assertEquals(AgentState.DISCONNECTED, awaitItem()) + } + } + + sessionConnect() + + job.join() + } + + + @Test + fun agentTimeoutWithPreconnect() = runTest { + val job = launch { + moleculeFlow(RecompositionMode.Immediate) { + val session = rememberSession( + tokenSource = TokenSource.fromLiteral(TestData.EXAMPLE_URL, "token"), + options = SessionOptions( + room = room, + agentConnectTimeout = 100.milliseconds + ) + ) + + val agent = rememberAgent(session) + + LaunchedEffect(Unit) { + val startResult = session.start() + assertTrue(startResult.isSuccess) + } + agent.agentState + } + .distinctUntilChanged() + .composeTest { + assertEquals(AgentState.DISCONNECTED, awaitItem()) + assertEquals(AgentState.CONNECTING, awaitItem()) assertEquals(AgentState.LISTENING, awaitItem()) + assertEquals(AgentState.FAILED, awaitItem()) + } + } + + sessionConnect() + + job.join() + } + + + @Test + fun agentTimeoutWithoutPreconnect() = runTest { + val job = launch { + moleculeFlow(RecompositionMode.Immediate) { + val session = rememberSession( + tokenSource = TokenSource.fromLiteral(TestData.EXAMPLE_URL, "token"), + options = SessionOptions( + room = room, + agentConnectTimeout = 100.milliseconds + ) + ) + + val agent = rememberAgent(session) + + LaunchedEffect(Unit) { + val startResult = session.start( + options = SessionConnectOptions( + tracks = SessionConnectTrackOptions( + microphoneEnabled = false, + usePreconnectBuffer = false, + ) + ) + ) + assertTrue(startResult.isSuccess) + } + agent.agentState + } + .distinctUntilChanged() + .composeTest { assertEquals(AgentState.DISCONNECTED, awaitItem()) + assertEquals(AgentState.CONNECTING, awaitItem()) + assertEquals(AgentState.FAILED, awaitItem()) } } diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt index e8fa946..e76fece 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/test/util/TurbineExt.kt @@ -17,17 +17,16 @@ package io.livekit.android.compose.test.util import app.cash.turbine.TurbineTestContext -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.yield import kotlin.time.Duration import app.cash.turbine.test as turbineTest /** * Due to the use of [kotlinx.coroutines.test.UnconfinedTestDispatcher], - * Turbine fails to validate unconsumed events. This adds a small delay - * at the end of the validation block to release the coroutine to finish - * any extra work. + * Turbine fails to validate unconsumed events. This yields at the end of + * the validation block to release the coroutine to finish any extra work. */ suspend fun Flow.composeTest( distinctUntilChanged: Boolean = true, @@ -45,7 +44,7 @@ suspend fun Flow.composeTest( name, { validate() - delay(1) + yield() } ) } From 34307b1ae56fa450a4b53970d5124ed9243d3e48 Mon Sep 17 00:00:00 2001 From: davidliu Date: Mon, 17 Nov 2025 01:52:35 +0900 Subject: [PATCH 31/43] more agent timeout stuff --- .../io/livekit/android/compose/types/Agent.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 livekit-compose-components/src/main/java/io/livekit/android/compose/types/Agent.kt diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/Agent.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/Agent.kt new file mode 100644 index 0000000..b266acb --- /dev/null +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/Agent.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 LiveKit, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.livekit.android.compose.types + +enum class AgentFailure(val reason: String) { + TIMEOUT("Agent did not connect within the timeout duration.") +} \ No newline at end of file From 6b7cf553182a31394299d1e4d9f3f8d663afc946 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Nov 2025 15:31:53 +0900 Subject: [PATCH 32/43] spotless --- .../java/io/livekit/android/compose/state/RememberAgent.kt | 4 ++-- .../src/main/java/io/livekit/android/compose/types/Agent.kt | 2 +- .../io/livekit/android/compose/state/RememberAgentTest.kt | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt index 9b0eecc..eb44e9b 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt @@ -237,8 +237,8 @@ fun rememberAgent(session: Session? = null): Agent { val isBufferingSpeech = remember { derivedStateOf { !(connectionState == ConnectionState.DISCONNECTED || - isAvailableState.value || - localMicTracks.isNotEmpty()) + isAvailableState.value || + localMicTracks.isNotEmpty()) } } diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/Agent.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/Agent.kt index b266acb..eed1384 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/Agent.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/Agent.kt @@ -18,4 +18,4 @@ package io.livekit.android.compose.types enum class AgentFailure(val reason: String) { TIMEOUT("Agent did not connect within the timeout duration.") -} \ No newline at end of file +} diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt index a0d00bc..8490dd9 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/state/RememberAgentTest.kt @@ -112,6 +112,7 @@ class RememberAgentTest : MockE2ETest() { job.join() } + @Test fun agentStateWithPreconnect() = runTest { val job = launch { @@ -149,7 +150,6 @@ class RememberAgentTest : MockE2ETest() { job.join() } - @Test fun agentTimeoutWithPreconnect() = runTest { val job = launch { @@ -184,7 +184,6 @@ class RememberAgentTest : MockE2ETest() { job.join() } - @Test fun agentTimeoutWithoutPreconnect() = runTest { val job = launch { From e5b6e5cd6b9e796a86740cd2404e4550bec7ccf2 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Nov 2025 15:34:55 +0900 Subject: [PATCH 33/43] make agent and local media abstract classes instead of interfaces --- .../android/compose/state/RememberAgent.kt | 31 ++++++------- .../android/compose/types/LocalMedia.kt | 44 +++++++++---------- 2 files changed, 36 insertions(+), 39 deletions(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt index eb44e9b..0f9ef5d 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberAgent.kt @@ -39,31 +39,28 @@ import io.livekit.android.util.flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.takeWhile -interface Agent { - val agentParticipant: RemoteParticipant? +abstract class Agent { + abstract val agentParticipant: RemoteParticipant? - /** - * @suppress - */ - val workerParticipant: RemoteParticipant? + internal abstract val workerParticipant: RemoteParticipant? - val attributes: AgentAttributes? + abstract val attributes: AgentAttributes? - val failureReasons: List + abstract val failureReasons: List - val agentState: AgentState + abstract val agentState: AgentState - val audioTrack: TrackReference? + abstract val audioTrack: TrackReference? - val videoTrack: TrackReference? + abstract val videoTrack: TrackReference? - val isAvailable: Boolean + abstract val isAvailable: Boolean - val isBufferingSpeech: Boolean + abstract val isBufferingSpeech: Boolean - suspend fun waitUntilAvailable() - suspend fun waitUntilCamera() - suspend fun waitUntilMicrophone() + abstract suspend fun waitUntilAvailable() + abstract suspend fun waitUntilCamera() + abstract suspend fun waitUntilMicrophone() } @Stable @@ -80,7 +77,7 @@ internal class AgentImpl( private val waitUntilAvailableFn: suspend () -> Unit, private val waitUntilCameraFn: suspend () -> Unit, private val waitUntilMicrophoneFn: suspend () -> Unit, -) : Agent { +) : Agent() { override val agentParticipant by agentParticipantState override val workerParticipant by workerParticipantState diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt index 8483e4a..1d248cd 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/types/LocalMedia.kt @@ -21,30 +21,30 @@ import com.twilio.audioswitch.AudioDevice import io.livekit.android.room.track.screencapture.ScreenCaptureParams import livekit.org.webrtc.CameraEnumerator -interface LocalMedia { - val microphoneTrack: TrackReference? - val cameraTrack: TrackReference? - val screenShareTrack: TrackReference? +abstract class LocalMedia { + abstract val microphoneTrack: TrackReference? + abstract val cameraTrack: TrackReference? + abstract val screenShareTrack: TrackReference? - val isMicrophoneEnabled: Boolean - val isCameraEnabled: Boolean - val isScreenShareEnabled: Boolean + abstract val isMicrophoneEnabled: Boolean + abstract val isCameraEnabled: Boolean + abstract val isScreenShareEnabled: Boolean - val audioDevices: List - val cameraDevices: SnapshotStateList - val selectedAudioDevice: AudioDevice? - val selectedCameraId: String? - val canSwitchPosition: Boolean + abstract val audioDevices: List + abstract val cameraDevices: SnapshotStateList + abstract val selectedAudioDevice: AudioDevice? + abstract val selectedCameraId: String? + abstract val canSwitchPosition: Boolean - val cameraEnumerator: CameraEnumerator + abstract val cameraEnumerator: CameraEnumerator - suspend fun startMicrophone() - suspend fun stopMicrophone() - suspend fun startCamera() - suspend fun stopCamera() - suspend fun startScreenShare(params: ScreenCaptureParams) - suspend fun stopScreenShare() - fun selectAudioDevice(audioDevice: AudioDevice) - fun selectCamera(deviceName: String) - fun switchCamera() + abstract suspend fun startMicrophone() + abstract suspend fun stopMicrophone() + abstract suspend fun startCamera() + abstract suspend fun stopCamera() + abstract suspend fun startScreenShare(params: ScreenCaptureParams) + abstract suspend fun stopScreenShare() + abstract fun selectAudioDevice(audioDevice: AudioDevice) + abstract fun selectCamera(deviceName: String) + abstract fun switchCamera() } From 31b718baf007e009a236cbceaf6a5bfd4ed6490f Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Nov 2025 15:52:16 +0900 Subject: [PATCH 34/43] forgot commit --- .../java/io/livekit/android/compose/state/RememberLocalMedia.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt index a294996..2f56626 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberLocalMedia.kt @@ -56,7 +56,7 @@ internal class LocalMediaImpl( private val selectCameraFn: (String) -> Unit?, private val switchCameraFn: () -> Unit?, override val cameraEnumerator: CameraEnumerator, -) : LocalMedia { +) : LocalMedia() { override val microphoneTrack by microphoneTrackState override val cameraTrack by cameraTrackState override val screenShareTrack by screenShareTrackState From a0bd274a9bc6beeb0b2b60fbfa52715d01418984 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Nov 2025 15:58:45 +0900 Subject: [PATCH 35/43] Update livekit sdk version to 2.23.0 --- livekit-compose-components/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livekit-compose-components/build.gradle b/livekit-compose-components/build.gradle index cb5040a..994957a 100644 --- a/livekit-compose-components/build.gradle +++ b/livekit-compose-components/build.gradle @@ -86,7 +86,7 @@ dokkaHtml { } } -var livekitVersion = "2.18.3" +var livekitVersion = "2.23.0" dependencies { // For local development with the LiveKit Android SDK only. // api "io.livekit:livekit-android-sdk" From 9ecb9115d454fdb0c012978ddf78a509c4ed517d Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Nov 2025 16:05:27 +0900 Subject: [PATCH 36/43] properly set ignoreLegacy on messages sent through chat --- .../src/main/java/io/livekit/android/compose/chat/Chat.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt index 730b0cc..9014417 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/chat/Chat.kt @@ -113,6 +113,7 @@ class Chat( id = UUID.randomUUID().toString(), timestamp = timestamp, message = message, + ignoreLegacy = serverSupportsDataStreams(), ) val encodedMessage = Json.encodeToString(chatMessage).toByteArray(Charsets.UTF_8) From d7a99382d5b907a665a1af2e5b05bf88e4341bf2 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Nov 2025 16:12:33 +0900 Subject: [PATCH 37/43] IgnoreLegacy test --- .../livekit/android/compose/chat/ChatTest.kt | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/livekit-compose-components/src/test/java/io/livekit/android/compose/chat/ChatTest.kt b/livekit-compose-components/src/test/java/io/livekit/android/compose/chat/ChatTest.kt index ba9202e..81dcc08 100644 --- a/livekit-compose-components/src/test/java/io/livekit/android/compose/chat/ChatTest.kt +++ b/livekit-compose-components/src/test/java/io/livekit/android/compose/chat/ChatTest.kt @@ -28,6 +28,7 @@ import io.livekit.android.room.RTCEngine import io.livekit.android.test.MockE2ETest import io.livekit.android.test.mock.MockDataChannel import io.livekit.android.test.mock.MockPeerConnection +import io.livekit.android.test.mock.TestData import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.serialization.decodeFromString @@ -45,7 +46,15 @@ import java.nio.ByteBuffer class ChatTest : MockE2ETest() { @Test fun sendMessage() = runTest { - connect() + connect( + joinResponse = with(TestData.JOIN.toBuilder()) { + join = with(join.toBuilder()) { + serverVersion = "1.9.0" + build() + } + build() + } + ) val messageString = "message" moleculeFlow(RecompositionMode.Immediate) { @@ -83,6 +92,51 @@ class ChatTest : MockE2ETest() { } assertEquals(messageString, chatMessage.message) + assertEquals(true, chatMessage.ignoreLegacy) + } + } + } + + @Test + fun whenSendingToLegacyServerIgnoreLegacyIsFalse() = runTest { + connect( + joinResponse = with(TestData.JOIN.toBuilder()) { + join = with(join.toBuilder()) { + serverVersion = "1.8.0" + build() + } + build() + } + ) + + val messageString = "message" + moleculeFlow(RecompositionMode.Immediate) { + rememberChat(room) + }.composeTest { + val chat = awaitItem() + assertNotNull(chat) + + val result = chat.send(messageString) + assertTrue(result.isSuccess) + val pubPeerConnection = component.rtcEngine().getPublisherPeerConnection() as MockPeerConnection + val dataChannel = pubPeerConnection.dataChannels[RTCEngine.RELIABLE_DATA_CHANNEL_LABEL] as MockDataChannel + + assertEquals(4, dataChannel.sentBuffers.size) + + // Legacy chat send + run { + val data = dataChannel.sentBuffers[3].data + val dataPacket = LivekitModels.DataPacket.parseFrom(ByteString.copyFrom(data)) + val chatMessage = dataPacket.user.payload!! + .toByteArray() + .decodeToString() + .run { + val json = Json { ignoreUnknownKeys = true } + json.decodeFromString(this) + } + + assertEquals(messageString, chatMessage.message) + assertEquals(false, chatMessage.ignoreLegacy) } } } From 8243a595728528a46cbb1e9061d534df586bb5b6 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Nov 2025 16:15:09 +0900 Subject: [PATCH 38/43] Convert SessionMessages to abstract class --- .../compose/state/RememberSessionMessages.kt | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt index 58712b5..a712080 100644 --- a/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt +++ b/livekit-compose-components/src/main/java/io/livekit/android/compose/state/RememberSessionMessages.kt @@ -23,9 +23,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import io.livekit.android.annotations.Beta import io.livekit.android.compose.chat.rememberChat @@ -38,30 +36,29 @@ import io.livekit.android.compose.types.ReceivedUserTranscriptionMessage import io.livekit.android.room.datastream.StreamTextOptions import kotlinx.coroutines.launch -interface SessionMessages { +abstract class SessionMessages { /** * The log of all messages sent and received. */ - val messages: List + abstract val messages: List - /** - * A hot flow emitting a [ReceivedMessage] for each individual message sent and received. - */ - // TODO val messagesFlow: Flow +// /** +// * A hot flow emitting a [ReceivedMessage] for each individual message sent and received. +// */ +// val messagesFlow: Flow - val isSending: Boolean + abstract val isSending: Boolean - suspend fun send(message: String, options: StreamTextOptions = StreamTextOptions()): Result + abstract suspend fun send(message: String, options: StreamTextOptions = StreamTextOptions()): Result } internal class SessionMessagesImpl( - private val messagesState: State>, + messagesState: State>, + isSendingState: State, val sendImpl: suspend (message: String, options: StreamTextOptions) -> Result -) : SessionMessages { - override val messages - get() = messagesState.value - override var isSending by mutableStateOf(false) // TOD - internal set +) : SessionMessages() { + override val messages by messagesState + override val isSending by isSendingState override suspend fun send( message: String, @@ -80,6 +77,7 @@ fun rememberSessionMessages(session: Session? = null): SessionMessages { val transcriptions by rememberTranscriptions(room) val chat = rememberChat(room = room) + val isSendingState = chat.isSending val transcriptionMessages by remember(room) { derivedStateOf { transcriptions.map { transcription -> @@ -156,6 +154,7 @@ fun rememberSessionMessages(session: Session? = null): SessionMessages { val sessionMessages = remember(chat) { SessionMessagesImpl( messagesState = receivedMessages, + isSendingState = isSendingState, { message, options -> chat.send(message, options) } From 20a8f358946d6dc0cf7d72791ee95282d6625889 Mon Sep 17 00:00:00 2001 From: davidliu Date: Tue, 18 Nov 2025 17:06:59 +0900 Subject: [PATCH 39/43] document methods --- .idea/inspectionProfiles/Project_Default.xml | 4 + .../android/compose/state/RememberAgent.kt | 57 ++++++++--- .../compose/state/RememberLocalMedia.kt | 20 ++-- .../android/compose/state/RememberSession.kt | 16 +++- .../compose/state/RememberSessionMessages.kt | 18 +++- .../compose/state/RememberVoiceAssistant.kt | 1 + .../android/compose/types/LocalMedia.kt | 96 ++++++++++++++++++- .../compose/state/RememberAgentTest.kt | 3 +- .../compose/state/RememberLocalMediaTest.kt | 2 + 9 files changed, 181 insertions(+), 36 deletions(-) diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 290fa49..6d87616 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -2,6 +2,7 @@