Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b82f6d4
feat: add rpc tester to sample-app
1egoman May 19, 2026
9ddb7c7
feat: advertise client protocol to other participants
1egoman May 19, 2026
06019c4
feat: add initial rpc v2 implementation
1egoman May 19, 2026
bddc5e8
feat: swap client protocol logic to work like regular sfu protocol
1egoman May 19, 2026
53b4221
feat: add missing tests from rpc spec
1egoman May 19, 2026
7c372d1
fix: add missing changeset
1egoman May 19, 2026
9d3b679
fix: bump protocol version
1egoman May 19, 2026
54fcc7a
feat: add bits to sdk which require bumped protocol version
1egoman May 22, 2026
3014dc8
feat: try to fix build in ci
1egoman May 22, 2026
978def4
fix: run spotless apply
1egoman May 22, 2026
0b743e8
fix: add unused protocol fields to whitelist
1egoman May 22, 2026
bcfe4d4
fix: run spotless apply
1egoman May 22, 2026
bb136c8
fix: commit detekt file
1egoman May 22, 2026
dee3814
Merge branch 'main' into add-rpc-v2
davidliu Jun 1, 2026
a3be9ef
cleanup and merge fixes
davidliu Jun 2, 2026
5e18e87
Set clientProtocol on local participant when connecting
davidliu Jun 2, 2026
7144ecd
reset client protocol version for local participant when resetting
davidliu Jun 2, 2026
70af837
cleanup
davidliu Jun 2, 2026
159bef1
update baseline
davidliu Jun 2, 2026
1253991
clean up protos
davidliu Jun 2, 2026
17148d6
switch protobuf proguard to official recommended rule
davidliu Jun 2, 2026
e127734
changesets
davidliu Jun 2, 2026
25c0f36
switch to allowlist and cut down on the generated protos
davidliu Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/heavy-parrots-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"client-sdk-android": patch
---

Change proguard rule for protobufs to official recommended rule, allowing unused protobuf classes to be removed with minification
5 changes: 5 additions & 0 deletions .changeset/wacky-turtles-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"client-sdk-android": minor
---

Add support for RPC V2
72 changes: 72 additions & 0 deletions gradle/livekit-protobuf.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Shared protobuf setup for LiveKit client protos.
//
// Configure before applying:
// ext.livekitProtoIncludes = ['livekit_models.proto', ...]
// Optional (for modules that compile a subset but import others):
// ext.livekitProtoImportSrc = true

if (!project.ext.has('livekitProtoIncludes')) {
throw new GradleException("ext.livekitProtoIncludes must be set before applying livekit-protobuf.gradle")
}

def stagedProtoSrcDir = layout.buildDirectory.dir("staged-proto-src")
def stageProtoSources = tasks.register("stageProtoSources", Copy) {
from(generated.protoSrc) {
include project.ext.livekitProtoIncludes as String[]
}
into stagedProtoSrcDir
}

android.sourceSets.main.proto {
srcDir stagedProtoSrcDir
}

android.sourceSets.main.java {
srcDir "${protobuf.generatedFilesBaseDir}/main/javalite"
}

configurations {
descriptorProtoSource
}

dependencies {
descriptorProtoSource "com.google.protobuf:protobuf-java:${libs.versions.protobuf.get()}"
}

def extractedImportProtosDir = layout.buildDirectory.dir("extracted-import-protos")
def extractProtoImports = tasks.register("extractProtoImports", Copy) {
into extractedImportProtosDir
from { zipTree(configurations.descriptorProtoSource.singleFile) } {
include "google/protobuf/descriptor.proto"
}
from(generated.protoSrc) {
include "logger/**"
}
}

protobuf {
protoc {
// for apple m1, please add protoc_platform=osx-x86_64 in $HOME/.gradle/gradle.properties
if (project.hasProperty('protoc_platform')) {
artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}:${protoc_platform}"
} else {
artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}"
}
}

generateProtoTasks {
all().each { task ->
task.dependsOn stageProtoSources
task.dependsOn extractProtoImports
task.addIncludeDir files(extractedImportProtosDir)
if (project.ext.has('livekitProtoImportSrc') && project.ext.livekitProtoImportSrc) {
task.addIncludeDir files(generated.protoSrc)
}
task.builtins {
java {
option "lite"
}
}
}
}
}
38 changes: 6 additions & 32 deletions livekit-android-sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,6 @@ android {
}
}

sourceSets {
main {
proto {
srcDir generated.protoSrc
exclude '*/*.proto' // only use top-level protos.
}
java {
srcDir "${protobuf.generatedFilesBaseDir}/main/javalite"
}
}
}

testOptions {
unitTests {
includeAndroidResources = true
Expand Down Expand Up @@ -74,26 +62,12 @@ android {

}

protobuf {
protoc {
// for apple m1, please add protoc_platform=osx-x86_64 in $HOME/.gradle/gradle.properties
if (project.hasProperty('protoc_platform')) {
artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}:${protoc_platform}"
} else {
artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}"
}
}

generateProtoTasks {
all().each { task ->
task.builtins {
java {
option "lite"
}
}
}
}
}
ext.livekitProtoIncludes = [
'livekit_models.proto',
'livekit_rtc.proto',
'livekit_metrics.proto',
]
apply from: rootProject.file('gradle/livekit-protobuf.gradle')

jacoco {
toolVersion = "0.8.14"
Expand Down
4 changes: 3 additions & 1 deletion livekit-android-sdk/consumer-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,6 @@

# Protobuf
#########################################
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
<fields>;
}
21 changes: 5 additions & 16 deletions livekit-android-sdk/detekt-baseline-release.xml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023-2024 LiveKit, Inc.
* Copyright 2023-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.
Expand All @@ -16,6 +16,7 @@

package io.livekit.android

import io.livekit.android.room.ClientProtocolVersion
import io.livekit.android.room.ProtocolVersion
import io.livekit.android.room.Room
import livekit.org.webrtc.PeerConnection
Expand Down Expand Up @@ -53,6 +54,13 @@ data class ConnectOptions(
* the protocol version to use with the server.
*/
val protocolVersion: ProtocolVersion = ProtocolVersion.v13,

/**
* The client protocol version to advertise to other participants in the room
* for peer-to-peer feature negotiation (RPC v2, etc.). Defaults to the latest
* version supported by this SDK build.
*/
val clientProtocol: ClientProtocolVersion = ClientProtocolVersion.DATA_STREAM_RPC,
) {
internal var reconnect: Boolean = false
internal var participantSid: String? = null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023-2025 LiveKit, Inc.
* Copyright 2023-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.
Expand Down Expand Up @@ -328,6 +328,7 @@ fun LivekitModels.DisconnectReason?.convert(): DisconnectReason {
LivekitModels.DisconnectReason.SIP_TRUNK_FAILURE -> DisconnectReason.SIP_TRUNK_FAILURE
LivekitModels.DisconnectReason.CONNECTION_TIMEOUT -> DisconnectReason.CONNECTION_TIMEOUT
LivekitModels.DisconnectReason.MEDIA_FAILURE -> DisconnectReason.MEDIA_FAILURE
LivekitModels.DisconnectReason.AGENT_ERROR,
LivekitModels.DisconnectReason.UNKNOWN_REASON,
LivekitModels.DisconnectReason.UNRECOGNIZED,
null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ import io.livekit.android.room.participant.RpcHandler
import io.livekit.android.room.participant.VideoTrackPublishDefaults
import io.livekit.android.room.participant.publishTracksInfo
import io.livekit.android.room.provisions.LKObjects
import io.livekit.android.room.rpc.RPC_REQUEST_DATA_STREAM_TOPIC
import io.livekit.android.room.rpc.RPC_RESPONSE_DATA_STREAM_TOPIC
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.track.LocalAudioTrackOptions
import io.livekit.android.room.track.LocalTrackPublication
import io.livekit.android.room.track.LocalVideoTrackOptions
Expand Down Expand Up @@ -146,6 +150,8 @@ constructor(
private val connectionWarmer: ConnectionWarmer,
private val audioRecordPrewarmer: AudioRecordPrewarmer,
private val incomingDataStreamManager: IncomingDataStreamManager,
private val rpcClientManager: RpcClientManager,
private val rpcServerManager: RpcServerManager,
private val remoteParticipantFactory: RemoteParticipant.Factory,
) : RTCEngine.Listener, ParticipantListener, RpcManager, IncomingDataStreamManager by incomingDataStreamManager {

Expand All @@ -155,6 +161,27 @@ constructor(

init {
engine.listener = this

// Register SDK-internal text-stream handlers for the RPC v2 transport. These reserve
// the topics `lk.rpc_request` and `lk.rpc_response` from user-level handler registration.
incomingDataStreamManager.registerTextStreamHandler(RPC_REQUEST_DATA_STREAM_TOPIC) { receiver, fromIdentity ->
coroutineScope.launch {
rpcServerManager.handleIncomingDataStream(receiver, fromIdentity)
}
}
incomingDataStreamManager.registerTextStreamHandler(RPC_RESPONSE_DATA_STREAM_TOPIC) { receiver, fromIdentity ->
coroutineScope.launch {
rpcClientManager.handleIncomingDataStreamResponse(receiver, fromIdentity)
}
}

// Wire each manager's clientProtocol lookup via the remote-participants store.
val getRemoteClientProtocol: (Participant.Identity) -> Int = { id ->
remoteParticipants[id]?.clientProtocol
?: ClientProtocolVersion.DEFAULT.value
}
rpcClientManager.getRemoteClientProtocol = getRemoteClientProtocol
rpcServerManager.getRemoteClientProtocol = getRemoteClientProtocol
}

enum class State {
Expand Down Expand Up @@ -454,7 +481,7 @@ constructor(
roomOptions = getCurrentRoomOptions()

// Setup local participant.
localParticipant.reinitialize()
localParticipant.reinitialize(options)
setupLocalParticipantEventHandling()

if (roomOptions.e2eeOptions != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ constructor(
// Clean up any pre-existing connection.
close(reason = "Starting new connection", shouldClearQueuedRequests = false)

val wsUrlString = "${url.toWebsocketUrl()}/rtc${createConnectionParams(getClientInfo(), options, roomOptions)}"
val wsUrlString = "${url.toWebsocketUrl()}/rtc${createConnectionParams(getClientInfo(options.clientProtocol), options, roomOptions)}"
isReconnecting = options.reconnect

LKLog.i { "connecting to $wsUrlString" }
Expand Down Expand Up @@ -240,6 +240,7 @@ constructor(
addParam(CONNECT_QUERY_OS, clientInfo.os)
addParam(CONNECT_QUERY_OS_VERSION, clientInfo.osVersion)
addParam(CONNECT_QUERY_NETWORK_TYPE, networkInfo.getNetworkType().protoName)
addParam(CONNECT_QUERY_CLIENT_PROTOCOL, options.clientProtocol.value.toString())

return queryBuilder.toString()
}
Expand Down Expand Up @@ -856,6 +857,18 @@ constructor(
LivekitRtc.SignalResponse.MessageCase.SUBSCRIBED_AUDIO_CODEC_UPDATE -> {
// TODO
}

LivekitRtc.SignalResponse.MessageCase.PUBLISH_DATA_TRACK_RESPONSE -> {
// TODO
}

LivekitRtc.SignalResponse.MessageCase.UNPUBLISH_DATA_TRACK_RESPONSE -> {
// TODO
}

LivekitRtc.SignalResponse.MessageCase.DATA_TRACK_SUBSCRIBER_HANDLES -> {
// TODO
}
}
}

Expand Down Expand Up @@ -959,6 +972,7 @@ constructor(
const val CONNECT_QUERY_OS_VERSION = "os_version"
const val CONNECT_QUERY_NETWORK_TYPE = "network"
const val CONNECT_QUERY_PARTICIPANT_SID = "sid"
const val CONNECT_QUERY_CLIENT_PROTOCOL = "client_protocol"

const val SD_TYPE_ANSWER = "answer"
const val SD_TYPE_OFFER = "offer"
Expand Down Expand Up @@ -1012,6 +1026,27 @@ enum class ProtocolVersion(val value: Int) {
v13(13),
}

/**
* The protocol version this SDK advertises to **peers** (other participants) for
* client-to-client feature negotiation (RPC v2, etc.). Distinct from [ProtocolVersion],
* which tracks the signaling protocol between client and server.
*
* Sent to the server during the join handshake via the `client_protocol` connection
* query parameter and `ClientInfo.client_protocol`; the server then populates
* `ParticipantInfo.client_protocol` for other peers in the room to read.
*/
@Suppress("unused")
enum class ClientProtocolVersion(val value: Int) {
/** Initial client protocol. RPC v1 only (15 KB packet payload limit). */
DEFAULT(0),

/**
* RPC v2: request and success-response payloads are carried over text data streams
* instead of inline packets, lifting the 15 KB payload limit.
*/
DATA_STREAM_RPC(1),
}

class ServerInfo(
val edition: Edition,
val version: Semver?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ typealias DataChunker<T> = (data: T, chunkSize: Int) -> List<ByteArray>
* On success, [block] should still attempt to close [sender] when the stream is
* finished normally. If it is left open, any exceptions thrown by [sender.close]
* will be ignored.
*
* Any exceptions thrown within [block] will be caught and returned in the result.
*
* @return A successful [Result] object containing the return value of [block], or
* a failure if any exceptions were thrown.
*/
@CheckResult
suspend inline fun <S : BaseStreamSender<*>, R> useStreamSender(
Expand Down
Loading