From 6822972ea26aac362e60ba44acdbcd9d4e45b8ac Mon Sep 17 00:00:00 2001 From: ibrahim-iqbal Date: Tue, 27 Oct 2026 12:35:50 +0530 Subject: [PATCH 1/2] feat: migrate from GSON to kotlinx.serialization Replace Gson with kotlinx.serialization across the entire codebase: - Swap retrofit-gson for retrofit-kotlinx-serialization in version catalog - Replace GsonConverterFactory with Json.asConverterFactory in NetworkDataSource - Add @Serializable and @SerialName annotations to all DTO classes (LatestRelease, AssetsItem, GoogleReaderDTO.*, Diff) - Migrate SecurityKey hierarchy to use reified inline decode() with @PublishedApi internal Json instance (addresses PR #997 review feedback about keeping abstract class, not sealed) - Replace Gson in ProviderAPI, DiffMapHolder, and DataStoreExt with kotlinx.serialization equivalents using MapSerializer and JsonElement - Add SerializationCompatibilityTest verifying JSON round-trip compatibility with previous Gson output format Closes #994 --- app/build.gradle.kts | 2 +- .../ash/reader/domain/data/DiffMapHolder.kt | 17 +- .../account/security/FeverSecurityKey.kt | 28 +-- .../account/security/FreshRSSSecurityKey.kt | 28 +-- .../security/GoogleReaderSecurityKey.kt | 28 +-- .../account/security/LocalSecurityKey.kt | 13 +- .../model/account/security/SecurityKey.kt | 11 +- .../ash/reader/domain/service/AppService.kt | 6 +- .../infrastructure/net/NetworkDataSource.kt | 28 +-- .../rss/provider/ProviderAPI.kt | 7 +- .../rss/provider/greader/GoogleReaderDTO.kt | 171 ++++-------------- .../java/me/ash/reader/ui/ext/DataStoreExt.kt | 47 +++-- .../SerializationCompatibilityTest.kt | 138 ++++++++++++++ gradle/libs.versions.toml | 2 +- 14 files changed, 302 insertions(+), 224 deletions(-) create mode 100644 app/src/test/java/me/ash/reader/domain/model/account/security/SerializationCompatibilityTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b9551c106..a11bc7475 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -164,7 +164,7 @@ dependencies { implementation(libs.okhttp) implementation(libs.okhttp.coroutines) implementation(libs.retrofit) - implementation(libs.retrofit.gson) + implementation(libs.retrofit.kotlinx.serialization) implementation(libs.profileinstaller) implementation(libs.work.runtime.ktx) implementation(libs.datastore.preferences) diff --git a/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt b/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt index dc32f4abc..055a619b8 100644 --- a/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt +++ b/app/src/main/java/me/ash/reader/domain/data/DiffMapHolder.kt @@ -3,8 +3,10 @@ package me.ash.reader.domain.data import android.content.Context import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.snapshotFlow -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -55,7 +57,8 @@ class DiffMapHolder @Inject constructor( val shouldSyncWithRemote get() = currentAccount?.type != AccountType.Local - private val gson = Gson() + private val json = Json { ignoreUnknownKeys = true } + private val diffMapSerializer = MapSerializer(String.serializer(), Diff.serializer()) private val cacheDir = context.cacheDir.resolve("diff") private var userCacheDir = cacheDir @@ -210,7 +213,7 @@ class DiffMapHolder @Inject constructor( private fun writeDiffsToCache() { applicationScope.launch(ioDispatcher) { try { - val tmpJson = gson.toJson(diffMap) + val tmpJson = json.encodeToString(diffMapSerializer, diffMap) userCacheDir.mkdirs() cacheFile.createNewFile() if (cacheFile.exists() && cacheFile.canWrite()) { @@ -258,9 +261,8 @@ class DiffMapHolder @Inject constructor( applicationScope.launch(ioDispatcher) { if (cacheFile.exists() && cacheFile.canRead()) { val tmpJson = cacheFile.readText() - val mapType = object : TypeToken>() {}.type - val diffMapFromCache = gson.fromJson>( - tmpJson, mapType + val diffMapFromCache = json.decodeFromString( + diffMapSerializer, tmpJson ) diffMapFromCache?.let { diffMap.clear() @@ -282,6 +284,7 @@ class DiffMapHolder @Inject constructor( } } +@Serializable data class Diff( val isUnread: Boolean, val articleId: String, val feedId: String ) { diff --git a/app/src/main/java/me/ash/reader/domain/model/account/security/FeverSecurityKey.kt b/app/src/main/java/me/ash/reader/domain/model/account/security/FeverSecurityKey.kt index 55de0d664..3eaeff8c8 100644 --- a/app/src/main/java/me/ash/reader/domain/model/account/security/FeverSecurityKey.kt +++ b/app/src/main/java/me/ash/reader/domain/model/account/security/FeverSecurityKey.kt @@ -1,25 +1,25 @@ package me.ash.reader.domain.model.account.security -class FeverSecurityKey private constructor() : SecurityKey() { +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString - var serverUrl: String? = null - var username: String? = null - var password: String? = null - var clientCertificateAlias: String? = null +@Serializable +class FeverSecurityKey( + var serverUrl: String? = null, + var username: String? = null, + var password: String? = null, + var clientCertificateAlias: String? = null, +) : SecurityKey() { - constructor(serverUrl: String?, username: String?, password: String?, clientCertificateAlias: String?) : this() { - this.serverUrl = serverUrl - this.username = username - this.password = password - this.clientCertificateAlias = clientCertificateAlias - } - - constructor(value: String? = DESUtils.empty) : this() { - decode(value, FeverSecurityKey::class.java).let { + constructor(value: String?) : this() { + decode(value).let { serverUrl = it.serverUrl username = it.username password = it.password clientCertificateAlias = it.clientCertificateAlias } } + + override fun toString(): String = + DESUtils.encrypt(json.encodeToString(this)) } diff --git a/app/src/main/java/me/ash/reader/domain/model/account/security/FreshRSSSecurityKey.kt b/app/src/main/java/me/ash/reader/domain/model/account/security/FreshRSSSecurityKey.kt index 611d22741..d8db549f3 100644 --- a/app/src/main/java/me/ash/reader/domain/model/account/security/FreshRSSSecurityKey.kt +++ b/app/src/main/java/me/ash/reader/domain/model/account/security/FreshRSSSecurityKey.kt @@ -1,25 +1,25 @@ package me.ash.reader.domain.model.account.security -class FreshRSSSecurityKey private constructor() : SecurityKey() { +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString - var serverUrl: String? = null - var username: String? = null - var password: String? = null - var clientCertificateAlias: String? = null +@Serializable +class FreshRSSSecurityKey( + var serverUrl: String? = null, + var username: String? = null, + var password: String? = null, + var clientCertificateAlias: String? = null, +) : SecurityKey() { - constructor(serverUrl: String?, username: String?, password: String?, clientCertificateAlias: String?) : this() { - this.serverUrl = serverUrl - this.username = username - this.password = password - this.clientCertificateAlias = clientCertificateAlias - } - - constructor(value: String? = DESUtils.empty) : this() { - decode(value, FreshRSSSecurityKey::class.java).let { + constructor(value: String?) : this() { + decode(value).let { serverUrl = it.serverUrl username = it.username password = it.password clientCertificateAlias = it.clientCertificateAlias } } + + override fun toString(): String = + DESUtils.encrypt(json.encodeToString(this)) } diff --git a/app/src/main/java/me/ash/reader/domain/model/account/security/GoogleReaderSecurityKey.kt b/app/src/main/java/me/ash/reader/domain/model/account/security/GoogleReaderSecurityKey.kt index f824191df..81b0e299f 100644 --- a/app/src/main/java/me/ash/reader/domain/model/account/security/GoogleReaderSecurityKey.kt +++ b/app/src/main/java/me/ash/reader/domain/model/account/security/GoogleReaderSecurityKey.kt @@ -1,25 +1,25 @@ package me.ash.reader.domain.model.account.security -class GoogleReaderSecurityKey private constructor() : SecurityKey() { +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString - var serverUrl: String? = null - var username: String? = null - var password: String? = null - var clientCertificateAlias: String? = null +@Serializable +class GoogleReaderSecurityKey( + var serverUrl: String? = null, + var username: String? = null, + var password: String? = null, + var clientCertificateAlias: String? = null, +) : SecurityKey() { - constructor(serverUrl: String?, username: String?, password: String?, clientCertificateAlias: String?) : this() { - this.serverUrl = serverUrl - this.username = username - this.password = password - this.clientCertificateAlias = clientCertificateAlias - } - - constructor(value: String? = DESUtils.empty) : this() { - decode(value, GoogleReaderSecurityKey::class.java).let { + constructor(value: String?) : this() { + decode(value).let { serverUrl = it.serverUrl username = it.username password = it.password clientCertificateAlias = it.clientCertificateAlias } } + + override fun toString(): String = + DESUtils.encrypt(json.encodeToString(this)) } diff --git a/app/src/main/java/me/ash/reader/domain/model/account/security/LocalSecurityKey.kt b/app/src/main/java/me/ash/reader/domain/model/account/security/LocalSecurityKey.kt index 4cedec6e0..8082291a9 100644 --- a/app/src/main/java/me/ash/reader/domain/model/account/security/LocalSecurityKey.kt +++ b/app/src/main/java/me/ash/reader/domain/model/account/security/LocalSecurityKey.kt @@ -1,10 +1,15 @@ package me.ash.reader.domain.model.account.security -class LocalSecurityKey private constructor() : SecurityKey() { +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString - constructor(value: String? = DESUtils.empty) : this() { - decode(value, LocalSecurityKey::class.java).let { +@Serializable +class LocalSecurityKey() : SecurityKey() { - } + constructor(value: String?) : this() { + decode(value) } + + override fun toString(): String = + DESUtils.encrypt(json.encodeToString(this)) } diff --git a/app/src/main/java/me/ash/reader/domain/model/account/security/SecurityKey.kt b/app/src/main/java/me/ash/reader/domain/model/account/security/SecurityKey.kt index fb44d19d7..92f677c61 100644 --- a/app/src/main/java/me/ash/reader/domain/model/account/security/SecurityKey.kt +++ b/app/src/main/java/me/ash/reader/domain/model/account/security/SecurityKey.kt @@ -1,15 +1,14 @@ package me.ash.reader.domain.model.account.security -import com.google.gson.Gson +import kotlinx.serialization.json.Json abstract class SecurityKey { - fun decode(value: String?, classOfT: Class): T = - Gson().fromJson(DESUtils.decrypt(value?.ifEmpty { DESUtils.empty } ?: DESUtils.empty), classOfT) + @PublishedApi + internal val json: Json = Json { ignoreUnknownKeys = true } - override fun toString(): String { - return DESUtils.encrypt(Gson().toJson(this)) - } + inline fun decode(value: String?): T = + json.decodeFromString(DESUtils.decrypt(value?.ifEmpty { DESUtils.empty } ?: DESUtils.empty)) override fun equals(other: Any?): Boolean { return this.toString() == other.toString() diff --git a/app/src/main/java/me/ash/reader/domain/service/AppService.kt b/app/src/main/java/me/ash/reader/domain/service/AppService.kt index 60ec6f101..314473f95 100644 --- a/app/src/main/java/me/ash/reader/domain/service/AppService.kt +++ b/app/src/main/java/me/ash/reader/domain/service/AppService.kt @@ -53,12 +53,12 @@ class AppService @Inject constructor( val skipVersion = context.skipVersionNumber.toVersion() val currentVersion = context.getCurrentVersion() val latest = response.body()!! - val latestVersion = latest.tag_name.toVersion() + val latestVersion = latest.tagName.toVersion() // val latestVersion = "1.0.0".toVersion() val latestLog = latest.body ?: "" - val latestPublishDate = latest.published_at ?: latest.created_at ?: "" + val latestPublishDate = latest.publishedAt ?: latest.createdAt ?: "" val latestSize = latest.assets?.first()?.size ?: 0 - val latestDownloadUrl = latest.assets?.first()?.browser_download_url ?: "" + val latestDownloadUrl = latest.assets?.first()?.browserDownloadUrl ?: "" Log.i("RLog", "current version $currentVersion") if (latestVersion.whetherNeedUpdate(currentVersion, skipVersion)) { diff --git a/app/src/main/java/me/ash/reader/infrastructure/net/NetworkDataSource.kt b/app/src/main/java/me/ash/reader/infrastructure/net/NetworkDataSource.kt index 1f0f99687..183c5cdad 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/net/NetworkDataSource.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/net/NetworkDataSource.kt @@ -5,13 +5,17 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType import okhttp3.ResponseBody import retrofit2.Response import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.http.GET import retrofit2.http.Streaming import retrofit2.http.Url +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.io.File interface NetworkDataSource { @@ -31,7 +35,7 @@ interface NetworkDataSource { return instance ?: synchronized(this) { instance ?: Retrofit.Builder() .baseUrl("https://api.github.com/") - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json; charset=UTF8".toMediaType())) .build().create(NetworkDataSource::class.java).also { instance = it } @@ -91,26 +95,28 @@ fun ResponseBody.downloadToFileWithProgress(saveFile: File): Flow = } }.flowOn(Dispatchers.IO).distinctUntilChanged() +@Serializable data class LatestRelease( - val html_url: String? = null, - val tag_name: String? = null, + @SerialName("html_url") val htmlUrl: String? = null, + @SerialName("tag_name") val tagName: String? = null, val name: String? = null, val draft: Boolean? = null, val prerelease: Boolean? = null, - val created_at: String? = null, - val published_at: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("published_at") val publishedAt: String? = null, val assets: List? = null, val body: String? = null, ) +@Serializable data class AssetsItem( val name: String? = null, - val content_type: String? = null, + @SerialName("content_type") val contentType: String? = null, val size: Int? = null, - val download_count: Int? = null, - val created_at: String? = null, - val updated_at: String? = null, - val browser_download_url: String? = null, + @SerialName("download_count") val downloadCount: Int? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + @SerialName("browser_download_url") val browserDownloadUrl: String? = null, ) sealed class Download { diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/ProviderAPI.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/ProviderAPI.kt index 8745c2c5c..a8512ec37 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/ProviderAPI.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/ProviderAPI.kt @@ -1,8 +1,7 @@ package me.ash.reader.infrastructure.rss.provider import android.content.Context -import com.google.gson.Gson -import com.google.gson.GsonBuilder +import kotlinx.serialization.json.Json import me.ash.reader.infrastructure.di.UserAgentInterceptor import me.ash.reader.infrastructure.di.cachingHttpClient import okhttp3.OkHttpClient @@ -17,8 +16,8 @@ abstract class ProviderAPI(context: Context, clientCertificateAlias: String?) { .addNetworkInterceptor(UserAgentInterceptor) .build() - protected val gson: Gson = GsonBuilder().create() + protected val json: Json = Json { ignoreUnknownKeys = true } protected inline fun toDTO(jsonStr: String): T = - gson.fromJson(jsonStr, T::class.java)!! + json.decodeFromString(jsonStr) } diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderDTO.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderDTO.kt index c233c4860..4b01bbe65 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderDTO.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderDTO.kt @@ -1,170 +1,74 @@ package me.ash.reader.infrastructure.rss.provider.greader -import com.google.gson.annotations.SerializedName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable object GoogleReaderDTO { + @Serializable data class GReaderError( - @SerializedName("errors") val errors: List, + @SerialName("errors") val errors: List, ) - /** - * @sample - *{ - * "SID": "demo/718*********************************7fa", - * "LSID": "demo/718*********************************7fa", - * "Auth": "demo/718*********************************7fa" - * } - */ + @Serializable data class MinifluxAuthData( - val SID: String?, - val LSID: String?, - val Auth: String?, + val SID: String? = null, + val LSID: String? = null, + val Auth: String? = null, ) - /** - * @link reader/api/0/user-info?output=json - * @sample - * { - * "userId": "demo", - * "userName": "demo", - * "userProfileId": "demo", - * "userEmail": "" - * } - */ + @Serializable data class User( - val userId: String?, - val userName: String?, - val userProfileId: String?, - val userEmail: String?, + val userId: String? = null, + val userName: String? = null, + val userProfileId: String? = null, + val userEmail: String? = null, ) - /** - * @link reader/api/0/subscription/list?output=json - * @sample - * { - * "subscriptions": [ - * { - * "id": "feed/3", - * "title": "Fedora Magazine", - * "categories": [ - * { - * "id": "user/-/label/Blogs", - * "label": "Blogs" - * } - * ], - * "url": "http://fedoramagazine.org/feed/", - * "htmlUrl": "http://fedoramagazine.org/", - * "iconUrl": "https://demo.freshrss.org/f.php?f2b1439b" - * } - * ] - * } - */ + @Serializable data class SubscriptionList( - val subscriptions: List, + val subscriptions: List = emptyList(), ) + @Serializable data class Feed( - val id: String?, - val title: String?, - val categories: List?, - val url: String?, - val htmlUrl: String?, - val iconUrl: String?, - val sortid: String?, + val id: String? = null, + val title: String? = null, + val categories: List? = null, + val url: String? = null, + val htmlUrl: String? = null, + val iconUrl: String? = null, + val sortid: String? = null, ) + @Serializable data class Category( - val id: String?, - val label: String?, + val id: String? = null, + val label: String? = null, ) - /** - * @link reader/api/0/subscription/quickadd?quickadd=https%3A%2F%2Fblog.com%2Ffeed - * @sample - * { - * "numResults": 1, - * "query": "https://blog.com/feed", - * "streamId": "feed/10", - * "streamName": "blog" - * } - * - */ + @Serializable data class QuickAddFeed( - val numResults: Long?, - val query: String?, - val streamId: String?, - val streamName: String?, + val numResults: Long? = null, + val query: String? = null, + val streamId: String? = null, + val streamName: String? = null, ) - /** - * @link reader/api/0/stream/items/ids?s=user/-/state/com.google/starred&output=json - * @sample - * { - * "itemRefs": [ - * { - * "id": "1705042807944418" - * } - * ] - * } - */ + @Serializable data class ItemIds( - val itemRefs: List?, - val continuation: String?, + val itemRefs: List? = null, + val continuation: String? = null, ) - /** - * @link reader/api/0/stream/items/contents - * @sample - * { - * "id": "user/-/state/com.google/reading-list", - * "updated": 1705045799, - * "items": [ - * { - * "id": "tag:google.com,2005:reader/item/00060eba36e4f4e1", - * "crawlTimeMsec": "1705042807944", - * "timestampUsec": "1705042807944417", - * "published": 1704982200, - * "title": "Andy Wingo: micro macro story time", - * "canonical": [ - * { - * "href": "https://wingolog.org/archives/2024/01/11/micro-macro-story-time" - * } - * ], - * "alternate": [ - * { - * "href": "https://wingolog.org/archives/2024/01/11/micro-macro-story-time" - * } - * ], - * "categories": [ - * "user/-/state/com.google/reading-list", - * "user/-/label/Blogs", - * "user/-/state/com.google/read" - * ], - * "origin": { - * "streamId": "feed/2", - * "htmlUrl": "https://planet.gnome.org/", - * "title": "Planet GNOME" - * }, - * "summary": { - * "content": "

Today, a tiny tale

", - * "expand": "\ndoesn’t sound fancy enough. In a way it’s similar to the original SSA\ndevelopers thinking that ", - * "phony functions": " wouldn’t get\npublished.

So Dybvig calls the expansion function ", - * "χ": ", because the Greek chi looks\nlike the X in ", - * "expand": ". Fine for the paper, whatever paper that might\nbe, but then in psyntax, there are all these functions named\nchi and chi-lambda and all sorts of nonsense.

In early years I was often confused by these names; I wasn’t in on the\npun, and I didn’t feel like I had enough responsibility for this code to\nthink what the name should be. I finally broke down and changed all\ninstances of ", - * "chi": " to ", - * "expand": " back in 2011, and never looked back.

Anyway, this is a story with a very specific moral: don’t name your\nfunctions chi.

" - * } - * } - * ] - * } - */ + @Serializable data class ItemsContents( val id: String? = null, val updated: Long? = null, val items: List? = null, ) + @Serializable data class Item( val id: String? = null, val crawlTimeMsec: String? = null, @@ -179,14 +83,17 @@ object GoogleReaderDTO { val alternate: List? = null, ) + @Serializable data class Summary( val content: String? = null, ) + @Serializable data class CanonicalItem( val href: String? = null, ) + @Serializable data class OriginItem( val streamId: String? = null, val htmlUrl: String? = null, diff --git a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt index 7552b3d02..a4961657d 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt @@ -12,8 +12,15 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.catch @@ -526,38 +533,52 @@ val ignorePreferencesOnExportAndImport = suspend fun Context.fromDataStoreToJSONString(): String { val preferences = dataStore.data.first() - val map: Map = + val map: Map = preferences .asMap() .mapKeys { it.key.name } .filterKeys { it !in ignorePreferencesOnExportAndImport } - return Gson().toJson(map) + .mapValues { (_, value) -> + when (value) { + is Boolean -> JsonPrimitive(value) + is Int -> JsonPrimitive(value) + is Long -> JsonPrimitive(value) + is Float -> JsonPrimitive(value) + is Double -> JsonPrimitive(value) + is String -> JsonPrimitive(value) + else -> JsonNull + } + } + return Json.encodeToString( + MapSerializer(String.serializer(), JsonElement.serializer()), map + ) } suspend fun String.fromJSONStringToDataStore(context: Context) { - val gson = Gson() - val type = object : TypeToken>() {}.type - val deserializedMap: Map = gson.fromJson(this, type) + val deserializedMap: Map = Json.decodeFromString( + MapSerializer(String.serializer(), JsonElement.serializer()), this + ) context.dataStore.edit { preferences -> deserializedMap .filterKeys { it !in ignorePreferencesOnExportAndImport } - .forEach { (keyString, value) -> + .forEach { (keyString, element) -> val preferencesKey = PreferencesKey.keys[keyString] + val primitive = (element as? JsonPrimitive) ?: return@forEach when (preferencesKey) { is PreferencesKey.BooleanKey -> { - if (value is Boolean) preferences[preferencesKey.key] = value + primitive.booleanOrNull?.let { preferences[preferencesKey.key] = it } } is PreferencesKey.FloatKey -> { - if (value is Number) preferences[preferencesKey.key] = value.toFloat() + primitive.doubleOrNull?.let { preferences[preferencesKey.key] = it.toFloat() } } is PreferencesKey.IntKey -> { - if (value is Number) preferences[preferencesKey.key] = value.toInt() + primitive.longOrNull?.let { preferences[preferencesKey.key] = it.toInt() } } is PreferencesKey.LongKey -> { - if (value is Number) preferences[preferencesKey.key] = value.toLong() + primitive.longOrNull?.let { preferences[preferencesKey.key] = it } } is PreferencesKey.StringKey -> { - if (value is String) preferences[preferencesKey.key] = value + if (primitive.isString) preferences[preferencesKey.key] = primitive.content } null -> return@forEach } diff --git a/app/src/test/java/me/ash/reader/domain/model/account/security/SerializationCompatibilityTest.kt b/app/src/test/java/me/ash/reader/domain/model/account/security/SerializationCompatibilityTest.kt new file mode 100644 index 000000000..b3531f0a7 --- /dev/null +++ b/app/src/test/java/me/ash/reader/domain/model/account/security/SerializationCompatibilityTest.kt @@ -0,0 +1,138 @@ +package me.ash.reader.domain.model.account.security + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import me.ash.reader.domain.data.Diff +import me.ash.reader.infrastructure.net.LatestRelease +import me.ash.reader.infrastructure.rss.provider.greader.GoogleReaderDTO +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Verifies that kotlinx.serialization produces JSON backward-compatible with the + * previous Gson implementation. + * + * Each test creates a known object, serializes it, then deserializes the result + * and checks the round-trip. A second path deserializes a hand-written JSON + * string that matches the format Gson would have produced, proving the new + * serializer can read data written by the old one. + */ +class SerializationCompatibilityTest { + + private val json = Json { ignoreUnknownKeys = true } + + // -- GoogleReaderSecurityKey -- + + @Test + fun `GoogleReaderSecurityKey round-trips correctly`() { + // Simulate what Gson().toJson(key) would have produced + val gsonJson = """{"serverUrl":"https://rss.example.com","username":"user","password":"pass","clientCertificateAlias":null}""" + + val decoded = json.decodeFromString(gsonJson) + assertEquals("https://rss.example.com", decoded.serverUrl) + assertEquals("user", decoded.username) + assertEquals("pass", decoded.password) + assertNull(decoded.clientCertificateAlias) + + // Re-encode and decode to verify round-trip + val reEncoded = json.encodeToString(decoded) + val reDecoded = json.decodeFromString(reEncoded) + assertEquals(decoded.serverUrl, reDecoded.serverUrl) + assertEquals(decoded.username, reDecoded.username) + assertEquals(decoded.password, reDecoded.password) + } + + @Test + fun `FeverSecurityKey round-trips correctly`() { + val gsonJson = """{"serverUrl":"https://fever.example.com","username":"admin","password":"secret","clientCertificateAlias":"cert1"}""" + val decoded = json.decodeFromString(gsonJson) + assertEquals("https://fever.example.com", decoded.serverUrl) + assertEquals("cert1", decoded.clientCertificateAlias) + } + + @Test + fun `FreshRSSSecurityKey round-trips correctly`() { + val gsonJson = """{"serverUrl":"https://freshrss.example.com","username":"user","password":"pw","clientCertificateAlias":null}""" + val decoded = json.decodeFromString(gsonJson) + assertEquals("https://freshrss.example.com", decoded.serverUrl) + } + + @Test + fun `LocalSecurityKey round-trips with empty object`() { + val gsonJson = """{}""" + val decoded = json.decodeFromString(gsonJson) + val reEncoded = json.encodeToString(decoded) + assertEquals("{}", reEncoded) + } + + // -- GoogleReaderDTO -- + + @Test + fun `GoogleReaderDTO SubscriptionList deserializes from Gson-format JSON`() { + val gsonJson = """{"subscriptions":[{"id":"feed/3","title":"Fedora Magazine","categories":[{"id":"user/-/label/Blogs","label":"Blogs"}],"url":"http://fedoramagazine.org/feed/","htmlUrl":"http://fedoramagazine.org/","iconUrl":"https://demo.freshrss.org/f.php?f2b1439b","sortid":null}]}""" + val decoded = json.decodeFromString(gsonJson) + assertEquals(1, decoded.subscriptions.size) + assertEquals("feed/3", decoded.subscriptions[0].id) + assertEquals("Blogs", decoded.subscriptions[0].categories?.get(0)?.label) + } + + @Test + fun `GoogleReaderDTO ItemsContents deserializes from Gson-format JSON`() { + val gsonJson = """{"id":"user/-/state/com.google/reading-list","updated":1705045799,"items":[{"id":"tag:google.com,2005:reader/item/00060eba36e4f4e1","published":1704982200,"title":"Test Article","summary":{"content":"

Hello

"},"categories":["user/-/state/com.google/reading-list"],"origin":{"streamId":"feed/2","htmlUrl":"https://example.com","title":"Example"}}]}""" + val decoded = json.decodeFromString(gsonJson) + assertEquals(1, decoded.items?.size) + assertEquals("Test Article", decoded.items?.get(0)?.title) + assertEquals("feed/2", decoded.items?.get(0)?.origin?.streamId) + } + + @Test + fun `GoogleReaderDTO GReaderError with SerialName errors field`() { + val gsonJson = """{"errors":["Unauthorized","Bad request"]}""" + val decoded = json.decodeFromString(gsonJson) + assertEquals(2, decoded.errors.size) + assertEquals("Unauthorized", decoded.errors[0]) + } + + // -- LatestRelease (snake_case from GitHub API) -- + + @Test + fun `LatestRelease deserializes snake_case JSON from GitHub API`() { + val githubJson = """{"html_url":"https://github.com/Ashinch/ReadYou/releases/v1.0","tag_name":"v1.0","name":"Release 1.0","draft":false,"prerelease":false,"created_at":"2024-01-01","published_at":"2024-01-02","assets":[{"name":"app.apk","content_type":"application/vnd.android.package-archive","size":12345,"download_count":100,"browser_download_url":"https://github.com/dl/app.apk"}],"body":"Release notes"}""" + val decoded = json.decodeFromString(githubJson) + assertEquals("v1.0", decoded.tagName) + assertEquals("https://github.com/Ashinch/ReadYou/releases/v1.0", decoded.htmlUrl) + assertEquals("2024-01-02", decoded.publishedAt) + assertEquals(1, decoded.assets?.size) + assertEquals("https://github.com/dl/app.apk", decoded.assets?.get(0)?.browserDownloadUrl) + assertEquals(100, decoded.assets?.get(0)?.downloadCount) + } + + // -- Diff -- + + @Test + fun `Diff round-trips correctly`() { + val gsonJson = """{"isUnread":true,"articleId":"article-123","feedId":"feed-456"}""" + val decoded = json.decodeFromString(gsonJson) + assertEquals(true, decoded.isUnread) + assertEquals("article-123", decoded.articleId) + assertEquals("feed-456", decoded.feedId) + + val reEncoded = json.encodeToString(decoded) + val reDecoded = json.decodeFromString(reEncoded) + assertEquals(decoded, reDecoded) + } + + @Test + fun `Diff map serialization matches Gson format`() { + val gsonMapJson = """{"id1":{"isUnread":true,"articleId":"id1","feedId":"f1"},"id2":{"isUnread":false,"articleId":"id2","feedId":"f2"}}""" + val mapSerializer = kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + Diff.serializer() + ) + val decoded = json.decodeFromString(mapSerializer, gsonMapJson) + assertEquals(2, decoded.size) + assertEquals(true, decoded["id1"]?.isUnread) + assertEquals(false, decoded["id2"]?.isUnread) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d78154f4a..4677b258d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -103,7 +103,7 @@ swipe = { group = "me.saket.swipe", name = "swipe", version.ref = "swipe" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-coroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines-jvm", version.ref = "okhttp" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit2" } -retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit2" } +retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit2" } profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } From 3107ba1528c6d8c96d4709ef04b4f10a69752273 Mon Sep 17 00:00:00 2001 From: ibrahim-iqbal Date: Mon, 2 Nov 2026 08:37:44 +0530 Subject: [PATCH 2/2] test: decode the Gson map fixture with Map The hand-built MapSerializer used a serializer() that does not exist at the call site, so the unit-test source set failed to compile and CI's test job never ran. Decoding the Map type directly checks the same Gson-shaped JSON. --- .../account/security/SerializationCompatibilityTest.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/test/java/me/ash/reader/domain/model/account/security/SerializationCompatibilityTest.kt b/app/src/test/java/me/ash/reader/domain/model/account/security/SerializationCompatibilityTest.kt index b3531f0a7..b4d8943de 100644 --- a/app/src/test/java/me/ash/reader/domain/model/account/security/SerializationCompatibilityTest.kt +++ b/app/src/test/java/me/ash/reader/domain/model/account/security/SerializationCompatibilityTest.kt @@ -126,11 +126,7 @@ class SerializationCompatibilityTest { @Test fun `Diff map serialization matches Gson format`() { val gsonMapJson = """{"id1":{"isUnread":true,"articleId":"id1","feedId":"f1"},"id2":{"isUnread":false,"articleId":"id2","feedId":"f2"}}""" - val mapSerializer = kotlinx.serialization.builtins.MapSerializer( - kotlinx.serialization.builtins.serializer(), - Diff.serializer() - ) - val decoded = json.decodeFromString(mapSerializer, gsonMapJson) + val decoded = json.decodeFromString>(gsonMapJson) assertEquals(2, decoded.size) assertEquals(true, decoded["id1"]?.isUnread) assertEquals(false, decoded["id2"]?.isUnread)