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
+1 -1
View File
@@ -80,5 +80,5 @@ Code shared between phone and Wear OS apps is placed in `app-common`. When modif
- **Hilt**: Dependency injection framework
- **AndroidX Navigation**: Fragment navigation with SafeArgs
- **Moshi**: JSON serialization for configuration and debugging
- **kotlinx.serialization**: JSON serialization for configuration and caching
- **Material Design**: UI components following Material Design guidelines
-2
View File
@@ -177,8 +177,6 @@ dependencies {
addDagger()
addMoshi()
addOkio()
addBaseAndroid()
-3
View File
@@ -1,3 +0,0 @@
-keepclassmembernames @com.squareup.moshi.JsonClass class * extends java.lang.Enum {
<fields>;
}
@@ -1,23 +1,19 @@
package eu.darken.capod.common.upgrade.core
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Serializable
@JsonClass(generateAdapter = true)
data class FossUpgrade(
@Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant,
val reason: Reason
@SerialName("upgradedAt") @Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant,
@SerialName("reason") val reason: Reason
) {
@Serializable
@JsonClass(generateAdapter = false)
enum class Reason {
@SerialName("foss.upgrade.reason.donated") @Json(name = "foss.upgrade.reason.donated") DONATED,
@SerialName("foss.upgrade.reason.alreadydonated") @Json(name = "foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
@SerialName("foss.upgrade.reason.nomoney") @Json(name = "foss.upgrade.reason.nomoney") NO_MONEY;
@SerialName("foss.upgrade.reason.donated") DONATED,
@SerialName("foss.upgrade.reason.alreadydonated") ALREADY_DONATED,
@SerialName("foss.upgrade.reason.nomoney") NO_MONEY;
}
}
@@ -3,19 +3,21 @@ package eu.darken.capod.common.bluetooth
import android.bluetooth.le.ScanResult
import android.os.Parcelable
import androidx.core.util.forEach
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
import eu.darken.capod.common.serialization.MapIntByteArrayBase64Serializer
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
@Parcelize
@JsonClass(generateAdapter = true)
@Serializable
data class BleScanResult(
@Json(name = "receivedAt") val receivedAt: Instant,
@Json(name = "address") val address: String,
@Json(name = "rssi") val rssi: Int,
@Json(name = "generatedAtNanos") val generatedAtNanos: Long,
@Json(name = "manufacturerSpecificData") val manufacturerSpecificData: Map<Int, ByteArray>
@SerialName("receivedAt") @Serializable(with = InstantEpochMillisSerializer::class) val receivedAt: Instant,
@SerialName("address") val address: String,
@SerialName("rssi") val rssi: Int,
@SerialName("generatedAtNanos") val generatedAtNanos: Long,
@SerialName("manufacturerSpecificData") @Serializable(with = MapIntByteArrayBase64Serializer::class) val manufacturerSpecificData: Map<Int, ByteArray>
) : Parcelable {
fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id]
@@ -1,27 +1,24 @@
package eu.darken.capod.common.bluetooth
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ScannerMode(
val identifier: String,
@StringRes val labelRes: Int
) {
@SerialName("scanner.mode.lowpower") @Json(name = "scanner.mode.lowpower") LOW_POWER(
@SerialName("scanner.mode.lowpower") LOW_POWER(
"scanner.mode.lowpower",
R.string.settings_scanner_mode_lowpower_label
),
@SerialName("scanner.mode.balanced") @Json(name = "scanner.mode.balanced") BALANCED(
@SerialName("scanner.mode.balanced") BALANCED(
"scanner.mode.balanced",
R.string.settings_scanner_mode_balanced_label
),
@SerialName("scanner.mode.lowlatency") @Json(name = "scanner.mode.lowlatency") LOW_LATENCY(
@SerialName("scanner.mode.lowlatency") LOW_LATENCY(
"scanner.mode.lowlatency",
R.string.settings_scanner_mode_lowlatency_label
),
@@ -1,16 +0,0 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@Suppress("unused")
@OptIn(ExperimentalEncodingApi::class)
class ByteArrayAdapter {
@ToJson
fun toJson(obj: ByteArray): String = Base64.encode(obj)
@FromJson
fun fromJson(base64: String): ByteArray? = Base64.decode(base64)
}
@@ -1,13 +0,0 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import java.time.Instant
class JavaInstantAdapter {
@ToJson
fun toJson(obj: Instant): Long = obj.toEpochMilli()
@FromJson
fun fromJson(epochMillis: Long): Instant = Instant.ofEpochMilli(epochMillis)
}
@@ -0,0 +1,23 @@
package eu.darken.capod.common.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object MapIntByteArrayBase64Serializer : KSerializer<Map<Int, ByteArray>> {
private val delegate = MapSerializer(Int.serializer(), ByteArrayBase64Serializer)
override val descriptor: SerialDescriptor = delegate.descriptor
override fun serialize(encoder: Encoder, value: Map<Int, ByteArray>) {
delegate.serialize(encoder, value)
}
override fun deserialize(decoder: Decoder): Map<Int, ByteArray> {
return delegate.deserialize(decoder)
}
}
@@ -1,116 +0,0 @@
@file:Suppress("MemberVisibilityCanBePrivate")
package eu.darken.capod.common.serialization
import com.squareup.moshi.*
import java.lang.reflect.Type
import java.util.*
import javax.annotation.CheckReturnValue
import kotlin.apply
import kotlin.collections.isNotEmpty
import kotlin.collections.plus
import kotlin.collections.toTypedArray
import kotlin.io.use
import kotlin.jvm.javaClass
class NameBasedPolyJsonAdapterFactory<T> internal constructor(
val baseType: Class<T>,
val keyLabels: List<String> = emptyList(),
val subtypes: List<Type> = emptyList(),
) : JsonAdapter.Factory {
fun withSubtype(subtype: Class<out T>, label: String): NameBasedPolyJsonAdapterFactory<T> {
require(!keyLabels.contains(label)) { "Labels must be unique." }
return NameBasedPolyJsonAdapterFactory(
baseType = baseType,
keyLabels = keyLabels + label,
subtypes = subtypes + subtype,
)
}
override fun create(type: Type, annotations: Set<Annotation>, moshi: Moshi): JsonAdapter<*>? {
if (Types.getRawType(type) != baseType || annotations.isNotEmpty()) {
return null
}
val jsonAdapters = ArrayList<JsonAdapter<Any>>(subtypes.size)
var i = 0
val size = subtypes.size
while (i < size) {
jsonAdapters.add(moshi.adapter(subtypes[i]))
i++
}
return PolymorphicJsonAdapter(
nameLabels = keyLabels,
subTypes = subtypes,
jsonAdapters = jsonAdapters,
).nullSafe()
}
internal class PolymorphicJsonAdapter(
val nameLabels: List<String>,
val subTypes: List<Type>,
val jsonAdapters: List<JsonAdapter<Any>>,
) : JsonAdapter<Any>() {
private val nameOptions: JsonReader.Options = JsonReader.Options.of(*nameLabels.toTypedArray())
override fun fromJson(reader: JsonReader): Any? {
val peeked = reader.peekJson().apply {
setFailOnUnknown(false)
}
val labelIndex = peeked.use(::labelIndex)
if (labelIndex == -1) {
throw JsonDataException("No matching Field names for $nameLabels")
}
return jsonAdapters[labelIndex].fromJson(reader)
}
private fun labelIndex(reader: JsonReader): Int {
reader.beginObject()
while (reader.hasNext()) {
val labelIndex = reader.selectName(nameOptions)
if (labelIndex == -1) {
reader.skipName()
reader.skipValue()
continue
}
return labelIndex
}
return -1
// throw JsonDataException("Missing label for $labelKey")
}
override fun toJson(writer: JsonWriter, value: Any?) {
val type = value!!.javaClass
val typeIndex = subTypes.indexOf(type)
if (typeIndex == -1) {
throw JsonDataException("No matching name label for $value. Valid labels are $nameLabels")
}
val adapter = jsonAdapters[typeIndex]
writer.beginObject()
val flattenToken = writer.beginFlatten()
adapter.toJson(writer, value)
writer.endFlatten(flattenToken)
writer.endObject()
}
override fun toString(): String = "KeyBasedPolyJsonAdapterFactory($nameLabels)"
}
companion object {
@CheckReturnValue
fun <T> of(baseType: Class<T>): NameBasedPolyJsonAdapterFactory<T> = NameBasedPolyJsonAdapterFactory(
baseType
)
}
}
@@ -1,11 +1,9 @@
package eu.darken.capod.common.serialization
import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import eu.darken.capod.profiles.core.DeviceProfile
import kotlinx.serialization.json.Json
import javax.inject.Qualifier
import javax.inject.Singleton
@@ -14,14 +12,6 @@ import javax.inject.Singleton
@Module
class SerializationModule {
@Provides
@Singleton
fun moshi(): Moshi = Moshi.Builder().apply {
add(JavaInstantAdapter())
add(ByteArrayAdapter())
add(DeviceProfile.MOSHI_FACTORY)
}.build()
@Provides
@Singleton
@SerializationCapod
@@ -29,6 +19,7 @@ class SerializationModule {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
}
@@ -1,18 +1,15 @@
package eu.darken.capod.common.theming
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ThemeColor(
@StringRes val labelRes: Int
) {
@SerialName("theme.color.blue") @Json(name = "theme.color.blue") BLUE(R.string.ui_theme_color_blue_label),
@SerialName("theme.color.green") @Json(name = "theme.color.green") GREEN(R.string.ui_theme_color_green_label),
@SerialName("theme.color.amber") @Json(name = "theme.color.amber") AMBER(R.string.ui_theme_color_amber_label),
@SerialName("theme.color.blue") BLUE(R.string.ui_theme_color_blue_label),
@SerialName("theme.color.green") GREEN(R.string.ui_theme_color_green_label),
@SerialName("theme.color.amber") AMBER(R.string.ui_theme_color_amber_label),
}
@@ -1,18 +1,15 @@
package eu.darken.capod.common.theming
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ThemeMode(
@StringRes val labelRes: Int
) {
@SerialName("theme.mode.system") @Json(name = "theme.mode.system") SYSTEM(R.string.ui_theme_mode_system_label),
@SerialName("theme.mode.dark") @Json(name = "theme.mode.dark") DARK(R.string.ui_theme_mode_dark_label),
@SerialName("theme.mode.light") @Json(name = "theme.mode.light") LIGHT(R.string.ui_theme_mode_light_label),
@SerialName("theme.mode.system") SYSTEM(R.string.ui_theme_mode_system_label),
@SerialName("theme.mode.dark") DARK(R.string.ui_theme_mode_dark_label),
@SerialName("theme.mode.light") LIGHT(R.string.ui_theme_mode_light_label),
}
@@ -1,19 +1,16 @@
package eu.darken.capod.common.theming
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class ThemeStyle(
@StringRes val labelRes: Int
) {
@SerialName("theme.style.default") @Json(name = "theme.style.default") DEFAULT(R.string.ui_theme_style_default_label),
@SerialName("theme.style.materialyou") @Json(name = "theme.style.materialyou") MATERIAL_YOU(R.string.ui_theme_style_materialyou_label),
@SerialName("theme.style.mediumcontrast") @Json(name = "theme.style.mediumcontrast") MEDIUM_CONTRAST(R.string.ui_theme_style_medium_contrast_label),
@SerialName("theme.style.highcontrast") @Json(name = "theme.style.highcontrast") HIGH_CONTRAST(R.string.ui_theme_style_high_contrast_label),
@SerialName("theme.style.default") DEFAULT(R.string.ui_theme_style_default_label),
@SerialName("theme.style.materialyou") MATERIAL_YOU(R.string.ui_theme_style_materialyou_label),
@SerialName("theme.style.mediumcontrast") MEDIUM_CONTRAST(R.string.ui_theme_style_medium_contrast_label),
@SerialName("theme.style.highcontrast") HIGH_CONTRAST(R.string.ui_theme_style_high_contrast_label),
}
@@ -1,24 +1,21 @@
package eu.darken.capod.main.core
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class MonitorMode(
@StringRes val labelRes: Int
) {
@SerialName("monitor.mode.manual") @Json(name = "monitor.mode.manual") MANUAL(
@SerialName("monitor.mode.manual") MANUAL(
R.string.settings_monitor_mode_manual_label
),
@SerialName("monitor.mode.automatic") @Json(name = "monitor.mode.automatic") AUTOMATIC(
@SerialName("monitor.mode.automatic") AUTOMATIC(
R.string.settings_monitor_mode_automatic_label
),
@SerialName("monitor.mode.always") @Json(name = "monitor.mode.always") ALWAYS(
@SerialName("monitor.mode.always") ALWAYS(
R.string.settings_monitor_mode_always_label
),
}
@@ -138,13 +138,6 @@ fun AcknowledgementsScreen(
onClick = { onOpenUrl("https://github.com/google/dagger") },
)
}
item {
SettingsBaseItem(
title = "Moshi",
subtitle = "A modern JSON library for Kotlin and Java. (APACHE 2.0)",
onClick = { onOpenUrl("https://github.com/square/moshi") },
)
}
item {
SettingsBaseItem(
title = "Android",
@@ -1,8 +1,6 @@
package eu.darken.capod.monitor.core
import android.content.Context
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.coroutine.DispatcherProvider
@@ -11,10 +9,12 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.serialization.SerializationCapod
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@@ -23,12 +23,11 @@ import javax.inject.Singleton
class PodDeviceCache @Inject constructor(
@ApplicationContext private val context: Context,
private val dispatcherProvider: DispatcherProvider,
moshi: Moshi,
@SerializationCapod private val json: Json,
) {
private val cacheDir by lazy {
File(context.cacheDir, "device_cache").apply { mkdirs() }
}
private val jsonAdapter = moshi.adapter<BleScanResult>()
private val lock = Mutex()
private fun ProfileId.toCacheFile(): File = File(cacheDir, "profile_${this}.json")
@@ -40,7 +39,7 @@ class PodDeviceCache @Inject constructor(
if (!cacheFile.exists()) return@withLock null
try {
val raw = cacheFile.readText()
jsonAdapter.fromJson(raw)
json.decodeFromString<BleScanResult>(raw)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to read profile $id device: ${e.asLog()}, deleting corrupted cache file" }
cacheFile.delete()
@@ -56,8 +55,8 @@ class PodDeviceCache @Inject constructor(
log(TAG, VERBOSE) { "save(id=$id, device=$device)" }
val cacheFile = id.toCacheFile()
try {
val json = jsonAdapter.toJson(device)
cacheFile.writeText(json)
val encoded = json.encodeToString(BleScanResult.serializer(), device)
cacheFile.writeText(encoded)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to save profile $id device $device: ${e.asLog()}" }
cacheFile.delete()
@@ -2,8 +2,6 @@ package eu.darken.capod.pods.core
import android.content.Context
import androidx.annotation.DrawableRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BluetoothAddress
@@ -77,112 +75,111 @@ interface PodDevice {
value class Id(private val id: UUID = UUID.randomUUID())
@Serializable
@JsonClass(generateAdapter = false)
enum class Model(
val label: String,
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
) {
@SerialName("airpods.gen1") @Json(name = "airpods.gen1") AIRPODS_GEN1(
@SerialName("airpods.gen1") AIRPODS_GEN1(
label = "AirPods (Gen 1)",
iconRes = R.drawable.device_airpods_gen1_both,
),
@SerialName("airpods.gen2") @Json(name = "airpods.gen2") AIRPODS_GEN2(
@SerialName("airpods.gen2") AIRPODS_GEN2(
"AirPods (Gen 2)",
R.drawable.device_airpods_gen1_both,
),
@SerialName("airpods.gen3") @Json(name = "airpods.gen3") AIRPODS_GEN3(
@SerialName("airpods.gen3") AIRPODS_GEN3(
"AirPods (Gen 3)",
R.drawable.device_airpods_gen3_both,
),
@SerialName("airpods.gen4") @Json(name = "airpods.gen4") AIRPODS_GEN4(
@SerialName("airpods.gen4") AIRPODS_GEN4(
"AirPods (Gen 4)",
R.drawable.device_airpods_gen3_both,
),
@SerialName("airpods.gen4.anc") @Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC(
@SerialName("airpods.gen4.anc") AIRPODS_GEN4_ANC(
"AirPods (Gen 4 ANC)",
R.drawable.device_airpods_gen4anc_both,
),
@SerialName("airpods.pro") @Json(name = "airpods.pro") AIRPODS_PRO(
@SerialName("airpods.pro") AIRPODS_PRO(
"AirPods Pro",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro2") @Json(name = "airpods.pro2") AIRPODS_PRO2(
@SerialName("airpods.pro2") AIRPODS_PRO2(
"AirPods Pro 2",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro2.usbc") @Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC(
@SerialName("airpods.pro2.usbc") AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.pro3") @Json(name = "airpods.pro3") AIRPODS_PRO3(
@SerialName("airpods.pro3") AIRPODS_PRO3(
"AirPods Pro 3",
R.drawable.device_airpods_pro2_both
),
@SerialName("airpods.max") @Json(name = "airpods.max") AIRPODS_MAX(
@SerialName("airpods.max") AIRPODS_MAX(
"AirPods Max",
R.drawable.device_airpods_max
),
@SerialName("airpods.max.usbc") @Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC(
@SerialName("airpods.max.usbc") AIRPODS_MAX_USBC(
"AirPods Max USB-C",
R.drawable.device_airpods_max
),
@SerialName("beats.flex") @Json(name = "beats.flex") BEATS_FLEX(
@SerialName("beats.flex") BEATS_FLEX(
"Beats Flex",
R.drawable.device_beats_earbuds,
),
@SerialName("beats.solo.3") @Json(name = "beats.solo.3") BEATS_SOLO_3(
@SerialName("beats.solo.3") BEATS_SOLO_3(
"Beats Solo 3",
R.drawable.device_beats_headphones,
),
@SerialName("beats.studio.3") @Json(name = "beats.studio.3") BEATS_STUDIO_3(
@SerialName("beats.studio.3") BEATS_STUDIO_3(
"Beats Studio 3",
R.drawable.device_beats_studio3,
),
@SerialName("beats.x") @Json(name = "beats.x") BEATS_X(
@SerialName("beats.x") BEATS_X(
"Beats X",
R.drawable.device_beats_x,
),
@SerialName("beats.powerbeats.3") @Json(name = "beats.powerbeats.3") POWERBEATS_3(
@SerialName("beats.powerbeats.3") POWERBEATS_3(
"Power Beats 3",
R.drawable.device_powerbeats_3,
),
@SerialName("beats.powerbeats.4") @Json(name = "beats.powerbeats.4") POWERBEATS_4(
@SerialName("beats.powerbeats.4") POWERBEATS_4(
"Power Beats 4",
R.drawable.device_powerbeats_4,
),
@SerialName("beats.powerbeats.pro") @Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
@SerialName("beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro",
R.drawable.device_powerbeats_pro_both,
),
@SerialName("beats.powerbeats.pro2") @Json(name = "beats.powerbeats.pro2") POWERBEATS_PRO2(
@SerialName("beats.powerbeats.pro2") POWERBEATS_PRO2(
"Power Beats Pro 2",
R.drawable.device_powerbeats_pro2_both,
),
@SerialName("beats.fit.pro") @Json(name = "beats.fit.pro") BEATS_FIT_PRO(
@SerialName("beats.fit.pro") BEATS_FIT_PRO(
"Beats Fit Pro",
R.drawable.device_beats_fitpro_both,
),
@SerialName("fakes.tws.i99999") @Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1(
@SerialName("fakes.tws.i99999") FAKE_AIRPODS_GEN1(
"AirPods (Gen 1)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
),
@SerialName("fakes.generic.airpods.gen2") @Json(name = "fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
@SerialName("fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
"AirPods (Gen 2)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
),
@SerialName("fakes.generic.airpods.gen3") @Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
@SerialName("fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
"AirPods (Gen 3)? \uD83C\uDFAD",
R.drawable.device_airpods_gen3_both,
),
@SerialName("fakes.varunr.airpodspro") @Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
@SerialName("fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
"AirPods Pro? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
),
@SerialName("fakes.generic.airpods.pro2") @Json(name = "fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
@SerialName("fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
"AirPods Pro2? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
),
@SerialName("unknown") @Json(name = "unknown") UNKNOWN(
@SerialName("unknown") UNKNOWN(
"Unknown"
);
}
@@ -1,7 +1,5 @@
package eu.darken.capod.profiles.core
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey
@@ -14,14 +12,13 @@ import java.util.UUID
@Parcelize
@Serializable
@SerialName("apple")
@JsonClass(generateAdapter = true)
data class AppleDeviceProfile(
@Json(name = "id") override val id: ProfileId = UUID.randomUUID().toString(),
@Json(name = "label") override val label: String,
@Json(name = "priority") override val priority: Int = 0,
@Json(name = "model") override val model: PodDevice.Model = PodDevice.Model.UNKNOWN,
@Json(name = "minimumSignalQuality") override val minimumSignalQuality: Float = DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY,
@Serializable(with = ByteArrayBase64Serializer::class) @Json(name = "identityKey") val identityKey: IdentityResolvingKey? = null,
@Serializable(with = ByteArrayBase64Serializer::class) @Json(name = "encryptionKey") val encryptionKey: ProximityEncryptionKey? = null,
@Json(name = "address") override val address: String? = null,
@SerialName("id") override val id: ProfileId = UUID.randomUUID().toString(),
@SerialName("label") override val label: String,
@SerialName("priority") override val priority: Int = 0,
@SerialName("model") override val model: PodDevice.Model = PodDevice.Model.UNKNOWN,
@SerialName("minimumSignalQuality") override val minimumSignalQuality: Float = DeviceProfile.DEFAULT_MINIMUM_SIGNAL_QUALITY,
@SerialName("identityKey") @Serializable(with = ByteArrayBase64Serializer::class) val identityKey: IdentityResolvingKey? = null,
@SerialName("encryptionKey") @Serializable(with = ByteArrayBase64Serializer::class) val encryptionKey: ProximityEncryptionKey? = null,
@SerialName("address") override val address: String? = null,
) : DeviceProfile
@@ -1,9 +1,7 @@
package eu.darken.capod.profiles.core
import android.os.Parcelable
import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory
import eu.darken.capod.pods.core.PodDevice
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@@ -17,8 +15,5 @@ sealed interface DeviceProfile : Parcelable {
companion object {
const val DEFAULT_MINIMUM_SIGNAL_QUALITY = 0.15f
val MOSHI_FACTORY = PolymorphicJsonAdapterFactory.of(DeviceProfile::class.java, "type")
.withSubtype(AppleDeviceProfile::class.java, "apple")
}
}
@@ -1,11 +1,9 @@
package eu.darken.capod.profiles.core
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = true)
data class DeviceProfilesContainer(
@Json(name = "profiles") val profiles: List<DeviceProfile> = emptyList()
@SerialName("profiles") val profiles: List<DeviceProfile> = emptyList()
)
@@ -1,27 +1,24 @@
package eu.darken.capod.reaction.core.autoconnect
import androidx.annotation.StringRes
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import eu.darken.capod.R
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@JsonClass(generateAdapter = false)
enum class AutoConnectCondition(
val identifier: String,
@StringRes val labelRes: Int
) {
@SerialName("autoconnect.condition.seen") @Json(name = "autoconnect.condition.seen") WHEN_SEEN(
@SerialName("autoconnect.condition.seen") WHEN_SEEN(
"monitor.mode.manual",
R.string.settings_reaction_autoconnect_whenseen_label
),
@SerialName("autoconnect.condition.case") @Json(name = "autoconnect.condition.case") CASE_OPEN(
@SerialName("autoconnect.condition.case") CASE_OPEN(
"autoconnect.condition.case",
R.string.settings_reaction_autoconnect_caseopen_label
),
@SerialName("autoconnect.condition.inear") @Json(name = "autoconnect.condition.inear") IN_EAR(
@SerialName("autoconnect.condition.inear") IN_EAR(
"autoconnect.condition.inear",
R.string.settings_reaction_autoconnect_inear_label
),
@@ -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) }
}
@@ -1,10 +1,8 @@
package eu.darken.capod.common.datastore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.squareup.moshi.Json as MoshiJson
import eu.darken.capod.common.upgrade.core.FossUpgrade
import io.kotest.matchers.shouldBe
import kotlinx.serialization.SerialName
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import org.junit.jupiter.api.Test
@@ -25,6 +23,7 @@ class FossUpgradeSerializationTest : BaseTest() {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
private fun createDataStore() = PreferenceDataStoreFactory.create(
@@ -50,24 +49,27 @@ class FossUpgradeSerializationTest : BaseTest() {
}
@Test
fun `SerialName matches Json name - FossUpgrade Reason`() {
val enumClass = FossUpgrade.Reason::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)
if (moshiAnnotation != null && serialNameAnnotation != null) {
serialNameAnnotation.value shouldBe moshiAnnotation.name
}
}
fun `FossUpgrade Reason encodes to canonical string`() {
json.encodeToString(serializer<FossUpgrade.Reason>(), FossUpgrade.Reason.DONATED) shouldBe "\"foss.upgrade.reason.donated\""
}
@Test
fun `Moshi-serialized FossUpgrade JSON is readable by kotlinx`() {
val moshiJson = """{"upgradedAt":1709553600000,"reason":"foss.upgrade.reason.donated"}"""
val result = json.decodeFromString(serializer<FossUpgrade>(), moshiJson)
fun `legacy FossUpgrade JSON decodes correctly`() {
val legacyJson = """{"upgradedAt":1709553600000,"reason":"foss.upgrade.reason.donated"}"""
val result = json.decodeFromString(serializer<FossUpgrade>(), legacyJson)
result.upgradedAt shouldBe Instant.ofEpochMilli(1709553600000)
result.reason shouldBe FossUpgrade.Reason.DONATED
}
@Test
fun `FossUpgrade round-trip`() {
val upgrade = FossUpgrade(
upgradedAt = Instant.ofEpochMilli(1709553600000),
reason = FossUpgrade.Reason.ALREADY_DONATED,
)
val encoded = json.encodeToString(serializer<FossUpgrade>(), upgrade)
val decoded = json.decodeFromString(serializer<FossUpgrade>(), encoded)
decoded.upgradedAt shouldBe upgrade.upgradedAt
decoded.reason shouldBe upgrade.reason
}
}
-5
View File
@@ -60,11 +60,6 @@ fun DependencyHandlerScope.addDagger() {
kspAndroidTest("com.google.dagger:hilt-android-compiler:${Versions.Dagger.core}")
}
fun DependencyHandlerScope.addMoshi() {
implementation("com.squareup.moshi:moshi:1.15.2")
implementation("com.squareup.moshi:moshi-adapters:1.15.2")
add("ksp", "com.squareup.moshi:moshi-kotlin-codegen:1.15.2")
}
fun DependencyHandlerScope.addOkio() {
implementation("com.squareup.okio:okio:3.1.0")