diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b1154837..1aeb78aa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -189,6 +189,7 @@ dependencies { addCompose() addGlance() + addDataStore() addNavigation3() addSerialization() diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt index 83446f36..30f693bf 100644 --- a/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/core/FossCache.kt @@ -1,24 +1,35 @@ package eu.darken.capod.common.upgrade.core import android.content.Context -import com.squareup.moshi.Moshi +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext -import eu.darken.capod.common.preferences.createFlowPreference +import eu.darken.capod.common.datastore.createValue +import eu.darken.capod.common.serialization.SerializationCapod +import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @Singleton class FossCache @Inject constructor( @ApplicationContext context: Context, - moshi: Moshi + @SerializationCapod json: Json, ) { - private val preferences = context.getSharedPreferences("settings_foss", Context.MODE_PRIVATE) - - val upgrade = preferences.createFlowPreference( - key = "foss.upgrade", - moshi = moshi, - defaultValue = null, + private val Context.dataStore by preferencesDataStore( + name = "settings_foss", + produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_foss")) } ) -} \ No newline at end of file + private val dataStore: DataStore = context.dataStore + + val upgrade = dataStore.createValue( + key = "foss.upgrade", + defaultValue = null, + json = json, + onErrorFallbackToDefault = true, + ) + +} 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 febc77ec..cba35ad1 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 @@ -2,17 +2,22 @@ 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( - val upgradedAt: Instant, + @Serializable(with = InstantEpochMillisSerializer::class) val upgradedAt: Instant, val reason: Reason ) { + @Serializable @JsonClass(generateAdapter = false) enum class Reason { - @Json(name = "foss.upgrade.reason.donated") DONATED, - @Json(name = "foss.upgrade.reason.alreadydonated") ALREADY_DONATED, - @Json(name = "foss.upgrade.reason.nomoney") NO_MONEY; + @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; } } \ No newline at end of file diff --git a/app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt b/app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt index b70d98f4..7e4f57d2 100644 --- a/app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt +++ b/app/src/foss/java/eu/darken/capod/common/upgrade/core/UpgradeControlFoss.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.map import java.time.Instant import javax.inject.Inject import javax.inject.Singleton +import eu.darken.capod.common.datastore.valueBlocking @Singleton class UpgradeControlFoss @Inject constructor( @@ -25,7 +26,7 @@ class UpgradeControlFoss @Inject constructor( } fun upgrade(reason: FossUpgrade.Reason) { - fossCache.upgrade.value = FossUpgrade( + fossCache.upgrade.valueBlocking = FossUpgrade( upgradedAt = Instant.now(), reason = reason ) diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt index df657806..3f82628f 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingCache.kt @@ -1,8 +1,12 @@ package eu.darken.capod.common.upgrade.core import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext -import eu.darken.capod.common.preferences.createFlowPreference +import eu.darken.capod.common.datastore.createValue import javax.inject.Inject import javax.inject.Singleton @@ -11,9 +15,14 @@ class BillingCache @Inject constructor( @ApplicationContext private val context: Context, ) { - private val preferences = context.getSharedPreferences("settings_gplay", Context.MODE_PRIVATE) + private val Context.dataStore by preferencesDataStore( + name = "settings_gplay", + produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_gplay")) } + ) - val lastProStateAt = preferences.createFlowPreference( + private val dataStore: DataStore get() = context.dataStore + + val lastProStateAt = dataStore.createValue( "gplay.cache.lastProAt", 0L ) diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt index 2e72431a..b2f38463 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt @@ -18,6 +18,7 @@ import java.time.Instant import javax.inject.Inject import javax.inject.Singleton import kotlin.math.pow +import eu.darken.capod.common.datastore.valueBlocking @Singleton class UpgradeRepoGplay @Inject constructor( @@ -27,8 +28,8 @@ class UpgradeRepoGplay @Inject constructor( ) : UpgradeRepo { private var lastProStateAt: Long - get() = billingCache.lastProStateAt.value - set(value) = billingCache.lastProStateAt.update { value } + get() = billingCache.lastProStateAt.valueBlocking + set(value) { billingCache.lastProStateAt.valueBlocking = value } override val upgradeInfo: Flow = billingDataRepo.billingData .map { data -> // Only relinquish pro state if we haven't had it for a while diff --git a/app/src/gplay/java/eu/darken/capod/debug/autoreport/GplayAutoReporting.kt b/app/src/gplay/java/eu/darken/capod/debug/autoreport/GplayAutoReporting.kt index 7b9ad882..f9f2fc6e 100644 --- a/app/src/gplay/java/eu/darken/capod/debug/autoreport/GplayAutoReporting.kt +++ b/app/src/gplay/java/eu/darken/capod/debug/autoreport/GplayAutoReporting.kt @@ -12,6 +12,7 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import javax.inject.Inject import javax.inject.Singleton +import eu.darken.capod.common.datastore.valueBlocking @Singleton class GplayAutoReporting @Inject constructor( @@ -21,7 +22,7 @@ class GplayAutoReporting @Inject constructor( ) : AutomaticBugReporter { override fun setup(application: Application) { - val isEnabled = debugSettings.isAutoReportingEnabled.value + val isEnabled = debugSettings.isAutoReportingEnabled.valueBlocking log(TAG) { "setup(): isEnabled=$isEnabled" } if (!isEnabled) return diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/FakeBleData.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/FakeBleData.kt index e8899463..48cbc006 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/FakeBleData.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/FakeBleData.kt @@ -7,6 +7,7 @@ import eu.darken.capod.common.fromHex import java.time.Instant import javax.inject.Inject import kotlin.random.Random +import eu.darken.capod.common.datastore.valueBlocking @Reusable class FakeBleData @Inject constructor( @@ -14,7 +15,7 @@ class FakeBleData @Inject constructor( ) { fun maybeAddfakeData(originals: Collection): Collection { - if (!debugSettings.showFakeData.value) return originals + if (!debugSettings.showFakeData.valueBlocking) return originals return originals + getFakeData() } 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 494755f7..52839026 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 @@ -4,21 +4,24 @@ 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 ) { - @Json(name = "scanner.mode.lowpower") LOW_POWER( + @SerialName("scanner.mode.lowpower") @Json(name = "scanner.mode.lowpower") LOW_POWER( "scanner.mode.lowpower", R.string.settings_scanner_mode_lowpower_label ), - @Json(name = "scanner.mode.balanced") BALANCED( + @SerialName("scanner.mode.balanced") @Json(name = "scanner.mode.balanced") BALANCED( "scanner.mode.balanced", R.string.settings_scanner_mode_balanced_label ), - @Json(name = "scanner.mode.lowlatency") LOW_LATENCY( + @SerialName("scanner.mode.lowlatency") @Json(name = "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/datastore/DataStoreValue.kt b/app/src/main/java/eu/darken/capod/common/datastore/DataStoreValue.kt new file mode 100644 index 00000000..bfb538e7 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/datastore/DataStoreValue.kt @@ -0,0 +1,95 @@ +package eu.darken.capod.common.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.capod.common.debug.logging.log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking + +class DataStoreValue( + private val dataStore: DataStore, + val key: Preferences.Key<*>, + private val reader: (Any?) -> T, + private val writer: (T) -> Any?, +) { + val keyName: String get() = key.name + + val flow: Flow = dataStore.data.map { prefs -> + reader(prefs[key]) + } + + data class Updated(val old: T, val new: T) + + suspend fun update(transform: (T) -> T): Updated { + var old: T? = null + var new: T? = null + dataStore.edit { prefs -> + old = reader(prefs[key]) + new = transform(old as T) + val raw = writer(new as T) + if (raw == null) { + prefs.remove(key) + } else { + @Suppress("UNCHECKED_CAST") + prefs[key as Preferences.Key] = raw + } + } + log(VERBOSE) { "DataStoreValue($keyName) updated from $old to $new" } + @Suppress("UNCHECKED_CAST") + return Updated(old as T, new as T) + } +} + +suspend fun DataStoreValue.value(): T = flow.first() + +suspend fun DataStoreValue.value(value: T) = update { value } + +var DataStoreValue.valueBlocking: T + get() = runBlocking { flow.first() } + set(value) = runBlocking { update { value } } + +inline fun basicKey(key: String, defaultValue: T): Preferences.Key<*> = when (defaultValue) { + is Boolean -> booleanPreferencesKey(key) + is String -> stringPreferencesKey(key) + is Int -> intPreferencesKey(key) + is Long -> longPreferencesKey(key) + is Float -> floatPreferencesKey(key) + else -> throw IllegalArgumentException("Unsupported type for basicKey: ${T::class}") +} + +inline fun basicReader(defaultValue: T): (Any?) -> T = { raw -> + @Suppress("UNCHECKED_CAST") + (raw as? T) ?: defaultValue +} + +inline fun basicWriter(): (T) -> Any? = { value -> value } + +inline fun DataStore.createValue( + key: String, + defaultValue: T, +): DataStoreValue = DataStoreValue( + dataStore = this, + key = basicKey(key, defaultValue), + reader = basicReader(defaultValue), + writer = basicWriter(), +) + +fun DataStore.createValue( + key: Preferences.Key<*>, + reader: (Any?) -> T, + writer: (T) -> Any?, +): DataStoreValue = DataStoreValue( + dataStore = this, + key = key, + reader = reader, + writer = writer, +) diff --git a/app/src/main/java/eu/darken/capod/common/datastore/DataStoreValueSerializationExtension.kt b/app/src/main/java/eu/darken/capod/common/datastore/DataStoreValueSerializationExtension.kt new file mode 100644 index 00000000..706f7d2f --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/datastore/DataStoreValueSerializationExtension.kt @@ -0,0 +1,84 @@ +package eu.darken.capod.common.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer + +@PublishedApi internal val TAG = logTag("DataStoreValue") + +inline fun serializationReader( + json: Json, + defaultValue: T, + onErrorFallbackToDefault: Boolean = false, +): (Any?) -> T { + val serializer: KSerializer = serializer() + return { rawValue -> + val raw = rawValue as String? + if (raw == null) { + defaultValue + } else if (onErrorFallbackToDefault) { + try { + json.decodeFromString(serializer, raw) + } catch (e: Exception) { + log(TAG, WARN) { "Failed to decode, fallback to default: ${e.message}" } + defaultValue + } + } else { + json.decodeFromString(serializer, raw) + } + } +} + +inline fun serializationWriter(json: Json): (T) -> Any? { + val serializer: KSerializer = serializer() + return { value: T -> + value?.let { json.encodeToString(serializer, it) } + } +} + +inline fun DataStore.createValue( + key: String, + defaultValue: T, + json: Json, + onErrorFallbackToDefault: Boolean = false, +): DataStoreValue = DataStoreValue( + dataStore = this, + key = stringPreferencesKey(key), + reader = serializationReader(json, defaultValue, onErrorFallbackToDefault), + writer = serializationWriter(json), +) + +fun DataStore.createValue( + key: String, + defaultValue: T, + json: Json, + serializer: KSerializer, + onErrorFallbackToDefault: Boolean = false, +): DataStoreValue = DataStoreValue( + dataStore = this, + key = stringPreferencesKey(key), + reader = { rawValue -> + val raw = rawValue as String? + if (raw == null) { + defaultValue + } else if (onErrorFallbackToDefault) { + try { + json.decodeFromString(serializer, raw) + } catch (e: Exception) { + log(TAG, WARN) { "Failed to decode, fallback to default: ${e.message}" } + defaultValue + } + } else { + json.decodeFromString(serializer, raw) + } + }, + writer = { value: T -> + value?.let { json.encodeToString(serializer, it) } + }, +) diff --git a/app/src/main/java/eu/darken/capod/common/debug/DebugSettings.kt b/app/src/main/java/eu/darken/capod/common/debug/DebugSettings.kt index 33f822bb..e3ff4f24 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/DebugSettings.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/DebugSettings.kt @@ -1,38 +1,37 @@ package eu.darken.capod.common.debug import android.content.Context -import android.content.SharedPreferences -import androidx.preference.PreferenceDataStore +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.common.BuildConfigWrap -import eu.darken.capod.common.preferences.PreferenceStoreMapper -import eu.darken.capod.common.preferences.Settings -import eu.darken.capod.common.preferences.createFlowPreference +import eu.darken.capod.common.datastore.createValue import javax.inject.Inject import javax.inject.Singleton @Singleton class DebugSettings @Inject constructor( @ApplicationContext private val context: Context, -) : Settings() { +) { - override val preferences: SharedPreferences = context.getSharedPreferences("settings_debug", Context.MODE_PRIVATE) + private val Context.dataStore by preferencesDataStore( + name = "settings_debug", + produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_debug")) } + ) - val isAutoReportingEnabled = preferences.createFlowPreference( + private val dataStore: DataStore get() = context.dataStore + + val isAutoReportingEnabled = dataStore.createValue( key = "debug.bugreport.automatic.enabled", // Reporting is opt-out for gplay, and opt-in for github builds defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY ) - val isDebugModeEnabled = preferences.createFlowPreference("debug.mode.enabled", false) + val isDebugModeEnabled = dataStore.createValue("debug.mode.enabled", false) - val showFakeData = preferences.createFlowPreference("debug.fakedata.enabled", false) + val showFakeData = dataStore.createValue("debug.fakedata.enabled", false) - val showUnfiltered = preferences.createFlowPreference("debug.blescanner.unfiltered.enabled", false) + val showUnfiltered = dataStore.createValue("debug.blescanner.unfiltered.enabled", false) - override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( - isDebugModeEnabled, - showFakeData, - showUnfiltered, - ) - -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/common/preferences/FlowPreference.kt b/app/src/main/java/eu/darken/capod/common/preferences/FlowPreference.kt deleted file mode 100644 index bd246315..00000000 --- a/app/src/main/java/eu/darken/capod/common/preferences/FlowPreference.kt +++ /dev/null @@ -1,63 +0,0 @@ -package eu.darken.capod.common.preferences - -import android.content.SharedPreferences -import androidx.core.content.edit -import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE -import eu.darken.capod.common.debug.logging.log -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow - -class FlowPreference( - private val preferences: SharedPreferences, - val key: String, - val rawReader: (Any?) -> T, - val rawWriter: (T) -> Any? -) { - - private val flowInternal = MutableStateFlow(value) - val flow: Flow = flowInternal - - private val preferenceChangeListener = - SharedPreferences.OnSharedPreferenceChangeListener { changedPrefs, changedKey -> - if (changedKey != key) return@OnSharedPreferenceChangeListener - - val newValue = rawReader(changedPrefs.all[key]) - - val currentValue = flowInternal.value - if (currentValue != newValue && flowInternal.compareAndSet(currentValue, newValue)) { - log(VERBOSE) { "$changedPrefs:$changedKey changed to $newValue" } - } - } - - init { - preferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) - } - - var value: T - get() = rawReader(valueRaw) - set(newVal) { - valueRaw = rawWriter(newVal) - } - - var valueRaw: Any? - get() = preferences.all[key] ?: rawWriter(rawReader(null)) - set(value) { - preferences.edit { - when (value) { - is Boolean -> putBoolean(key, value) - is String -> putString(key, value) - is Int -> putInt(key, value) - is Long -> putLong(key, value) - is Float -> putFloat(key, value) - null -> remove(key) - else -> throw NotImplementedError() - } - - } - flowInternal.value = rawReader(value) - } - - fun update(update: (T) -> T) { - value = update(value) - } -} diff --git a/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceExtension.kt b/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceExtension.kt deleted file mode 100644 index cb4475c6..00000000 --- a/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceExtension.kt +++ /dev/null @@ -1,43 +0,0 @@ -package eu.darken.capod.common.preferences - -import android.content.SharedPreferences - - -inline fun basicReader(defaultValue: T): (rawValue: Any?) -> T = - { rawValue -> - (rawValue ?: defaultValue) as T - } - -inline fun basicWriter(): (T) -> Any? = - { value -> - when (value) { - is Boolean -> value - is String -> value - is Int -> value - is Long -> value - is Float -> value - null -> null - else -> throw NotImplementedError() - } - } - -inline fun SharedPreferences.createFlowPreference( - key: String, - defaultValue: T = null as T -) = FlowPreference( - preferences = this, - key = key, - rawReader = basicReader(defaultValue), - rawWriter = basicWriter() -) - -inline fun SharedPreferences.createFlowPreference( - key: String, - noinline reader: (rawValue: Any?) -> T, - noinline writer: (value: T) -> Any? -) = FlowPreference( - preferences = this, - key = key, - rawReader = reader, - rawWriter = writer -) diff --git a/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceMoshiExtension.kt b/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceMoshiExtension.kt deleted file mode 100644 index 584b522d..00000000 --- a/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceMoshiExtension.kt +++ /dev/null @@ -1,56 +0,0 @@ -package eu.darken.capod.common.preferences - -import android.content.SharedPreferences -import com.squareup.moshi.JsonDataException -import com.squareup.moshi.JsonEncodingException -import com.squareup.moshi.Moshi -import eu.darken.capod.common.debug.logging.Logging.Priority.WARN -import eu.darken.capod.common.debug.logging.log -import eu.darken.capod.common.debug.logging.logTag - -inline fun moshiReader( - moshi: Moshi, - defaultValue: T, - onErrorFallbackToDefault: Boolean = false, -): (Any?) -> T { - val adapter = moshi.adapter(T::class.java) - return { rawValue -> - rawValue as String? - if (rawValue == null) { - defaultValue - } else if (onErrorFallbackToDefault) { - try { - adapter.fromJson(rawValue) ?: defaultValue - } catch (e: JsonDataException) { - log(logTag("FlowPreference"), WARN) { "Failed to decode, fallback to default: ${e.message}" } - defaultValue - } catch (e: JsonEncodingException) { - log(logTag("FlowPreference"), WARN) { "Failed to decode, fallback to default: ${e.message}" } - defaultValue - } - } else { - adapter.fromJson(rawValue) ?: defaultValue - } - } -} - -inline fun moshiWriter( - moshi: Moshi, -): (T) -> Any? { - val adapter = moshi.adapter(T::class.java) - return { newValue: T -> - newValue?.let { adapter.toJson(it) } - } -} - -inline fun SharedPreferences.createFlowPreference( - key: String, - defaultValue: T = null as T, - moshi: Moshi, - onErrorFallbackToDefault: Boolean = false, -) = FlowPreference( - preferences = this, - key = key, - rawReader = moshiReader(moshi, defaultValue, onErrorFallbackToDefault), - rawWriter = moshiWriter(moshi) -) diff --git a/app/src/main/java/eu/darken/capod/common/preferences/PreferenceStoreMapper.kt b/app/src/main/java/eu/darken/capod/common/preferences/PreferenceStoreMapper.kt deleted file mode 100644 index aa03d5a9..00000000 --- a/app/src/main/java/eu/darken/capod/common/preferences/PreferenceStoreMapper.kt +++ /dev/null @@ -1,81 +0,0 @@ -package eu.darken.capod.common.preferences - -import androidx.preference.PreferenceDataStore - -open class PreferenceStoreMapper( - private vararg val flowPreferences: FlowPreference<*> -) : PreferenceDataStore() { - - override fun getBoolean(key: String, defValue: Boolean): Boolean { - return flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> - flowPref.valueRaw as Boolean - } ?: throw NotImplementedError("getBoolean(key=$key, defValue=$defValue)") - } - - override fun putBoolean(key: String, value: Boolean) { - flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> - flowPref.valueRaw = value - } ?: throw NotImplementedError("putBoolean(key=$key, defValue=$value)") - } - - override fun getString(key: String, defValue: String?): String? { - val pref = flowPreferences.singleOrNull { it.key == key } - ?: throw NotImplementedError("getString(key=$key, defValue=$defValue)") - - return pref.let { flowPref -> - flowPref.valueRaw as String? - } - } - - override fun putString(key: String, value: String?) { - val pref = flowPreferences.singleOrNull { it.key == key } - ?: throw NotImplementedError("putString(key=$key, defValue=$value)") - pref.let { flowPref -> - flowPref.valueRaw = value - } - } - - override fun getInt(key: String?, defValue: Int): Int { - return flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> - flowPref.valueRaw as Int - } ?: throw NotImplementedError("getInt(key=$key, defValue=$defValue)") - } - - override fun putInt(key: String?, value: Int) { - flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> - flowPref.valueRaw = value - } ?: throw NotImplementedError("putInt(key=$key, defValue=$value)") - } - - override fun getLong(key: String?, defValue: Long): Long { - return flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> - flowPref.valueRaw as Long - } ?: throw NotImplementedError("getLong(key=$key, defValue=$defValue)") - } - - override fun putLong(key: String?, value: Long) { - flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> - flowPref.valueRaw = value - } ?: throw NotImplementedError("putLong(key=$key, defValue=$value)") - } - - override fun getFloat(key: String?, defValue: Float): Float { - return flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> - flowPref.valueRaw as Float - } ?: throw NotImplementedError("getFloat(key=$key, defValue=$defValue)") - } - - override fun putFloat(key: String?, value: Float) { - flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> - flowPref.valueRaw = value - } ?: throw NotImplementedError("putFloat(key=$key, defValue=$value)") - } - - override fun putStringSet(key: String?, values: MutableSet?) { - throw NotImplementedError("putStringSet(key=$key, defValue=$values)") - } - - override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet { - throw NotImplementedError("getStringSet(key=$key, defValue=$defValues)") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/preferences/Settings.kt b/app/src/main/java/eu/darken/capod/common/preferences/Settings.kt deleted file mode 100644 index f9723f84..00000000 --- a/app/src/main/java/eu/darken/capod/common/preferences/Settings.kt +++ /dev/null @@ -1,12 +0,0 @@ -package eu.darken.capod.common.preferences - -import android.content.SharedPreferences -import androidx.preference.PreferenceDataStore - -abstract class Settings { - - abstract val preferenceDataStore: PreferenceDataStore - - abstract val preferences: SharedPreferences - -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/preferences/SharedPreferenceExtensions.kt b/app/src/main/java/eu/darken/capod/common/preferences/SharedPreferenceExtensions.kt deleted file mode 100644 index 1e263be0..00000000 --- a/app/src/main/java/eu/darken/capod/common/preferences/SharedPreferenceExtensions.kt +++ /dev/null @@ -1,16 +0,0 @@ -package eu.darken.capod.common.preferences - -import android.content.SharedPreferences -import androidx.core.content.edit -import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE -import eu.darken.capod.common.debug.logging.log - -fun SharedPreferences.clearAndNotify() { - val currentKeys = this.all.keys.toSet() - log(VERBOSE) { "$this clearAndNotify(): $currentKeys" } - edit { - currentKeys.forEach { remove(it) } - } - // Clear does not notify anyone using registerOnSharedPreferenceChangeListener - edit(commit = true) { clear() } -} diff --git a/app/src/main/java/eu/darken/capod/common/serialization/ByteArrayBase64Serializer.kt b/app/src/main/java/eu/darken/capod/common/serialization/ByteArrayBase64Serializer.kt new file mode 100644 index 00000000..f112579c --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/serialization/ByteArrayBase64Serializer.kt @@ -0,0 +1,24 @@ +package eu.darken.capod.common.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@OptIn(ExperimentalEncodingApi::class) +object ByteArrayBase64Serializer : KSerializer { + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ByteArrayBase64", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: ByteArray) { + encoder.encodeString(Base64.encode(value)) + } + + override fun deserialize(decoder: Decoder): ByteArray { + return Base64.decode(decoder.decodeString()) + } +} diff --git a/app/src/main/java/eu/darken/capod/common/serialization/InstantEpochMillisSerializer.kt b/app/src/main/java/eu/darken/capod/common/serialization/InstantEpochMillisSerializer.kt new file mode 100644 index 00000000..a219e026 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/serialization/InstantEpochMillisSerializer.kt @@ -0,0 +1,22 @@ +package eu.darken.capod.common.serialization + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.Instant + +object InstantEpochMillisSerializer : KSerializer { + + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("InstantEpochMillis", PrimitiveKind.LONG) + + override fun serialize(encoder: Encoder, value: Instant) { + encoder.encodeLong(value.toEpochMilli()) + } + + override fun deserialize(decoder: Decoder): Instant { + return Instant.ofEpochMilli(decoder.decodeLong()) + } +} 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 2a499b68..03fb7331 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 @@ -6,6 +6,8 @@ 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 @InstallIn(SingletonComponent::class) @@ -20,4 +22,17 @@ class SerializationModule { add(DeviceProfile.MOSHI_FACTORY) }.build() + @Provides + @Singleton + @SerializationCapod + fun json(): Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } } + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class SerializationCapod 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 66452a5c..7e5704d2 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 @@ -4,12 +4,15 @@ 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 ) { - @Json(name = "theme.color.blue") BLUE(R.string.ui_theme_color_blue_label), - @Json(name = "theme.color.green") GREEN(R.string.ui_theme_color_green_label), - @Json(name = "theme.color.amber") AMBER(R.string.ui_theme_color_amber_label), + @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), } 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 644496d2..6b32e5da 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 @@ -4,12 +4,15 @@ 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 ) { - @Json(name = "theme.mode.system") SYSTEM(R.string.ui_theme_mode_system_label), - @Json(name = "theme.mode.dark") DARK(R.string.ui_theme_mode_dark_label), - @Json(name = "theme.mode.light") LIGHT(R.string.ui_theme_mode_light_label), + @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), } 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 d9b7f75a..61413830 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 @@ -4,13 +4,16 @@ 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 ) { - @Json(name = "theme.style.default") DEFAULT(R.string.ui_theme_style_default_label), - @Json(name = "theme.style.materialyou") MATERIAL_YOU(R.string.ui_theme_style_materialyou_label), - @Json(name = "theme.style.mediumcontrast") MEDIUM_CONTRAST(R.string.ui_theme_style_medium_contrast_label), - @Json(name = "theme.style.highcontrast") HIGH_CONTRAST(R.string.ui_theme_style_high_contrast_label), + @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), } diff --git a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt index 5a59109c..9c37e931 100644 --- a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt +++ b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt @@ -1,101 +1,93 @@ package eu.darken.capod.main.core import android.content.Context -import android.content.SharedPreferences -import androidx.preference.PreferenceDataStore -import com.squareup.moshi.Moshi +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.common.BuildConfigWrap import eu.darken.capod.common.bluetooth.BluetoothAddress import eu.darken.capod.common.bluetooth.ScannerMode -import eu.darken.capod.common.debug.DebugSettings -import eu.darken.capod.common.preferences.PreferenceStoreMapper -import eu.darken.capod.common.preferences.Settings -import eu.darken.capod.common.preferences.createFlowPreference +import eu.darken.capod.common.datastore.createValue +import eu.darken.capod.common.serialization.ByteArrayBase64Serializer +import eu.darken.capod.common.serialization.SerializationCapod 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.pods.core.PodDevice import eu.darken.capod.pods.core.apple.protocol.IdentityResolvingKey import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @Singleton class GeneralSettings @Inject constructor( @ApplicationContext private val context: Context, - debugSettings: DebugSettings, - moshi: Moshi, -) : Settings() { + @SerializationCapod json: Json, +) { - override val preferences: SharedPreferences = context.getSharedPreferences("settings_general", Context.MODE_PRIVATE) + private val Context.dataStore by preferencesDataStore( + name = "settings_general", + produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_general")) } + ) - val monitorMode = preferences.createFlowPreference("core.monitor.mode", MonitorMode.AUTOMATIC, moshi) + private val dataStore: DataStore get() = context.dataStore - val useExtraMonitorNotification = preferences.createFlowPreference("core.monitor.notification.connected", false) + val monitorMode = dataStore.createValue("core.monitor.mode", MonitorMode.AUTOMATIC, json, onErrorFallbackToDefault = true) + + val useExtraMonitorNotification = dataStore.createValue("core.monitor.notification.connected", false) val keepConnectedNotificationAfterDisconnect = - preferences.createFlowPreference("core.monitor.notification.connected.keepafterdisconnected", false) + dataStore.createValue("core.monitor.notification.connected.keepafterdisconnected", false) - val scannerMode = preferences.createFlowPreference("core.scanner.mode", ScannerMode.BALANCED, moshi) + val scannerMode = dataStore.createValue("core.scanner.mode", ScannerMode.BALANCED, json, onErrorFallbackToDefault = true) - val oldMinimumSignalQuality = preferences.createFlowPreference("core.signal.minimum", 0.20f) + val oldMinimumSignalQuality = dataStore.createValue("core.signal.minimum", 0.20f) - val oldMainDeviceAddress = preferences.createFlowPreference("core.maindevice.address", null) - - val oldMainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi) - - val oldMainDeviceIdentityKey = preferences.createFlowPreference( - "core.maindevice.identitykey", - null, - moshi + val oldMainDeviceAddress = dataStore.createValue( + key = stringPreferencesKey("core.maindevice.address"), + reader = { raw -> raw as? String }, + writer = { value -> value }, ) - val oldMainDeviceEncryptionKey = preferences.createFlowPreference( - "core.maindevice.encryptionkey", - null, - moshi + val oldMainDeviceModel = dataStore.createValue("core.maindevice.model", PodDevice.Model.UNKNOWN, json, onErrorFallbackToDefault = true) + + val oldMainDeviceIdentityKey = dataStore.createValue( + key = "core.maindevice.identitykey", + defaultValue = null, + json = json, + serializer = ByteArrayBase64Serializer.nullable, + onErrorFallbackToDefault = true, ) - val isOffloadedFilteringDisabled = preferences.createFlowPreference( - "core.compat.offloaded.filtering.disabled", - false + val oldMainDeviceEncryptionKey = dataStore.createValue( + key = "core.maindevice.encryptionkey", + defaultValue = null, + json = json, + serializer = ByteArrayBase64Serializer.nullable, + onErrorFallbackToDefault = true, ) - val isOffloadedBatchingDisabled = preferences.createFlowPreference("core.compat.offloaded.batching.disabled", false) - val useIndirectScanResultCallback = preferences.createFlowPreference("core.compat.indirectcallback.enabled", false) - val isOnboardingDone = preferences.createFlowPreference("core.onboarding.done", false) + val isOffloadedFilteringDisabled = dataStore.createValue("core.compat.offloaded.filtering.disabled", false) + val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false) + val useIndirectScanResultCallback = dataStore.createValue("core.compat.indirectcallback.enabled", false) - val themeMode = preferences.createFlowPreference( - "core.ui.theme.mode", - ThemeMode.SYSTEM, - moshi, + val isOnboardingDone = dataStore.createValue("core.onboarding.done", false) + + val themeMode = dataStore.createValue( + "core.ui.theme.mode", ThemeMode.SYSTEM, json, onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV, ) - val themeStyle = preferences.createFlowPreference( - "core.ui.theme.style", - ThemeStyle.DEFAULT, - moshi, + val themeStyle = dataStore.createValue( + "core.ui.theme.style", ThemeStyle.DEFAULT, json, onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV, ) - val themeColor = preferences.createFlowPreference( - "core.ui.theme.color", - ThemeColor.BLUE, - moshi, + val themeColor = dataStore.createValue( + "core.ui.theme.color", ThemeColor.BLUE, json, onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV, ) - - override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( - monitorMode, - useExtraMonitorNotification, - keepConnectedNotificationAfterDisconnect, - scannerMode, - isOffloadedFilteringDisabled, - isOffloadedBatchingDisabled, - useIndirectScanResultCallback, - themeMode, - themeStyle, - themeColor, - debugSettings.isAutoReportingEnabled, - ) } diff --git a/app/src/main/java/eu/darken/capod/main/core/GeneralSettingsExtensions.kt b/app/src/main/java/eu/darken/capod/main/core/GeneralSettingsExtensions.kt index 93ca5664..1907c368 100644 --- a/app/src/main/java/eu/darken/capod/main/core/GeneralSettingsExtensions.kt +++ b/app/src/main/java/eu/darken/capod/main/core/GeneralSettingsExtensions.kt @@ -3,6 +3,7 @@ package eu.darken.capod.main.core import eu.darken.capod.common.theming.ThemeState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import eu.darken.capod.common.datastore.valueBlocking val GeneralSettings.themeState: Flow get() = combine(themeMode.flow, themeStyle.flow, themeColor.flow) { mode, style, color -> @@ -10,4 +11,4 @@ val GeneralSettings.themeState: Flow } val GeneralSettings.currentThemeState: ThemeState - get() = ThemeState(themeMode.value, themeStyle.value, themeColor.value) + get() = ThemeState(themeMode.valueBlocking, themeStyle.valueBlocking, themeColor.valueBlocking) 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 2ee4fc9c..04c00d9c 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 @@ -4,18 +4,21 @@ 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 ) { - @Json(name = "monitor.mode.manual") MANUAL( + @SerialName("monitor.mode.manual") @Json(name = "monitor.mode.manual") MANUAL( R.string.settings_monitor_mode_manual_label ), - @Json(name = "monitor.mode.automatic") AUTOMATIC( + @SerialName("monitor.mode.automatic") @Json(name = "monitor.mode.automatic") AUTOMATIC( R.string.settings_monitor_mode_automatic_label ), - @Json(name = "monitor.mode.always") ALWAYS( + @SerialName("monitor.mode.always") @Json(name = "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/MainActivity.kt b/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt index 3f802c6e..32fbf7e9 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/MainActivity.kt @@ -31,6 +31,7 @@ import eu.darken.capod.main.core.GeneralSettings import eu.darken.capod.main.core.currentThemeState import eu.darken.capod.main.core.themeState import javax.inject.Inject +import eu.darken.capod.common.datastore.valueBlocking @AndroidEntryPoint class MainActivity : Activity2() { @@ -44,7 +45,7 @@ class MainActivity : Activity2() { installSplashScreen() enableEdgeToEdge() - val startDestination: NavKey = if (generalSettings.isOnboardingDone.value) { + val startDestination: NavKey = if (generalSettings.isOnboardingDone.valueBlocking) { Nav.Main.Overview } else { Nav.Main.Onboarding @@ -99,7 +100,7 @@ class MainActivity : Activity2() { private fun consumeUpgradeExtra(intent: Intent?) { if (intent?.getBooleanExtra(EXTRA_NAVIGATE_TO_UPGRADE, false) == true) { intent.removeExtra(EXTRA_NAVIGATE_TO_UPGRADE) - if (generalSettings.isOnboardingDone.value) { + if (generalSettings.isOnboardingDone.valueBlocking) { navCtrl.goTo(Nav.Main.Upgrade) } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingViewModel.kt index edcb3e50..58583d48 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/onboarding/OnboardingViewModel.kt @@ -9,6 +9,7 @@ import eu.darken.capod.common.navigation.Nav import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.main.core.GeneralSettings import javax.inject.Inject +import eu.darken.capod.common.datastore.valueBlocking @HiltViewModel class OnboardingViewModel @Inject constructor( @@ -22,7 +23,7 @@ class OnboardingViewModel @Inject constructor( } fun finishOnboarding() = launch { - generalSettings.isOnboardingDone.value = true + generalSettings.isOnboardingDone.valueBlocking = true navTo(Nav.Main.Overview, popUpTo = Nav.Main.Onboarding, inclusive = true) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index bbad5e23..e23d024f 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject +import eu.darken.capod.common.datastore.valueBlocking @HiltViewModel class OverviewViewModel @Inject constructor( @@ -59,7 +60,7 @@ class OverviewViewModel @Inject constructor( return@onEach } - val shouldStart = when (generalSettings.monitorMode.value) { + val shouldStart = when (generalSettings.monitorMode.valueBlocking) { MonitorMode.MANUAL -> false MonitorMode.AUTOMATIC -> { val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() } diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt index afb34297..e76fbff2 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject +import eu.darken.capod.common.datastore.valueBlocking @HiltViewModel class GeneralSettingsViewModel @Inject constructor( @@ -75,36 +76,36 @@ class GeneralSettingsViewModel @Inject constructor( }.asLiveState() fun setMonitorMode(mode: MonitorMode) { - generalSettings.monitorMode.value = mode + generalSettings.monitorMode.valueBlocking = mode } fun setScannerMode(mode: ScannerMode) { - generalSettings.scannerMode.value = mode + generalSettings.scannerMode.valueBlocking = mode } fun setShowConnectedNotification(enabled: Boolean) { - generalSettings.useExtraMonitorNotification.value = enabled + generalSettings.useExtraMonitorNotification.valueBlocking = enabled } fun setKeepNotificationAfterDisconnect(enabled: Boolean) { - generalSettings.keepConnectedNotificationAfterDisconnect.value = enabled + generalSettings.keepConnectedNotificationAfterDisconnect.valueBlocking = enabled } fun setOffloadedFilteringDisabled(disabled: Boolean) { - generalSettings.isOffloadedFilteringDisabled.value = disabled + generalSettings.isOffloadedFilteringDisabled.valueBlocking = disabled } fun setOffloadedBatchingDisabled(disabled: Boolean) { - generalSettings.isOffloadedBatchingDisabled.value = disabled + generalSettings.isOffloadedBatchingDisabled.valueBlocking = disabled } fun setUseIndirectScanResultCallback(enabled: Boolean) { - generalSettings.useIndirectScanResultCallback.value = enabled + generalSettings.useIndirectScanResultCallback.valueBlocking = enabled } fun setThemeMode(mode: ThemeMode) = launch { if (isPro.first()) { - generalSettings.themeMode.value = mode + generalSettings.themeMode.valueBlocking = mode } else { navTo(Nav.Main.Upgrade) } @@ -112,7 +113,7 @@ class GeneralSettingsViewModel @Inject constructor( fun setThemeStyle(style: ThemeStyle) = launch { if (isPro.first()) { - generalSettings.themeStyle.value = style + generalSettings.themeStyle.valueBlocking = style } else { navTo(Nav.Main.Upgrade) } @@ -120,7 +121,7 @@ class GeneralSettingsViewModel @Inject constructor( fun setThemeColor(color: ThemeColor) = launch { if (isPro.first()) { - generalSettings.themeColor.value = color + generalSettings.themeColor.valueBlocking = color } else { navTo(Nav.Main.Upgrade) } diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsViewModel.kt index d0031bb9..7b05427f 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/debug/DebugSettingsViewModel.kt @@ -8,6 +8,7 @@ import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.uix.ViewModel4 import kotlinx.coroutines.flow.combine import javax.inject.Inject +import eu.darken.capod.common.datastore.valueBlocking @HiltViewModel class DebugSettingsViewModel @Inject constructor( @@ -34,15 +35,15 @@ class DebugSettingsViewModel @Inject constructor( }.asLiveState() fun setDebugModeEnabled(enabled: Boolean) { - debugSettings.isDebugModeEnabled.value = enabled + debugSettings.isDebugModeEnabled.valueBlocking = enabled } fun setShowFakeData(enabled: Boolean) { - debugSettings.showFakeData.value = enabled + debugSettings.showFakeData.valueBlocking = enabled } fun setShowUnfiltered(enabled: Boolean) { - debugSettings.showUnfiltered.value = enabled + debugSettings.showUnfiltered.valueBlocking = enabled } companion object { diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetSettings.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetSettings.kt index e0bdae3d..b40054aa 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetSettings.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetSettings.kt @@ -1,13 +1,19 @@ package eu.darken.capod.main.ui.widget import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.profiles.core.ProfileId +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import javax.inject.Inject import javax.inject.Singleton @@ -16,28 +22,32 @@ class WidgetSettings @Inject constructor( @ApplicationContext private val context: Context ) { - private val preferences: SharedPreferences = context.getSharedPreferences( - "widget_preferences", - Context.MODE_PRIVATE + private val Context.dataStore by preferencesDataStore( + name = "widget_preferences", + produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "widget_preferences")) } ) + private val dataStore: DataStore get() = context.dataStore + fun saveWidgetProfile(widgetId: Int, profileId: ProfileId) { log(TAG, VERBOSE) { "saveWidgetProfile(widgetId=$widgetId, profileId=$profileId)" } - preferences.edit { - putString(getWidgetProfileKey(widgetId), profileId) + runBlocking { + dataStore.edit { it[stringPreferencesKey(getWidgetProfileKey(widgetId))] = profileId } } } fun getWidgetProfile(widgetId: Int): ProfileId? { - val profileId = preferences.getString(getWidgetProfileKey(widgetId), null) + val profileId = runBlocking { + dataStore.data.first()[stringPreferencesKey(getWidgetProfileKey(widgetId))] + } log(TAG, VERBOSE) { "getWidgetProfile(widgetId=$widgetId) = $profileId" } return profileId } fun removeWidget(widgetId: Int) { log(TAG, VERBOSE) { "removeWidget(widgetId=$widgetId)" } - preferences.edit { - remove(getWidgetProfileKey(widgetId)) + runBlocking { + dataStore.edit { it.remove(stringPreferencesKey(getWidgetProfileKey(widgetId))) } } } @@ -47,4 +57,4 @@ class WidgetSettings @Inject constructor( private const val WIDGET_PROFILE_PREFIX = "widget_profile_" private val TAG = logTag("Widget", "Settings") } -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 20d55661..0364e0a3 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -53,6 +53,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject +import eu.darken.capod.common.datastore.valueBlocking @AndroidEntryPoint class MonitorService : Service() { @@ -172,12 +173,12 @@ class MonitorService : Service() { .distinctUntilChanged() .throttleLatest(1000) .onEach { currentDevice -> - val useExtraNotification = generalSettings.useExtraMonitorNotification.value + val useExtraNotification = generalSettings.useExtraMonitorNotification.valueBlocking notificationManager.notify( MonitorNotifications.NOTIFICATION_ID, notifications.getNotification(currentDevice, showHint = useExtraNotification), ) - if (generalSettings.useExtraMonitorNotification.value && currentDevice != null) { + if (generalSettings.useExtraMonitorNotification.valueBlocking && currentDevice != null) { notificationManager.notify( MonitorNotifications.NOTIFICATION_ID_CONNECTED, notifications.getNotificationConnected(currentDevice), @@ -284,7 +285,7 @@ class MonitorService : Service() { log(TAG, VERBOSE) { "onDestroy()" } monitorScope.cancel("Service destroyed") - if (generalSettings.useExtraMonitorNotification.value && !generalSettings.keepConnectedNotificationAfterDisconnect.value) { + if (generalSettings.useExtraMonitorNotification.valueBlocking && !generalSettings.keepConnectedNotificationAfterDisconnect.valueBlocking) { try { notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED) } catch (e: Exception) { 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 3caa2b8f..c225327f 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 @@ -10,6 +10,8 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import eu.darken.capod.profiles.core.DeviceProfile +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.time.Duration import java.time.Instant import java.util.UUID @@ -74,112 +76,113 @@ interface PodDevice { @JvmInline 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, ) { - @Json(name = "airpods.gen1") AIRPODS_GEN1( + @SerialName("airpods.gen1") @Json(name = "airpods.gen1") AIRPODS_GEN1( label = "AirPods (Gen 1)", iconRes = R.drawable.device_airpods_gen1_both, ), - @Json(name = "airpods.gen2") AIRPODS_GEN2( + @SerialName("airpods.gen2") @Json(name = "airpods.gen2") AIRPODS_GEN2( "AirPods (Gen 2)", R.drawable.device_airpods_gen1_both, ), - @Json(name = "airpods.gen3") AIRPODS_GEN3( + @SerialName("airpods.gen3") @Json(name = "airpods.gen3") AIRPODS_GEN3( "AirPods (Gen 3)", R.drawable.device_airpods_gen3_both, ), - @Json(name = "airpods.gen4") AIRPODS_GEN4( + @SerialName("airpods.gen4") @Json(name = "airpods.gen4") AIRPODS_GEN4( "AirPods (Gen 4)", R.drawable.device_airpods_gen3_both, ), - @Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC( + @SerialName("airpods.gen4.anc") @Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC( "AirPods (Gen 4 ANC)", R.drawable.device_airpods_gen4anc_both, ), - @Json(name = "airpods.pro") AIRPODS_PRO( + @SerialName("airpods.pro") @Json(name = "airpods.pro") AIRPODS_PRO( "AirPods Pro", R.drawable.device_airpods_pro2_both ), - @Json(name = "airpods.pro2") AIRPODS_PRO2( + @SerialName("airpods.pro2") @Json(name = "airpods.pro2") AIRPODS_PRO2( "AirPods Pro 2", R.drawable.device_airpods_pro2_both ), - @Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC( + @SerialName("airpods.pro2.usbc") @Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC( "AirPods Pro 2 USB-C", R.drawable.device_airpods_pro2_both ), - @Json(name = "airpods.pro3") AIRPODS_PRO3( + @SerialName("airpods.pro3") @Json(name = "airpods.pro3") AIRPODS_PRO3( "AirPods Pro 3", R.drawable.device_airpods_pro2_both ), - @Json(name = "airpods.max") AIRPODS_MAX( + @SerialName("airpods.max") @Json(name = "airpods.max") AIRPODS_MAX( "AirPods Max", R.drawable.device_airpods_max ), - @Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC( + @SerialName("airpods.max.usbc") @Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC( "AirPods Max USB-C", R.drawable.device_airpods_max ), - @Json(name = "beats.flex") BEATS_FLEX( + @SerialName("beats.flex") @Json(name = "beats.flex") BEATS_FLEX( "Beats Flex", R.drawable.device_beats_earbuds, ), - @Json(name = "beats.solo.3") BEATS_SOLO_3( + @SerialName("beats.solo.3") @Json(name = "beats.solo.3") BEATS_SOLO_3( "Beats Solo 3", R.drawable.device_beats_headphones, ), - @Json(name = "beats.studio.3") BEATS_STUDIO_3( + @SerialName("beats.studio.3") @Json(name = "beats.studio.3") BEATS_STUDIO_3( "Beats Studio 3", R.drawable.device_beats_studio3, ), - @Json(name = "beats.x") BEATS_X( + @SerialName("beats.x") @Json(name = "beats.x") BEATS_X( "Beats X", R.drawable.device_beats_x, ), - @Json(name = "beats.powerbeats.3") POWERBEATS_3( + @SerialName("beats.powerbeats.3") @Json(name = "beats.powerbeats.3") POWERBEATS_3( "Power Beats 3", R.drawable.device_powerbeats_3, ), - @Json(name = "beats.powerbeats.4") POWERBEATS_4( + @SerialName("beats.powerbeats.4") @Json(name = "beats.powerbeats.4") POWERBEATS_4( "Power Beats 4", R.drawable.device_powerbeats_4, ), - @Json(name = "beats.powerbeats.pro") POWERBEATS_PRO( + @SerialName("beats.powerbeats.pro") @Json(name = "beats.powerbeats.pro") POWERBEATS_PRO( "Power Beats Pro", R.drawable.device_powerbeats_pro_both, ), - @Json(name = "beats.powerbeats.pro2") POWERBEATS_PRO2( + @SerialName("beats.powerbeats.pro2") @Json(name = "beats.powerbeats.pro2") POWERBEATS_PRO2( "Power Beats Pro 2", R.drawable.device_powerbeats_pro2_both, ), - @Json(name = "beats.fit.pro") BEATS_FIT_PRO( + @SerialName("beats.fit.pro") @Json(name = "beats.fit.pro") BEATS_FIT_PRO( "Beats Fit Pro", R.drawable.device_beats_fitpro_both, ), - @Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1( + @SerialName("fakes.tws.i99999") @Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1( "AirPods (Gen 1)? \uD83C\uDFAD", R.drawable.device_airpods_gen1_both, ), - @Json(name = "fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2( + @SerialName("fakes.generic.airpods.gen2") @Json(name = "fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2( "AirPods (Gen 2)? \uD83C\uDFAD", R.drawable.device_airpods_gen1_both, ), - @Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3( + @SerialName("fakes.generic.airpods.gen3") @Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3( "AirPods (Gen 3)? \uD83C\uDFAD", R.drawable.device_airpods_gen3_both, ), - @Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO( + @SerialName("fakes.varunr.airpodspro") @Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO( "AirPods Pro? \uD83C\uDFAD", R.drawable.device_airpods_pro2_both, ), - @Json(name = "fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2( + @SerialName("fakes.generic.airpods.pro2") @Json(name = "fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2( "AirPods Pro2? \uD83C\uDFAD", R.drawable.device_airpods_pro2_both, ), - @Json(name = "unknown") UNKNOWN( + @SerialName("unknown") @Json(name = "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 fed45cac..05228d4f 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 @@ -2,13 +2,18 @@ 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 import eu.darken.capod.pods.core.apple.protocol.ProximityEncryptionKey import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.util.UUID @Parcelize +@Serializable +@SerialName("apple") @JsonClass(generateAdapter = true) data class AppleDeviceProfile( @Json(name = "id") override val id: ProfileId = UUID.randomUUID().toString(), @@ -16,7 +21,7 @@ data class AppleDeviceProfile( @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, - @Json(name = "identityKey") val identityKey: IdentityResolvingKey? = null, - @Json(name = "encryptionKey") val encryptionKey: ProximityEncryptionKey? = null, + @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, ) : 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 ef068e78..516fba25 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 @@ -3,7 +3,10 @@ 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 sealed interface DeviceProfile : Parcelable { val id: ProfileId val label: String @@ -14,7 +17,7 @@ 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") } 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 c35b9e48..cdbe970f 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 @@ -2,7 +2,9 @@ package eu.darken.capod.profiles.core import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import kotlinx.serialization.Serializable +@Serializable @JsonClass(generateAdapter = true) data class DeviceProfilesContainer( @Json(name = "profiles") val profiles: List = emptyList() diff --git a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt index 66d0bb2f..db5601c4 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Singleton +import eu.darken.capod.common.datastore.valueBlocking @Singleton class DeviceProfilesRepo @Inject constructor( @@ -31,66 +32,66 @@ class DeviceProfilesRepo @Inject constructor( init { scope.launch { - if (settings.defaultProfileCreated.value) return@launch + if (settings.defaultProfileCreated.valueBlocking) return@launch log(TAG) { "Creating default profile" } var defaultProfile = AppleDeviceProfile( label = context.getString(R.string.profiles_name_default), ) - if (!settings.singleToMultiMigrationDone.value) { + if (!settings.singleToMultiMigrationDone.valueBlocking) { log(TAG) { "Migrating settings default profile" } defaultProfile = defaultProfile.copy( - minimumSignalQuality = generalSettings.oldMinimumSignalQuality.value, - model = generalSettings.oldMainDeviceModel.value, - address = generalSettings.oldMainDeviceAddress.value, - identityKey = generalSettings.oldMainDeviceIdentityKey.value, - encryptionKey = generalSettings.oldMainDeviceEncryptionKey.value, + minimumSignalQuality = generalSettings.oldMinimumSignalQuality.valueBlocking, + model = generalSettings.oldMainDeviceModel.valueBlocking, + address = generalSettings.oldMainDeviceAddress.valueBlocking, + identityKey = generalSettings.oldMainDeviceIdentityKey.valueBlocking, + encryptionKey = generalSettings.oldMainDeviceEncryptionKey.valueBlocking, ) - settings.singleToMultiMigrationDone.value = true + settings.singleToMultiMigrationDone.valueBlocking = true } log(TAG) { "Default profile: $defaultProfile" } addProfile(defaultProfile) - settings.defaultProfileCreated.value = true + settings.defaultProfileCreated.valueBlocking = true } } val profiles: Flow> = settings.profiles.flow.map { it.profiles } suspend fun addProfile(profile: DeviceProfile, addFirst: Boolean = false) = mutex.withLock { - val currentContainer = settings.profiles.value + val currentContainer = settings.profiles.valueBlocking val updatedProfiles = currentContainer.profiles.toMutableList().apply { if (addFirst) add(0, profile) else add(profile) }.toList() - settings.profiles.value = DeviceProfilesContainer(updatedProfiles) + settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles) log(VERBOSE) { "Added device profile: ${profile.label}" } } suspend fun updateProfile(profile: DeviceProfile) = mutex.withLock { - val currentContainer = settings.profiles.value + val currentContainer = settings.profiles.valueBlocking val updatedProfiles = currentContainer.profiles.map { if (it.id == profile.id) profile else it } - settings.profiles.value = DeviceProfilesContainer(updatedProfiles) + settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles) log(VERBOSE) { "Updated device profile: ${profile.label}" } } suspend fun removeProfile(profileId: ProfileId) = mutex.withLock { - val currentContainer = settings.profiles.value + val currentContainer = settings.profiles.valueBlocking val updatedProfiles = currentContainer.profiles.filter { it.id != profileId } - settings.profiles.value = DeviceProfilesContainer(updatedProfiles) + settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles) log(VERBOSE) { "Removed device profile with ID: $profileId" } podDeviceCache.delete(profileId) } suspend fun reorderProfiles(profiles: List) = mutex.withLock { - settings.profiles.value = DeviceProfilesContainer(profiles.toList()) + settings.profiles.valueBlocking = DeviceProfilesContainer(profiles.toList()) log(VERBOSE) { "Reordered ${profiles.size} device profiles" } } suspend fun clear() { - settings.profiles.value = DeviceProfilesContainer(emptyList()) + settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList()) } companion object { diff --git a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesSettings.kt b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesSettings.kt index 5a6f174d..7d946782 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesSettings.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesSettings.kt @@ -1,31 +1,37 @@ package eu.darken.capod.profiles.core import android.content.Context -import android.content.SharedPreferences -import androidx.preference.PreferenceDataStore -import com.squareup.moshi.Moshi +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext -import eu.darken.capod.common.preferences.PreferenceStoreMapper -import eu.darken.capod.common.preferences.Settings -import eu.darken.capod.common.preferences.createFlowPreference +import eu.darken.capod.common.datastore.createValue +import eu.darken.capod.common.serialization.SerializationCapod +import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @Singleton class DeviceProfilesSettings @Inject constructor( @ApplicationContext private val context: Context, - moshi: Moshi, -) : Settings() { + @SerializationCapod json: Json, +) { - override val preferences: SharedPreferences = context.getSharedPreferences("device_profiles", Context.MODE_PRIVATE) + private val Context.dataStore by preferencesDataStore( + name = "device_profiles", + produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "device_profiles")) } + ) - val profiles = preferences.createFlowPreference( + private val dataStore: DataStore get() = context.dataStore + + val profiles = dataStore.createValue( "profiles.data", DeviceProfilesContainer(), - moshi + json, + onErrorFallbackToDefault = true, ) - val singleToMultiMigrationDone = preferences.createFlowPreference("profiles.migration.v2.done", false) - val defaultProfileCreated = preferences.createFlowPreference("profiles.default.v2.created", false) + val singleToMultiMigrationDone = dataStore.createValue("profiles.migration.v2.done", false) + val defaultProfileCreated = dataStore.createValue("profiles.default.v2.created", false) - override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper() -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/ReactionSettings.kt b/app/src/main/java/eu/darken/capod/reaction/core/ReactionSettings.kt index 11f75eb2..00f114b4 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/ReactionSettings.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/ReactionSettings.kt @@ -1,69 +1,48 @@ package eu.darken.capod.reaction.core import android.content.Context -import android.content.SharedPreferences -import androidx.preference.PreferenceDataStore -import com.squareup.moshi.Moshi +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext -import eu.darken.capod.common.preferences.PreferenceStoreMapper -import eu.darken.capod.common.preferences.Settings -import eu.darken.capod.common.preferences.createFlowPreference +import eu.darken.capod.common.datastore.createValue +import eu.darken.capod.common.serialization.SerializationCapod import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition +import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @Singleton class ReactionSettings @Inject constructor( @ApplicationContext private val context: Context, - moshi: Moshi, -) : Settings() { + @SerializationCapod json: Json, +) { - override val preferences: SharedPreferences = - context.getSharedPreferences("settings_reaction", Context.MODE_PRIVATE) - - val autoPause = preferences.createFlowPreference( - "reaction.autopause.enabled", - false + private val Context.dataStore by preferencesDataStore( + name = "settings_reaction", + produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_reaction")) } ) - val autoPlay = preferences.createFlowPreference( - "reaction.autoplay.enabled", - false - ) + private val dataStore: DataStore get() = context.dataStore - val autoConnect = preferences.createFlowPreference( - "reaction.autoconnect.enabled", - false - ) + val autoPause = dataStore.createValue("reaction.autopause.enabled", false) - val autoConnectCondition = preferences.createFlowPreference( + val autoPlay = dataStore.createValue("reaction.autoplay.enabled", false) + + val autoConnect = dataStore.createValue("reaction.autoconnect.enabled", false) + + val autoConnectCondition = dataStore.createValue( "reaction.autoconnect.condition", AutoConnectCondition.WHEN_SEEN, - moshi + json, + onErrorFallbackToDefault = true, ) - val showPopUpOnCaseOpen = preferences.createFlowPreference( - "reaction.popup.caseopen", - false - ) + val showPopUpOnCaseOpen = dataStore.createValue("reaction.popup.caseopen", false) - val showPopUpOnConnection = preferences.createFlowPreference( - "reaction.popup.connected", - false - ) + val showPopUpOnConnection = dataStore.createValue("reaction.popup.connected", false) - val onePodMode = preferences.createFlowPreference( - "reaction.onepod.enabled", - false - ) + val onePodMode = dataStore.createValue("reaction.onepod.enabled", false) - override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( - autoPause, - autoPlay, - autoConnect, - autoConnectCondition, - showPopUpOnCaseOpen, - showPopUpOnConnection, - onePodMode, - ) -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt index 75b38b4c..b32e0d6b 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/autoconnect/AutoConnect.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton +import eu.darken.capod.common.datastore.valueBlocking @Singleton class AutoConnect @Inject constructor( @@ -74,7 +75,7 @@ class AutoConnect @Inject constructor( return@map } - val condition = reactionSettings.autoConnectCondition.value + val condition = reactionSettings.autoConnectCondition.valueBlocking log(TAG) { "Checking condition $condition" } val conditionFulfilled = when (condition) { AutoConnectCondition.WHEN_SEEN -> true @@ -84,7 +85,7 @@ class AutoConnect @Inject constructor( } AutoConnectCondition.IN_EAR -> when (mainDevice) { is HasEarDetection -> { - if (mainDevice is HasEarDetectionDual && reactionSettings.onePodMode.value) { + if (mainDevice is HasEarDetectionDual && reactionSettings.onePodMode.valueBlocking) { mainDevice.isEitherPodInEar } else { mainDevice.isBeingWorn 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 80ebd4c7..ebe2dd9e 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 @@ -4,21 +4,24 @@ 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 ) { - @Json(name = "autoconnect.condition.seen") WHEN_SEEN( + @SerialName("autoconnect.condition.seen") @Json(name = "autoconnect.condition.seen") WHEN_SEEN( "monitor.mode.manual", R.string.settings_reaction_autoconnect_whenseen_label ), - @Json(name = "autoconnect.condition.case") CASE_OPEN( + @SerialName("autoconnect.condition.case") @Json(name = "autoconnect.condition.case") CASE_OPEN( "autoconnect.condition.case", R.string.settings_reaction_autoconnect_caseopen_label ), - @Json(name = "autoconnect.condition.inear") IN_EAR( + @SerialName("autoconnect.condition.inear") @Json(name = "autoconnect.condition.inear") IN_EAR( "autoconnect.condition.inear", R.string.settings_reaction_autoconnect_inear_label ), diff --git a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt index fd989f38..74c2bfc7 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.onEach import javax.inject.Inject import javax.inject.Singleton +import eu.darken.capod.common.datastore.valueBlocking @Singleton class PlayPause @Inject constructor( @@ -88,7 +89,7 @@ class PlayPause @Inject constructor( val decision = evaluatePlayPauseAction( previous = prevState, current = currState, - onePodMode = reactionSettings.onePodMode.value, + onePodMode = reactionSettings.onePodMode.valueBlocking, isCurrentlyPlaying = mediaControl.isPlaying ) @@ -96,21 +97,21 @@ class PlayPause @Inject constructor( // Execute the decision when { - decision.shouldPlay && reactionSettings.autoPlay.value -> { + decision.shouldPlay && reactionSettings.autoPlay.valueBlocking -> { log(TAG) { "autoPlay is triggered, sendPlay() - ${decision.reason}" } mediaControl.sendPlay() } - decision.shouldPlay && !reactionSettings.autoPlay.value -> { + decision.shouldPlay && !reactionSettings.autoPlay.valueBlocking -> { log(TAG, VERBOSE) { "autoPlay is disabled" } } - decision.shouldPause && reactionSettings.autoPause.value -> { + decision.shouldPause && reactionSettings.autoPause.valueBlocking -> { log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" } mediaControl.sendPause() } - decision.shouldPause && !reactionSettings.autoPause.value -> { + decision.shouldPause && !reactionSettings.autoPause.valueBlocking -> { log(TAG, VERBOSE) { "autoPause is disabled" } } } diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsViewModel.kt index a3599a54..ab69cc57 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/ReactionSettingsViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject +import eu.darken.capod.common.datastore.valueBlocking @HiltViewModel class ReactionSettingsViewModel @Inject constructor( @@ -59,16 +60,16 @@ class ReactionSettingsViewModel @Inject constructor( }.asLiveState() fun setOnePodMode(enabled: Boolean) { - reactionSettings.onePodMode.value = enabled + reactionSettings.onePodMode.valueBlocking = enabled } fun setAutoPlay(enabled: Boolean) = launch { if (!enabled) { - reactionSettings.autoPlay.value = false + reactionSettings.autoPlay.valueBlocking = false return@launch } if (isPro.first()) { - reactionSettings.autoPlay.value = true + reactionSettings.autoPlay.valueBlocking = true } else { navTo(Nav.Main.Upgrade) } @@ -76,11 +77,11 @@ class ReactionSettingsViewModel @Inject constructor( fun setAutoPause(enabled: Boolean) = launch { if (!enabled) { - reactionSettings.autoPause.value = false + reactionSettings.autoPause.valueBlocking = false return@launch } if (isPro.first()) { - reactionSettings.autoPause.value = true + reactionSettings.autoPause.valueBlocking = true } else { navTo(Nav.Main.Upgrade) } @@ -88,28 +89,28 @@ class ReactionSettingsViewModel @Inject constructor( fun setAutoConnect(enabled: Boolean) = launch { if (!enabled) { - reactionSettings.autoConnect.value = false + reactionSettings.autoConnect.valueBlocking = false return@launch } if (isPro.first()) { - reactionSettings.autoConnect.value = true - generalSettings.monitorMode.value = MonitorMode.ALWAYS + reactionSettings.autoConnect.valueBlocking = true + generalSettings.monitorMode.valueBlocking = MonitorMode.ALWAYS } else { navTo(Nav.Main.Upgrade) } } fun setAutoConnectCondition(condition: AutoConnectCondition) { - reactionSettings.autoConnectCondition.value = condition + reactionSettings.autoConnectCondition.valueBlocking = condition } fun setShowPopUpOnCaseOpen(enabled: Boolean) = launch { if (!enabled) { - reactionSettings.showPopUpOnCaseOpen.value = false + reactionSettings.showPopUpOnCaseOpen.valueBlocking = false return@launch } if (isPro.first()) { - reactionSettings.showPopUpOnCaseOpen.value = true + reactionSettings.showPopUpOnCaseOpen.valueBlocking = true } else { navTo(Nav.Main.Upgrade) } @@ -117,11 +118,11 @@ class ReactionSettingsViewModel @Inject constructor( fun setShowPopUpOnConnection(enabled: Boolean) = launch { if (!enabled) { - reactionSettings.showPopUpOnConnection.value = false + reactionSettings.showPopUpOnConnection.valueBlocking = false return@launch } if (isPro.first()) { - reactionSettings.showPopUpOnConnection.value = true + reactionSettings.showPopUpOnConnection.valueBlocking = true } else { navTo(Nav.Main.Upgrade) } diff --git a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt index d9e14ef4..a50f1082 100644 --- a/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt +++ b/app/src/main/java/eu/darken/capod/troubleshooter/ui/TroubleShooterViewModel.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.toList import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject +import eu.darken.capod.common.datastore.valueBlocking @HiltViewModel class TroubleShooterViewModel @Inject constructor( @@ -80,7 +81,7 @@ class TroubleShooterViewModel @Inject constructor( fun troubleShootBle() = launch(context = dispatcherProvider.IO) { log(TAG) { "troubleShootBle()" } - generalSettings.scannerMode.value = ScannerMode.LOW_LATENCY + generalSettings.scannerMode.valueBlocking = ScannerMode.LOW_LATENCY run { progress("Checking for headphones...") @@ -105,10 +106,10 @@ class TroubleShooterViewModel @Inject constructor( sb.append("indirectCallback=$indirectCallback, ") sb.append("unfiltered=$unfiltered") progress(sb.toString()) - generalSettings.isOffloadedFilteringDisabled.value = hardwareFilteringDisabled - generalSettings.isOffloadedBatchingDisabled.value = hardwareBatchingDisabled - generalSettings.useIndirectScanResultCallback.value = indirectCallback - debugSettings.showUnfiltered.value = unfiltered + generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled + generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled + generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback + debugSettings.showUnfiltered.valueBlocking = unfiltered val start = System.currentTimeMillis() val devices = withTimeoutOrNull(STEP_TIME) { @@ -142,10 +143,10 @@ class TroubleShooterViewModel @Inject constructor( failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE) - generalSettings.isOffloadedFilteringDisabled.value = false - generalSettings.isOffloadedBatchingDisabled.value = false - generalSettings.useIndirectScanResultCallback.value = false - debugSettings.showUnfiltered.value = false + generalSettings.isOffloadedFilteringDisabled.valueBlocking = false + generalSettings.isOffloadedBatchingDisabled.valueBlocking = false + generalSettings.useIndirectScanResultCallback.valueBlocking = false + debugSettings.showUnfiltered.valueBlocking = false return@launch } @@ -166,9 +167,9 @@ class TroubleShooterViewModel @Inject constructor( failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES) - generalSettings.isOffloadedFilteringDisabled.value = false - generalSettings.isOffloadedBatchingDisabled.value = false - generalSettings.useIndirectScanResultCallback.value = false + generalSettings.isOffloadedFilteringDisabled.valueBlocking = false + generalSettings.isOffloadedBatchingDisabled.valueBlocking = false + generalSettings.useIndirectScanResultCallback.valueBlocking = false return@launch } @@ -229,7 +230,7 @@ class TroubleShooterViewModel @Inject constructor( podMonitor.primaryDevice().filterNotNull().firstOrNull() } - generalSettings.scannerMode.value = ScannerMode.BALANCED + generalSettings.scannerMode.valueBlocking = ScannerMode.BALANCED if (mainDevice != null) { success("Success! Detected your headphones.") 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 new file mode 100644 index 00000000..d7908f0e --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/datastore/DataStoreMigrationCompatTest.kt @@ -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 > 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() + + @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 + } + + @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 + } + + @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 + } + + @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(), 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(), 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(), moshiJson) + 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/eu/darken/capod/common/datastore/DataStoreValueSerializationTest.kt b/app/src/test/java/eu/darken/capod/common/datastore/DataStoreValueSerializationTest.kt new file mode 100644 index 00000000..4acf322b --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/datastore/DataStoreValueSerializationTest.kt @@ -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 { + 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(0x01, 0x02) + restored.encryptionKey!!.toList() shouldBe listOf(0x03, 0x04) + } +} diff --git a/app/src/test/java/eu/darken/capod/common/datastore/DataStoreValueTest.kt b/app/src/test/java/eu/darken/capod/common/datastore/DataStoreValueTest.kt new file mode 100644 index 00000000..21a61dd1 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/datastore/DataStoreValueTest.kt @@ -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" + } +} diff --git a/app/src/test/java/eu/darken/capod/common/preferences/FlowPreferenceMoshiTest.kt b/app/src/test/java/eu/darken/capod/common/preferences/FlowPreferenceMoshiTest.kt deleted file mode 100644 index e7edff8f..00000000 --- a/app/src/test/java/eu/darken/capod/common/preferences/FlowPreferenceMoshiTest.kt +++ /dev/null @@ -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( - 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( - 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 { - 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 - } -} diff --git a/app/src/test/java/eu/darken/capod/common/preferences/FlowPreferenceTest.kt b/app/src/test/java/eu/darken/capod/common/preferences/FlowPreferenceTest.kt deleted file mode 100644 index 9f5ac8e6..00000000 --- a/app/src/test/java/eu/darken/capod/common/preferences/FlowPreferenceTest.kt +++ /dev/null @@ -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( - 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( - 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( - 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( - 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( - 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 - } - } - -} diff --git a/app/src/test/java/testhelpers/preferences/MockFlowPreference.kt b/app/src/test/java/testhelpers/preferences/MockFlowPreference.kt deleted file mode 100644 index 8e8d133e..00000000 --- a/app/src/test/java/testhelpers/preferences/MockFlowPreference.kt +++ /dev/null @@ -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 mockFlowPreference( - defaultValue: T -): FlowPreference { - val instance = mockk>() - 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 -} diff --git a/app/src/test/java/testhelpers/preferences/MockSharedPreferences.kt b/app/src/test/java/testhelpers/preferences/MockSharedPreferences.kt deleted file mode 100644 index c094e560..00000000 --- a/app/src/test/java/testhelpers/preferences/MockSharedPreferences.kt +++ /dev/null @@ -1,99 +0,0 @@ -package testhelpers.preferences - -import android.content.SharedPreferences - -class MockSharedPreferences : SharedPreferences { - private val listeners = mutableListOf() - private val dataMap = mutableMapOf() - val dataMapPeek: Map - get() = dataMap.toMap() - - override fun getAll(): MutableMap = dataMap - - override fun getString(key: String, defValue: String?): String? = - dataMap[key] as? String ?: defValue - - override fun getStringSet(key: String, defValues: MutableSet?): MutableSet { - 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, - onSave: (Map) -> 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? - ): 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) - } - } - } -} diff --git a/app/src/test/java/testhelpers/preferences/MockSharedPreferencesTest.kt b/app/src/test/java/testhelpers/preferences/MockSharedPreferencesTest.kt deleted file mode 100644 index d6909726..00000000 --- a/app/src/test/java/testhelpers/preferences/MockSharedPreferencesTest.kt +++ /dev/null @@ -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 - } -} diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 7b1ba56b..e40a8566 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -131,6 +131,10 @@ fun DependencyHandlerScope.addSerialization() { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.Serialization.core}") } +fun DependencyHandlerScope.addDataStore() { + implementation("androidx.datastore:datastore-preferences:1.1.4") +} + fun DependencyHandlerScope.addGlance() { implementation("androidx.glance:glance-appwidget:${Versions.Glance.core}") implementation("androidx.glance:glance-material3:${Versions.Glance.core}")