diff --git a/.changeset/late-lamps-greet.md b/.changeset/late-lamps-greet.md
new file mode 100644
index 000000000..67ce31650
--- /dev/null
+++ b/.changeset/late-lamps-greet.md
@@ -0,0 +1,5 @@
+---
+"client-sdk-android": minor
+---
+
+Add support for generic RequestResponse
diff --git a/.changeset/polite-goats-refuse.md b/.changeset/polite-goats-refuse.md
new file mode 100644
index 000000000..5dc93133e
--- /dev/null
+++ b/.changeset/polite-goats-refuse.md
@@ -0,0 +1,15 @@
+---
+"client-sdk-android": minor
+---
+
+Add the following suspend methods to LocalParticipant:
+
+- `setName`
+- `setMetadata`
+- `setAttributes`
+
+These replace the following deprecated methods:
+
+- `updateName`
+- `updateMetadata`
+- `updateAttributes`
diff --git a/livekit-android-sdk/detekt-baseline-release.xml b/livekit-android-sdk/detekt-baseline-release.xml
index 418e8ea50..799319cca 100644
--- a/livekit-android-sdk/detekt-baseline-release.xml
+++ b/livekit-android-sdk/detekt-baseline-release.xml
@@ -3,6 +3,7 @@
ComplexCondition:LocalParticipant.kt$LocalParticipant$(originalEncoding == null && !simulcast) || width == 0 || height == 0
+ ComplexCondition:LocalParticipant.kt$LocalParticipant$attributes != null && !attributes.all { (key, value) -> val current = this.attributes[key] current == value || (value.isEmpty() && current.isNullOrEmpty()) }
ComplexCondition:PeerConnectionTransport.kt$PeerConnectionTransport$sd.type == SessionDescription.Type.ANSWER && currentOfferId > 0 && offerId > 0 && currentOfferId > offerId
ComplexCondition:PreconnectAudioBuffer.kt$sentIdentities.contains(identity) || kind != Participant.Kind.AGENT || state != Participant.State.ACTIVE || identity == null
ComplexCondition:RTCEngine.kt$RTCEngine$(connectionState == ConnectionState.CONNECTED || connectionState == ConnectionState.RESUMING) && subscriberConnected && publisherConnected
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt
index b7fe11b86..b90d797d6 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/ConnectOptions.kt
@@ -53,7 +53,7 @@ data class ConnectOptions(
/**
* the protocol version to use with the server.
*/
- val protocolVersion: ProtocolVersion = ProtocolVersion.v13,
+ val protocolVersion: ProtocolVersion = ProtocolVersion.v15,
/**
* The client protocol version to advertise to other participants in the room
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt
index dee0adab0..05f59992c 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt
@@ -1037,6 +1037,7 @@ internal constructor(
fun onLocalTrackUnpublished(trackUnpublished: LivekitRtc.TrackUnpublishedResponse)
fun onTranscriptionReceived(transcription: LivekitModels.Transcription)
fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed)
+ fun onRequestResponse(response: LivekitRtc.RequestResponse)
fun onRpcPacketReceived(dp: LivekitModels.DataPacket)
fun onDataStreamPacket(dp: LivekitModels.DataPacket, encryptionType: LivekitModels.Encryption.Type)
}
@@ -1280,6 +1281,10 @@ internal constructor(
listener?.onLocalTrackUnpublished(trackUnpublished)
}
+ override fun onRequestResponse(response: LivekitRtc.RequestResponse) {
+ listener?.onRequestResponse(response)
+ }
+
// --------------------------------- DataChannel.Observer ------------------------------------//
fun onBufferedAmountChange(dataChannel: DataChannel, previousAmount: Long) {
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt
index 9d5c71bf3..afc4676b0 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt
@@ -1422,6 +1422,13 @@ constructor(
localParticipant.handleSubscribedQualityUpdate(subscribedQualityUpdate)
}
+ /**
+ * @suppress
+ */
+ override fun onRequestResponse(response: LivekitRtc.RequestResponse) {
+ localParticipant.handleRequestResponse(response)
+ }
+
/**
* @suppress
*/
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt
index 5951becc6..5b7276f77 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/SignalClient.kt
@@ -60,6 +60,7 @@ import okhttp3.WebSocketListener
import okio.ByteString
import okio.ByteString.Companion.toByteString
import java.util.Date
+import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@@ -97,6 +98,7 @@ constructor(
private var lastUrl: String? = null
internal var lastOptions: ConnectOptions? = null
private var lastRoomOptions: RoomOptions? = null
+ private val nextRequestId = AtomicInteger(0)
// join will always return a JoinResponse.
// reconnect will return a ReconnectResponse or a Unit if a different response was received.
@@ -563,8 +565,16 @@ constructor(
sendRequest(request)
}
- fun sendUpdateLocalMetadata(metadata: String?, name: String?, attributes: Map? = emptyMap()) {
+ internal fun allocateRequestId(): Int = nextRequestId.incrementAndGet()
+
+ fun sendUpdateLocalMetadata(
+ metadata: String?,
+ name: String?,
+ attributes: Map = emptyMap(),
+ requestId: Int = allocateRequestId(),
+ ): Int {
val update = LivekitRtc.UpdateParticipantMetadata.newBuilder()
+ .setRequestId(requestId)
.setMetadata(metadata ?: "")
.setName(name ?: "")
.putAllAttributes(attributes)
@@ -574,6 +584,7 @@ constructor(
.build()
sendRequest(request)
+ return requestId
}
fun sendSyncState(syncState: LivekitRtc.SyncState) {
@@ -837,7 +848,7 @@ constructor(
}
LivekitRtc.SignalResponse.MessageCase.REQUEST_RESPONSE -> {
- // TODO
+ listener?.onRequestResponse(response.requestResponse)
}
LivekitRtc.SignalResponse.MessageCase.ROOM_MOVED -> {
@@ -958,6 +969,7 @@ constructor(
fun onRefreshToken(token: String)
fun onLocalTrackUnpublished(trackUnpublished: LivekitRtc.TrackUnpublishedResponse)
fun onLocalTrackSubscribed(trackSubscribed: LivekitRtc.TrackSubscribed)
+ fun onRequestResponse(response: LivekitRtc.RequestResponse)
}
companion object {
@@ -1025,6 +1037,9 @@ enum class ProtocolVersion(val value: Int) {
// new leave request handling
v13(13),
+
+ // signal request response handling
+ v15(15),
}
/**
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt
index 5daa840db..52a4620ac 100644
--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt
@@ -41,6 +41,7 @@ import io.livekit.android.room.isSVCCodec
import io.livekit.android.room.rpc.RpcClientManager
import io.livekit.android.room.rpc.RpcManager
import io.livekit.android.room.rpc.RpcServerManager
+import io.livekit.android.room.signal.SignalRequestException
import io.livekit.android.room.track.DataPublishReliability
import io.livekit.android.room.track.LocalAudioTrack
import io.livekit.android.room.track.LocalAudioTrackOptions
@@ -59,13 +60,19 @@ import io.livekit.android.room.track.screencapture.ScreenCaptureParams
import io.livekit.android.room.util.EncodingUtils
import io.livekit.android.rpc.RpcError
import io.livekit.android.util.LKLog
+import io.livekit.android.util.TimeoutException
import io.livekit.android.util.flow
import io.livekit.android.util.rethrowIfCancellationSignal
+import io.livekit.android.util.withDeadline
import io.livekit.android.webrtc.sortVideoCodecPreferences
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -90,6 +97,7 @@ import javax.inject.Named
import kotlin.math.max
import kotlin.math.min
import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
class LocalParticipant
@AssistedInject
@@ -130,6 +138,8 @@ internal constructor(
private val jobs = mutableMapOf()
+ private val pendingSignalRequests = Collections.synchronizedMap(mutableMapOf>())
+
// For ensuring that only one caller can execute setTrackEnabled at a time.
// Without it, there's a potential to create multiple of the same source,
// Camera has deadlock issues with multiple CameraCapturers trying to activate/stop.
@@ -1109,12 +1119,59 @@ internal constructor(
}
}
+ /**
+ * Sets and updates the metadata of the local participant.
+ * Note: this requires `CanUpdateOwnMetadata` permission encoded in the token.
+ *
+ * @param metadata the metadata to set
+ * @return a [Result] that succeeds when the server confirms the update, or fails with
+ * [SignalRequestException] if the server rejects the request or
+ * [TimeoutException] if it times out
+ */
+ @CheckResult
+ suspend fun setMetadata(metadata: String): Result {
+ return requestMetadataUpdate(metadata = metadata)
+ }
+
+ /**
+ * Sets and updates the name of the local participant.
+ * Note: this requires `CanUpdateOwnMetadata` permission encoded in the token.
+ *
+ * @param name the name to set
+ * @return a [Result] that succeeds when the server confirms the update, or fails with
+ * [SignalRequestException] if the server rejects the request or
+ * [TimeoutException] if it times out
+ */
+ @CheckResult
+ suspend fun setName(name: String): Result {
+ return requestMetadataUpdate(name = name)
+ }
+
+ /**
+ * Set or update participant attributes. It will make updates only to keys that
+ * are present in [attributes], and will not override others.
+ *
+ * To delete a value, set the value to an empty string.
+ *
+ * Note: this requires `CanUpdateOwnMetadata` permission encoded in the token.
+ *
+ * @param attributes attributes to update
+ * @return a [Result] that succeeds when the server confirms the update, or fails with
+ * [SignalRequestException] if the server rejects the request or
+ * [TimeoutException] if it times out
+ */
+ @CheckResult
+ suspend fun setAttributes(attributes: Map): Result {
+ return requestMetadataUpdate(attributes = attributes)
+ }
+
/**
* Updates the metadata of the local participant. Changes will not be reflected until the
* server responds confirming the update.
* Note: this requires `CanUpdateOwnMetadata` permission encoded in the token.
* @param metadata
*/
+ @Deprecated(message = "Use the suspend function setMetadata instead.")
fun updateMetadata(metadata: String) {
this.engine.client.sendUpdateLocalMetadata(metadata, name)
}
@@ -1125,6 +1182,7 @@ internal constructor(
* Note: this requires `CanUpdateOwnMetadata` permission encoded in the token.
* @param name
*/
+ @Deprecated(message = "Use the suspend function setName instead.")
fun updateName(name: String) {
this.engine.client.sendUpdateLocalMetadata(metadata, name)
}
@@ -1138,6 +1196,7 @@ internal constructor(
* Note: this requires `canUpdateOwnMetadata` permission.
* @param attributes attributes to update
*/
+ @Deprecated(message = "Use the suspend function setAttributes instead.")
fun updateAttributes(attributes: Map) {
this.engine.client.sendUpdateLocalMetadata(metadata, name, attributes)
}
@@ -1147,6 +1206,88 @@ internal constructor(
pub?.muted = muted
}
+ internal fun handleRequestResponse(response: LivekitRtc.RequestResponse) {
+ val deferred = pendingSignalRequests[response.requestId] ?: return
+ if (response.reason != LivekitRtc.RequestResponse.Reason.OK) {
+ pendingSignalRequests.remove(response.requestId)
+ deferred.completeExceptionally(SignalRequestException.fromResponse(response))
+ return
+ }
+ pendingSignalRequests.remove(response.requestId)
+ }
+
+ private suspend fun requestMetadataUpdate(
+ metadata: String? = null,
+ name: String? = null,
+ attributes: Map? = null,
+ ): Result {
+ val requestId = engine.client.allocateRequestId()
+ val deferred = CompletableDeferred()
+ pendingSignalRequests[requestId] = deferred
+
+ return try {
+ engine.client.sendUpdateLocalMetadata(
+ metadata = metadata ?: this.metadata,
+ name = name ?: this.name,
+ attributes = attributes ?: emptyMap(),
+ requestId = requestId,
+ )
+ withDeadline(METADATA_UPDATE_TIMEOUT) {
+ coroutineScope {
+ val confirmationJob = launch {
+ combine(
+ ::name.flow,
+ ::metadata.flow,
+ ::attributes.flow,
+ ) { _, _, _ -> }
+ .first { isMetadataUpdateConfirmed(metadata, name, attributes) }
+ if (!deferred.isCompleted) {
+ deferred.complete(Unit)
+ }
+ }
+ try {
+ deferred.await()
+ } finally {
+ confirmationJob.cancel()
+ }
+ }
+ }
+ Result.success(Unit)
+ } catch (e: TimeoutException) {
+ deferred.completeExceptionally(e)
+ Result.failure(e)
+ } catch (e: CancellationException) {
+ deferred.cancel()
+ throw e
+ } catch (e: Exception) {
+ Result.failure(e)
+ } finally {
+ pendingSignalRequests.remove(requestId)
+ }
+ }
+
+ private fun isMetadataUpdateConfirmed(
+ metadata: String?,
+ name: String?,
+ attributes: Map?,
+ ): Boolean {
+ if (name != null && this.name != name) {
+ return false
+ }
+ if (metadata != null && this.metadata != metadata) {
+ return false
+ }
+ if (attributes != null &&
+ !attributes.all { (key, value) ->
+ val current = this.attributes[key]
+ current == value || (value.isEmpty() && current.isNullOrEmpty())
+ }
+ ) {
+ return false
+ }
+ return true
+ }
+
internal fun handleSubscribedQualityUpdate(subscribedQualityUpdate: LivekitRtc.SubscribedQualityUpdate) {
if (!dynacast) {
return
@@ -1319,6 +1460,9 @@ internal constructor(
* @suppress
*/
fun cleanup() {
+ pendingSignalRequests.values.forEach { it.cancel() }
+ pendingSignalRequests.clear()
+
for (pub in trackPublications.values) {
val track = pub.track
@@ -1394,6 +1538,10 @@ internal constructor(
interface Factory {
fun create(dynacast: Boolean): LocalParticipant
}
+
+ companion object {
+ private val METADATA_UPDATE_TIMEOUT = 5_000.milliseconds
+ }
}
internal fun LocalParticipant.publishTracksInfo(): List {
diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/signal/SignalRequest.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/signal/SignalRequest.kt
new file mode 100644
index 000000000..99d304042
--- /dev/null
+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/signal/SignalRequest.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2026 LiveKit, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.livekit.android.room.signal
+
+import livekit.LivekitRtc
+
+/**
+ * Error returned when a signal request is rejected by the server
+ */
+class SignalRequestException(
+ message: String? = null,
+ val reason: SignalResponseReason,
+ cause: Throwable? = null,
+) : Exception(message, cause) {
+
+ companion object {
+ fun fromResponse(response: LivekitRtc.RequestResponse): SignalRequestException {
+ return SignalRequestException(
+ message = response.message.takeIf { it.isNotEmpty() },
+ reason = SignalResponseReason.fromProto(response.reason),
+ )
+ }
+ }
+}
+
+enum class SignalResponseReason {
+ OK,
+ NOT_FOUND,
+ NOT_ALLOWED,
+ LIMIT_EXCEEDED,
+ QUEUED,
+ UNSUPPORTED_TYPE,
+ UNCLASSIFIED_ERROR,
+ INVALID_HANDLE,
+ INVALID_NAME,
+ DUPLICATE_HANDLE,
+ DUPLICATE_NAME,
+ UNRECOGNIZED;
+
+ companion object {
+ internal fun fromProto(proto: LivekitRtc.RequestResponse.Reason): SignalResponseReason {
+ return when (proto) {
+ LivekitRtc.RequestResponse.Reason.OK -> OK
+ LivekitRtc.RequestResponse.Reason.NOT_FOUND -> NOT_FOUND
+ LivekitRtc.RequestResponse.Reason.NOT_ALLOWED -> NOT_ALLOWED
+ LivekitRtc.RequestResponse.Reason.LIMIT_EXCEEDED -> LIMIT_EXCEEDED
+ LivekitRtc.RequestResponse.Reason.QUEUED -> QUEUED
+ LivekitRtc.RequestResponse.Reason.UNSUPPORTED_TYPE -> UNSUPPORTED_TYPE
+ LivekitRtc.RequestResponse.Reason.UNCLASSIFIED_ERROR -> UNCLASSIFIED_ERROR
+ LivekitRtc.RequestResponse.Reason.INVALID_HANDLE -> INVALID_HANDLE
+ LivekitRtc.RequestResponse.Reason.INVALID_NAME -> INVALID_NAME
+ LivekitRtc.RequestResponse.Reason.DUPLICATE_HANDLE -> DUPLICATE_HANDLE
+ LivekitRtc.RequestResponse.Reason.DUPLICATE_NAME -> DUPLICATE_NAME
+ else -> UNRECOGNIZED
+ }
+ }
+ }
+}
diff --git a/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt b/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt
index 8bd9a933f..ca6b844e3 100644
--- a/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt
+++ b/livekit-android-test/src/test/java/io/livekit/android/proto/ProtoConverterTest.kt
@@ -18,6 +18,7 @@ package io.livekit.android.proto
import io.livekit.android.room.RegionSettings
import io.livekit.android.room.participant.ParticipantPermission
+import io.livekit.android.room.signal.SignalResponseReason
import io.livekit.android.rpc.RpcError
import io.livekit.android.token.RoomAgentDispatch
import io.livekit.android.token.RoomConfiguration
@@ -100,6 +101,10 @@ class ProtoConverterTest(
RoomAgentDispatch::class.java,
whitelist = listOf("restartPolicy", "deployment"),
),
+ ProtoConverterTestCase(
+ LivekitRtc.RequestResponse.Reason::class.java,
+ SignalResponseReason::class.java,
+ ),
)
@JvmStatic
diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantRequestResponseTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantRequestResponseTest.kt
new file mode 100644
index 000000000..212096813
--- /dev/null
+++ b/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantRequestResponseTest.kt
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2026 LiveKit, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.livekit.android.room.participant
+
+import io.livekit.android.room.signal.SignalRequestException
+import io.livekit.android.room.signal.SignalResponseReason
+import io.livekit.android.test.MockE2ETest
+import io.livekit.android.test.mock.TestData
+import io.livekit.android.test.util.toPBByteString
+import io.livekit.android.util.TimeoutException
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import livekit.LivekitModels
+import livekit.LivekitRtc
+import livekit.LivekitRtc.ParticipantUpdate
+import livekit.LivekitRtc.RequestResponse
+import livekit.LivekitRtc.SignalResponse
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@ExperimentalCoroutinesApi
+@RunWith(RobolectricTestRunner::class)
+class LocalParticipantRequestResponseTest : MockE2ETest() {
+
+ @Test
+ fun setMetadataSendsRequestId() = runTest {
+ connect()
+ registerMetadataConfirmationHandler()
+ wsFactory.ws.clearRequests()
+
+ val result = room.localParticipant.setMetadata("new_metadata")
+
+ assertTrue(result.isSuccess)
+ val sentRequest = parseSentUpdateMetadataRequest()
+ assertTrue(sentRequest.updateMetadata.requestId > 0)
+ assertEquals("new_metadata", sentRequest.updateMetadata.metadata)
+ }
+
+ @Test
+ fun setMetadataSucceedsOnParticipantUpdate() = runTest {
+ connect()
+ registerMetadataConfirmationHandler()
+
+ val newMetadata = "confirmed_metadata"
+ val result = room.localParticipant.setMetadata(newMetadata)
+
+ assertTrue(result.isSuccess)
+ assertEquals(newMetadata, room.localParticipant.metadata)
+ }
+
+ @Test
+ fun setNameSucceedsOnParticipantUpdate() = runTest {
+ connect()
+ registerMetadataConfirmationHandler()
+
+ val newName = "confirmed_name"
+ val result = room.localParticipant.setName(newName)
+
+ assertTrue(result.isSuccess)
+ assertEquals(newName, room.localParticipant.name)
+ }
+
+ @Test
+ fun setAttributesSucceedsOnParticipantUpdate() = runTest {
+ connect()
+ registerMetadataConfirmationHandler()
+
+ val newAttributes = mapOf("attribute" to "changedValue")
+ val result = room.localParticipant.setAttributes(newAttributes)
+
+ assertTrue(result.isSuccess)
+ assertEquals("changedValue", room.localParticipant.attributes["attribute"])
+ }
+
+ @Test
+ fun setMetadataFailsOnRequestResponseError() = runTest {
+ connect()
+ wsFactory.registerSignalRequestHandler { request ->
+ if (request.hasUpdateMetadata()) {
+ wsFactory.receiveMessage(
+ requestResponse(
+ requestId = request.updateMetadata.requestId,
+ reason = RequestResponse.Reason.NOT_ALLOWED,
+ message = "not allowed",
+ ),
+ )
+ return@registerSignalRequestHandler true
+ }
+ return@registerSignalRequestHandler false
+ }
+
+ val result = room.localParticipant.setMetadata("new_metadata")
+
+ assertTrue(result.isFailure)
+ val error = result.exceptionOrNull()
+ assertTrue(error is SignalRequestException)
+ error as SignalRequestException
+ assertEquals(SignalResponseReason.NOT_ALLOWED, error.reason)
+ assertEquals("not allowed", error.message)
+ }
+
+ @Test
+ fun setMetadataTimesOutWithoutConfirmation() = runTest {
+ connect()
+ wsFactory.ws.clearRequests()
+
+ val deferred = async {
+ room.localParticipant.setMetadata("new_metadata")
+ }
+ coroutineRule.dispatcher.scheduler.advanceTimeBy(5_001)
+
+ val result = deferred.await()
+ assertTrue(result.isFailure)
+ assertTrue(result.exceptionOrNull() is TimeoutException)
+ }
+
+ private fun registerMetadataConfirmationHandler() {
+ wsFactory.registerSignalRequestHandler { request ->
+ if (request.hasUpdateMetadata()) {
+ val update = request.updateMetadata
+ val newInfo = with(TestData.LOCAL_PARTICIPANT.toBuilder()) {
+ if (update.metadata.isNotEmpty()) {
+ metadata = update.metadata
+ }
+ if (update.name.isNotEmpty()) {
+ name = update.name
+ }
+ if (update.attributesCount > 0) {
+ putAllAttributes(update.attributesMap)
+ }
+ build()
+ }
+ wsFactory.receiveMessage(participantUpdate(newInfo))
+ return@registerSignalRequestHandler true
+ }
+ return@registerSignalRequestHandler false
+ }
+ }
+
+ private fun parseSentUpdateMetadataRequest(): LivekitRtc.SignalRequest {
+ val requestString = wsFactory.ws.sentRequests.first().toPBByteString()
+ return LivekitRtc.SignalRequest.newBuilder()
+ .mergeFrom(requestString)
+ .build()
+ }
+
+ private fun participantUpdate(participant: LivekitModels.ParticipantInfo): SignalResponse {
+ return SignalResponse.newBuilder()
+ .setUpdate(
+ ParticipantUpdate.newBuilder()
+ .addParticipants(participant)
+ .build(),
+ )
+ .build()
+ }
+
+ private fun requestResponse(
+ requestId: Int,
+ reason: RequestResponse.Reason,
+ message: String = "",
+ ): SignalResponse {
+ return SignalResponse.newBuilder()
+ .setRequestResponse(
+ RequestResponse.newBuilder()
+ .setRequestId(requestId)
+ .setReason(reason)
+ .setMessage(message)
+ .build(),
+ )
+ .build()
+ }
+}