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
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)