From 18f3e05aff6d5219e892b73f1657436c002ad0b5 Mon Sep 17 00:00:00 2001 From: davidliu Date: Wed, 14 Jan 2026 20:22:26 -0800 Subject: [PATCH 1/2] Migrate from Klaxon decoding to kotlinx-serialization --- .changeset/shy-foxes-brake.md | 5 + .../livekit/android/room/types/AgentTypes.kt | 145 ++++++++++++------ .../android/room/types/AgentTypesExt.kt | 65 +++++--- .../room/types/AgentTypesConvertersTest.kt | 7 +- .../android/room/types/AgentTypesTest.kt | 53 +++++-- 5 files changed, 188 insertions(+), 87 deletions(-) create mode 100644 .changeset/shy-foxes-brake.md diff --git a/.changeset/shy-foxes-brake.md b/.changeset/shy-foxes-brake.md new file mode 100644 index 00000000..5898898e --- /dev/null +++ b/.changeset/shy-foxes-brake.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +Migrate from Klaxon decoding to kotlinx-serialization for AgentAttribute deserialization diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/types/AgentTypes.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/types/AgentTypes.kt index e424ea44..ad888e3e 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/types/AgentTypes.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/types/AgentTypes.kt @@ -16,61 +16,50 @@ package io.livekit.android.room.types -import android.annotation.SuppressLint import androidx.annotation.Keep -import com.beust.klaxon.Converter -import com.beust.klaxon.Json -import com.beust.klaxon.JsonValue -import com.beust.klaxon.Klaxon import io.livekit.android.util.LKLog +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable - -private fun Klaxon.convert(k: kotlin.reflect.KClass<*>, fromJson: (JsonValue) -> T, toJson: (T) -> String, isUnion: Boolean = false) = - this.converter( - object : Converter { - @Suppress("UNCHECKED_CAST") - override fun toJson(value: Any) = toJson(value as T) - override fun fromJson(jv: JsonValue) = fromJson(jv) as Any? - override fun canConvert(cls: Class<*>) = cls == k.java || (isUnion && cls.superclass == k.java) - }, - ) - -internal val klaxon = Klaxon() - .convert(AgentInput::class, { it.string?.let { AgentInput.fromValue(it) } }, { "\"${it?.value}\"" }) - .convert(AgentOutput::class, { it.string?.let { AgentOutput.fromValue(it) } }, { "\"${it?.value}\"" }) - .convert(AgentSdkState::class, { it.string?.let { AgentSdkState.fromValue(it) } }, { "\"${it?.value}\"" }) +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder @Keep +@Serializable data class AgentAttributes( - @Json(name = "lk.agent.inputs") + @SerialName("lk.agent.inputs") val lkAgentInputs: List? = null, - @Json(name = "lk.agent.outputs") + @SerialName("lk.agent.outputs") val lkAgentOutputs: List? = null, - @Json(name = "lk.agent.state") + @SerialName("lk.agent.state") val lkAgentState: AgentSdkState? = null, - @Json(name = "lk.publish_on_behalf") + @SerialName("lk.publish_on_behalf") val lkPublishOnBehalf: String? = null, -) { - fun toJson() = klaxon.toJsonString(this) - - companion object { - fun fromJson(json: String) = klaxon.parse(json) - } -} +) @Keep +@Serializable(with = AgentInputSerializer::class) enum class AgentInput(val value: String) { + @SerialName("audio") Audio("audio"), + + @SerialName("text") Text("text"), + + @SerialName("video") Video("video"), - Unknown("unknown"), - ; + + @SerialName("unknown") + Unknown("unknown"); companion object { - fun fromValue(value: String): AgentInput? = when (value) { + fun fromValue(value: String): AgentInput = when (value) { "audio" -> Audio "text" -> Text "video" -> Video @@ -83,14 +72,34 @@ enum class AgentInput(val value: String) { } @Keep +internal object AgentInputSerializer : KSerializer { + // Serial names of descriptors should be unique, this is why we advise including app package in the name. + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("io.livekit.android.room.types.AgentInput", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: AgentInput) { + encoder.encodeString(value.value) + } + + override fun deserialize(decoder: Decoder): AgentInput { + val string = decoder.decodeString() + return AgentInput.fromValue(string) + } +} + +@Keep +@Serializable(with = AgentOutputSerializer::class) enum class AgentOutput(val value: String) { + @SerialName("audio") Audio("audio"), + + @SerialName("transcription") Transcription("transcription"), - Unknown("unknown"), - ; + + @SerialName("unknown") + Unknown("unknown"); companion object { - fun fromValue(value: String): AgentOutput? = when (value) { + fun fromValue(value: String): AgentOutput = when (value) { "audio" -> Audio "transcription" -> Transcription else -> { @@ -101,19 +110,45 @@ enum class AgentOutput(val value: String) { } } +@Keep +internal object AgentOutputSerializer : KSerializer { + // Serial names of descriptors should be unique, this is why we advise including app package in the name. + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("io.livekit.android.room.types.AgentOutput", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: AgentOutput) { + encoder.encodeString(value.value) + } + + override fun deserialize(decoder: Decoder): AgentOutput { + val string = decoder.decodeString() + return AgentOutput.fromValue(string) + } +} + // Renamed from AgentState to AgentSdkState to avoid naming conflicts elsewhere. @Keep +@Serializable(with = AgentSdkStateSerializer::class) enum class AgentSdkState(val value: String) { + @SerialName("idle") Idle("idle"), + + @SerialName("initializing") Initializing("initializing"), + + @SerialName("listening") Listening("listening"), + + @SerialName("speaking") Speaking("speaking"), + + @SerialName("thinking") Thinking("thinking"), - Unknown("unknown"), - ; + + @SerialName("unknown") + Unknown("unknown"); companion object { - fun fromValue(value: String): AgentSdkState? = when (value) { + fun fromValue(value: String): AgentSdkState = when (value) { "idle" -> Idle "initializing" -> Initializing "listening" -> Listening @@ -127,34 +162,42 @@ enum class AgentSdkState(val value: String) { } } +@Keep +internal object AgentSdkStateSerializer : KSerializer { + // Serial names of descriptors should be unique, this is why we advise including app package in the name. + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("io.livekit.android.room.types.AgentSdkState", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: AgentSdkState) { + encoder.encodeString(value.value) + } + + override fun deserialize(decoder: Decoder): AgentSdkState { + val string = decoder.decodeString() + return AgentSdkState.fromValue(string) + } +} + /** * Schema for transcription-related attributes */ @Keep -@SuppressLint("UnsafeOptInUsageError") @Serializable data class TranscriptionAttributes( /** * The segment id of the transcription */ - @Json(name = "lk.segment_id") + @SerialName("lk.segment_id") val lkSegmentID: String? = null, /** * The associated track id of the transcription */ - @Json(name = "lk.transcribed_track_id") + @SerialName("lk.transcribed_track_id") val lkTranscribedTrackID: String? = null, /** * Whether the transcription is final */ - @Json(name = "lk.transcription_final") + @SerialName("lk.transcription_final") val lkTranscriptionFinal: Boolean? = null, -) { - fun toJson() = klaxon.toJsonString(this) - - companion object { - fun fromJson(json: String) = klaxon.parse(json) - } -} +) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/types/AgentTypesExt.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/types/AgentTypesExt.kt index 72ec9ef6..f8a3944b 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/types/AgentTypesExt.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/types/AgentTypesExt.kt @@ -16,32 +16,44 @@ package io.livekit.android.room.types -import com.beust.klaxon.JsonObject +import androidx.annotation.VisibleForTesting +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement // AgentTypes.kt is a generated file and should not be edited. // Add any required functions through extensions here. - +private val jsonSerializer = Json { + allowStructuredMapKeys = true + coerceInputValues = true +} internal fun AgentAttributes.Companion.fromJsonObject(jsonObject: JsonObject) = - klaxon.parseFromJsonObject(jsonObject) + jsonSerializer.decodeFromJsonElement(jsonObject) /** * @suppress */ -fun AgentAttributes.Companion.fromMap(map: Map): AgentAttributes { - if (map.values.all { it == null }) { +fun AgentAttributes.Companion.fromMap(map: Map): AgentAttributes { + if (map.values.none()) { return AgentAttributes() } - return fromJsonObject(JsonObject(map)) ?: AgentAttributes() + return fromJsonObject(JsonObject(map)) } /** * @suppress */ fun AgentAttributes.Companion.fromStringMap(map: Map): AgentAttributes { - val parseMap = mutableMapOf() + val parseMap = mutableMapOf() for ((key, converter) in AGENT_ATTRIBUTES_CONVERSION) { - parseMap[key] = converter(map[key]) + converter(map[key])?.let { converted -> + parseMap[key] = converted + } } return fromMap(parseMap) @@ -51,31 +63,39 @@ fun AgentAttributes.Companion.fromStringMap(map: Map): AgentAttr * Protobuf attribute maps are [String, String], so need to parse arrays/maps manually. * @suppress */ -val AGENT_ATTRIBUTES_CONVERSION = mapOf Any?>( - "lk.agent.inputs" to { json -> json?.let { klaxon.parseArray>(json) } }, - "lk.agent.outputs" to { json -> json?.let { klaxon.parseArray>(json) } }, - "lk.agent.state" to { json -> json }, - "lk.publish_on_behalf" to { json -> json }, +@VisibleForTesting +val AGENT_ATTRIBUTES_CONVERSION = mapOf JsonElement?>( + "lk.agent.inputs" to { json -> json?.let { jsonSerializer.decodeFromString(json) } }, + "lk.agent.outputs" to { json -> json?.let { jsonSerializer.decodeFromString(json) } }, + "lk.agent.state" to { json -> JsonPrimitive(json) }, + "lk.publish_on_behalf" to { json -> JsonPrimitive(json) }, ) internal fun TranscriptionAttributes.Companion.fromJsonObject(jsonObject: JsonObject) = - klaxon.parseFromJsonObject(jsonObject) + jsonSerializer.decodeFromJsonElement(jsonObject) /** * @suppress */ -fun TranscriptionAttributes.Companion.fromMap(map: Map): TranscriptionAttributes { - return fromJsonObject(JsonObject(map)) ?: TranscriptionAttributes() +fun TranscriptionAttributes.Companion.fromMap(map: Map): TranscriptionAttributes { + if (map.values.none()) { + return TranscriptionAttributes() + } + + return fromJsonObject(JsonObject(map)) } /** * @suppress */ fun TranscriptionAttributes.Companion.fromStringMap(map: Map): TranscriptionAttributes { - val parseMap = mutableMapOf() + val parseMap = mutableMapOf() for ((key, converter) in TRANSCRIPTION_ATTRIBUTES_CONVERSION) { - parseMap[key] = converter(map[key]) + converter(map[key])?.let { converted -> + parseMap[key] = converted + } } + return fromMap(parseMap) } @@ -83,8 +103,9 @@ fun TranscriptionAttributes.Companion.fromStringMap(map: Map): T * Protobuf attribute maps are [String, String], so need to parse arrays/maps manually. * @suppress */ -val TRANSCRIPTION_ATTRIBUTES_CONVERSION = mapOf Any?>( - "lk.segment_id" to { json -> json }, - "lk.transcribed_track_id" to { json -> json }, - "lk.transcription_final" to { json -> json?.let { klaxon.parse(json) } }, +@VisibleForTesting +val TRANSCRIPTION_ATTRIBUTES_CONVERSION = mapOf JsonElement?>( + "lk.segment_id" to { json -> JsonPrimitive(json) }, + "lk.transcribed_track_id" to { json -> JsonPrimitive(json) }, + "lk.transcription_final" to { json -> json?.let { jsonSerializer.decodeFromString(json) } }, ) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesConvertersTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesConvertersTest.kt index cb09c1e1..25b23035 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesConvertersTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesConvertersTest.kt @@ -16,7 +16,7 @@ package io.livekit.android.room.types -import com.beust.klaxon.Json +import kotlinx.serialization.SerialName import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -67,11 +67,10 @@ class AgentTypesConvertersTest( @Test fun convertersVerify() { - (AgentAttributes::class.members.first().annotations.first() as Json).name val jsonFields = quickTypeClass.members .map { it.annotations } - .mapNotNull { it.firstOrNull { annotation -> annotation is Json } as Json? } - .map { it.name } + .mapNotNull { it.firstOrNull { annotation -> annotation is SerialName } as SerialName? } + .map { it.value } val converters = convertersMap.keys diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesTest.kt index ef7db4b4..0d016eb3 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesTest.kt @@ -16,6 +16,10 @@ package io.livekit.android.room.types +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -25,7 +29,7 @@ class AgentTypesTest { // Some basic tests to ensure klaxon functionality. @Test fun testEmptyMapConversion() { - val agentAttributes = AgentAttributes.fromMap(emptyMap()) + val agentAttributes = AgentAttributes.fromMap(emptyMap()) assertNull(agentAttributes.lkAgentInputs) assertNull(agentAttributes.lkAgentOutputs) @@ -37,9 +41,9 @@ class AgentTypesTest { fun testSimpleMapConversion() { val map = mapOf( "lk.agent.state" to "idle", - "lk.publish_on_behalf" to "agent_identity" + "lk.publish_on_behalf" to "agent_identity", ) - val agentAttributes = AgentAttributes.fromMap(map) + val agentAttributes = AgentAttributes.fromStringMap(map) assertNull(agentAttributes.lkAgentInputs) assertNull(agentAttributes.lkAgentOutputs) @@ -49,13 +53,23 @@ class AgentTypesTest { @Test fun testDeepMapConversion() { + val json = Json val map = mapOf( - "lk.agent.inputs" to listOf("audio", "text"), - "lk.agent.outputs" to listOf("audio"), + "lk.agent.inputs" to json.encodeToString( + buildJsonArray { + add("audio") + add("text") + }, + ), + "lk.agent.outputs" to json.encodeToString( + buildJsonArray { + add("audio") + }, + ), "lk.agent.state" to "idle", - "lk.publish_on_behalf" to "agent_identity" + "lk.publish_on_behalf" to "agent_identity", ) - val agentAttributes = AgentAttributes.fromMap(map) + val agentAttributes = AgentAttributes.fromStringMap(map) assertEquals(listOf(AgentInput.Audio, AgentInput.Text), agentAttributes.lkAgentInputs) assertEquals(listOf(AgentOutput.Audio), agentAttributes.lkAgentOutputs) @@ -71,8 +85,27 @@ class AgentTypesTest { @Test fun testInvalidStringConversionDoesNotThrow() { - AgentInput.fromValue("lorem") - AgentOutput.fromValue("lorem") - AgentSdkState.fromValue("lorem") + val json = Json + val map = mapOf( + "lk.agent.inputs" to json.encodeToString( + buildJsonArray { + add("audio") + add("text") + }, + ), + "lk.agent.outputs" to json.encodeToString( + buildJsonArray { + add("audio") + }, + ), + "lk.agent.state" to "idle", + "lk.publish_on_behalf" to "agent_identity", + ) + val agentAttributes = AgentAttributes.fromStringMap(map) + + assertEquals(listOf(AgentInput.Audio, AgentInput.Text), agentAttributes.lkAgentInputs) + assertEquals(listOf(AgentOutput.Audio), agentAttributes.lkAgentOutputs) + assertEquals(AgentSdkState.Idle, agentAttributes.lkAgentState) + assertEquals("agent_identity", agentAttributes.lkPublishOnBehalf) } } From 0f6f3550120ee93ffd59b0fc001b29bde8c2801c Mon Sep 17 00:00:00 2001 From: davidliu Date: Wed, 14 Jan 2026 20:53:00 -0800 Subject: [PATCH 2/2] Remove klaxon --- gradle/libs.versions.toml | 3 --- livekit-android-sdk/build.gradle | 1 - livekit-android-sdk/consumer-rules.pro | 8 -------- livekit-android-test/build.gradle | 2 +- .../java/io/livekit/android/room/types/AgentTypesTest.kt | 4 ++-- 5 files changed, 3 insertions(+), 15 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index da856e17..3501a8d7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,6 @@ groupie = "2.9.0" junit-lib = "4.13.2" junit-jupiter = "5.5.0" jwtdecode = "2.0.2" -klaxon = "5.5" kotlinx-serialization = "1.5.0" leakcanaryAndroid = "2.8.1" lint = "30.0.1" @@ -51,8 +50,6 @@ dagger-compiler = { module = "com.google.dagger:dagger-compiler", version.ref = groupie = { module = "com.github.lisawray.groupie:groupie", version.ref = "groupie" } groupie-viewbinding = { module = "com.github.lisawray.groupie:groupie-viewbinding", version.ref = "groupie" } jwtdecode = { module = "com.auth0.android:jwtdecode", version.ref = "jwtdecode" } -klaxon = { module = "com.beust:klaxon", version.ref = "klaxon" } -noise = { module = "com.github.paramsen:noise", version.ref = "noise" } androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } diff --git a/livekit-android-sdk/build.gradle b/livekit-android-sdk/build.gradle index 9fc042bb..45a3f698 100644 --- a/livekit-android-sdk/build.gradle +++ b/livekit-android-sdk/build.gradle @@ -148,7 +148,6 @@ dependencies { api libs.okhttp.lib implementation libs.okhttp.coroutines api libs.audioswitch - implementation libs.klaxon implementation libs.jwtdecode implementation libs.androidx.annotation diff --git a/livekit-android-sdk/consumer-rules.pro b/livekit-android-sdk/consumer-rules.pro index 9674f5d4..a3d65428 100644 --- a/livekit-android-sdk/consumer-rules.pro +++ b/livekit-android-sdk/consumer-rules.pro @@ -38,11 +38,3 @@ # Protobuf ######################################### -keep class * extends com.google.protobuf.GeneratedMessageLite { *; } - -# Klaxon JSON parsing -######################################### -# Klaxon uses reflection and doesn't ship ProGuard rules. -# Keep Klaxon library classes for reflection to work --keep class com.beust.klaxon.** { *; } --keep interface com.beust.klaxon.** { *; } -# Data classes using Klaxon should be annotated with @Keep at the source level diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle index 7fd48e5a..30d84c28 100644 --- a/livekit-android-test/build.gradle +++ b/livekit-android-test/build.gradle @@ -97,7 +97,7 @@ dependencies { testImplementation libs.junit testImplementation libs.robolectric testImplementation libs.okhttp.mockwebserver - testImplementation libs.klaxon + testImplementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" kaptTest libs.dagger.compiler androidTestImplementation libs.androidx.test.junit diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesTest.kt index 0d016eb3..ddd67a86 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/types/AgentTypesTest.kt @@ -26,10 +26,10 @@ import org.junit.Test class AgentTypesTest { - // Some basic tests to ensure klaxon functionality. + // Some basic tests to ensure deserialization functionality. @Test fun testEmptyMapConversion() { - val agentAttributes = AgentAttributes.fromMap(emptyMap()) + val agentAttributes = AgentAttributes.fromStringMap(emptyMap()) assertNull(agentAttributes.lkAgentInputs) assertNull(agentAttributes.lkAgentOutputs)