refactor: Replace Moshi with kotlinx.serialization

Remove Moshi dependency entirely, completing the migration to kotlinx.serialization. All JSON serialization now uses kotlinx with explicit @SerialName annotations for wire format stability.

- Migrate PodDeviceCache from Moshi to kotlinx Json injection

- Add MapIntByteArrayBase64Serializer for BleScanResult cache compat

- Strip @JsonClass/@Json annotations from all dual-annotated classes

- Delete Moshi adapters, ProGuard rules, and build config

- Convert compat tests to pure kotlinx round-trip tests
This commit is contained in:
darken
2026-03-09 20:29:19 +00:00
committed by Matthias Urhahn
parent 8675d73a98
commit a79f6a0535
27 changed files with 338 additions and 413 deletions
@@ -0,0 +1,76 @@
package eu.darken.capod.common.bluetooth
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
/**
* Tests backward compatibility with legacy Moshi-serialized BleScanResult cache files.
*/
class BleScanResultSerializationTest : BaseTest() {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
@Test
fun `legacy Moshi cache JSON decodes correctly`() {
// This matches the format PodDeviceCache previously wrote via Moshi
val legacyJson = """{"receivedAt":1709553600000,"address":"AA:BB:CC:DD:EE:FF","rssi":-55,"generatedAtNanos":123456789,"manufacturerSpecificData":{"76":"AQID"}}"""
val result = json.decodeFromString<BleScanResult>(legacyJson)
result.receivedAt shouldBe Instant.ofEpochMilli(1709553600000)
result.address shouldBe "AA:BB:CC:DD:EE:FF"
result.rssi shouldBe -55
result.generatedAtNanos shouldBe 123456789L
result.manufacturerSpecificData.size shouldBe 1
result.manufacturerSpecificData[76]!!.toList() shouldBe listOf<Byte>(0x01, 0x02, 0x03)
}
@Test
fun `round-trip preserves all fields`() {
val original = BleScanResult(
receivedAt = Instant.ofEpochMilli(1709553600000),
address = "11:22:33:44:55:66",
rssi = -42,
generatedAtNanos = 987654321L,
manufacturerSpecificData = mapOf(
76 to byteArrayOf(0x01, 0x02, 0x03),
77 to byteArrayOf(0xAA.toByte(), 0xBB.toByte()),
)
)
val encoded = json.encodeToString(BleScanResult.serializer(), original)
val decoded = json.decodeFromString<BleScanResult>(encoded)
decoded.receivedAt shouldBe original.receivedAt
decoded.address shouldBe original.address
decoded.rssi shouldBe original.rssi
decoded.generatedAtNanos shouldBe original.generatedAtNanos
decoded.manufacturerSpecificData.size shouldBe original.manufacturerSpecificData.size
original.manufacturerSpecificData.forEach { (key, value) ->
decoded.manufacturerSpecificData[key]!!.toList() shouldBe value.toList()
}
}
@Test
fun `empty manufacturer data round-trips`() {
val original = BleScanResult(
receivedAt = Instant.EPOCH,
address = "00:00:00:00:00:00",
rssi = 0,
generatedAtNanos = 0L,
manufacturerSpecificData = emptyMap()
)
val encoded = json.encodeToString(BleScanResult.serializer(), original)
val decoded = json.decodeFromString<BleScanResult>(encoded)
decoded.manufacturerSpecificData shouldBe emptyMap()
}
}
@@ -1,6 +1,5 @@
package eu.darken.capod.common.datastore
import com.squareup.moshi.Json as MoshiJson
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode
@@ -11,16 +10,15 @@ import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesContainer
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import io.kotest.matchers.shouldBe
import kotlinx.serialization.SerialName
import io.kotest.matchers.string.shouldContain
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
/**
* Tests that verify @SerialName values match @Json(name=...) values,
* ensuring Moshi-serialized SharedPreferences data can be read by kotlinx-serialization
* after the DataStore migration.
* Tests that verify kotlinx-serialization produces the expected wire format
* and can decode legacy JSON strings (originally written by Moshi).
*/
class DataStoreMigrationCompatTest : BaseTest() {
@@ -28,106 +26,132 @@ class DataStoreMigrationCompatTest : BaseTest() {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
/**
* For each enum with both @SerialName and @Json annotations,
* verify they produce the same string. This catches typos in @SerialName values.
*/
private inline fun <reified T : Enum<T>> verifyEnumSerialNameParity() {
val enumClass = T::class.java
for (constant in enumClass.enumConstants!!) {
val field = enumClass.getField(constant.name)
val moshiAnnotation = field.getAnnotation(MoshiJson::class.java)
val serialNameAnnotation = field.getAnnotation(SerialName::class.java)
@Test
fun `ThemeMode encodes to canonical string`() {
json.encodeToString(serializer<ThemeMode>(), ThemeMode.DARK) shouldBe "\"theme.mode.dark\""
}
if (moshiAnnotation != null && serialNameAnnotation != null) {
serialNameAnnotation.value shouldBe moshiAnnotation.name
}
@Test
fun `ThemeMode legacy string decodes correctly`() {
val legacyOutput = "\"theme.mode.dark\""
json.decodeFromString(serializer<ThemeMode>(), legacyOutput) shouldBe ThemeMode.DARK
}
@Test
fun `ThemeMode round-trip`() {
ThemeMode.entries.forEach { mode ->
val encoded = json.encodeToString(serializer<ThemeMode>(), mode)
json.decodeFromString(serializer<ThemeMode>(), encoded) shouldBe mode
}
}
@Test
fun `SerialName matches Json name - ThemeMode`() = verifyEnumSerialNameParity<ThemeMode>()
@Test
fun `SerialName matches Json name - ThemeStyle`() = verifyEnumSerialNameParity<ThemeStyle>()
@Test
fun `SerialName matches Json name - ThemeColor`() = verifyEnumSerialNameParity<ThemeColor>()
@Test
fun `SerialName matches Json name - MonitorMode`() = verifyEnumSerialNameParity<MonitorMode>()
@Test
fun `SerialName matches Json name - ScannerMode`() = verifyEnumSerialNameParity<ScannerMode>()
@Test
fun `SerialName matches Json name - AutoConnectCondition`() = verifyEnumSerialNameParity<AutoConnectCondition>()
@Test
fun `SerialName matches Json name - PodDevice Model`() = verifyEnumSerialNameParity<PodDevice.Model>()
@Test
fun `Moshi-serialized ThemeMode string is readable by kotlinx`() {
// Moshi stores enums as JSON strings like: "theme.mode.dark"
val moshiOutput = "\"theme.mode.dark\""
val result = json.decodeFromString(serializer<ThemeMode>(), moshiOutput)
result shouldBe ThemeMode.DARK
fun `ScannerMode encodes to canonical string`() {
json.encodeToString(serializer<ScannerMode>(), ScannerMode.BALANCED) shouldBe "\"scanner.mode.balanced\""
}
@Test
fun `Moshi-serialized ScannerMode string is readable by kotlinx`() {
val moshiOutput = "\"scanner.mode.balanced\""
val result = json.decodeFromString(serializer<ScannerMode>(), moshiOutput)
result shouldBe ScannerMode.BALANCED
fun `ScannerMode legacy string decodes correctly`() {
val legacyOutput = "\"scanner.mode.balanced\""
json.decodeFromString(serializer<ScannerMode>(), legacyOutput) shouldBe ScannerMode.BALANCED
}
@Test
fun `Moshi-serialized MonitorMode string is readable by kotlinx`() {
val moshiOutput = "\"monitor.mode.automatic\""
val result = json.decodeFromString(serializer<MonitorMode>(), moshiOutput)
result shouldBe MonitorMode.AUTOMATIC
fun `ScannerMode round-trip`() {
ScannerMode.entries.forEach { mode ->
val encoded = json.encodeToString(serializer<ScannerMode>(), mode)
json.decodeFromString(serializer<ScannerMode>(), encoded) shouldBe mode
}
}
@Test
fun `all PodDevice Model values can be decoded from Moshi format`() {
fun `MonitorMode encodes to canonical string`() {
json.encodeToString(serializer<MonitorMode>(), MonitorMode.AUTOMATIC) shouldBe "\"monitor.mode.automatic\""
}
@Test
fun `MonitorMode legacy string decodes correctly`() {
val legacyOutput = "\"monitor.mode.automatic\""
json.decodeFromString(serializer<MonitorMode>(), legacyOutput) shouldBe MonitorMode.AUTOMATIC
}
@Test
fun `MonitorMode round-trip`() {
MonitorMode.entries.forEach { mode ->
val encoded = json.encodeToString(serializer<MonitorMode>(), mode)
json.decodeFromString(serializer<MonitorMode>(), encoded) shouldBe mode
}
}
@Test
fun `ThemeStyle encodes to canonical string`() {
json.encodeToString(serializer<ThemeStyle>(), ThemeStyle.MATERIAL_YOU) shouldBe "\"theme.style.materialyou\""
}
@Test
fun `ThemeStyle legacy string decodes correctly`() {
val legacyOutput = "\"theme.style.materialyou\""
json.decodeFromString(serializer<ThemeStyle>(), legacyOutput) shouldBe ThemeStyle.MATERIAL_YOU
}
@Test
fun `ThemeStyle round-trip`() {
ThemeStyle.entries.forEach { style ->
val encoded = json.encodeToString(serializer<ThemeStyle>(), style)
json.decodeFromString(serializer<ThemeStyle>(), encoded) shouldBe style
}
}
@Test
fun `ThemeColor encodes to canonical string`() {
json.encodeToString(serializer<ThemeColor>(), ThemeColor.GREEN) shouldBe "\"theme.color.green\""
}
@Test
fun `ThemeColor legacy string decodes correctly`() {
val legacyOutput = "\"theme.color.green\""
json.decodeFromString(serializer<ThemeColor>(), legacyOutput) shouldBe ThemeColor.GREEN
}
@Test
fun `ThemeColor round-trip`() {
ThemeColor.entries.forEach { color ->
val encoded = json.encodeToString(serializer<ThemeColor>(), color)
json.decodeFromString(serializer<ThemeColor>(), encoded) shouldBe color
}
}
@Test
fun `AutoConnectCondition encodes to canonical string`() {
json.encodeToString(serializer<AutoConnectCondition>(), AutoConnectCondition.WHEN_SEEN) shouldBe "\"autoconnect.condition.seen\""
}
@Test
fun `AutoConnectCondition legacy string decodes correctly`() {
val legacyOutput = "\"autoconnect.condition.seen\""
json.decodeFromString(serializer<AutoConnectCondition>(), legacyOutput) shouldBe AutoConnectCondition.WHEN_SEEN
}
@Test
fun `all PodDevice Model values round-trip`() {
for (model in PodDevice.Model.entries) {
val field = PodDevice.Model::class.java.getField(model.name)
val moshiAnnotation = field.getAnnotation(MoshiJson::class.java)
if (moshiAnnotation != null) {
val moshiOutput = "\"${moshiAnnotation.name}\""
val result = json.decodeFromString(serializer<PodDevice.Model>(), moshiOutput)
result shouldBe model
}
val encoded = json.encodeToString(serializer<PodDevice.Model>(), model)
json.decodeFromString(serializer<PodDevice.Model>(), encoded) shouldBe model
}
}
@Test
fun `Moshi-serialized ThemeStyle string is readable by kotlinx`() {
val moshiOutput = "\"theme.style.materialyou\""
val result = json.decodeFromString(serializer<ThemeStyle>(), moshiOutput)
result shouldBe ThemeStyle.MATERIAL_YOU
fun `PodDevice Model legacy string decodes correctly`() {
val legacyOutput = "\"airpods.pro\""
json.decodeFromString(serializer<PodDevice.Model>(), legacyOutput) shouldBe PodDevice.Model.AIRPODS_PRO
}
@Test
fun `Moshi-serialized ThemeColor string is readable by kotlinx`() {
val moshiOutput = "\"theme.color.green\""
val result = json.decodeFromString(serializer<ThemeColor>(), moshiOutput)
result shouldBe ThemeColor.GREEN
}
@Test
fun `Moshi-serialized AutoConnectCondition string is readable by kotlinx`() {
val moshiOutput = "\"autoconnect.condition.seen\""
val result = json.decodeFromString(serializer<AutoConnectCondition>(), moshiOutput)
result shouldBe AutoConnectCondition.WHEN_SEEN
}
@Test
fun `Moshi-serialized DeviceProfilesContainer JSON is readable by kotlinx`() {
// This is what Moshi would produce for a container with one Apple profile
val moshiJson = """
fun `legacy DeviceProfilesContainer JSON decodes correctly`() {
val legacyJson = """
{
"profiles": [
{
@@ -143,7 +167,7 @@ class DataStoreMigrationCompatTest : BaseTest() {
}
""".trimIndent()
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), legacyJson)
result.profiles.size shouldBe 1
val profile = result.profiles[0] as AppleDeviceProfile
@@ -157,9 +181,43 @@ class DataStoreMigrationCompatTest : BaseTest() {
}
@Test
fun `Moshi-serialized DeviceProfile with ByteArray fields is readable by kotlinx`() {
fun `DeviceProfilesContainer round-trip`() {
val container = DeviceProfilesContainer(
profiles = listOf(
AppleDeviceProfile(
id = "round-trip-id",
label = "Test Profile",
model = PodDevice.Model.AIRPODS_GEN2,
address = "11:22:33:44:55:66",
)
)
)
val encoded = json.encodeToString(serializer<DeviceProfilesContainer>(), container)
val decoded = json.decodeFromString(serializer<DeviceProfilesContainer>(), encoded)
decoded.profiles.size shouldBe 1
val profile = decoded.profiles[0] as AppleDeviceProfile
profile.id shouldBe "round-trip-id"
profile.label shouldBe "Test Profile"
profile.model shouldBe PodDevice.Model.AIRPODS_GEN2
profile.address shouldBe "11:22:33:44:55:66"
}
@Test
fun `DeviceProfilesContainer includes type discriminator`() {
val container = DeviceProfilesContainer(
profiles = listOf(
AppleDeviceProfile(id = "disc-test", label = "Test")
)
)
val encoded = json.encodeToString(serializer<DeviceProfilesContainer>(), container)
encoded shouldContain "\"type\":\"apple\""
}
@Test
fun `legacy DeviceProfile with ByteArray fields decodes correctly`() {
// Base64 encoded: [0x01, 0x02, 0x03] = "AQID"
val moshiJson = """
val legacyJson = """
{
"profiles": [
{
@@ -176,7 +234,7 @@ class DataStoreMigrationCompatTest : BaseTest() {
}
""".trimIndent()
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), legacyJson)
val profile = result.profiles[0] as AppleDeviceProfile
profile.identityKey!!.toList() shouldBe listOf<Byte>(0x01, 0x02, 0x03)
profile.encryptionKey!!.toList() shouldBe listOf<Byte>(0x04, 0x05, 0x06)
@@ -1,29 +0,0 @@
package testhelpers.json
import com.squareup.moshi.JsonReader
import com.squareup.moshi.Moshi
import okio.Buffer
import okio.ByteString.Companion.encode
import okio.buffer
import okio.sink
import java.io.File
fun String.toComparableJson(): String {
val value = Buffer().use {
it.writeUtf8(this)
val reader = JsonReader.of(it)
reader.readJsonValue()
}
val adapter = Moshi.Builder().build().adapter(Any::class.java).indent(" ")
return adapter.toJson(value)
}
fun String.writeToFile(file: File) = encode().let { text ->
require(!file.exists())
file.parentFile?.mkdirs()
file.createNewFile()
file.sink().buffer().use { it.write(text) }
}