From dd670ca42cdb701e0caf0f3b32a5e81642cf250a Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:59:17 -0600 Subject: [PATCH 1/3] Add more custom serializers for kotlinx serialization This also seperates the tests, and test serializers that are in library, using commonMain tests rather than testing library serializers in androidTest also. The tests in androidTest have been changed to just test UriSerializer, which is the only one actually there. This adds numerous other serializers to replicate Jackson mapper configuration behavior, that some extensions will need (such as [InternetArchiveProvider](https://github.com/recloudstream/extensions/blob/cf16b3b45cda911a36ddca69f9acbe4763c6a4b1/InternetArchiveProvider/src/main/kotlin/recloudstream/InternetArchiveProvider.kt#L47-L56)) This adds support for replicating the following behaviors: ```kt jacksonObjectMapper().apply { configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) configure(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT, true) configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true) configure(DeserializationFeature.ACCEPT_FLOAT_AS_INT, true) } ``` For `DeserializationFeature.ACCEPT_FLOAT_AS_INT`, which allows converting both Int or Long to a Float, it was split into two separate serializers, `FloatAsIntSerializer` and `FloatAsLongSerializer`, as it was complex to do both at once and I also figured it would be nice to allow splitting this before for more explicit control. Another difference is that unlike custom mappers that can be applied to the whole thing, when migrated from Jackson to Kotlinx, it would have to drop custom mappers entirely, use the normal `parseJson`/`tryParseJson`/etc... and be explicit about what classes need this, which in my personal opinion is better to be more explicit and allows us to be more unified in how the `AppUtils` methods are used more accept under some way more rare circumstances where you may need a custom `Json` instance. I also explained how to use all the serializers in doc comments. We could also later add more serializers if necessary. --- .../utils/serializers/SerializerTest.kt | 157 ----- .../utils/serializers/UriSerializerTest.kt | 41 ++ .../utils/serializers/FloatAsIntSerializer.kt | 41 ++ .../serializers/FloatAsLongSerializer.kt | 41 ++ .../serializers/JsonTransformSerializer.kt | 75 +++ .../utils/serializers/NonEmptySerializer.kt | 9 +- .../serializers/NullableStringSerializer.kt | 49 ++ .../utils/serializers/WriteOnlySerializer.kt | 6 +- .../utils/serializers/SerializersTest.kt | 602 ++++++++++++++++++ 9 files changed, 856 insertions(+), 165 deletions(-) delete mode 100644 app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt create mode 100644 app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/UriSerializerTest.kt create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsIntSerializer.kt create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsLongSerializer.kt create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/JsonTransformSerializer.kt create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NullableStringSerializer.kt create mode 100644 library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/serializers/SerializersTest.kt diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt deleted file mode 100644 index 15ad532f85e..00000000000 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.lagradost.cloudstream3.utils.serializers - -import android.net.Uri -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KeepGeneratedSerializer -import kotlinx.serialization.Serializable -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = NonEmptyData.Serializer::class) -data class NonEmptyData( - val title: String = "", - val tags: List = emptyList(), - val meta: Map = emptyMap(), - val name: String = "hello", -) { - object Serializer : NonEmptySerializer(NonEmptyData.generatedSerializer()) -} - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = WriteOnlyData.Serializer::class) -data class WriteOnlyData( - val fieldA: String = "", - val fieldB: String = "", -) { - object Serializer : WriteOnlySerializer( - WriteOnlyData.generatedSerializer(), - setOf("fieldB"), - ) -} - -@OptIn(ExperimentalSerializationApi::class) -@KeepGeneratedSerializer -@Serializable(with = MultiWriteOnly.Serializer::class) -data class MultiWriteOnly( - val fieldA: String = "", - val fieldB: String = "", - val fieldC: String = "", -) { - object Serializer : WriteOnlySerializer( - MultiWriteOnly.generatedSerializer(), - setOf("fieldB", "fieldC"), - ) -} - -@Serializable -data class UriData( - @Serializable(with = UriSerializer::class) - val uri: Uri = Uri.EMPTY, -) - -class SerializerTest { - - @Test - fun nonEmptySerializerOmitsEmptyStrings() { - val data = NonEmptyData(title = "", name = "hello") - val result = data.toJson() - assertFalse(result.contains("title")) - assertTrue(result.contains("name")) - } - - @Test - fun nonEmptySerializerOmitsEmptyLists() { - val data = NonEmptyData(tags = emptyList(), name = "hello") - val result = data.toJson() - assertFalse(result.contains("tags")) - } - - @Test - fun nonEmptySerializerOmitsEmptyMaps() { - val data = NonEmptyData(meta = emptyMap(), name = "hello") - val result = data.toJson() - assertFalse(result.contains("meta")) - } - - @Test - fun nonEmptySerializerKeepsNonEmptyFields() { - val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v")) - val result = data.toJson() - assertTrue(result.contains("title")) - assertTrue(result.contains("tags")) - assertTrue(result.contains("meta")) - } - - @Test - fun nonEmptySerializerDoesNotAffectDeserialization() { - val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}""" - val result = parseJson(input) - assertEquals("hello", result.title) - assertEquals(listOf("a"), result.tags) - assertEquals(mapOf("k" to "v"), result.meta) - assertEquals("world", result.name) - } - - @Test - fun writeOnlySerializerOmitsFieldOnSerialize() { - val data = WriteOnlyData(fieldA = "hello", fieldB = "secret") - val result = data.toJson() - assertTrue(result.contains("fieldA")) - assertFalse(result.contains("fieldB")) - } - - @Test - fun writeOnlySerializerDeserializesNormally() { - val input = """{"fieldA":"hello","fieldB":"secret"}""" - val result = parseJson(input) - assertEquals("hello", result.fieldA) - assertEquals("secret", result.fieldB) - } - - @Test - fun writeOnlySerializerDeserializesMissingAsDefault() { - val input = """{"fieldA":"hello"}""" - val result = parseJson(input) - assertEquals("hello", result.fieldA) - assertEquals("", result.fieldB) - } - - @Test - fun writeOnlySerializerHandlesMultipleKeys() { - val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2") - val result = data.toJson() - assertTrue(result.contains("fieldA")) - assertFalse(result.contains("fieldB")) - assertFalse(result.contains("fieldC")) - } - - @Test - fun uriSerializerSerializesUriToString() { - val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) - val result = data.toJson() - assertTrue(result.contains("https://example.com/path?query=1")) - } - - @Test - fun uriSerializerDeserializesStringToUri() { - val input = """{"uri":"https://example.com/path?query=1"}""" - val result = parseJson(input) - assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri) - } - - @Test - fun uriSerializerRoundtripsCorrectly() { - val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) - val encoded = data.toJson() - val decoded = parseJson(encoded) - assertEquals(data.uri, decoded.uri) - } -} diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/UriSerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/UriSerializerTest.kt new file mode 100644 index 00000000000..3ffd3712462 --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/UriSerializerTest.kt @@ -0,0 +1,41 @@ +package com.lagradost.cloudstream3.utils.serializers + +import android.net.Uri +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@Serializable +data class UriData( + @Serializable(with = UriSerializer::class) + @SerialName("uri") val uri: Uri = Uri.EMPTY, +) + +class UriSerializerTest { + + @Test + fun uriSerializerSerializesUriToString() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val result = data.toJson() + assertTrue(result.contains("https://example.com/path?query=1")) + } + + @Test + fun uriSerializerDeserializesStringToUri() { + val input = """{"uri":"https://example.com/path?query=1"}""" + val result = parseJson(input) + assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri) + } + + @Test + fun uriSerializerRoundtripsCorrectly() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data.uri, decoded.uri) + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsIntSerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsIntSerializer.kt new file mode 100644 index 00000000000..afbeb1f5fe2 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsIntSerializer.kt @@ -0,0 +1,41 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.Prerelease +import kotlinx.serialization.KSerializer +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 +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull + +/** + * Replicates Jackson's ACCEPT_FLOAT_AS_INT behaviour for Int fields. + * A floating-point JSON number is truncated to Int by dropping the + * fractional part, exactly like Jackson's default truncation behaviour. + * + * Usage: + * + * @Serializable + * data class MyData( + * @Serializable(with = FloatAsIntSerializer::class) + * @SerialName("count") val count: Int = 0, + * ) + */ +@Prerelease +object FloatAsIntSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("FloatAsInt", PrimitiveKind.INT) + + override fun deserialize(decoder: Decoder): Int { + val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeInt() + val element = jsonDecoder.decodeJsonElement() + if (element !is JsonPrimitive) return 0 + return element.intOrNull ?: element.doubleOrNull?.toInt() ?: 0 + } + + override fun serialize(encoder: Encoder, value: Int) = encoder.encodeInt(value) +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsLongSerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsLongSerializer.kt new file mode 100644 index 00000000000..31f3857b5f7 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsLongSerializer.kt @@ -0,0 +1,41 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.Prerelease +import kotlinx.serialization.KSerializer +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 +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Replicates Jackson's ACCEPT_FLOAT_AS_INT behaviour for Long fields. + * A floating-point JSON number is truncated to Long by dropping the + * fractional part, exactly like Jackson's default truncation behaviour. + * + * Usage: + * + * @Serializable + * data class MyData( + * @Serializable(with = FloatAsLongSerializer::class) + * @SerialName("timestamp") val timestamp: Long = 0L, + * ) + */ +@Prerelease +object FloatAsLongSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("FloatAsLong", PrimitiveKind.LONG) + + override fun deserialize(decoder: Decoder): Long { + val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeLong() + val element = jsonDecoder.decodeJsonElement() + if (element !is JsonPrimitive) return 0L + return element.longOrNull ?: element.doubleOrNull?.toLong() ?: 0L + } + + override fun serialize(encoder: Encoder, value: Long) = encoder.encodeLong(value) +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/JsonTransformSerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/JsonTransformSerializer.kt new file mode 100644 index 00000000000..6b9c25b5963 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/JsonTransformSerializer.kt @@ -0,0 +1,75 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.Prerelease +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer + +/** + * Composable deserialize transformer that applies multiple Jackson-equivalent + * behaviours to specific fields by key. Pass only the sets you need; all + * default to empty (no-op). + * + * Behaviours (applied in order per field): + * singleValueAsListKeys ACCEPT_SINGLE_VALUE_AS_ARRAY + * emptyStringAsNullKeys ACCEPT_EMPTY_STRING_AS_NULL_OBJECT + * emptyArrayAsNullKeys ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT + * + * Usage: + * + * @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now + * @KeepGeneratedSerializer + * @Serializable(with = MyData.Serializer::class) + * data class MyData( + * @SerialName("title") val title: String? = null, + * @SerialName("tags") val tags: List? = null, + * @SerialName("meta") val meta: MyMeta? = null, + * ) { + * object Serializer : JsonTransformSerializer( + * MyData.generatedSerializer(), + * singleValueAsListKeys = setOf("tags"), + * emptyStringAsNullKeys = setOf("title", "tags"), + * emptyArrayAsNullKeys = setOf("tags", "meta"), + * ) + * } + */ +@Prerelease +abstract class JsonTransformSerializer( + tSerializer: KSerializer, + private val singleValueAsListKeys: Set = emptySet(), + private val emptyArrayAsNullKeys: Set = emptySet(), + private val emptyStringAsNullKeys: Set = emptySet(), +) : JsonTransformingSerializer(tSerializer) { + + override fun transformDeserialize(element: JsonElement): JsonElement { + if (element !is JsonObject) return element + return JsonObject(element.mapValues { (key, value) -> + var result = value + if (key in singleValueAsListKeys) { + result = when (result) { + is JsonArray -> result + JsonNull -> JsonArray(emptyList()) + else -> JsonArray(listOf(result)) + } + } + + if (key in emptyStringAsNullKeys) { + if (result is JsonPrimitive && result.isString && result.content.isEmpty()) { + result = JsonNull + } + } + + if (key in emptyArrayAsNullKeys) { + if (result is JsonArray && result.isEmpty()) { + result = JsonNull + } + } + + result + }) + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt index 82de9f7f709..28ec72d124a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt @@ -17,13 +17,13 @@ import kotlinx.serialization.json.JsonTransformingSerializer * * Usage: * - * @OptIn(ExperimentalSerializationApi::class) + * @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now * @KeepGeneratedSerializer * @Serializable(with = MyData.Serializer::class) * data class MyData( - * val tags: List = emptyList(), - * val title: String = "", - * val meta: Map = emptyMap(), + * @SerialName("tags") val tags: List = emptyList(), + * @SerialName("title") val title: String = "", + * @SerialName("meta") val meta: Map = emptyMap(), * ) { * object Serializer : NonEmptySerializer(MyData.generatedSerializer()) * } @@ -34,7 +34,6 @@ abstract class NonEmptySerializer(tSerializer: KSerializer) : override fun transformSerialize(element: JsonElement): JsonElement { if (element !is JsonObject) return element - return JsonObject(element.filterValues { value -> when (value) { is JsonPrimitive -> value.content.isNotEmpty() diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NullableStringSerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NullableStringSerializer.kt new file mode 100644 index 00000000000..6755304a59b --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NullableStringSerializer.kt @@ -0,0 +1,49 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.Prerelease +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +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 +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement + +/** + * Convenience serializer for nullable String fields that combines + * ACCEPT_EMPTY_STRING_AS_NULL_OBJECT with null passthrough. + * An empty JSON string (`""`) or JSON null is decoded as null. + * + * Usage: + * + * @Serializable + * data class MyData( + * @Serializable(with = NullableStringSerializer::class) + * @SerialName("title") val title: String? = null, + * ) + */ +@Prerelease +object NullableStringSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("NullableString", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String? { + val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString() + return when (val element = jsonDecoder.decodeJsonElement()) { + is JsonPrimitive -> element.content.ifEmpty { null } + JsonNull -> null + else -> jsonDecoder.json.decodeFromJsonElement(String.serializer(), element) + } + } + + override fun serialize(encoder: Encoder, value: String?) { + @OptIn(ExperimentalSerializationApi::class) // encodeNull is experimental for now + if (value == null) encoder.encodeNull() else encoder.encodeString(value) + } +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt index c7f412eaa5c..147e33d8529 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt @@ -12,12 +12,12 @@ import kotlinx.serialization.json.JsonTransformingSerializer * * Usage: * - * @OptIn(ExperimentalSerializationApi::class) + * @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now * @KeepGeneratedSerializer * @Serializable(with = MyData.Serializer::class) * data class MyData( - * val fieldA: String = "", - * val fieldB: String = "", + * @SerialName("fieldA") val fieldA: String = "", + * @SerialName("fieldB") val fieldB: String = "", * ) { * object Serializer : WriteOnlySerializer( * MyData.generatedSerializer(), diff --git a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/serializers/SerializersTest.kt b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/serializers/SerializersTest.kt new file mode 100644 index 00000000000..89c0fea8178 --- /dev/null +++ b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/serializers/SerializersTest.kt @@ -0,0 +1,602 @@ +package com.lagradost.cloudstream3.utils.serializers + +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KeepGeneratedSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = NonEmptyData.Serializer::class) +data class NonEmptyData( + @SerialName("title") val title: String = "", + @SerialName("tags") val tags: List = emptyList(), + @SerialName("meta") val meta: Map = emptyMap(), + @SerialName("name") val name: String = "hello", +) { + object Serializer : NonEmptySerializer(NonEmptyData.generatedSerializer()) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = WriteOnlyData.Serializer::class) +data class WriteOnlyData( + @SerialName("fieldA") val fieldA: String = "", + @SerialName("fieldB") val fieldB: String = "", +) { + object Serializer : WriteOnlySerializer( + WriteOnlyData.generatedSerializer(), + setOf("fieldB"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = MultiWriteOnly.Serializer::class) +data class MultiWriteOnly( + @SerialName("fieldA") val fieldA: String = "", + @SerialName("fieldB") val fieldB: String = "", + @SerialName("fieldC") val fieldC: String = "", +) { + object Serializer : WriteOnlySerializer( + MultiWriteOnly.generatedSerializer(), + setOf("fieldB", "fieldC"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = SingleValueData.Serializer::class) +data class SingleValueData( + @SerialName("tags") val tags: List = emptyList(), + @SerialName("nums") val nums: List = emptyList(), +) { + object Serializer : JsonTransformSerializer( + SingleValueData.generatedSerializer(), + singleValueAsListKeys = setOf("tags", "nums"), + ) +} + +@Serializable +data class NestedMeta( + @SerialName("key") val key: String = "", +) + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = EmptyArrayData.Serializer::class) +data class EmptyArrayData( + @SerialName("meta") val meta: NestedMeta? = null, + @SerialName("other") val other: String = "hello", +) { + object Serializer : JsonTransformSerializer( + EmptyArrayData.generatedSerializer(), + emptyArrayAsNullKeys = setOf("meta"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = EmptyStringData.Serializer::class) +data class EmptyStringData( + @SerialName("title") val title: String? = null, + @SerialName("episode") val episode: NestedMeta? = null, + @SerialName("keep") val keep: String? = "hello", +) { + object Serializer : JsonTransformSerializer( + EmptyStringData.generatedSerializer(), + emptyStringAsNullKeys = setOf("title", "episode", "keep"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = SingleValueOrNullData.Serializer::class) +data class SingleValueOrNullData( + @SerialName("tags") val tags: List? = null, + @SerialName("nums") val nums: List? = null, + @SerialName("other") val other: String = "hello", +) { + object Serializer : JsonTransformSerializer( + SingleValueOrNullData.generatedSerializer(), + singleValueAsListKeys = setOf("tags", "nums"), + emptyArrayAsNullKeys = setOf("tags", "nums"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = NullableFieldsData.Serializer::class) +data class NullableFieldsData( + @SerialName("title") val title: String? = null, + @SerialName("meta") val meta: NestedMeta? = null, + @SerialName("other") val other: String = "hello", +) { + object Serializer : JsonTransformSerializer( + NullableFieldsData.generatedSerializer(), + emptyStringAsNullKeys = setOf("title"), + emptyArrayAsNullKeys = setOf("meta"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = SingleValueOrEmptyStringData.Serializer::class) +data class SingleValueOrEmptyStringData( + @SerialName("tags") val tags: List = emptyList(), + @SerialName("title") val title: String? = null, +) { + object Serializer : JsonTransformSerializer( + SingleValueOrEmptyStringData.generatedSerializer(), + singleValueAsListKeys = setOf("tags"), + emptyStringAsNullKeys = setOf("title"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now +@KeepGeneratedSerializer +@Serializable(with = AllTransformsData.Serializer::class) +data class AllTransformsData( + @SerialName("tags") val tags: List? = null, + @SerialName("title") val title: String? = null, + @SerialName("meta") val meta: NestedMeta? = null, + @SerialName("other") val other: String = "hello", +) { + object Serializer : JsonTransformSerializer( + AllTransformsData.generatedSerializer(), + singleValueAsListKeys = setOf("tags"), + emptyArrayAsNullKeys = setOf("tags", "meta"), + emptyStringAsNullKeys = setOf("title", "tags"), + ) +} + +@Serializable +data class FloatIntData( + @Serializable(with = FloatAsIntSerializer::class) + @SerialName("count") val count: Int = 0, +) + +@Serializable +data class FloatLongData( + @Serializable(with = FloatAsLongSerializer::class) + @SerialName("timestamp") val timestamp: Long = 0L, +) + +@Serializable +data class NullableStringData( + @Serializable(with = NullableStringSerializer::class) + @SerialName("title") val title: String? = null, +) + +class SerializersTest { + + @Test + fun nonEmptySerializerOmitsEmptyStrings() { + val data = NonEmptyData(title = "", name = "hello") + val result = data.toJson() + assertFalse(result.contains("title")) + assertTrue(result.contains("name")) + } + + @Test + fun nonEmptySerializerOmitsEmptyLists() { + val data = NonEmptyData(tags = emptyList(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("tags")) + } + + @Test + fun nonEmptySerializerOmitsEmptyMaps() { + val data = NonEmptyData(meta = emptyMap(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("meta")) + } + + @Test + fun nonEmptySerializerKeepsNonEmptyFields() { + val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v")) + val result = data.toJson() + assertTrue(result.contains("title")) + assertTrue(result.contains("tags")) + assertTrue(result.contains("meta")) + } + + @Test + fun nonEmptySerializerDoesNotAffectDeserialization() { + val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + assertEquals(listOf("a"), result.tags) + assertEquals(mapOf("k" to "v"), result.meta) + assertEquals("world", result.name) + } + + @Test + fun writeOnlySerializerOmitsFieldOnSerialize() { + val data = WriteOnlyData(fieldA = "hello", fieldB = "secret") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + } + + @Test + fun writeOnlySerializerDeserializesNormally() { + val input = """{"fieldA":"hello","fieldB":"secret"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("secret", result.fieldB) + } + + @Test + fun writeOnlySerializerDeserializesMissingAsDefault() { + val input = """{"fieldA":"hello"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("", result.fieldB) + } + + @Test + fun writeOnlySerializerHandlesMultipleKeys() { + val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + assertFalse(result.contains("fieldC")) + } + + @Test + fun singleValueAsListDecodesArrayNormally() { + val input = """{"tags":["a","b"],"nums":[1,2]}""" + val result = parseJson(input) + assertEquals(listOf("a", "b"), result.tags) + assertEquals(listOf(1, 2), result.nums) + } + + @Test + fun singleValueAsListWrapsBareStringInList() { + val input = """{"tags":"a","nums":[1]}""" + val result = parseJson(input) + assertEquals(listOf("a"), result.tags) + } + + @Test + fun singleValueAsListWrapsBareIntInList() { + val input = """{"tags":[],"nums":42}""" + val result = parseJson(input) + assertEquals(listOf(42), result.nums) + } + + @Test + fun singleValueAsListDecodesNullAsEmptyList() { + val input = """{"tags":null,"nums":[]}""" + val result = parseJson(input) + assertEquals(emptyList(), result.tags) + } + + @Test + fun singleValueAsListRoundtripsCorrectly() { + val data = SingleValueData(tags = listOf("x", "y"), nums = listOf(1, 2)) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun emptyArrayAsNullDecodesEmptyArrayAsNull() { + val input = """{"meta":[],"other":"hello"}""" + val result = parseJson(input) + assertNull(result.meta) + } + + @Test + fun emptyArrayAsNullDecodesNullAsNull() { + val input = """{"meta":null,"other":"hello"}""" + val result = parseJson(input) + assertNull(result.meta) + } + + @Test + fun emptyArrayAsNullDecodesObjectNormally() { + val input = """{"meta":{"key":"value"},"other":"hello"}""" + val result = parseJson(input) + assertEquals(NestedMeta("value"), result.meta) + } + + @Test + fun emptyArrayAsNullDoesNotAffectOtherFields() { + val input = """{"meta":[],"other":"world"}""" + val result = parseJson(input) + assertEquals("world", result.other) + } + + @Test + fun emptyArrayAsNullRoundtripsCorrectly() { + val data = EmptyArrayData(meta = NestedMeta("test"), other = "hello") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun emptyStringAsNullDecodesEmptyStringAsNull() { + val input = """{"title":"","episode":null,"keep":"hello"}""" + val result = parseJson(input) + assertNull(result.title) + } + + @Test + fun emptyStringAsNullDecodesNullAsNull() { + val input = """{"title":null,"episode":null,"keep":"hello"}""" + val result = parseJson(input) + assertNull(result.title) + } + + @Test + fun emptyStringAsNullKeepsNonEmptyString() { + val input = """{"title":"hello","episode":null,"keep":"world"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + } + + @Test + fun emptyStringAsNullDecodesObjectNormally() { + val input = """{"title":null,"episode":{"key":"value"},"keep":"hello"}""" + val result = parseJson(input) + assertEquals(NestedMeta("value"), result.episode) + } + + @Test + fun emptyStringAsNullDoesNotAffectNonEmptyFields() { + val input = """{"title":"","episode":null,"keep":"world"}""" + val result = parseJson(input) + assertEquals("world", result.keep) + } + + @Test + fun emptyStringAsNullRoundtripsCorrectly() { + val data = EmptyStringData(title = "hello", episode = NestedMeta("x"), keep = "world") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun singleValueOrNullWrapsBareValueInList() { + val input = """{"tags":"a","nums":1,"other":"hello"}""" + val result = parseJson(input) + assertEquals(listOf("a"), result.tags) + assertEquals(listOf(1), result.nums) + } + + @Test + fun singleValueOrNullTreatsEmptyArrayAsNull() { + val input = """{"tags":[],"nums":[],"other":"hello"}""" + val result = parseJson(input) + assertNull(result.tags) + assertNull(result.nums) + } + + @Test + fun singleValueOrNullDecodesArrayNormally() { + val input = """{"tags":["a","b"],"nums":[1,2],"other":"hello"}""" + val result = parseJson(input) + assertEquals(listOf("a", "b"), result.tags) + assertEquals(listOf(1, 2), result.nums) + } + + @Test + fun singleValueOrNullDoesNotAffectOtherFields() { + val input = """{"tags":[],"nums":[],"other":"world"}""" + val result = parseJson(input) + assertEquals("world", result.other) + } + + @Test + fun nullableFieldsEmptyStringBecomesNull() { + val input = """{"title":"","meta":{"key":"value"},"other":"hello"}""" + val result = parseJson(input) + assertNull(result.title) + assertEquals(NestedMeta("value"), result.meta) + } + + @Test + fun nullableFieldsEmptyArrayBecomesNull() { + val input = """{"title":"hello","meta":[],"other":"hello"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + assertNull(result.meta) + } + + @Test + fun nullableFieldsBothNullAtOnce() { + val input = """{"title":"","meta":[],"other":"hello"}""" + val result = parseJson(input) + assertNull(result.title) + assertNull(result.meta) + } + + @Test + fun nullableFieldsDoesNotAffectOtherFields() { + val input = """{"title":"","meta":[],"other":"world"}""" + val result = parseJson(input) + assertEquals("world", result.other) + } + + @Test + fun nullableFieldsRoundtripsCorrectly() { + val data = NullableFieldsData(title = "hello", meta = NestedMeta("x"), other = "world") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun singleValueOrEmptyStringWrapsBareString() { + val input = """{"tags":"a","title":"hello"}""" + val result = parseJson(input) + assertEquals(listOf("a"), result.tags) + assertEquals("hello", result.title) + } + + @Test + fun singleValueOrEmptyStringTurnsEmptyTitleToNull() { + val input = """{"tags":["a"],"title":""}""" + val result = parseJson(input) + assertEquals(listOf("a"), result.tags) + assertNull(result.title) + } + + @Test + fun singleValueOrEmptyStringRoundtripsCorrectly() { + val data = SingleValueOrEmptyStringData(tags = listOf("x"), title = "hello") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun allTransformsWrapsBareStringInList() { + val input = """{"tags":"a","title":"hello","meta":{"key":"value"},"other":"world"}""" + val result = parseJson(input) + assertEquals(listOf("a"), result.tags) + } + + @Test + fun allTransformsEmptyArrayTagsBecomesNull() { + val input = """{"tags":[],"title":"hello","meta":{"key":"value"},"other":"world"}""" + val result = parseJson(input) + assertNull(result.tags) + } + + @Test + fun allTransformsEmptyMetaBecomesNull() { + val input = """{"tags":["a"],"title":"hello","meta":[],"other":"world"}""" + val result = parseJson(input) + assertNull(result.meta) + } + + @Test + fun allTransformsEmptyTitleBecomesNull() { + val input = """{"tags":["a"],"title":"","meta":{"key":"value"},"other":"world"}""" + val result = parseJson(input) + assertNull(result.title) + } + + @Test + fun allTransformsAllNullAtOnce() { + val input = """{"tags":[],"title":"","meta":[],"other":"world"}""" + val result = parseJson(input) + assertNull(result.tags) + assertNull(result.title) + assertNull(result.meta) + } + + @Test + fun allTransformsDoesNotAffectOtherFields() { + val input = """{"tags":[],"title":"","meta":[],"other":"world"}""" + val result = parseJson(input) + assertEquals("world", result.other) + } + + @Test + fun allTransformsRoundtripsCorrectly() { + val data = AllTransformsData(tags = listOf("a"), title = "hello", meta = NestedMeta("x"), other = "world") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun floatAsIntTruncatesFloat() { + val input = """{"count":3.9}""" + val result = parseJson(input) + assertEquals(3, result.count) + } + + @Test + fun floatAsIntDecodesIntNormally() { + val input = """{"count":42}""" + val result = parseJson(input) + assertEquals(42, result.count) + } + + @Test + fun floatAsIntHandlesNegativeFloat() { + val input = """{"count":-2.7}""" + val result = parseJson(input) + assertEquals(-2, result.count) + } + + @Test + fun floatAsIntRoundtripsCorrectly() { + val data = FloatIntData(count = 7) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun floatAsLongTruncatesFloat() { + val input = """{"timestamp":1234567890.9}""" + val result = parseJson(input) + assertEquals(1234567890L, result.timestamp) + } + + @Test + fun floatAsLongDecodesLongNormally() { + val input = """{"timestamp":9999999999}""" + val result = parseJson(input) + assertEquals(9999999999L, result.timestamp) + } + + @Test + fun floatAsLongHandlesNegativeFloat() { + val input = """{"timestamp":-100.6}""" + val result = parseJson(input) + assertEquals(-100L, result.timestamp) + } + + @Test + fun floatAsLongRoundtripsCorrectly() { + val data = FloatLongData(timestamp = 1700000000L) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } + + @Test + fun nullableStringDecodesEmptyStringAsNull() { + val input = """{"title":""}""" + val result = parseJson(input) + assertNull(result.title) + } + + @Test + fun nullableStringDecodesNullAsNull() { + val input = """{"title":null}""" + val result = parseJson(input) + assertNull(result.title) + } + + @Test + fun nullableStringKeepsNonEmptyString() { + val input = """{"title":"hello"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + } + + @Test + fun nullableStringRoundtripsCorrectly() { + val data = NullableStringData(title = "world") + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data, decoded) + } +} From ab2113fc1b5b77a256251a1c314a6ecc3fa12b19 Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:00:32 -0600 Subject: [PATCH 2/3] Add newline --- .../cloudstream3/utils/serializers/FloatAsIntSerializer.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsIntSerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsIntSerializer.kt index afbeb1f5fe2..12c8f6ed021 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsIntSerializer.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsIntSerializer.kt @@ -27,6 +27,7 @@ import kotlinx.serialization.json.intOrNull */ @Prerelease object FloatAsIntSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("FloatAsInt", PrimitiveKind.INT) From d92fd9ff0a3abd40748f5f8983e18de1e8d96eee Mon Sep 17 00:00:00 2001 From: Luna712 <142361265+Luna712@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:00:51 -0600 Subject: [PATCH 3/3] Add newline --- .../cloudstream3/utils/serializers/FloatAsLongSerializer.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsLongSerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsLongSerializer.kt index 31f3857b5f7..7fc394107bc 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsLongSerializer.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/FloatAsLongSerializer.kt @@ -27,6 +27,7 @@ import kotlinx.serialization.json.longOrNull */ @Prerelease object FloatAsLongSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("FloatAsLong", PrimitiveKind.LONG)