mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 03:06:11 -04:00
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:
@@ -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,
|
||||
)
|
||||
+84
@@ -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())
|
||||
}
|
||||
}
|
||||
+22
@@ -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() }
|
||||
|
||||
+11
-10
@@ -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)
|
||||
}
|
||||
|
||||
+4
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
+170
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user