refactor(settings): Migrate preferences from SharedPreferences to DataStore

Replace FlowPreference<T> wrapping SharedPreferences with DataStoreValue<T> wrapping AndroidX DataStore. Includes SharedPreferencesMigration for preserving existing user data, kotlinx-serialization for complex types (replacing Moshi for preferences), and comprehensive unit tests for the new infrastructure.
This commit is contained in:
darken
2026-03-04 15:23:34 +00:00
committed by Matthias Urhahn
parent 6326f4a5b9
commit a92e364ca6
55 changed files with 1083 additions and 1051 deletions
@@ -0,0 +1,163 @@
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
import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.pods.core.PodDevice
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 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.
*/
class DataStoreMigrationCompatTest : BaseTest() {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
/**
* 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)
if (moshiAnnotation != null && serialNameAnnotation != null) {
serialNameAnnotation.value shouldBe moshiAnnotation.name
}
}
}
@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
}
@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
}
@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
}
@Test
fun `all PodDevice Model values can be decoded from Moshi format`() {
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
}
}
}
@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 = """
{
"profiles": [
{
"type": "apple",
"id": "test-uuid",
"label": "My AirPods Pro",
"priority": 0,
"model": "airpods.pro",
"minimumSignalQuality": 0.15,
"address": "AA:BB:CC:DD:EE:FF"
}
]
}
""".trimIndent()
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
result.profiles.size shouldBe 1
val profile = result.profiles[0] as AppleDeviceProfile
profile.id shouldBe "test-uuid"
profile.label shouldBe "My AirPods Pro"
profile.model shouldBe PodDevice.Model.AIRPODS_PRO
profile.minimumSignalQuality shouldBe 0.15f
profile.address shouldBe "AA:BB:CC:DD:EE:FF"
profile.identityKey shouldBe null
profile.encryptionKey shouldBe null
}
@Test
fun `Moshi-serialized DeviceProfile with ByteArray fields is readable by kotlinx`() {
// Base64 encoded: [0x01, 0x02, 0x03] = "AQID"
val moshiJson = """
{
"profiles": [
{
"type": "apple",
"id": "key-test",
"label": "Keyed Profile",
"priority": 1,
"model": "airpods.gen2",
"minimumSignalQuality": 0.2,
"identityKey": "AQID",
"encryptionKey": "BAUG"
}
]
}
""".trimIndent()
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
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)
}
}
@@ -0,0 +1,170 @@
package eu.darken.capod.common.datastore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
import eu.darken.capod.common.theming.ThemeMode
import kotlinx.serialization.builtins.nullable
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesContainer
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
import java.io.File
import eu.darken.capod.common.datastore.value
class DataStoreValueSerializationTest : BaseTest() {
@TempDir
lateinit var tempDir: File
private var dsCounter = 0
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
private fun createDataStore() = PreferenceDataStoreFactory.create(
produceFile = { File(tempDir, "test_${dsCounter++}.preferences_pb") }
)
@Test
fun `enum round-trip - ThemeMode`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json)
pref.value() shouldBe ThemeMode.SYSTEM
pref.value(ThemeMode.DARK)
pref.value() shouldBe ThemeMode.DARK
pref.value(ThemeMode.LIGHT)
pref.value() shouldBe ThemeMode.LIGHT
}
@Test
fun `enum round-trip - PodDevice Model`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("model", PodDevice.Model.UNKNOWN, json)
PodDevice.Model.entries.forEach { model ->
pref.value(model)
pref.value() shouldBe model
}
}
@Test
fun `data class round-trip - DeviceProfilesContainer`() = runTest2 {
val ds = createDataStore()
val container = DeviceProfilesContainer(
profiles = listOf(
AppleDeviceProfile(
id = "test-id-1",
label = "My AirPods",
model = PodDevice.Model.AIRPODS_PRO,
address = "AA:BB:CC:DD:EE:FF",
)
)
)
val pref = ds.createValue("profiles", DeviceProfilesContainer(), json)
pref.value(container)
val result = pref.value()
result.profiles.size shouldBe 1
val profile = result.profiles[0] as AppleDeviceProfile
profile.id shouldBe "test-id-1"
profile.label shouldBe "My AirPods"
profile.model shouldBe PodDevice.Model.AIRPODS_PRO
profile.address shouldBe "AA:BB:CC:DD:EE:FF"
}
@Test
fun `onErrorFallbackToDefault returns default on corrupt JSON`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json, onErrorFallbackToDefault = true)
// Write corrupt JSON directly
val corruptPref = ds.createValue("theme", "not valid json")
corruptPref.value("{{{corrupt json")
// Now read it as ThemeMode - should fallback to default
pref.value() shouldBe ThemeMode.SYSTEM
}
@Test
fun `onErrorFallbackToDefault returns default on unknown enum value`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json, onErrorFallbackToDefault = true)
// Write an unknown enum value
val rawPref = ds.createValue("theme", "placeholder")
rawPref.value("\"theme.mode.nonexistent\"")
pref.value() shouldBe ThemeMode.SYSTEM
}
@Test
fun `onErrorFallbackToDefault false - corrupt JSON throws`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json, onErrorFallbackToDefault = false)
val rawPref = ds.createValue("theme", "placeholder")
rawPref.value("{{{corrupt")
shouldThrow<Exception> {
pref.value()
}
}
@Test
fun `ByteArray round-trip via explicit serializer`() = runTest2 {
val ds = createDataStore()
val testBytes = byteArrayOf(0x01, 0x02, 0x03, 0xAA.toByte(), 0xFF.toByte())
val pref = ds.createValue(
key = "bytes",
defaultValue = null as ByteArray?,
json = json,
serializer = ByteArrayBase64Serializer.nullable,
)
pref.value() shouldBe null
pref.value(testBytes)
val result = pref.value()
result!!.toList() shouldBe testBytes.toList()
}
@Test
fun `sealed interface polymorphic - DeviceProfile`() = runTest2 {
val ds = createDataStore()
val profile: DeviceProfile = AppleDeviceProfile(
id = "poly-test",
label = "Test Profile",
model = PodDevice.Model.AIRPODS_GEN2,
identityKey = byteArrayOf(0x01, 0x02),
encryptionKey = byteArrayOf(0x03, 0x04),
)
val container = DeviceProfilesContainer(profiles = listOf(profile))
val pref = ds.createValue("profiles", DeviceProfilesContainer(), json)
pref.value(container)
val result = pref.value()
result.profiles.size shouldBe 1
val restored = result.profiles[0] as AppleDeviceProfile
restored.id shouldBe "poly-test"
restored.label shouldBe "Test Profile"
restored.model shouldBe PodDevice.Model.AIRPODS_GEN2
restored.identityKey!!.toList() shouldBe listOf<Byte>(0x01, 0x02)
restored.encryptionKey!!.toList() shouldBe listOf<Byte>(0x03, 0x04)
}
}
@@ -0,0 +1,156 @@
package eu.darken.capod.common.datastore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
import java.io.File
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.datastore.value
class DataStoreValueTest : BaseTest() {
@TempDir
lateinit var tempDir: File
private var dsCounter = 0
private fun createDataStore() = PreferenceDataStoreFactory.create(
produceFile = { File(tempDir, "test_${dsCounter++}.preferences_pb") }
)
@Test
fun `read default value when key not set - String`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", "default_val")
pref.value() shouldBe "default_val"
}
@Test
fun `read default value when key not set - Boolean`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", false)
pref.value() shouldBe false
}
@Test
fun `read default value when key not set - Int`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 42)
pref.value() shouldBe 42
}
@Test
fun `read default value when key not set - Long`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 123L)
pref.value() shouldBe 123L
}
@Test
fun `read default value when key not set - Float`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 0.5f)
pref.value() shouldBe 0.5f
}
@Test
fun `write and read back String`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", "default")
pref.value("new_value")
pref.value() shouldBe "new_value"
}
@Test
fun `write and read back Boolean`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", false)
pref.value(true)
pref.value() shouldBe true
}
@Test
fun `write and read back Int`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 0)
pref.value(99)
pref.value() shouldBe 99
}
@Test
fun `write and read back Long`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 0L)
pref.value(Long.MAX_VALUE)
pref.value() shouldBe Long.MAX_VALUE
}
@Test
fun `write and read back Float`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 0f)
pref.value(3.14f)
pref.value() shouldBe 3.14f
}
@Test
fun `flow emits default then updated value`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", "initial")
pref.flow.first() shouldBe "initial"
pref.value("updated")
pref.flow.first() shouldBe "updated"
}
@Test
fun `update returns old and new values`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 10)
val result = pref.update { it + 5 }
result shouldBe DataStoreValue.Updated(old = 10, new = 15)
pref.value() shouldBe 15
}
@Test
fun `update transforms from current value`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", "hello")
pref.value("world")
val result = pref.update { "$it!" }
result shouldBe DataStoreValue.Updated(old = "world", new = "world!")
}
@Test
fun `valueBlocking get returns current value`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", "blocking_default")
pref.valueBlocking shouldBe "blocking_default"
pref.value("new_blocking")
pref.valueBlocking shouldBe "new_blocking"
}
@Test
fun `valueBlocking set writes value`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 0)
pref.valueBlocking = 42
pref.value() shouldBe 42
}
@Test
fun `keyName returns the preference key name`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("my.special.key", true)
pref.keyName shouldBe "my.special.key"
}
}
@@ -1,189 +0,0 @@
package eu.darken.capod.common.preferences
import com.squareup.moshi.JsonClass
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import eu.darken.capod.common.theming.ThemeMode
import eu.darken.capod.main.core.MonitorMode
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.assertThrows
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.json.toComparableJson
import testhelpers.preferences.MockSharedPreferences
class FlowPreferenceMoshiTest : BaseTest() {
private val mockPreferences = MockSharedPreferences()
@JsonClass(generateAdapter = true)
data class TestGson(
val string: String = "",
val boolean: Boolean = true,
val float: Float = 1.0f,
val int: Int = 1,
val long: Long = 1L
)
@Test
fun `reading and writing using manual reader and writer`() = runTest {
val testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build()
FlowPreference<TestGson?>(
preferences = mockPreferences,
key = "testKey",
rawReader = moshiReader(moshi, testData1),
rawWriter = moshiWriter(moshi)
).apply {
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe testData1
it!!.copy(string = "update")
}
value shouldBe testData2
flow.first() shouldBe testData2
(mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """
{
"string":"update",
"boolean":true,
"float":1.0,
"int":1,
"long":1
}
""".toComparableJson()
update {
it shouldBe testData2
null
}
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing using autocreated reader and writer`() = runTest {
val testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build()
mockPreferences.createFlowPreference<TestGson?>(
key = "testKey",
defaultValue = testData1,
moshi = moshi
).apply {
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe testData1
it!!.copy(string = "update")
}
value shouldBe testData2
flow.first() shouldBe testData2
(mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """
{
"string":"update",
"boolean":true,
"float":1.0,
"int":1,
"long":1
}
""".toComparableJson()
update {
it shouldBe testData2
null
}
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `enum serialization`() = runTest {
val moshi = Moshi.Builder().build()
val monitorMode = mockPreferences.createFlowPreference(
"core.monitor.mode",
MonitorMode.AUTOMATIC,
moshi
)
monitorMode.value shouldBe MonitorMode.AUTOMATIC
monitorMode.update { MonitorMode.MANUAL }
monitorMode.value shouldBe MonitorMode.MANUAL
}
@Test
fun `bad enum value throws without fallback`() = runTest {
val moshi = Moshi.Builder().build()
mockPreferences.edit().putString("theme.mode", "\"theme.mode.bogus\"").apply()
assertThrows<JsonDataException> {
mockPreferences.createFlowPreference(
key = "theme.mode",
defaultValue = ThemeMode.SYSTEM,
moshi = moshi,
onErrorFallbackToDefault = false,
)
}
}
@Test
fun `bad enum value returns default with fallback`() = runTest {
val moshi = Moshi.Builder().build()
mockPreferences.edit().putString("theme.mode", "\"theme.mode.bogus\"").apply()
val pref = mockPreferences.createFlowPreference(
key = "theme.mode",
defaultValue = ThemeMode.SYSTEM,
moshi = moshi,
onErrorFallbackToDefault = true,
)
pref.value shouldBe ThemeMode.SYSTEM
pref.flow.first() shouldBe ThemeMode.SYSTEM
}
@Test
fun `corrupt json returns default with fallback`() = runTest {
val moshi = Moshi.Builder().build()
mockPreferences.edit().putString("theme.mode", "not-json-at-all").apply()
val pref = mockPreferences.createFlowPreference(
key = "theme.mode",
defaultValue = ThemeMode.DARK,
moshi = moshi,
onErrorFallbackToDefault = true,
)
pref.value shouldBe ThemeMode.DARK
}
@Test
fun `valid enum roundtrips with fallback enabled`() = runTest {
val moshi = Moshi.Builder().build()
val pref = mockPreferences.createFlowPreference(
key = "theme.mode",
defaultValue = ThemeMode.SYSTEM,
moshi = moshi,
onErrorFallbackToDefault = true,
)
pref.value shouldBe ThemeMode.SYSTEM
pref.update { ThemeMode.DARK }
pref.value shouldBe ThemeMode.DARK
pref.flow.first() shouldBe ThemeMode.DARK
}
}
@@ -1,159 +0,0 @@
package eu.darken.capod.common.preferences
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.preferences.MockSharedPreferences
class FlowPreferenceTest : BaseTest() {
private val mockPreferences = MockSharedPreferences()
@Test
fun `reading and writing strings`() = runTest {
mockPreferences.createFlowPreference<String?>(
key = "testKey",
defaultValue = "default"
).apply {
value shouldBe "default"
flow.first() shouldBe "default"
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe "default"
"newvalue"
}
value shouldBe "newvalue"
flow.first() shouldBe "newvalue"
mockPreferences.dataMapPeek.values.first() shouldBe "newvalue"
update {
it shouldBe "newvalue"
null
}
value shouldBe "default"
flow.first() shouldBe "default"
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing boolean`() = runTest {
mockPreferences.createFlowPreference<Boolean?>(
key = "testKey",
defaultValue = true
).apply {
value shouldBe true
flow.first() shouldBe true
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe true
false
}
value shouldBe false
flow.first() shouldBe false
mockPreferences.dataMapPeek.values.first() shouldBe false
update {
it shouldBe false
null
}
value shouldBe true
flow.first() shouldBe true
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing long`() = runTest {
mockPreferences.createFlowPreference<Long?>(
key = "testKey",
defaultValue = 9000L
).apply {
value shouldBe 9000L
flow.first() shouldBe 9000L
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe 9000L
9001L
}
value shouldBe 9001L
flow.first() shouldBe 9001L
mockPreferences.dataMapPeek.values.first() shouldBe 9001L
update {
it shouldBe 9001L
null
}
value shouldBe 9000L
flow.first() shouldBe 9000L
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing integer`() = runTest {
mockPreferences.createFlowPreference<Long?>(
key = "testKey",
defaultValue = 123
).apply {
value shouldBe 123
flow.first() shouldBe 123
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe 123
44
}
value shouldBe 44
flow.first() shouldBe 44
mockPreferences.dataMapPeek.values.first() shouldBe 44
update {
it shouldBe 44
null
}
value shouldBe 123
flow.first() shouldBe 123
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing float`() = runTest {
mockPreferences.createFlowPreference<Float?>(
key = "testKey",
defaultValue = 3.6f
).apply {
value shouldBe 3.6f
flow.first() shouldBe 3.6f
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe 3.6f
15000f
}
value shouldBe 15000f
flow.first() shouldBe 15000f
mockPreferences.dataMapPeek.values.first() shouldBe 15000f
update {
it shouldBe 15000f
null
}
value shouldBe 3.6f
flow.first() shouldBe 3.6f
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
}
@@ -1,21 +0,0 @@
package testhelpers.preferences
import eu.darken.capod.common.preferences.FlowPreference
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
fun <T> mockFlowPreference(
defaultValue: T
): FlowPreference<T> {
val instance = mockk<FlowPreference<T>>()
val flow = MutableStateFlow(defaultValue)
every { instance.flow } answers { flow }
every { instance.value } answers { flow.value }
every { instance.update(any()) } answers {
val updateCall = arg<(T) -> T>(0)
flow.value = updateCall(flow.value)
}
return instance
}
@@ -1,99 +0,0 @@
package testhelpers.preferences
import android.content.SharedPreferences
class MockSharedPreferences : SharedPreferences {
private val listeners = mutableListOf<SharedPreferences.OnSharedPreferenceChangeListener>()
private val dataMap = mutableMapOf<String, Any>()
val dataMapPeek: Map<String, Any>
get() = dataMap.toMap()
override fun getAll(): MutableMap<String, *> = dataMap
override fun getString(key: String, defValue: String?): String? =
dataMap[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: MutableSet<String>?): MutableSet<String> {
throw NotImplementedError()
}
override fun getInt(key: String, defValue: Int): Int =
dataMap[key] as? Int ?: defValue
override fun getLong(key: String, defValue: Long): Long =
dataMap[key] as? Long ?: defValue
override fun getFloat(key: String, defValue: Float): Float {
throw NotImplementedError()
}
override fun getBoolean(key: String, defValue: Boolean): Boolean =
dataMap[key] as? Boolean ?: defValue
override fun contains(key: String): Boolean = dataMap.contains(key)
override fun edit(): SharedPreferences.Editor = createEditor(dataMap.toMap()) { newData ->
dataMap.clear()
dataMap.putAll(newData)
}
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
listeners.add(listener)
}
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
listeners.remove(listener)
}
private fun createEditor(
toEdit: Map<String, Any>,
onSave: (Map<String, Any>) -> Unit
): SharedPreferences.Editor {
return object : SharedPreferences.Editor {
private val editorData = toEdit.toMutableMap()
override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
value?.let { editorData[key] = it } ?: editorData.remove(key)
}
override fun putStringSet(
key: String?,
values: MutableSet<String>?
): SharedPreferences.Editor {
throw NotImplementedError()
}
override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun remove(key: String): SharedPreferences.Editor = apply {
editorData.remove(key)
}
override fun clear(): SharedPreferences.Editor = apply {
editorData.clear()
}
override fun commit(): Boolean {
onSave(editorData)
return true
}
override fun apply() {
onSave(editorData)
}
}
}
}
@@ -1,21 +0,0 @@
package testhelpers.preferences
import androidx.core.content.edit
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class MockSharedPreferencesTest : BaseTest() {
private fun createInstance() = MockSharedPreferences()
@Test
fun `test boolean insertion`() {
val prefs = createInstance()
prefs.dataMapPeek shouldBe emptyMap()
prefs.getBoolean("key", true) shouldBe true
prefs.edit { putBoolean("key", false) }
prefs.getBoolean("key", true) shouldBe false
prefs.dataMapPeek["key"] shouldBe false
}
}