diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae530192998..b23be183198 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.android.application) alias(libs.plugins.dokka) + alias(libs.plugins.kotlin.serialization) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -206,9 +207,12 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.json) androidTestImplementation(libs.core) - implementation(libs.junit.ktx) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.kotlin.test) + androidTestImplementation(libs.classgraph) + androidTestImplementation(libs.instancio.core) + implementation(libs.junit.ktx) // Android Core & Lifecycle implementation(libs.core.ktx) @@ -219,6 +223,7 @@ dependencies { implementation(libs.bundles.lifecycle) implementation(libs.bundles.navigation) implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.serialization.json) // JSON Parser // Design & UI implementation(libs.preference.ktx) diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt new file mode 100644 index 00000000000..0b19535cbd7 --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt @@ -0,0 +1,127 @@ +package com.lagradost.cloudstream3 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import io.github.classgraph.ClassGraph +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import kotlinx.serialization.serializerOrNull +import org.instancio.Instancio +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.reflect.KClass +import kotlin.reflect.jvm.jvmName +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(AndroidJUnit4::class) +class SerializationClassTester { + // Same as app, or using app reference + val jacksonMapper = mapper + val kotlinxMapper = json + + @Test + fun isIdenticalSerialization() { + val serializableClasses = findSerializableClasses("com.lagradost") + println("Number of serializable classes: ${serializableClasses.size}") + + serializableClasses.forEach { kClass -> + val instance = Instancio.create(kClass.java) + + val jacksonJson = jacksonMapper.writeValueAsString(instance) + val kotlinxJson = serializeWithKotlinx(kClass, instance) + + assertEquals( + jacksonJson, + kotlinxJson, + """ + Serialization mismatch for: + ${kClass.qualifiedName} + + Jackson: + $jacksonJson + + Kotlinx: + $kotlinxJson + + """.trimIndent() + ) + println("Identical serialization for: ${kClass.jvmName}") + } + } + + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + @Test + fun isIdenticalDeserialization() { + val serializableClasses = findSerializableClasses("com.lagradost") + println("Number of serializable classes: ${serializableClasses.size}") + + serializableClasses.forEach { kClass -> + val instance = Instancio.create(kClass.java) + // Convert to JSON to get example JSON object + // We prefer jackson here because the app may have many jackson JSON strings in local storage + val originalJson = jacksonMapper.writeValueAsString(instance) + + // Create an object from the JSON using kotlinx + val serializer = + kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass) + assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!") + val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson) + + // Create an object from the JSON using jackson + val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java) + + + // Deep inspect both object using the mapper toJson function. + // This deep equality check can be performed using other methods, but this just works. + val jacksonJson = mapperDecoded.toJson() + val kotlinxJson = kotlinxDecoded.toJson() + + assertEquals( + jacksonJson, + kotlinxJson, + """ + Serialization mismatch for: + ${kClass.qualifiedName} + + Jackson: + $jacksonJson + + Kotlinx: + $kotlinxJson + + """.trimIndent() + ) + println("Identical deserialization for: ${kClass.jvmName}") + } + } + + private fun findSerializableClasses(packageName: String): List> { + val context = InstrumentationRegistry + .getInstrumentation() + .targetContext + + return ClassGraph() + .enableClassInfo() + .enableAnnotationInfo() + .overrideClassLoaders(context.classLoader) + .acceptPackages(packageName) + .scan() + .getClassesWithAnnotation(Serializable::class.java.name) + .mapNotNull { runCatching { Class.forName(it.name, false, context.classLoader).kotlin }.getOrNull() } + } + + @OptIn(InternalSerializationApi::class) + @Suppress("UNCHECKED_CAST") + private fun serializeWithKotlinx( + kClass: KClass<*>, + value: Any + ): String { + val serializer = kClass.serializer() as KSerializer + return kotlinxMapper.encodeToString(serializer, value) + } +} 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 new file mode 100644 index 00000000000..15ad532f85e --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt @@ -0,0 +1,157 @@ +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/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index eae14a6c0c3..c15c6ea72a6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -610,7 +610,7 @@ object PluginManager { return false } InputStreamReader(stream).use { reader -> - manifest = parseJson(reader, BasePlugin.Manifest::class.java) + manifest = parseJson(reader.readText()) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt index c4095e2d881..84a498bb005 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -16,7 +16,6 @@ import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SimklSyncServices import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mapper import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING @@ -30,6 +29,7 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import com.lagradost.cloudstream3.utils.txt import java.math.BigInteger @@ -117,13 +117,8 @@ class SimklApi : SyncAPI() { * Gets cached object, if object is not fresh returns null and removes it from cache */ inline fun getKey(path: String): T? { - // Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject" - val type = mapper.typeFactory.constructParametricType( - SimklCacheWrapper::class.java, - T::class.java - ) val cache = getKey(SIMKL_CACHE_KEY, path)?.let { - mapper.readValue>(it, type) + tryParseJson>(it) } return if (cache?.isFresh() == true) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt index f91d40f28e0..2aadfb13c5a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -12,9 +12,6 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.ListView import androidx.appcompat.app.AlertDialog -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.json.JsonMapper -import com.fasterxml.jackson.module.kotlin.kotlinModule import com.google.android.gms.cast.MediaLoadOptions import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaSeekOptions @@ -105,9 +102,6 @@ data class MetadataHolder( class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { - private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() - init { view.setImageResource(R.drawable.ic_baseline_playlist_play_24) view.setOnClickListener { @@ -449,4 +443,4 @@ class ControllerActivity : ExpandedControllerActivity() { SkipNextEpisodeController(skipOpButton) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 7dfe3cf5988..57e17cb1267 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -83,6 +83,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.CastHelper.startCast import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWork @@ -1324,7 +1325,7 @@ class ResultViewModel2 : ViewModel() { episodeIds: Array, watchState: VideoWatchState ) { - val watchStateString = DataStore.mapper.writeValueAsString(watchState) + val watchStateString = watchState.toJson() episodeIds.forEach { if (getVideoWatchState(it.toInt()) != watchState) { editor.setKeyRaw( @@ -2706,4 +2707,4 @@ class ResultViewModel2 : ViewModel() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 88cb7481c9a..62426197ef4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -10,7 +10,6 @@ import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R @@ -21,11 +20,12 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi.Companion.KITSU_CACHED_LIST +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs -import com.lagradost.cloudstream3.utils.DataStore.mapper import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.setupStream @@ -133,9 +133,7 @@ object BackupUtils { ) @Suppress("UNCHECKED_CAST") - private fun getBackup(context: Context?): BackupFile? { - if (context == null) return null - + private fun getBackup(context: Context): BackupFile { val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } @@ -214,7 +212,7 @@ object BackupUtils { fileStream = stream.openNew() printStream = PrintWriter(fileStream) - printStream.print(mapper.writeValueAsString(backupFile)) + printStream.print(backupFile.toJson()) showToast( R.string.backup_success, @@ -259,8 +257,8 @@ object BackupUtils { val input = activity.contentResolver.openInputStream(uri) ?: return@ioSafe - val restoredValue = - mapper.readValue(input) + val text = input.bufferedReader().readText() + val restoredValue = parseJson(text) restore( activity, diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 0a1db85fadb..221eff01230 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -2,17 +2,16 @@ package com.lagradost.cloudstream3.utils import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit import androidx.preference.PreferenceManager -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.json.JsonMapper -import com.fasterxml.jackson.module.kotlin.kotlinModule import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson import kotlin.reflect.KClass import kotlin.reflect.KProperty -import androidx.core.content.edit /** Used to display metadata about downloads and resume watching */ const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -88,8 +87,18 @@ data class Editor( } object DataStore { - val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() + // Extensions shouldn't have really been using this version of it, but it seems + // some have. Since there has always been a very easy alternative, we won't + // need to deprecate it that long, and should be able to fully remove it + // once extensions at least use the other version. + @Deprecated( + "Please do not use the mapper version from DataStore. Preferably use methods from AppUtils " + + "to parse JSON. However, you can use the stable-API version of the mapper at " + + "com.lagradost.cloudstream3.mapper to access the mapper directly if necessary.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith("com.lagradost.cloudstream3.mapper"), + ) + val mapper = com.lagradost.cloudstream3.mapper private fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) @@ -99,7 +108,6 @@ object DataStore { return getPreferences(this) } - fun getFolderName(folder: String, path: String): String { return "${folder}/${path}" } @@ -165,17 +173,17 @@ object DataStore { fun Context.setKey(path: String, value: T) { try { getSharedPrefs().edit { - putString(path, mapper.writeValueAsString(value)) + putString(path, value?.let { it.toJson() }) } } catch (e: Exception) { logError(e) } } - fun Context.getKey(path: String, valueType: Class): T? { + fun Context.getKey(path: String, valueType: Class): T? { try { val json: String = getSharedPrefs().getString(path, null) ?: return null - return json.toKotlinObject(valueType) + return parseJson(json, valueType.kotlin) } catch (e: Exception) { return null } @@ -186,11 +194,11 @@ object DataStore { } inline fun String.toKotlinObject(): T { - return mapper.readValue(this, T::class.java) + return parseJson(this) } - fun String.toKotlinObject(valueType: Class): T { - return mapper.readValue(this, valueType) + fun String.toKotlinObject(valueType: Class): T { + return parseJson(this, valueType.kotlin) } // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR @@ -214,4 +222,4 @@ object DataStore { inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { return getKey(getFolderName(folder, path), defVal) ?: defVal } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt index 351e77c8d72..e107c89418b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -8,7 +8,7 @@ import com.lagradost.cloudstream3.APIHolder.apis //import com.lagradost.cloudstream3.animeproviders.AniflixProvider import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import java.util.concurrent.TimeUnit object SyncUtil { @@ -71,7 +71,7 @@ object SyncUtil { val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/pages/$site/$slug.json" val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text - val mapped = parseJson(response) + val mapped = tryParseJson(response) val overrideMal = mapped?.malId ?: mapped?.mal?.id ?: mapped?.anilist?.malId val overrideAnilist = mapped?.aniId ?: mapped?.anilist?.id @@ -169,4 +169,4 @@ object SyncUtil { @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String? ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt new file mode 100644 index 00000000000..7c73a688974 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt @@ -0,0 +1,40 @@ +package com.lagradost.cloudstream3.utils.serializers + +import android.net.Uri +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 + +/** + * Custom KSerializer for Android's [Uri] type. + * + * Uri is an Android platform type and cannot be annotated with @Serializable directly. + * Registering it in a SerializersModule globally would require a custom module passed to + * every Json instance, which adds hidden coupling. This serializer is also used sparingly + * across the codebase, so the overhead of a global registration isn't justified. + * Instead, we keep it explicit so that each usage site opts in intentionally and the + * serialization behavior remains visible. + * + * Usage: + * + * @Serializable + * data class MyData( + * @Serializable(with = UriSerializer::class) + * val uri: Uri, + * ) + */ +object UriSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Uri) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Uri { + return Uri.parse(decoder.decodeString()) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index e35c1f61148..609a94b3ad4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.dokka) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.serialization) apply false } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a97145c3f81..0541bb5cdb7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ annotation = "1.10.0" appcompat = "1.7.1" biometric = "1.4.0-alpha06" buildkonfigGradlePlugin = "0.18.0" +classgraph = "4.8.184" coil = { strictly = "3.3.0" } # Later versions require jvmTarget 11 or later colorpicker = "6b46b49" conscryptAndroid = { strictly = "2.5.2" } # 2.5.3 crashes everything @@ -18,6 +19,7 @@ dokkaGradlePlugin = "2.2.0" espressoCore = "3.7.0" fragmentKtx = "1.8.9" fuzzywuzzy = "1.4.0" +instancioCore = "5.5.1" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) json = "20251224" jsoup = "1.22.1" @@ -28,6 +30,7 @@ juniversalchardet = "2.5.0" kotlinGradlePlugin = "2.3.20" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" +kotlinxSerializationJson = "1.11.0" lifecycleKtx = "2.10.0" material = "1.14.0-beta01" media3 = "1.9.3" @@ -62,6 +65,7 @@ anime-db = { module = "com.github.recloudstream:anime-db", version.ref = "animeD annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } +classgraph = { group = "io.github.classgraph", name = "classgraph", version.ref = "classgraph" } coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } colorpicker = { module = "com.github.recloudstream:color-picker-android", version.ref = "colorpicker" } @@ -75,14 +79,17 @@ espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = ext-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } fuzzywuzzy = { module = "me.xdrop:fuzzywuzzy", version.ref = "fuzzywuzzy" } +instancio-core = { group = "org.instancio", name = "instancio-core", version.ref = "instancioCore" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } json = { module = "org.json:json", version.ref = "json" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit = { module = "junit:junit", version.ref = "junit" } junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" } juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardet" } +kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinGradlePlugin" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } material = { module = "com.google.android.material:material", version.ref = "material" } @@ -125,6 +132,7 @@ buildkonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfigG dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaGradlePlugin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm" , version.ref = "kotlinGradlePlugin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlinGradlePlugin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinGradlePlugin" } [bundles] coil = ["coil", "coil-network-okhttp"] diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 073e49e6483..bbbc0b277b5 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.android.multiplatform.library) alias(libs.plugins.buildkonfig) alias(libs.plugins.dokka) + alias(libs.plugins.kotlin.serialization) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -57,6 +58,7 @@ kotlin { implementation(libs.nicehttp) // HTTP Lib implementation(libs.jackson.module.kotlin) // JSON Parser implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) // JSON Parser implementation(libs.fuzzywuzzy) // Match Extractors implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt index c590165a1ad..793b7370437 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt @@ -28,6 +28,7 @@ import okhttp3.RequestBody.Companion.toRequestBody import java.net.URI import java.text.SimpleDateFormat import java.util.* +import kotlinx.serialization.json.Json import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.absoluteValue @@ -74,6 +75,13 @@ const val USER_AGENT = class ErrorLoadingException(message: String? = null) : Exception(message) //val baseHeader = mapOf("User-Agent" to USER_AGENT) + +@Prerelease +val json = Json { + encodeDefaults = true + ignoreUnknownKeys = true +} + val mapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!! diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt index 4b163867de3..127b075daae 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt @@ -1,39 +1,33 @@ package com.lagradost.cloudstream3 -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.ResponseParser import kotlin.reflect.KClass // Short name for requests client to make it nicer to use -private val jacksonResponseParser = object : ResponseParser { - val mapper: ObjectMapper = jacksonObjectMapper().configure( - DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, - false - ) - +private val jsonResponseParser = object : ResponseParser { override fun parse(text: String, kClass: KClass): T { - return mapper.readValue(text, kClass.java) + return parseJson(text, kClass) } override fun parseSafe(text: String, kClass: KClass): T? { return try { - mapper.readValue(text, kClass.java) - } catch (e: Exception) { + parse(text, kClass) + } catch (_: Exception) { null } } override fun writeValueAsString(obj: Any): String { - return mapper.writeValueAsString(obj) + return obj.toJson() } } /** The default networking helper. This helper performs SSL checks. * If you need to make requests to websites with invalid SSL certificates use insecureApp instead. */ -var app = Requests(responseParser = jacksonResponseParser).apply { +var app = Requests(responseParser = jsonResponseParser).apply { defaultHeaders = mapOf("user-agent" to USER_AGENT) } @@ -41,6 +35,6 @@ var app = Requests(responseParser = jacksonResponseParser).apply { * This should NEVER be used for sensitive networking operations such as logins. Only use this when required. */ @Prerelease @UnsafeSSL -var insecureApp = Requests(responseParser = jacksonResponseParser).apply { +var insecureApp = Requests(responseParser = jsonResponseParser).apply { defaultHeaders = mapOf("user-agent" to USER_AGENT) -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt index ea6fba73b85..b80534db2b8 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/HDMomPlayerExtractor.kt @@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.extractors +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.api.Log import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.extractors.helper.AesHelper -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.utils.AppUtils.parseJson open class HDMomPlayer : ExtractorApi() { override val name = "HDMomPlayer" @@ -32,7 +31,7 @@ open class HDMomPlayer : ExtractorApi() { val trackStr = Regex("""tracks:\[([^\]]+)""").find(iSource)?.groupValues?.get(1) if (trackStr != null) { - val tracks:List = jacksonObjectMapper().readValue("[${trackStr}]") + val tracks:List = parseJson>("[${trackStr}]") for (track in tracks) { if (track.file == null || track.label == null) continue @@ -68,4 +67,4 @@ open class HDMomPlayer : ExtractorApi() { @JsonProperty("language") val language: String?, @JsonProperty("default") val default: String? ) -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt index 583d92322a9..bc94ae0cc2a 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/VideoSeyredExtractor.kt @@ -2,12 +2,11 @@ package com.lagradost.cloudstream3.extractors +import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.api.Log import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.utils.* -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson open class VideoSeyred : ExtractorApi() { override val name = "VideoSeyred" @@ -20,7 +19,7 @@ open class VideoSeyred : ExtractorApi() { val videoUrl = "${mainUrl}/playlist/${videoId}.json" val responseRaw = app.get(videoUrl) - val responseList:List = jacksonObjectMapper().readValue(responseRaw.text) ?: throw ErrorLoadingException("VideoSeyred") + val responseList: List = tryParseJson>(responseRaw.text) ?: throw ErrorLoadingException("VideoSeyred") val response = responseList[0] for (track in response.tracks) { @@ -68,4 +67,4 @@ open class VideoSeyred : ExtractorApi() { @JsonProperty("label") val label: String? = null, @JsonProperty("default") val default: String? = null ) -} \ No newline at end of file +} diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt index 6832ab8d27f..223ced73dcb 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -1,21 +1,62 @@ package com.lagradost.cloudstream3.utils import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.InternalAPI +import com.lagradost.cloudstream3.json import com.lagradost.cloudstream3.mapper -import java.io.Reader +import com.lagradost.cloudstream3.mvvm.logError +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.serializerOrNull +import kotlin.reflect.KClass +@OptIn(ExperimentalSerializationApi::class, InternalSerializationApi::class) object AppUtils { /** Any object as json string */ fun Any.toJson(): String { if (this is String) return this - return mapper.writeValueAsString(this) + // @Serializable generates a serializer at compile time; contextual serializers are + // registered manually in serializersModule, we need both to support all cases + val serializer = this::class.serializerOrNull() ?: json.serializersModule.getContextual(this::class) + return if (serializer != null) { + try { + @Suppress("UNCHECKED_CAST") + json.encodeToString(serializer as KSerializer, this) + } catch (e: SerializationException) { + logError(e) + mapper.writeValueAsString(this) + } + } else { + mapper.writeValueAsString(this) + } } - inline fun parseJson(value: String): T { - return mapper.readValue(value) + inline fun parseJson(value: String): T { + // @Serializable generates a serializer at compile time; contextual serializers are + // registered manually in serializersModule, we need both to support all cases + val serializer = T::class.serializerOrNull() ?: json.serializersModule.getContextual(T::class) + return if (serializer != null) { + try { + json.decodeFromString(serializer, value) + } catch (e: SerializationException) { + logError(e) + mapper.readValue(value) + } + } else { + mapper.readValue(value) + } } - inline fun parseJson(reader: Reader, valueType: Class): T { + @Deprecated( + "This overload was only ever used for BasePlugin.Manifest which has since been migrated. " + + "No other code should be using this. Use reader.readText() and call parseJson(String) instead.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith("parseJson(reader.readText())") + ) + inline fun parseJson(reader: java.io.Reader, valueType: Class): T { + // Reader-based parsing has no kotlinx equivalent, fall back to Jackson return mapper.readValue(reader, valueType) } @@ -26,4 +67,21 @@ object AppUtils { null } } -} \ No newline at end of file + + @InternalAPI + fun parseJson(value: String, kClass: KClass): T { + // @Serializable generates a serializer at compile time; contextual serializers are + // registered manually in serializersModule, we need both to support all cases + val serializer = kClass.serializerOrNull() ?: json.serializersModule.getContextual(kClass) + return if (serializer != null) { + try { + json.decodeFromString(serializer, value) + } catch (e: SerializationException) { + logError(e) + mapper.readValue(value, kClass.java) + } + } else { + mapper.readValue(value, kClass.java) + } + } +} 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 new file mode 100644 index 00000000000..735539a1ca5 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt @@ -0,0 +1,45 @@ +package com.lagradost.cloudstream3.utils.serializers + +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 + +/** + * Replicates Jackson's @JsonInclude(JsonInclude.Include.NON_EMPTY) behaviour. + * Strips null, empty strings, empty arrays, and empty objects from the serialized + * output. Requires the enclosing Json instance to have encodeDefaults = true, + * which is already in our default global Json instance. + * + * Usage: + * + * @OptIn(ExperimentalSerializationApi::class) + * @KeepGeneratedSerializer + * @Serializable(with = MyData.Serializer::class) + * data class MyData( + * val tags: List = emptyList(), + * val title: String = "", + * val meta: Map = emptyMap(), + * ) { + * object Serializer : NonEmptySerializer(MyData.generatedSerializer()) + * } + */ +abstract class NonEmptySerializer(tSerializer: KSerializer) : + JsonTransformingSerializer(tSerializer) { + + override fun transformSerialize(element: JsonElement): JsonElement { + if (element !is JsonObject) return element + + return JsonObject(element.filterValues { value -> + when (value) { + is JsonPrimitive -> value.content.isNotEmpty() + is JsonArray -> value.isNotEmpty() + is JsonObject -> value.isNotEmpty() + JsonNull -> false + } + }) + } +} 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 new file mode 100644 index 00000000000..1b36362e996 --- /dev/null +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt @@ -0,0 +1,36 @@ +package com.lagradost.cloudstream3.utils.serializers + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer + +/** + * Replicates Jackson's @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) behaviour. + * Properties in [keysToIgnore] are deserialized normally but omitted from serialized output. + * + * Usage: + * + * @OptIn(ExperimentalSerializationApi::class) + * @KeepGeneratedSerializer + * @Serializable(with = MyData.Serializer::class) + * data class MyData( + * val fieldA: String = "", + * val fieldB: String = "", + * ) { + * object Serializer : WriteOnlySerializer( + * MyData.generatedSerializer(), + * setOf("fieldB"), + * ) + * } + */ +abstract class WriteOnlySerializer( + tSerializer: KSerializer, + private val keysToIgnore: Set, +) : JsonTransformingSerializer(tSerializer) { + + override fun transformSerialize(element: JsonElement): JsonElement { + if (element !is JsonObject) return element + return JsonObject(element.filterKeys { it !in keysToIgnore }) + } +}