refactor(settings): Migrate preferences from SharedPreferences to DataStore

Replace FlowPreference<T> wrapping SharedPreferences with DataStoreValue<T> wrapping AndroidX DataStore. Includes SharedPreferencesMigration for preserving existing user data, kotlinx-serialization for complex types (replacing Moshi for preferences), and comprehensive unit tests for the new infrastructure.
This commit is contained in:
darken
2026-03-04 15:23:34 +00:00
committed by Matthias Urhahn
parent 6326f4a5b9
commit a92e364ca6
55 changed files with 1083 additions and 1051 deletions
+1
View File
@@ -189,6 +189,7 @@ dependencies {
addCompose()
addGlance()
addDataStore()
addNavigation3()
addSerialization()
@@ -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<FossUpgrade?>(
key = "foss.upgrade",
moshi = moshi,
defaultValue = null,
private val Context.dataStore by preferencesDataStore(
name = "settings_foss",
produceMigrations = { ctx -> listOf(SharedPreferencesMigration(ctx, "settings_foss")) }
)
}
private val dataStore: DataStore<Preferences> = context.dataStore
val upgrade = dataStore.createValue<FossUpgrade?>(
key = "foss.upgrade",
defaultValue = null,
json = json,
onErrorFallbackToDefault = true,
)
}
@@ -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;
}
}
@@ -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
)
@@ -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<Preferences> get() = context.dataStore
val lastProStateAt = dataStore.createValue(
"gplay.cache.lastProAt",
0L
)
@@ -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<UpgradeRepo.Info> = billingDataRepo.billingData
.map { data -> // Only relinquish pro state if we haven't had it for a while
@@ -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
@@ -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<BleScanResult>): Collection<BleScanResult> {
if (!debugSettings.showFakeData.value) return originals
if (!debugSettings.showFakeData.valueBlocking) return originals
return originals + getFakeData()
}
@@ -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
),
@@ -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<T>(
private val dataStore: DataStore<Preferences>,
val key: Preferences.Key<*>,
private val reader: (Any?) -> T,
private val writer: (T) -> Any?,
) {
val keyName: String get() = key.name
val flow: Flow<T> = dataStore.data.map { prefs ->
reader(prefs[key])
}
data class Updated<T>(val old: T, val new: T)
suspend fun update(transform: (T) -> T): Updated<T> {
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<Any>] = raw
}
}
log(VERBOSE) { "DataStoreValue($keyName) updated from $old to $new" }
@Suppress("UNCHECKED_CAST")
return Updated(old as T, new as T)
}
}
suspend fun <T> DataStoreValue<T>.value(): T = flow.first()
suspend fun <T> DataStoreValue<T>.value(value: T) = update { value }
var <T> DataStoreValue<T>.valueBlocking: T
get() = runBlocking { flow.first() }
set(value) = runBlocking { update { value } }
inline fun <reified T> 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 <reified T> basicReader(defaultValue: T): (Any?) -> T = { raw ->
@Suppress("UNCHECKED_CAST")
(raw as? T) ?: defaultValue
}
inline fun <reified T> basicWriter(): (T) -> Any? = { value -> value }
inline fun <reified T> DataStore<Preferences>.createValue(
key: String,
defaultValue: T,
): DataStoreValue<T> = DataStoreValue(
dataStore = this,
key = basicKey(key, defaultValue),
reader = basicReader(defaultValue),
writer = basicWriter(),
)
fun <T> DataStore<Preferences>.createValue(
key: Preferences.Key<*>,
reader: (Any?) -> T,
writer: (T) -> Any?,
): DataStoreValue<T> = DataStoreValue(
dataStore = this,
key = key,
reader = reader,
writer = writer,
)
@@ -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 <reified T> serializationReader(
json: Json,
defaultValue: T,
onErrorFallbackToDefault: Boolean = false,
): (Any?) -> T {
val serializer: KSerializer<T> = 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 <reified T> serializationWriter(json: Json): (T) -> Any? {
val serializer: KSerializer<T> = serializer()
return { value: T ->
value?.let { json.encodeToString(serializer, it) }
}
}
inline fun <reified T> DataStore<Preferences>.createValue(
key: String,
defaultValue: T,
json: Json,
onErrorFallbackToDefault: Boolean = false,
): DataStoreValue<T> = DataStoreValue(
dataStore = this,
key = stringPreferencesKey(key),
reader = serializationReader(json, defaultValue, onErrorFallbackToDefault),
writer = serializationWriter(json),
)
fun <T> DataStore<Preferences>.createValue(
key: String,
defaultValue: T,
json: Json,
serializer: KSerializer<T>,
onErrorFallbackToDefault: Boolean = false,
): DataStoreValue<T> = 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) }
},
)
@@ -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<Preferences> 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,
)
}
}
@@ -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<T>(
private val preferences: SharedPreferences,
val key: String,
val rawReader: (Any?) -> T,
val rawWriter: (T) -> Any?
) {
private val flowInternal = MutableStateFlow(value)
val flow: Flow<T> = 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)
}
}
@@ -1,43 +0,0 @@
package eu.darken.capod.common.preferences
import android.content.SharedPreferences
inline fun <reified T> basicReader(defaultValue: T): (rawValue: Any?) -> T =
{ rawValue ->
(rawValue ?: defaultValue) as T
}
inline fun <reified T> 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 <reified T : Any?> SharedPreferences.createFlowPreference(
key: String,
defaultValue: T = null as T
) = FlowPreference(
preferences = this,
key = key,
rawReader = basicReader(defaultValue),
rawWriter = basicWriter()
)
inline fun <reified T : Any?> SharedPreferences.createFlowPreference(
key: String,
noinline reader: (rawValue: Any?) -> T,
noinline writer: (value: T) -> Any?
) = FlowPreference(
preferences = this,
key = key,
rawReader = reader,
rawWriter = writer
)
@@ -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 <reified T> 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 <reified T> moshiWriter(
moshi: Moshi,
): (T) -> Any? {
val adapter = moshi.adapter(T::class.java)
return { newValue: T ->
newValue?.let { adapter.toJson(it) }
}
}
inline fun <reified T : Any?> 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)
)
@@ -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<String>?) {
throw NotImplementedError("putStringSet(key=$key, defValue=$values)")
}
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String> {
throw NotImplementedError("getStringSet(key=$key, defValue=$defValues)")
}
}
@@ -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
}
@@ -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() }
}
@@ -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<ByteArray> {
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())
}
}
@@ -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<Instant> {
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())
}
}
@@ -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
@@ -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),
}
@@ -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),
}
@@ -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),
}
@@ -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<Preferences> 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<BluetoothAddress?>("core.maindevice.address", null)
val oldMainDeviceModel = preferences.createFlowPreference("core.maindevice.model", PodDevice.Model.UNKNOWN, moshi)
val oldMainDeviceIdentityKey = preferences.createFlowPreference<IdentityResolvingKey?>(
"core.maindevice.identitykey",
null,
moshi
val oldMainDeviceAddress = dataStore.createValue<BluetoothAddress?>(
key = stringPreferencesKey("core.maindevice.address"),
reader = { raw -> raw as? String },
writer = { value -> value },
)
val oldMainDeviceEncryptionKey = preferences.createFlowPreference<ProximityEncryptionKey?>(
"core.maindevice.encryptionkey",
null,
moshi
val oldMainDeviceModel = dataStore.createValue("core.maindevice.model", PodDevice.Model.UNKNOWN, json, onErrorFallbackToDefault = true)
val oldMainDeviceIdentityKey = dataStore.createValue<IdentityResolvingKey?>(
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<ProximityEncryptionKey?>(
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,
)
}
@@ -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<ThemeState>
get() = combine(themeMode.flow, themeStyle.flow, themeColor.flow) { mode, style, color ->
@@ -10,4 +11,4 @@ val GeneralSettings.themeState: Flow<ThemeState>
}
val GeneralSettings.currentThemeState: ThemeState
get() = ThemeState(themeMode.value, themeStyle.value, themeColor.value)
get() = ThemeState(themeMode.valueBlocking, themeStyle.valueBlocking, themeColor.valueBlocking)
@@ -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
),
}
@@ -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)
}
}
@@ -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)
}
@@ -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() }
@@ -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)
}
@@ -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 {
@@ -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<Preferences> 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")
}
}
}
@@ -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) {
@@ -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"
);
}
@@ -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
@@ -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")
}
@@ -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<DeviceProfile> = emptyList()
@@ -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<List<DeviceProfile>> = 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<DeviceProfile>) = 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 {
@@ -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<DeviceProfilesContainer>(
private val dataStore: DataStore<Preferences> 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()
}
}
@@ -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<Preferences> 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,
)
}
}
@@ -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
@@ -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
),
@@ -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" }
}
}
@@ -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)
}
@@ -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.")
@@ -0,0 +1,163 @@
package eu.darken.capod.common.datastore
import com.squareup.moshi.Json as MoshiJson
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.theming.ThemeColor
import eu.darken.capod.common.theming.ThemeMode
import eu.darken.capod.common.theming.ThemeStyle
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesContainer
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import io.kotest.matchers.shouldBe
import kotlinx.serialization.SerialName
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
/**
* Tests that verify @SerialName values match @Json(name=...) values,
* ensuring Moshi-serialized SharedPreferences data can be read by kotlinx-serialization
* after the DataStore migration.
*/
class DataStoreMigrationCompatTest : BaseTest() {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
/**
* For each enum with both @SerialName and @Json annotations,
* verify they produce the same string. This catches typos in @SerialName values.
*/
private inline fun <reified T : Enum<T>> verifyEnumSerialNameParity() {
val enumClass = T::class.java
for (constant in enumClass.enumConstants!!) {
val field = enumClass.getField(constant.name)
val moshiAnnotation = field.getAnnotation(MoshiJson::class.java)
val serialNameAnnotation = field.getAnnotation(SerialName::class.java)
if (moshiAnnotation != null && serialNameAnnotation != null) {
serialNameAnnotation.value shouldBe moshiAnnotation.name
}
}
}
@Test
fun `SerialName matches Json name - ThemeMode`() = verifyEnumSerialNameParity<ThemeMode>()
@Test
fun `SerialName matches Json name - ThemeStyle`() = verifyEnumSerialNameParity<ThemeStyle>()
@Test
fun `SerialName matches Json name - ThemeColor`() = verifyEnumSerialNameParity<ThemeColor>()
@Test
fun `SerialName matches Json name - MonitorMode`() = verifyEnumSerialNameParity<MonitorMode>()
@Test
fun `SerialName matches Json name - ScannerMode`() = verifyEnumSerialNameParity<ScannerMode>()
@Test
fun `SerialName matches Json name - AutoConnectCondition`() = verifyEnumSerialNameParity<AutoConnectCondition>()
@Test
fun `SerialName matches Json name - PodDevice Model`() = verifyEnumSerialNameParity<PodDevice.Model>()
@Test
fun `Moshi-serialized ThemeMode string is readable by kotlinx`() {
// Moshi stores enums as JSON strings like: "theme.mode.dark"
val moshiOutput = "\"theme.mode.dark\""
val result = json.decodeFromString(serializer<ThemeMode>(), moshiOutput)
result shouldBe ThemeMode.DARK
}
@Test
fun `Moshi-serialized ScannerMode string is readable by kotlinx`() {
val moshiOutput = "\"scanner.mode.balanced\""
val result = json.decodeFromString(serializer<ScannerMode>(), moshiOutput)
result shouldBe ScannerMode.BALANCED
}
@Test
fun `Moshi-serialized MonitorMode string is readable by kotlinx`() {
val moshiOutput = "\"monitor.mode.automatic\""
val result = json.decodeFromString(serializer<MonitorMode>(), moshiOutput)
result shouldBe MonitorMode.AUTOMATIC
}
@Test
fun `all PodDevice Model values can be decoded from Moshi format`() {
for (model in PodDevice.Model.entries) {
val field = PodDevice.Model::class.java.getField(model.name)
val moshiAnnotation = field.getAnnotation(MoshiJson::class.java)
if (moshiAnnotation != null) {
val moshiOutput = "\"${moshiAnnotation.name}\""
val result = json.decodeFromString(serializer<PodDevice.Model>(), moshiOutput)
result shouldBe model
}
}
}
@Test
fun `Moshi-serialized DeviceProfilesContainer JSON is readable by kotlinx`() {
// This is what Moshi would produce for a container with one Apple profile
val moshiJson = """
{
"profiles": [
{
"type": "apple",
"id": "test-uuid",
"label": "My AirPods Pro",
"priority": 0,
"model": "airpods.pro",
"minimumSignalQuality": 0.15,
"address": "AA:BB:CC:DD:EE:FF"
}
]
}
""".trimIndent()
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
result.profiles.size shouldBe 1
val profile = result.profiles[0] as AppleDeviceProfile
profile.id shouldBe "test-uuid"
profile.label shouldBe "My AirPods Pro"
profile.model shouldBe PodDevice.Model.AIRPODS_PRO
profile.minimumSignalQuality shouldBe 0.15f
profile.address shouldBe "AA:BB:CC:DD:EE:FF"
profile.identityKey shouldBe null
profile.encryptionKey shouldBe null
}
@Test
fun `Moshi-serialized DeviceProfile with ByteArray fields is readable by kotlinx`() {
// Base64 encoded: [0x01, 0x02, 0x03] = "AQID"
val moshiJson = """
{
"profiles": [
{
"type": "apple",
"id": "key-test",
"label": "Keyed Profile",
"priority": 1,
"model": "airpods.gen2",
"minimumSignalQuality": 0.2,
"identityKey": "AQID",
"encryptionKey": "BAUG"
}
]
}
""".trimIndent()
val result = json.decodeFromString(serializer<DeviceProfilesContainer>(), moshiJson)
val profile = result.profiles[0] as AppleDeviceProfile
profile.identityKey!!.toList() shouldBe listOf<Byte>(0x01, 0x02, 0x03)
profile.encryptionKey!!.toList() shouldBe listOf<Byte>(0x04, 0x05, 0x06)
}
}
@@ -0,0 +1,170 @@
package eu.darken.capod.common.datastore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
import eu.darken.capod.common.theming.ThemeMode
import kotlinx.serialization.builtins.nullable
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesContainer
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
import java.io.File
import eu.darken.capod.common.datastore.value
class DataStoreValueSerializationTest : BaseTest() {
@TempDir
lateinit var tempDir: File
private var dsCounter = 0
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
}
private fun createDataStore() = PreferenceDataStoreFactory.create(
produceFile = { File(tempDir, "test_${dsCounter++}.preferences_pb") }
)
@Test
fun `enum round-trip - ThemeMode`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json)
pref.value() shouldBe ThemeMode.SYSTEM
pref.value(ThemeMode.DARK)
pref.value() shouldBe ThemeMode.DARK
pref.value(ThemeMode.LIGHT)
pref.value() shouldBe ThemeMode.LIGHT
}
@Test
fun `enum round-trip - PodDevice Model`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("model", PodDevice.Model.UNKNOWN, json)
PodDevice.Model.entries.forEach { model ->
pref.value(model)
pref.value() shouldBe model
}
}
@Test
fun `data class round-trip - DeviceProfilesContainer`() = runTest2 {
val ds = createDataStore()
val container = DeviceProfilesContainer(
profiles = listOf(
AppleDeviceProfile(
id = "test-id-1",
label = "My AirPods",
model = PodDevice.Model.AIRPODS_PRO,
address = "AA:BB:CC:DD:EE:FF",
)
)
)
val pref = ds.createValue("profiles", DeviceProfilesContainer(), json)
pref.value(container)
val result = pref.value()
result.profiles.size shouldBe 1
val profile = result.profiles[0] as AppleDeviceProfile
profile.id shouldBe "test-id-1"
profile.label shouldBe "My AirPods"
profile.model shouldBe PodDevice.Model.AIRPODS_PRO
profile.address shouldBe "AA:BB:CC:DD:EE:FF"
}
@Test
fun `onErrorFallbackToDefault returns default on corrupt JSON`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json, onErrorFallbackToDefault = true)
// Write corrupt JSON directly
val corruptPref = ds.createValue("theme", "not valid json")
corruptPref.value("{{{corrupt json")
// Now read it as ThemeMode - should fallback to default
pref.value() shouldBe ThemeMode.SYSTEM
}
@Test
fun `onErrorFallbackToDefault returns default on unknown enum value`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json, onErrorFallbackToDefault = true)
// Write an unknown enum value
val rawPref = ds.createValue("theme", "placeholder")
rawPref.value("\"theme.mode.nonexistent\"")
pref.value() shouldBe ThemeMode.SYSTEM
}
@Test
fun `onErrorFallbackToDefault false - corrupt JSON throws`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("theme", ThemeMode.SYSTEM, json, onErrorFallbackToDefault = false)
val rawPref = ds.createValue("theme", "placeholder")
rawPref.value("{{{corrupt")
shouldThrow<Exception> {
pref.value()
}
}
@Test
fun `ByteArray round-trip via explicit serializer`() = runTest2 {
val ds = createDataStore()
val testBytes = byteArrayOf(0x01, 0x02, 0x03, 0xAA.toByte(), 0xFF.toByte())
val pref = ds.createValue(
key = "bytes",
defaultValue = null as ByteArray?,
json = json,
serializer = ByteArrayBase64Serializer.nullable,
)
pref.value() shouldBe null
pref.value(testBytes)
val result = pref.value()
result!!.toList() shouldBe testBytes.toList()
}
@Test
fun `sealed interface polymorphic - DeviceProfile`() = runTest2 {
val ds = createDataStore()
val profile: DeviceProfile = AppleDeviceProfile(
id = "poly-test",
label = "Test Profile",
model = PodDevice.Model.AIRPODS_GEN2,
identityKey = byteArrayOf(0x01, 0x02),
encryptionKey = byteArrayOf(0x03, 0x04),
)
val container = DeviceProfilesContainer(profiles = listOf(profile))
val pref = ds.createValue("profiles", DeviceProfilesContainer(), json)
pref.value(container)
val result = pref.value()
result.profiles.size shouldBe 1
val restored = result.profiles[0] as AppleDeviceProfile
restored.id shouldBe "poly-test"
restored.label shouldBe "Test Profile"
restored.model shouldBe PodDevice.Model.AIRPODS_GEN2
restored.identityKey!!.toList() shouldBe listOf<Byte>(0x01, 0x02)
restored.encryptionKey!!.toList() shouldBe listOf<Byte>(0x03, 0x04)
}
}
@@ -0,0 +1,156 @@
package eu.darken.capod.common.datastore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
import java.io.File
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.datastore.value
class DataStoreValueTest : BaseTest() {
@TempDir
lateinit var tempDir: File
private var dsCounter = 0
private fun createDataStore() = PreferenceDataStoreFactory.create(
produceFile = { File(tempDir, "test_${dsCounter++}.preferences_pb") }
)
@Test
fun `read default value when key not set - String`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", "default_val")
pref.value() shouldBe "default_val"
}
@Test
fun `read default value when key not set - Boolean`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", false)
pref.value() shouldBe false
}
@Test
fun `read default value when key not set - Int`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 42)
pref.value() shouldBe 42
}
@Test
fun `read default value when key not set - Long`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 123L)
pref.value() shouldBe 123L
}
@Test
fun `read default value when key not set - Float`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 0.5f)
pref.value() shouldBe 0.5f
}
@Test
fun `write and read back String`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", "default")
pref.value("new_value")
pref.value() shouldBe "new_value"
}
@Test
fun `write and read back Boolean`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", false)
pref.value(true)
pref.value() shouldBe true
}
@Test
fun `write and read back Int`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 0)
pref.value(99)
pref.value() shouldBe 99
}
@Test
fun `write and read back Long`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 0L)
pref.value(Long.MAX_VALUE)
pref.value() shouldBe Long.MAX_VALUE
}
@Test
fun `write and read back Float`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 0f)
pref.value(3.14f)
pref.value() shouldBe 3.14f
}
@Test
fun `flow emits default then updated value`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", "initial")
pref.flow.first() shouldBe "initial"
pref.value("updated")
pref.flow.first() shouldBe "updated"
}
@Test
fun `update returns old and new values`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 10)
val result = pref.update { it + 5 }
result shouldBe DataStoreValue.Updated(old = 10, new = 15)
pref.value() shouldBe 15
}
@Test
fun `update transforms from current value`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", "hello")
pref.value("world")
val result = pref.update { "$it!" }
result shouldBe DataStoreValue.Updated(old = "world", new = "world!")
}
@Test
fun `valueBlocking get returns current value`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", "blocking_default")
pref.valueBlocking shouldBe "blocking_default"
pref.value("new_blocking")
pref.valueBlocking shouldBe "new_blocking"
}
@Test
fun `valueBlocking set writes value`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("test_key", 0)
pref.valueBlocking = 42
pref.value() shouldBe 42
}
@Test
fun `keyName returns the preference key name`() = runTest2 {
val ds = createDataStore()
val pref = ds.createValue("my.special.key", true)
pref.keyName shouldBe "my.special.key"
}
}
@@ -1,189 +0,0 @@
package eu.darken.capod.common.preferences
import com.squareup.moshi.JsonClass
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import eu.darken.capod.common.theming.ThemeMode
import eu.darken.capod.main.core.MonitorMode
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.assertThrows
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.json.toComparableJson
import testhelpers.preferences.MockSharedPreferences
class FlowPreferenceMoshiTest : BaseTest() {
private val mockPreferences = MockSharedPreferences()
@JsonClass(generateAdapter = true)
data class TestGson(
val string: String = "",
val boolean: Boolean = true,
val float: Float = 1.0f,
val int: Int = 1,
val long: Long = 1L
)
@Test
fun `reading and writing using manual reader and writer`() = runTest {
val testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build()
FlowPreference<TestGson?>(
preferences = mockPreferences,
key = "testKey",
rawReader = moshiReader(moshi, testData1),
rawWriter = moshiWriter(moshi)
).apply {
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe testData1
it!!.copy(string = "update")
}
value shouldBe testData2
flow.first() shouldBe testData2
(mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """
{
"string":"update",
"boolean":true,
"float":1.0,
"int":1,
"long":1
}
""".toComparableJson()
update {
it shouldBe testData2
null
}
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing using autocreated reader and writer`() = runTest {
val testData1 = TestGson(string = "teststring")
val testData2 = TestGson(string = "update")
val moshi = Moshi.Builder().build()
mockPreferences.createFlowPreference<TestGson?>(
key = "testKey",
defaultValue = testData1,
moshi = moshi
).apply {
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe testData1
it!!.copy(string = "update")
}
value shouldBe testData2
flow.first() shouldBe testData2
(mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """
{
"string":"update",
"boolean":true,
"float":1.0,
"int":1,
"long":1
}
""".toComparableJson()
update {
it shouldBe testData2
null
}
value shouldBe testData1
flow.first() shouldBe testData1
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `enum serialization`() = runTest {
val moshi = Moshi.Builder().build()
val monitorMode = mockPreferences.createFlowPreference(
"core.monitor.mode",
MonitorMode.AUTOMATIC,
moshi
)
monitorMode.value shouldBe MonitorMode.AUTOMATIC
monitorMode.update { MonitorMode.MANUAL }
monitorMode.value shouldBe MonitorMode.MANUAL
}
@Test
fun `bad enum value throws without fallback`() = runTest {
val moshi = Moshi.Builder().build()
mockPreferences.edit().putString("theme.mode", "\"theme.mode.bogus\"").apply()
assertThrows<JsonDataException> {
mockPreferences.createFlowPreference(
key = "theme.mode",
defaultValue = ThemeMode.SYSTEM,
moshi = moshi,
onErrorFallbackToDefault = false,
)
}
}
@Test
fun `bad enum value returns default with fallback`() = runTest {
val moshi = Moshi.Builder().build()
mockPreferences.edit().putString("theme.mode", "\"theme.mode.bogus\"").apply()
val pref = mockPreferences.createFlowPreference(
key = "theme.mode",
defaultValue = ThemeMode.SYSTEM,
moshi = moshi,
onErrorFallbackToDefault = true,
)
pref.value shouldBe ThemeMode.SYSTEM
pref.flow.first() shouldBe ThemeMode.SYSTEM
}
@Test
fun `corrupt json returns default with fallback`() = runTest {
val moshi = Moshi.Builder().build()
mockPreferences.edit().putString("theme.mode", "not-json-at-all").apply()
val pref = mockPreferences.createFlowPreference(
key = "theme.mode",
defaultValue = ThemeMode.DARK,
moshi = moshi,
onErrorFallbackToDefault = true,
)
pref.value shouldBe ThemeMode.DARK
}
@Test
fun `valid enum roundtrips with fallback enabled`() = runTest {
val moshi = Moshi.Builder().build()
val pref = mockPreferences.createFlowPreference(
key = "theme.mode",
defaultValue = ThemeMode.SYSTEM,
moshi = moshi,
onErrorFallbackToDefault = true,
)
pref.value shouldBe ThemeMode.SYSTEM
pref.update { ThemeMode.DARK }
pref.value shouldBe ThemeMode.DARK
pref.flow.first() shouldBe ThemeMode.DARK
}
}
@@ -1,159 +0,0 @@
package eu.darken.capod.common.preferences
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.preferences.MockSharedPreferences
class FlowPreferenceTest : BaseTest() {
private val mockPreferences = MockSharedPreferences()
@Test
fun `reading and writing strings`() = runTest {
mockPreferences.createFlowPreference<String?>(
key = "testKey",
defaultValue = "default"
).apply {
value shouldBe "default"
flow.first() shouldBe "default"
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe "default"
"newvalue"
}
value shouldBe "newvalue"
flow.first() shouldBe "newvalue"
mockPreferences.dataMapPeek.values.first() shouldBe "newvalue"
update {
it shouldBe "newvalue"
null
}
value shouldBe "default"
flow.first() shouldBe "default"
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing boolean`() = runTest {
mockPreferences.createFlowPreference<Boolean?>(
key = "testKey",
defaultValue = true
).apply {
value shouldBe true
flow.first() shouldBe true
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe true
false
}
value shouldBe false
flow.first() shouldBe false
mockPreferences.dataMapPeek.values.first() shouldBe false
update {
it shouldBe false
null
}
value shouldBe true
flow.first() shouldBe true
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing long`() = runTest {
mockPreferences.createFlowPreference<Long?>(
key = "testKey",
defaultValue = 9000L
).apply {
value shouldBe 9000L
flow.first() shouldBe 9000L
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe 9000L
9001L
}
value shouldBe 9001L
flow.first() shouldBe 9001L
mockPreferences.dataMapPeek.values.first() shouldBe 9001L
update {
it shouldBe 9001L
null
}
value shouldBe 9000L
flow.first() shouldBe 9000L
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing integer`() = runTest {
mockPreferences.createFlowPreference<Long?>(
key = "testKey",
defaultValue = 123
).apply {
value shouldBe 123
flow.first() shouldBe 123
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe 123
44
}
value shouldBe 44
flow.first() shouldBe 44
mockPreferences.dataMapPeek.values.first() shouldBe 44
update {
it shouldBe 44
null
}
value shouldBe 123
flow.first() shouldBe 123
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
@Test
fun `reading and writing float`() = runTest {
mockPreferences.createFlowPreference<Float?>(
key = "testKey",
defaultValue = 3.6f
).apply {
value shouldBe 3.6f
flow.first() shouldBe 3.6f
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
update {
it shouldBe 3.6f
15000f
}
value shouldBe 15000f
flow.first() shouldBe 15000f
mockPreferences.dataMapPeek.values.first() shouldBe 15000f
update {
it shouldBe 15000f
null
}
value shouldBe 3.6f
flow.first() shouldBe 3.6f
mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
}
}
}
@@ -1,21 +0,0 @@
package testhelpers.preferences
import eu.darken.capod.common.preferences.FlowPreference
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
fun <T> mockFlowPreference(
defaultValue: T
): FlowPreference<T> {
val instance = mockk<FlowPreference<T>>()
val flow = MutableStateFlow(defaultValue)
every { instance.flow } answers { flow }
every { instance.value } answers { flow.value }
every { instance.update(any()) } answers {
val updateCall = arg<(T) -> T>(0)
flow.value = updateCall(flow.value)
}
return instance
}
@@ -1,99 +0,0 @@
package testhelpers.preferences
import android.content.SharedPreferences
class MockSharedPreferences : SharedPreferences {
private val listeners = mutableListOf<SharedPreferences.OnSharedPreferenceChangeListener>()
private val dataMap = mutableMapOf<String, Any>()
val dataMapPeek: Map<String, Any>
get() = dataMap.toMap()
override fun getAll(): MutableMap<String, *> = dataMap
override fun getString(key: String, defValue: String?): String? =
dataMap[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: MutableSet<String>?): MutableSet<String> {
throw NotImplementedError()
}
override fun getInt(key: String, defValue: Int): Int =
dataMap[key] as? Int ?: defValue
override fun getLong(key: String, defValue: Long): Long =
dataMap[key] as? Long ?: defValue
override fun getFloat(key: String, defValue: Float): Float {
throw NotImplementedError()
}
override fun getBoolean(key: String, defValue: Boolean): Boolean =
dataMap[key] as? Boolean ?: defValue
override fun contains(key: String): Boolean = dataMap.contains(key)
override fun edit(): SharedPreferences.Editor = createEditor(dataMap.toMap()) { newData ->
dataMap.clear()
dataMap.putAll(newData)
}
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
listeners.add(listener)
}
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
listeners.remove(listener)
}
private fun createEditor(
toEdit: Map<String, Any>,
onSave: (Map<String, Any>) -> Unit
): SharedPreferences.Editor {
return object : SharedPreferences.Editor {
private val editorData = toEdit.toMutableMap()
override fun putString(key: String, value: String?): SharedPreferences.Editor = apply {
value?.let { editorData[key] = it } ?: editorData.remove(key)
}
override fun putStringSet(
key: String?,
values: MutableSet<String>?
): SharedPreferences.Editor {
throw NotImplementedError()
}
override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply {
editorData[key] = value
}
override fun remove(key: String): SharedPreferences.Editor = apply {
editorData.remove(key)
}
override fun clear(): SharedPreferences.Editor = apply {
editorData.clear()
}
override fun commit(): Boolean {
onSave(editorData)
return true
}
override fun apply() {
onSave(editorData)
}
}
}
}
@@ -1,21 +0,0 @@
package testhelpers.preferences
import androidx.core.content.edit
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class MockSharedPreferencesTest : BaseTest() {
private fun createInstance() = MockSharedPreferences()
@Test
fun `test boolean insertion`() {
val prefs = createInstance()
prefs.dataMapPeek shouldBe emptyMap()
prefs.getBoolean("key", true) shouldBe true
prefs.edit { putBoolean("key", false) }
prefs.getBoolean("key", true) shouldBe false
prefs.dataMapPeek["key"] shouldBe false
}
}
+4
View File
@@ -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}")