From a79f6a05357b85cf35a73ebce1658f2d933fdf9b Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 9 Mar 2026 21:04:20 +0100 Subject: [PATCH] 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 --- .claude/rules/architecture.md | 2 +- app/build.gradle.kts | 2 - app/proguard/proguard-moshi.pro | 3 - .../capod/common/upgrade/core/FossUpgrade.kt | 14 +- .../capod/common/bluetooth/BleScanResult.kt | 18 +- .../capod/common/bluetooth/ScannerMode.kt | 9 +- .../common/serialization/ByteArrayAdapter.kt | 16 -- .../serialization/JavaInstantAdapter.kt | 13 - .../MapIntByteArrayBase64Serializer.kt | 23 ++ .../NameBasedPolyJsonAdapterFactory.kt | 116 --------- .../serialization/SerializationModule.kt | 11 +- .../darken/capod/common/theming/ThemeColor.kt | 9 +- .../darken/capod/common/theming/ThemeMode.kt | 9 +- .../darken/capod/common/theming/ThemeStyle.kt | 11 +- .../eu/darken/capod/main/core/MonitorMode.kt | 9 +- .../settings/acks/AcknowledgementsScreen.kt | 7 - .../capod/monitor/core/PodDeviceCache.kt | 13 +- .../eu/darken/capod/pods/core/PodDevice.kt | 55 ++--- .../capod/profiles/core/AppleDeviceProfile.kt | 19 +- .../capod/profiles/core/DeviceProfile.kt | 5 - .../profiles/core/DeviceProfilesContainer.kt | 6 +- .../core/autoconnect/AutoConnectCondition.kt | 9 +- .../BleScanResultSerializationTest.kt | 76 ++++++ .../datastore/DataStoreMigrationCompatTest.kt | 228 +++++++++++------- .../java/testhelpers/json/JsonExtensions.kt | 29 --- .../datastore/FossUpgradeSerializationTest.kt | 34 +-- buildSrc/src/main/java/Dependencies.kt | 5 - 27 files changed, 338 insertions(+), 413 deletions(-) delete mode 100644 app/proguard/proguard-moshi.pro delete mode 100644 app/src/main/java/eu/darken/capod/common/serialization/ByteArrayAdapter.kt delete mode 100644 app/src/main/java/eu/darken/capod/common/serialization/JavaInstantAdapter.kt create mode 100644 app/src/main/java/eu/darken/capod/common/serialization/MapIntByteArrayBase64Serializer.kt delete mode 100644 app/src/main/java/eu/darken/capod/common/serialization/NameBasedPolyJsonAdapterFactory.kt create mode 100644 app/src/test/java/eu/darken/capod/common/bluetooth/BleScanResultSerializationTest.kt delete mode 100644 app/src/test/java/testhelpers/json/JsonExtensions.kt diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 0a21fd5b..1a22b201 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 47322639..b2de15ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -177,8 +177,6 @@ dependencies { addDagger() - addMoshi() - addOkio() addBaseAndroid() diff --git a/app/proguard/proguard-moshi.pro b/app/proguard/proguard-moshi.pro deleted file mode 100644 index 7138ec64..00000000 --- a/app/proguard/proguard-moshi.pro +++ /dev/null @@ -1,3 +0,0 @@ --keepclassmembernames @com.squareup.moshi.JsonClass class * extends java.lang.Enum { - ; -} \ No newline at end of file diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossUpgrade.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossUpgrade.kt index cba35ad1..622d0dd8 100644 --- a/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossUpgrade.kt +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossUpgrade.kt @@ -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; } } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/BleScanResult.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/BleScanResult.kt index 0d1bab93..09f7f13d 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/BleScanResult.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/BleScanResult.kt @@ -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 + @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 ) : Parcelable { fun getManufacturerSpecificData(id: Int): ByteArray? = manufacturerSpecificData[id] diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/ScannerMode.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/ScannerMode.kt index 52839026..816e9b67 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/ScannerMode.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/ScannerMode.kt @@ -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 ), diff --git a/app/src/main/java/eu/darken/capod/common/serialization/ByteArrayAdapter.kt b/app/src/main/java/eu/darken/capod/common/serialization/ByteArrayAdapter.kt deleted file mode 100644 index 9cf76776..00000000 --- a/app/src/main/java/eu/darken/capod/common/serialization/ByteArrayAdapter.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/serialization/JavaInstantAdapter.kt b/app/src/main/java/eu/darken/capod/common/serialization/JavaInstantAdapter.kt deleted file mode 100644 index 5b121ebe..00000000 --- a/app/src/main/java/eu/darken/capod/common/serialization/JavaInstantAdapter.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/serialization/MapIntByteArrayBase64Serializer.kt b/app/src/main/java/eu/darken/capod/common/serialization/MapIntByteArrayBase64Serializer.kt new file mode 100644 index 00000000..2e08e593 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/serialization/MapIntByteArrayBase64Serializer.kt @@ -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> { + + private val delegate = MapSerializer(Int.serializer(), ByteArrayBase64Serializer) + + override val descriptor: SerialDescriptor = delegate.descriptor + + override fun serialize(encoder: Encoder, value: Map) { + delegate.serialize(encoder, value) + } + + override fun deserialize(decoder: Decoder): Map { + return delegate.deserialize(decoder) + } +} diff --git a/app/src/main/java/eu/darken/capod/common/serialization/NameBasedPolyJsonAdapterFactory.kt b/app/src/main/java/eu/darken/capod/common/serialization/NameBasedPolyJsonAdapterFactory.kt deleted file mode 100644 index 089db3af..00000000 --- a/app/src/main/java/eu/darken/capod/common/serialization/NameBasedPolyJsonAdapterFactory.kt +++ /dev/null @@ -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 internal constructor( - val baseType: Class, - val keyLabels: List = emptyList(), - val subtypes: List = emptyList(), -) : JsonAdapter.Factory { - - fun withSubtype(subtype: Class, label: String): NameBasedPolyJsonAdapterFactory { - 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, moshi: Moshi): JsonAdapter<*>? { - if (Types.getRawType(type) != baseType || annotations.isNotEmpty()) { - return null - } - - val jsonAdapters = ArrayList>(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, - val subTypes: List, - val jsonAdapters: List>, - ) : JsonAdapter() { - - 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 of(baseType: Class): NameBasedPolyJsonAdapterFactory = NameBasedPolyJsonAdapterFactory( - baseType - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt b/app/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt index 03fb7331..f18cff1d 100644 --- a/app/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt +++ b/app/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt @@ -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" } } diff --git a/app/src/main/java/eu/darken/capod/common/theming/ThemeColor.kt b/app/src/main/java/eu/darken/capod/common/theming/ThemeColor.kt index 7e5704d2..7e350697 100644 --- a/app/src/main/java/eu/darken/capod/common/theming/ThemeColor.kt +++ b/app/src/main/java/eu/darken/capod/common/theming/ThemeColor.kt @@ -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), } diff --git a/app/src/main/java/eu/darken/capod/common/theming/ThemeMode.kt b/app/src/main/java/eu/darken/capod/common/theming/ThemeMode.kt index 6b32e5da..684dfb20 100644 --- a/app/src/main/java/eu/darken/capod/common/theming/ThemeMode.kt +++ b/app/src/main/java/eu/darken/capod/common/theming/ThemeMode.kt @@ -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), } diff --git a/app/src/main/java/eu/darken/capod/common/theming/ThemeStyle.kt b/app/src/main/java/eu/darken/capod/common/theming/ThemeStyle.kt index 61413830..9c2a6c04 100644 --- a/app/src/main/java/eu/darken/capod/common/theming/ThemeStyle.kt +++ b/app/src/main/java/eu/darken/capod/common/theming/ThemeStyle.kt @@ -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), } diff --git a/app/src/main/java/eu/darken/capod/main/core/MonitorMode.kt b/app/src/main/java/eu/darken/capod/main/core/MonitorMode.kt index 04c00d9c..4c54d2c2 100644 --- a/app/src/main/java/eu/darken/capod/main/core/MonitorMode.kt +++ b/app/src/main/java/eu/darken/capod/main/core/MonitorMode.kt @@ -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 ), } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt index 8e80285e..e9c415f9 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/acks/AcknowledgementsScreen.kt @@ -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", diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt index dcea022a..0a852417 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceCache.kt @@ -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() 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(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() diff --git a/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt index c225327f..817ab93c 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt @@ -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" ); } diff --git a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt index 05228d4f..d9e24432 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfile.kt b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfile.kt index 516fba25..44d6361c 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfile.kt @@ -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") } } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesContainer.kt b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesContainer.kt index cdbe970f..3e8fbb96 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesContainer.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesContainer.kt @@ -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 = emptyList() + @SerialName("profiles") val profiles: List = emptyList() ) \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectCondition.kt b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectCondition.kt index ebe2dd9e..d997b10c 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectCondition.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnectCondition.kt @@ -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 ), diff --git a/app/src/test/java/eu/darken/capod/common/bluetooth/BleScanResultSerializationTest.kt b/app/src/test/java/eu/darken/capod/common/bluetooth/BleScanResultSerializationTest.kt new file mode 100644 index 00000000..6c5063d6 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/bluetooth/BleScanResultSerializationTest.kt @@ -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(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(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(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(encoded) + + decoded.manufacturerSpecificData shouldBe emptyMap() + } +} diff --git a/app/src/test/java/eu/darken/capod/common/datastore/DataStoreMigrationCompatTest.kt b/app/src/test/java/eu/darken/capod/common/datastore/DataStoreMigrationCompatTest.kt index a22e979e..6d538382 100644 --- a/app/src/test/java/eu/darken/capod/common/datastore/DataStoreMigrationCompatTest.kt +++ b/app/src/test/java/eu/darken/capod/common/datastore/DataStoreMigrationCompatTest.kt @@ -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 > 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.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(), legacyOutput) shouldBe ThemeMode.DARK + } + + @Test + fun `ThemeMode round-trip`() { + ThemeMode.entries.forEach { mode -> + val encoded = json.encodeToString(serializer(), mode) + json.decodeFromString(serializer(), encoded) shouldBe mode } } @Test - fun `SerialName matches Json name - ThemeMode`() = verifyEnumSerialNameParity() - - @Test - fun `SerialName matches Json name - ThemeStyle`() = verifyEnumSerialNameParity() - - @Test - fun `SerialName matches Json name - ThemeColor`() = verifyEnumSerialNameParity() - - @Test - fun `SerialName matches Json name - MonitorMode`() = verifyEnumSerialNameParity() - - @Test - fun `SerialName matches Json name - ScannerMode`() = verifyEnumSerialNameParity() - - @Test - fun `SerialName matches Json name - AutoConnectCondition`() = verifyEnumSerialNameParity() - - @Test - fun `SerialName matches Json name - PodDevice Model`() = verifyEnumSerialNameParity() - - @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(), moshiOutput) - result shouldBe ThemeMode.DARK + fun `ScannerMode encodes to canonical string`() { + json.encodeToString(serializer(), 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(), moshiOutput) - result shouldBe ScannerMode.BALANCED + fun `ScannerMode legacy string decodes correctly`() { + val legacyOutput = "\"scanner.mode.balanced\"" + json.decodeFromString(serializer(), legacyOutput) shouldBe ScannerMode.BALANCED } @Test - fun `Moshi-serialized MonitorMode string is readable by kotlinx`() { - val moshiOutput = "\"monitor.mode.automatic\"" - val result = json.decodeFromString(serializer(), moshiOutput) - result shouldBe MonitorMode.AUTOMATIC + fun `ScannerMode round-trip`() { + ScannerMode.entries.forEach { mode -> + val encoded = json.encodeToString(serializer(), mode) + json.decodeFromString(serializer(), 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.AUTOMATIC) shouldBe "\"monitor.mode.automatic\"" + } + + @Test + fun `MonitorMode legacy string decodes correctly`() { + val legacyOutput = "\"monitor.mode.automatic\"" + json.decodeFromString(serializer(), legacyOutput) shouldBe MonitorMode.AUTOMATIC + } + + @Test + fun `MonitorMode round-trip`() { + MonitorMode.entries.forEach { mode -> + val encoded = json.encodeToString(serializer(), mode) + json.decodeFromString(serializer(), encoded) shouldBe mode + } + } + + @Test + fun `ThemeStyle encodes to canonical string`() { + json.encodeToString(serializer(), ThemeStyle.MATERIAL_YOU) shouldBe "\"theme.style.materialyou\"" + } + + @Test + fun `ThemeStyle legacy string decodes correctly`() { + val legacyOutput = "\"theme.style.materialyou\"" + json.decodeFromString(serializer(), legacyOutput) shouldBe ThemeStyle.MATERIAL_YOU + } + + @Test + fun `ThemeStyle round-trip`() { + ThemeStyle.entries.forEach { style -> + val encoded = json.encodeToString(serializer(), style) + json.decodeFromString(serializer(), encoded) shouldBe style + } + } + + @Test + fun `ThemeColor encodes to canonical string`() { + json.encodeToString(serializer(), ThemeColor.GREEN) shouldBe "\"theme.color.green\"" + } + + @Test + fun `ThemeColor legacy string decodes correctly`() { + val legacyOutput = "\"theme.color.green\"" + json.decodeFromString(serializer(), legacyOutput) shouldBe ThemeColor.GREEN + } + + @Test + fun `ThemeColor round-trip`() { + ThemeColor.entries.forEach { color -> + val encoded = json.encodeToString(serializer(), color) + json.decodeFromString(serializer(), encoded) shouldBe color + } + } + + @Test + fun `AutoConnectCondition encodes to canonical string`() { + json.encodeToString(serializer(), AutoConnectCondition.WHEN_SEEN) shouldBe "\"autoconnect.condition.seen\"" + } + + @Test + fun `AutoConnectCondition legacy string decodes correctly`() { + val legacyOutput = "\"autoconnect.condition.seen\"" + json.decodeFromString(serializer(), 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(), moshiOutput) - result shouldBe model - } + val encoded = json.encodeToString(serializer(), model) + json.decodeFromString(serializer(), encoded) shouldBe model } } @Test - fun `Moshi-serialized ThemeStyle string is readable by kotlinx`() { - val moshiOutput = "\"theme.style.materialyou\"" - val result = json.decodeFromString(serializer(), moshiOutput) - result shouldBe ThemeStyle.MATERIAL_YOU + fun `PodDevice Model legacy string decodes correctly`() { + val legacyOutput = "\"airpods.pro\"" + json.decodeFromString(serializer(), 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(), 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(), 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(), moshiJson) + val result = json.decodeFromString(serializer(), 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(), container) + val decoded = json.decodeFromString(serializer(), 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(), 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(), moshiJson) + val result = json.decodeFromString(serializer(), legacyJson) val profile = result.profiles[0] as AppleDeviceProfile profile.identityKey!!.toList() shouldBe listOf(0x01, 0x02, 0x03) profile.encryptionKey!!.toList() shouldBe listOf(0x04, 0x05, 0x06) diff --git a/app/src/test/java/testhelpers/json/JsonExtensions.kt b/app/src/test/java/testhelpers/json/JsonExtensions.kt deleted file mode 100644 index 7047d9f9..00000000 --- a/app/src/test/java/testhelpers/json/JsonExtensions.kt +++ /dev/null @@ -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) } -} \ No newline at end of file diff --git a/app/src/testFoss/java/eu/darken/capod/common/datastore/FossUpgradeSerializationTest.kt b/app/src/testFoss/java/eu/darken/capod/common/datastore/FossUpgradeSerializationTest.kt index b6cbe605..9b2f3564 100644 --- a/app/src/testFoss/java/eu/darken/capod/common/datastore/FossUpgradeSerializationTest.kt +++ b/app/src/testFoss/java/eu/darken/capod/common/datastore/FossUpgradeSerializationTest.kt @@ -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.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(), moshiJson) + fun `legacy FossUpgrade JSON decodes correctly`() { + val legacyJson = """{"upgradedAt":1709553600000,"reason":"foss.upgrade.reason.donated"}""" + val result = json.decodeFromString(serializer(), 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(), upgrade) + val decoded = json.decodeFromString(serializer(), encoded) + decoded.upgradedAt shouldBe upgrade.upgradedAt + decoded.reason shouldBe upgrade.reason + } } diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index e40a8566..197bdab0 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -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")