mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -177,8 +177,6 @@ dependencies {
|
||||
|
||||
addDagger()
|
||||
|
||||
addMoshi()
|
||||
|
||||
addOkio()
|
||||
|
||||
addBaseAndroid()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
-116
@@ -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()
|
||||
}
|
||||
}
|
||||
+143
-85
@@ -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) }
|
||||
}
|
||||
+18
-16
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user