From b82f6d429c12a953f0298b7c85dd002202aa4f17 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 19 May 2026 11:39:25 -0400 Subject: [PATCH 01/22] feat: add rpc tester to sample-app --- .../livekit/android/sample/CallViewModel.kt | 73 +++++ .../res/drawable/baseline_swap_horiz_24.xml | 10 + .../io/livekit/android/sample/CallActivity.kt | 5 + .../sample/dialog/RpcTestDialogFragment.kt | 285 ++++++++++++++++++ .../src/main/res/layout/call_activity.xml | 10 + .../src/main/res/layout/dialog_rpc_test.xml | 211 +++++++++++++ .../src/main/res/layout/item_rpc_handler.xml | 102 +++++++ .../main/res/layout/item_rpc_invocation.xml | 43 +++ 8 files changed, 739 insertions(+) create mode 100644 sample-app-common/src/main/res/drawable/baseline_swap_horiz_24.xml create mode 100644 sample-app/src/main/java/io/livekit/android/sample/dialog/RpcTestDialogFragment.kt create mode 100644 sample-app/src/main/res/layout/dialog_rpc_test.xml create mode 100644 sample-app/src/main/res/layout/item_rpc_handler.xml create mode 100644 sample-app/src/main/res/layout/item_rpc_invocation.xml diff --git a/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt b/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt index 6e9678e11..b218278af 100644 --- a/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt +++ b/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt @@ -49,6 +49,7 @@ 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.rpc.RpcError import io.livekit.android.sample.model.StressTest import io.livekit.android.sample.service.ForegroundService import io.livekit.android.util.LKLog @@ -144,6 +145,10 @@ class CallViewModel( private val mutablePermissionAllowed = MutableStateFlow(true) val permissionAllowed = mutablePermissionAllowed.hide() + // RPC tester state. Lives on the ViewModel so it survives dialog dismiss/reopen. + private val mutableHandlers = MutableStateFlow>(emptyList()) + val handlers: StateFlow> = mutableHandlers + init { CameraXHelper.createCameraProvider(ProcessLifecycleOwner.get()).let { @@ -335,6 +340,12 @@ class CallViewModel( override fun onCleared() { super.onCleared() + // Tear down any RPC handlers before releasing the room. + mutableHandlers.value.forEach { handler -> + runCatching { room.localParticipant.unregisterRpcMethod(handler.method) } + } + mutableHandlers.value = emptyList() + // Make sure to release any resources associated with LiveKit room.disconnect() room.release() @@ -384,6 +395,51 @@ class CallViewModel( } } + fun registerRpcHandler(method: String, initialResponse: String) { + if (method.isBlank()) return + val state = RpcHandlerState( + method = method, + staticResponse = MutableStateFlow(initialResponse), + invocations = MutableStateFlow(emptyList()), + ) + room.localParticipant.registerRpcMethod(method) { invocation -> + val record = RpcInvocationRecord( + timestamp = System.currentTimeMillis(), + caller = invocation.callerIdentity, + payload = invocation.payload, + ) + state.invocations.value = state.invocations.value + record + state.staticResponse.value + } + // Replace any prior entry for the same method (SDK overwrites anyway). + mutableHandlers.value = mutableHandlers.value.filterNot { it.method == method } + state + } + + fun unregisterRpcHandler(method: String) { + room.localParticipant.unregisterRpcMethod(method) + mutableHandlers.value = mutableHandlers.value.filterNot { it.method == method } + } + + fun updateStaticResponse(method: String, response: String) { + mutableHandlers.value.firstOrNull { it.method == method } + ?.staticResponse?.let { it.value = response } + } + + suspend fun performRpc( + destination: Participant.Identity, + method: String, + payload: String, + ): RpcRequestResult { + return try { + val response = room.localParticipant.performRpc(destination, method, payload) + RpcRequestResult.Success(response) + } catch (e: RpcError) { + RpcRequestResult.Error(e.code, e.message) + } catch (e: Throwable) { + RpcRequestResult.Error(null, e.message ?: e.toString()) + } + } + fun toggleSubscriptionPermissions() { mutablePermissionAllowed.value = !mutablePermissionAllowed.value room.localParticipant.setTrackSubscriptionPermissions(mutablePermissionAllowed.value) @@ -465,3 +521,20 @@ class CallViewModel( private fun LiveData.hide(): LiveData = this private fun MutableStateFlow.hide(): StateFlow = this + +data class RpcInvocationRecord( + val timestamp: Long, + val caller: Participant.Identity, + val payload: String, +) + +class RpcHandlerState( + val method: String, + val staticResponse: MutableStateFlow, + val invocations: MutableStateFlow>, +) + +sealed class RpcRequestResult { + data class Success(val response: String) : RpcRequestResult() + data class Error(val code: Int?, val message: String) : RpcRequestResult() +} diff --git a/sample-app-common/src/main/res/drawable/baseline_swap_horiz_24.xml b/sample-app-common/src/main/res/drawable/baseline_swap_horiz_24.xml new file mode 100644 index 000000000..db1e871a0 --- /dev/null +++ b/sample-app-common/src/main/res/drawable/baseline_swap_horiz_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt b/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt index 5942b6d31..2cd2b85d5 100644 --- a/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt +++ b/sample-app/src/main/java/io/livekit/android/sample/CallActivity.kt @@ -35,6 +35,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.xwray.groupie.GroupieAdapter import io.livekit.android.sample.common.R import io.livekit.android.sample.databinding.CallActivityBinding +import io.livekit.android.sample.dialog.RpcTestDialogFragment import io.livekit.android.sample.dialog.showAudioProcessorSwitchDialog import io.livekit.android.sample.dialog.showDebugMenuDialog import io.livekit.android.sample.dialog.showSelectAudioDeviceDialog @@ -219,6 +220,10 @@ class CallActivity : AppCompatActivity() { binding.debugMenu.setOnClickListener { showDebugMenuDialog(viewModel) } + + binding.rpcTest.setOnClickListener { + RpcTestDialogFragment().show(supportFragmentManager, "rpc_test") + } } override fun onResume() { diff --git a/sample-app/src/main/java/io/livekit/android/sample/dialog/RpcTestDialogFragment.kt b/sample-app/src/main/java/io/livekit/android/sample/dialog/RpcTestDialogFragment.kt new file mode 100644 index 000000000..4e943847d --- /dev/null +++ b/sample-app/src/main/java/io/livekit/android/sample/dialog/RpcTestDialogFragment.kt @@ -0,0 +1,285 @@ +/* + * 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.sample.dialog + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.xwray.groupie.GroupieAdapter +import com.xwray.groupie.viewbinding.BindableItem +import com.xwray.groupie.viewbinding.GroupieViewHolder +import io.livekit.android.room.participant.RemoteParticipant +import io.livekit.android.sample.CallViewModel +import io.livekit.android.sample.RpcHandlerState +import io.livekit.android.sample.RpcInvocationRecord +import io.livekit.android.sample.RpcRequestResult +import io.livekit.android.sample.databinding.DialogRpcTestBinding +import io.livekit.android.sample.databinding.ItemRpcHandlerBinding +import io.livekit.android.sample.databinding.ItemRpcInvocationBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private const val HELLO_PAYLOAD = "hello world" +private const val TWENTY_K_SIZE = 20 * 1024 + +private fun twentyKPayload(): String = "X".repeat(TWENTY_K_SIZE) + +class RpcTestDialogFragment : DialogFragment() { + + private var _binding: DialogRpcTestBinding? = null + private val binding get() = _binding!! + + private val viewModel: CallViewModel by activityViewModels() + + private val participantsList = mutableListOf() + private lateinit var participantSpinnerAdapter: ArrayAdapter + private val handlersAdapter = GroupieAdapter() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = DialogRpcTestBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.closeButton.setOnClickListener { dismiss() } + + participantSpinnerAdapter = ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_item, + mutableListOf(), + ) + participantSpinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.participantSpinner.adapter = participantSpinnerAdapter + + binding.presetHello.setOnClickListener { binding.payloadEdit.setText(HELLO_PAYLOAD) } + binding.preset20k.setOnClickListener { binding.payloadEdit.setText(twentyKPayload()) } + + binding.sendButton.setOnClickListener { sendRpc() } + + binding.registerButton.setOnClickListener { + val topic = binding.topicEdit.text.toString().trim() + if (topic.isEmpty()) { + Toast.makeText(requireContext(), "Enter a topic", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + viewModel.registerRpcHandler(topic, initialResponse = "") + binding.topicEdit.text.clear() + } + + binding.handlersList.layoutManager = LinearLayoutManager(requireContext()) + binding.handlersList.adapter = handlersAdapter + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.participants + .map { list -> list.filterIsInstance() } + .collect { remotes -> + participantsList.clear() + participantsList.addAll(remotes) + participantSpinnerAdapter.clear() + participantSpinnerAdapter.addAll( + remotes.map { it.identity?.value ?: "(unknown)" }, + ) + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.handlers.collect { handlers -> + handlersAdapter.update( + handlers.map { RpcHandlerItem(it, viewModel) }, + ) + } + } + } + } + + private fun sendRpc() { + val pos = binding.participantSpinner.selectedItemPosition + if (pos == AdapterView.INVALID_POSITION || pos >= participantsList.size) { + Toast.makeText(requireContext(), "No participant selected", Toast.LENGTH_SHORT).show() + return + } + val identity = participantsList[pos].identity + if (identity == null) { + Toast.makeText(requireContext(), "Participant has no identity", Toast.LENGTH_SHORT).show() + return + } + val method = binding.methodEdit.text.toString().trim() + if (method.isEmpty()) { + Toast.makeText(requireContext(), "Enter a method name", Toast.LENGTH_SHORT).show() + return + } + val payload = binding.payloadEdit.text.toString() + + binding.sendButton.isEnabled = false + binding.responseText.visibility = View.GONE + binding.sendSpinner.visibility = View.VISIBLE + + viewLifecycleOwner.lifecycleScope.launch { + val result = viewModel.performRpc(identity, method, payload) + val b = _binding ?: return@launch + b.sendSpinner.visibility = View.GONE + b.sendButton.isEnabled = true + b.responseText.visibility = View.VISIBLE + b.responseText.text = when (result) { + is RpcRequestResult.Success -> "Success:\n${result.response}" + is RpcRequestResult.Error -> "Error: [${result.code ?: "?"}] ${result.message}" + } + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onDestroyView() { + binding.handlersList.adapter = null + super.onDestroyView() + _binding = null + } +} + +private val invocationTimeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + +class RpcHandlerItem( + private val state: RpcHandlerState, + private val viewModel: CallViewModel, +) : BindableItem() { + + private var scope: CoroutineScope? = null + private var watcher: TextWatcher? = null + + override fun initializeViewBinding(view: View): ItemRpcHandlerBinding = + ItemRpcHandlerBinding.bind(view) + + override fun getLayout(): Int = io.livekit.android.sample.R.layout.item_rpc_handler + + override fun bind(viewBinding: ItemRpcHandlerBinding, position: Int) { + viewBinding.methodName.text = state.method + + viewBinding.unregisterButton.setOnClickListener { + viewModel.unregisterRpcHandler(state.method) + } + + // Bind the static-response edit. Remove any prior watcher before mutating text so + // we don't fire it during programmatic updates. + viewBinding.staticResponseEdit.removeTextChangedListener(watcher) + val current = state.staticResponse.value + if (viewBinding.staticResponseEdit.text?.toString() != current) { + viewBinding.staticResponseEdit.setText(current) + } + val newWatcher = object : TextWatcher { + override fun afterTextChanged(s: Editable?) { + viewModel.updateStaticResponse(state.method, s?.toString() ?: "") + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + } + viewBinding.staticResponseEdit.addTextChangedListener(newWatcher) + watcher = newWatcher + + viewBinding.handlerPresetHello.setOnClickListener { + viewBinding.staticResponseEdit.setText(HELLO_PAYLOAD) + } + viewBinding.handlerPreset20k.setOnClickListener { + viewBinding.staticResponseEdit.setText(twentyKPayload()) + } + + val invocationsAdapter = GroupieAdapter() + viewBinding.invocationList.layoutManager = LinearLayoutManager(viewBinding.root.context) + viewBinding.invocationList.adapter = invocationsAdapter + + scope?.cancel() + val newScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + scope = newScope + newScope.launch { + state.invocations.collect { records -> + viewBinding.emptyLabel.visibility = if (records.isEmpty()) View.VISIBLE else View.GONE + invocationsAdapter.update(records.map { RpcInvocationItem(it) }) + } + } + } + + override fun unbind(viewHolder: GroupieViewHolder) { + scope?.cancel() + scope = null + viewHolder.binding.staticResponseEdit.removeTextChangedListener(watcher) + watcher = null + viewHolder.binding.invocationList.adapter = null + super.unbind(viewHolder) + } + + override fun isSameAs(other: com.xwray.groupie.Item<*>): Boolean = + other is RpcHandlerItem && other.state.method == state.method + + override fun hasSameContentAs(other: com.xwray.groupie.Item<*>): Boolean = + other is RpcHandlerItem && other.state === state +} + +class RpcInvocationItem(private val record: RpcInvocationRecord) : + BindableItem() { + + override fun initializeViewBinding(view: View): ItemRpcInvocationBinding = + ItemRpcInvocationBinding.bind(view) + + override fun getLayout(): Int = io.livekit.android.sample.R.layout.item_rpc_invocation + + override fun bind(viewBinding: ItemRpcInvocationBinding, position: Int) { + val time = invocationTimeFormat.format(Date(record.timestamp)) + viewBinding.timestamp.text = "$time ${record.caller.value}" + val bytes = record.payload.toByteArray(Charsets.UTF_8).size + viewBinding.byteLength.text = "${bytes}B" + viewBinding.payloadText.text = record.payload + } + + override fun isSameAs(other: com.xwray.groupie.Item<*>): Boolean = + other is RpcInvocationItem && + other.record.timestamp == record.timestamp && + other.record.caller == record.caller +} diff --git a/sample-app/src/main/res/layout/call_activity.xml b/sample-app/src/main/res/layout/call_activity.xml index e30e9dadd..a76c2088b 100644 --- a/sample-app/src/main/res/layout/call_activity.xml +++ b/sample-app/src/main/res/layout/call_activity.xml @@ -139,5 +139,15 @@ android:padding="@dimen/control_padding" android:src="@drawable/dots_horizontal_circle_outline" app:tint="@android:color/white" /> + + diff --git a/sample-app/src/main/res/layout/dialog_rpc_test.xml b/sample-app/src/main/res/layout/dialog_rpc_test.xml new file mode 100644 index 000000000..2c21f6455 --- /dev/null +++ b/sample-app/src/main/res/layout/dialog_rpc_test.xml @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +