From bfe6dc65fd4470b54ef973dd99359984a843dea2 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 4 Jan 2022 18:51:05 +0100 Subject: [PATCH] Refactor/add settings --- app/build.gradle | 3 + .../common/debug/autoreport/DebugSettings.kt | 21 +-- .../common/preferences/FlowPreference.kt | 69 +++----- .../preferences/FlowPreferenceExtension.kt | 43 +++++ .../FlowPreferenceMoshiExtension.kt | 35 ++++ .../preferences/PreferenceStoreMapper.kt | 69 +++++--- .../serialization/SerializationModule.kt | 18 ++ .../darken/capod/main/core/GeneralSettings.kt | 27 ++- .../eu/darken/capod/main/core/MonitorMode.kt | 23 +++ .../eu/darken/capod/main/core/ScannerMode.kt | 23 +++ .../general/GeneralSettingsFragment.kt | 16 ++ .../capod/monitor/core/MonitorSettings.kt | 16 -- .../darken/capod/monitor/core/PodMonitor.kt | 22 ++- .../monitor/core/worker/MonitorWorker.kt | 19 ++- .../ic_baseline_disabled_visible_24.xml | 10 ++ .../res/drawable/ic_baseline_monitor_24.xml | 10 ++ .../ic_baseline_settings_bluetooth_24.xml | 10 ++ app/src/main/res/values/strings.xml | 10 ++ app/src/main/res/xml/preferences_general.xml | 12 ++ .../preferences/FlowPreferenceMoshiTest.kt | 124 ++++++++++++++ .../common/preferences/FlowPreferenceTest.kt | 159 ++++++++++++++++++ .../pods/core/apple/beats/BeatsFlexText.kt | 9 + .../java/testhelper/json/JsonExtensions.kt | 29 ++++ 23 files changed, 652 insertions(+), 125 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceExtension.kt create mode 100644 app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceMoshiExtension.kt create mode 100644 app/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt create mode 100644 app/src/main/java/eu/darken/capod/main/core/MonitorMode.kt create mode 100644 app/src/main/java/eu/darken/capod/main/core/ScannerMode.kt delete mode 100644 app/src/main/java/eu/darken/capod/monitor/core/MonitorSettings.kt create mode 100644 app/src/main/res/drawable/ic_baseline_disabled_visible_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_monitor_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_settings_bluetooth_24.xml create mode 100644 app/src/test/java/eu/darken/capod/common/preferences/FlowPreferenceMoshiTest.kt create mode 100644 app/src/test/java/eu/darken/capod/common/preferences/FlowPreferenceTest.kt create mode 100644 app/src/test/java/testhelper/json/JsonExtensions.kt diff --git a/app/build.gradle b/app/build.gradle index 63c88f61..e12225a2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -154,6 +154,9 @@ dependencies { implementation ('com.bugsnag:bugsnag-android:5.9.2') implementation 'com.getkeepsafe.relinker:relinker:1.4.3' + implementation("com.squareup.moshi:moshi:1.13.0") + kapt("com.squareup.moshi:moshi-kotlin-codegen:1.13.0") + // DI implementation "com.google.dagger:dagger:${versions.dagger.core}" implementation "com.google.dagger:dagger-android:${versions.dagger.core}" diff --git a/app/src/main/java/eu/darken/capod/common/debug/autoreport/DebugSettings.kt b/app/src/main/java/eu/darken/capod/common/debug/autoreport/DebugSettings.kt index 3e6fc4fb..e160e8d1 100644 --- a/app/src/main/java/eu/darken/capod/common/debug/autoreport/DebugSettings.kt +++ b/app/src/main/java/eu/darken/capod/common/debug/autoreport/DebugSettings.kt @@ -2,10 +2,7 @@ package eu.darken.capod.common.debug.autoreport import android.content.Context import android.content.SharedPreferences -import androidx.preference.PreferenceDataStore import dagger.hilt.android.qualifiers.ApplicationContext -import eu.darken.androidstarter.common.preferences.Settings -import eu.darken.capod.common.preferences.PreferenceStoreMapper import eu.darken.capod.common.preferences.createFlowPreference import javax.inject.Inject import javax.inject.Singleton @@ -13,26 +10,12 @@ 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 preferences: SharedPreferences = context.getSharedPreferences("settings_debug", Context.MODE_PRIVATE) val isAutoReportEnabled = preferences.createFlowPreference("debug.bugreport.automatic.enabled", true) val isDebugModeEnabled = preferences.createFlowPreference("debug.mode.enabled", false) - override val preferenceDataStore: PreferenceDataStore = object : PreferenceStoreMapper() { - override fun getBoolean(key: String, defValue: Boolean): Boolean = when (key) { - isAutoReportEnabled.key -> isAutoReportEnabled.value - isDebugModeEnabled.key -> isDebugModeEnabled.value - else -> super.getBoolean(key, defValue) - } - - override fun putBoolean(key: String, value: Boolean) = when (key) { - isAutoReportEnabled.key -> isAutoReportEnabled.update { value } - isDebugModeEnabled.key -> isDebugModeEnabled.update { value } - else -> super.putBoolean(key, value) - } - } - } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/preferences/FlowPreference.kt b/app/src/main/java/eu/darken/capod/common/preferences/FlowPreference.kt index 1a8a99e2..2c5b80ab 100644 --- a/app/src/main/java/eu/darken/capod/common/preferences/FlowPreference.kt +++ b/app/src/main/java/eu/darken/capod/common/preferences/FlowPreference.kt @@ -10,20 +10,21 @@ import kotlinx.coroutines.flow.MutableStateFlow class FlowPreference constructor( private val preferences: SharedPreferences, val key: String, - private val reader: SharedPreferences.(key: String) -> T, - private val writer: SharedPreferences.Editor.(key: String, value: T) -> Unit + val rawReader: (Any?) -> T, + val rawWriter: (T) -> Any? ) { - private val flowInternal = MutableStateFlow(internalValue) + private val flowInternal = MutableStateFlow(value) val flow: Flow = flowInternal private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { changedPrefs, changedKey -> if (changedKey != key) return@OnSharedPreferenceChangeListener - val newValue = reader(changedPrefs, changedKey) - val currentvalue = flowInternal.value - if (currentvalue != newValue && flowInternal.compareAndSet(currentvalue, newValue)) { + val newValue = rawReader(changedPrefs.all[key]) + + val currentValue = flowInternal.value + if (currentValue != newValue && flowInternal.compareAndSet(currentValue, newValue)) { log(VERBOSE) { "$changedPrefs:$changedKey changed to $newValue" } } } @@ -32,29 +33,16 @@ class FlowPreference constructor( preferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) } - private var internalValue: T - get() = reader(preferences, key) - set(newValue) { - preferences.edit { - writer(key, newValue) - } - flowInternal.value = internalValue + var value: T + get() = rawReader(valueRaw) + set(newVal) { + valueRaw = rawWriter(newVal) } - val value: T - get() = internalValue - fun update(update: (T) -> T) { - internalValue = update(internalValue) - } - - companion object { - inline fun basicReader(defaultValue: T): SharedPreferences.(key: String) -> T = - { key -> - (this.all[key] ?: defaultValue) as T - } - - inline fun basicWriter(): SharedPreferences.Editor.(key: String, value: T) -> Unit = - { key, value -> + 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) @@ -64,27 +52,12 @@ class FlowPreference constructor( null -> remove(key) else -> throw NotImplementedError() } + } + flowInternal.value = rawReader(value) + } + + fun update(update: (T) -> T) { + value = update(value) } } - -inline fun SharedPreferences.createFlowPreference( - key: String, - defaultValue: T = null as T -) = FlowPreference( - preferences = this, - key = key, - reader = FlowPreference.basicReader(defaultValue), - writer = FlowPreference.basicWriter() -) - -inline fun SharedPreferences.createFlowPreference( - key: String, - noinline reader: SharedPreferences.(key: String) -> T, - noinline writer: SharedPreferences.Editor.(key: String, value: T) -> Unit -) = FlowPreference( - preferences = this, - key = key, - reader = reader, - writer = writer -) diff --git a/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceExtension.kt b/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceExtension.kt new file mode 100644 index 00000000..cb4475c6 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceExtension.kt @@ -0,0 +1,43 @@ +package eu.darken.capod.common.preferences + +import android.content.SharedPreferences + + +inline fun basicReader(defaultValue: T): (rawValue: Any?) -> T = + { rawValue -> + (rawValue ?: defaultValue) as T + } + +inline fun basicWriter(): (T) -> Any? = + { value -> + when (value) { + is Boolean -> value + is String -> value + is Int -> value + is Long -> value + is Float -> value + null -> null + else -> throw NotImplementedError() + } + } + +inline fun SharedPreferences.createFlowPreference( + key: String, + defaultValue: T = null as T +) = FlowPreference( + preferences = this, + key = key, + rawReader = basicReader(defaultValue), + rawWriter = basicWriter() +) + +inline fun SharedPreferences.createFlowPreference( + key: String, + noinline reader: (rawValue: Any?) -> T, + noinline writer: (value: T) -> Any? +) = FlowPreference( + preferences = this, + key = key, + rawReader = reader, + rawWriter = writer +) diff --git a/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceMoshiExtension.kt b/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceMoshiExtension.kt new file mode 100644 index 00000000..2c2ac4a4 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/preferences/FlowPreferenceMoshiExtension.kt @@ -0,0 +1,35 @@ +package eu.darken.capod.common.preferences + +import android.content.SharedPreferences +import com.squareup.moshi.Moshi + +inline fun moshiReader( + moshi: Moshi, + defaultValue: T, +): (Any?) -> T { + val adapter = moshi.adapter(T::class.java) + return { rawValue -> + rawValue as String? + rawValue?.let { adapter.fromJson(it) } ?: defaultValue + } +} + +inline fun moshiWriter( + moshi: Moshi, +): (T) -> Any? { + val adapter = moshi.adapter(T::class.java) + return { newValue: T -> + newValue?.let { adapter.toJson(it) } + } +} + +inline fun SharedPreferences.createFlowPreference( + key: String, + defaultValue: T = null as T, + moshi: Moshi, +) = FlowPreference( + preferences = this, + key = key, + rawReader = moshiReader(moshi, defaultValue), + rawWriter = moshiWriter(moshi) +) \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/common/preferences/PreferenceStoreMapper.kt b/app/src/main/java/eu/darken/capod/common/preferences/PreferenceStoreMapper.kt index 42cfdc8a..099bac8b 100644 --- a/app/src/main/java/eu/darken/capod/common/preferences/PreferenceStoreMapper.kt +++ b/app/src/main/java/eu/darken/capod/common/preferences/PreferenceStoreMapper.kt @@ -2,51 +2,74 @@ package eu.darken.capod.common.preferences import androidx.preference.PreferenceDataStore -abstract class PreferenceStoreMapper : PreferenceDataStore() { +open class PreferenceStoreMapper( + private vararg val flowPreferences: FlowPreference<*> +) : PreferenceDataStore() { + override fun getBoolean(key: String, defValue: Boolean): Boolean { - throw NotImplementedError("getBoolean(key=$key, defValue=$defValue)") + 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) { - throw NotImplementedError("putBoolean(key=$key, defValue=$value)") + 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? { - throw NotImplementedError("getString(key=$key, defValue=$defValue)") + return flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw as String? + } ?: throw NotImplementedError("getString(key=$key, defValue=$defValue)") } override fun putString(key: String, value: String?) { - throw NotImplementedError("putString(key=$key, defValue=$value)") - } - - override fun putInt(key: String?, value: Int) { - throw NotImplementedError("putInt(key=$key, defValue=$value)") + flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw = value + } ?: throw NotImplementedError("putString(key=$key, defValue=$value)") } override fun getInt(key: String?, defValue: Int): Int { - throw NotImplementedError("getInt(key=$key, defValue=$defValue)") + 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) { - throw NotImplementedError("putLong(key=$key, defValue=$value)") + flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw = value + } ?: throw NotImplementedError("putLong(key=$key, defValue=$value)") + } + + override fun getFloat(key: String?, defValue: Float): Float { + return flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw as Float + } ?: throw NotImplementedError("getFloat(key=$key, defValue=$defValue)") + } + + override fun putFloat(key: String?, value: Float) { + flowPreferences.singleOrNull { it.key == key }?.let { flowPref -> + flowPref.valueRaw = value + } ?: throw NotImplementedError("putFloat(key=$key, defValue=$value)") } override fun putStringSet(key: String?, values: MutableSet?) { throw NotImplementedError("putStringSet(key=$key, defValue=$values)") } - override fun getLong(key: String?, defValue: Long): Long { - throw NotImplementedError("getLong(key=$key, defValue=$defValue)") - } - - override fun getFloat(key: String?, defValue: Float): Float { - throw NotImplementedError("getFloat(key=$key, defValue=$defValue)") - } - - override fun putFloat(key: String?, value: Float) { - throw NotImplementedError("putFloat(key=$key, defValue=$value)") - } - override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet { throw NotImplementedError("getStringSet(key=$key, defValue=$defValues)") } diff --git a/app/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt b/app/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt new file mode 100644 index 00000000..6dadfccd --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/serialization/SerializationModule.kt @@ -0,0 +1,18 @@ +package eu.darken.capod.common.serialization + +import com.squareup.moshi.Moshi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class SerializationModule { + + @Provides + @Singleton + fun moshi(): Moshi = Moshi.Builder().build() + +} diff --git a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt index dd6712fb..d0b0a89d 100644 --- a/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt +++ b/app/src/main/java/eu/darken/capod/main/core/GeneralSettings.kt @@ -3,11 +3,13 @@ package eu.darken.capod.main.core import android.content.Context import android.content.SharedPreferences import androidx.preference.PreferenceDataStore +import com.squareup.moshi.Moshi import dagger.hilt.android.qualifiers.ApplicationContext import eu.darken.androidstarter.common.preferences.Settings import eu.darken.capod.common.debug.autoreport.DebugSettings import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.preferences.PreferenceStoreMapper +import eu.darken.capod.common.preferences.createFlowPreference import javax.inject.Inject import javax.inject.Singleton @@ -15,20 +17,29 @@ import javax.inject.Singleton class GeneralSettings @Inject constructor( @ApplicationContext private val context: Context, private val debugSettings: DebugSettings, + private val moshi: Moshi, ) : Settings() { override val preferences: SharedPreferences = context.getSharedPreferences("settings_general", Context.MODE_PRIVATE) - override val preferenceDataStore: PreferenceDataStore = object : PreferenceStoreMapper() { - override fun getBoolean(key: String, defValue: Boolean): Boolean = when (key) { - else -> debugSettings.preferenceDataStore.getBoolean(key, defValue) - } + val monitorMode = preferences.createFlowPreference( + "core.monitor.mode", + MonitorMode.AUTOMATIC, + moshi + ) - override fun putBoolean(key: String, value: Boolean) = when (key) { - else -> debugSettings.preferenceDataStore.putBoolean(key, value) - } - } + val scannerMode = preferences.createFlowPreference( + "core.scanner.mode", + ScannerMode.BALANCED, + moshi + ) + override val preferenceDataStore: PreferenceDataStore = PreferenceStoreMapper( + monitorMode, + scannerMode, + debugSettings.isDebugModeEnabled, + debugSettings.isAutoReportEnabled + ) companion object { internal val TAG = logTag("Core", "Settings") diff --git a/app/src/main/java/eu/darken/capod/main/core/MonitorMode.kt b/app/src/main/java/eu/darken/capod/main/core/MonitorMode.kt new file mode 100644 index 00000000..053d7383 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/core/MonitorMode.kt @@ -0,0 +1,23 @@ +package eu.darken.capod.main.core + +import androidx.annotation.StringRes +import com.squareup.moshi.Json +import eu.darken.capod.R + +enum class MonitorMode( + val identifier: String, + @StringRes val labelRes: Int +) { + @Json(name = "monitor.mode.manual") MANUAL( + "monitor.mode.manual", + R.string.settings_monitor_mode_manual_label + ), + @Json(name = "monitor.mode.automatic") AUTOMATIC( + "monitor.mode.automatic", + R.string.settings_monitor_mode_automatic_label + ), + @Json(name = "monitor.mode.always") ALWAYS( + "monitor.mode.always", + R.string.settings_monitor_mode_always_label + ), +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/core/ScannerMode.kt b/app/src/main/java/eu/darken/capod/main/core/ScannerMode.kt new file mode 100644 index 00000000..42c7a4af --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/core/ScannerMode.kt @@ -0,0 +1,23 @@ +package eu.darken.capod.main.core + +import androidx.annotation.StringRes +import com.squareup.moshi.Json +import eu.darken.capod.R + +enum class ScannerMode( + val identifier: String, + @StringRes val labelRes: Int +) { + @Json(name = "scanner.mode.lowpower") LOW_POWER( + "scanner.mode.lowpower", + R.string.settings_scanner_mode_lowpower_label + ), + @Json(name = "scanner.mode.balanced") BALANCED( + "scanner.mode.balanced", + R.string.settings_scanner_mode_balanced_label + ), + @Json(name = "scanner.mode.lowlatency") LOW_LATENCY( + "scanner.mode.lowlatency", + R.string.settings_scanner_mode_lowlatency_label + ), +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt index d5484af6..999ad6cc 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt @@ -2,10 +2,13 @@ package eu.darken.capod.main.ui.settings.general import androidx.annotation.Keep import androidx.fragment.app.viewModels +import androidx.preference.ListPreference import dagger.hilt.android.AndroidEntryPoint import eu.darken.capod.R import eu.darken.capod.common.uix.PreferenceFragment2 import eu.darken.capod.main.core.GeneralSettings +import eu.darken.capod.main.core.MonitorMode +import eu.darken.capod.main.core.ScannerMode import javax.inject.Inject @Keep @@ -21,5 +24,18 @@ class GeneralSettingsFragment : PreferenceFragment2() { override val preferenceFile: Int = R.xml.preferences_general + private val monitorModePref by lazy { findPreference(generalSettings.monitorMode.key)!! } + private val scanModePref by lazy { findPreference(generalSettings.scannerMode.key)!! } + override fun onPreferencesCreated() { + monitorModePref.apply { + entries = MonitorMode.values().map { getString(it.labelRes) }.toTypedArray() + entryValues = MonitorMode.values().map { settings.monitorMode.rawWriter(it) as String }.toTypedArray() + } + scanModePref.apply { + entries = ScannerMode.values().map { getString(it.labelRes) }.toTypedArray() + entryValues = ScannerMode.values().map { settings.scannerMode.rawWriter(it) as String }.toTypedArray() + } + super.onPreferencesCreated() + } } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/monitor/core/MonitorSettings.kt b/app/src/main/java/eu/darken/capod/monitor/core/MonitorSettings.kt deleted file mode 100644 index 14dc0853..00000000 --- a/app/src/main/java/eu/darken/capod/monitor/core/MonitorSettings.kt +++ /dev/null @@ -1,16 +0,0 @@ -package eu.darken.capod.monitor.core - -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class MonitorSettings @Inject constructor() { - - val mode: Mode = Mode.ALWAYS - - enum class Mode(val raw: String) { - MANUAL("monitor.mode.manual"), - AUTOMATIC("monitor.mode.automatic"), - ALWAYS("monitor.mode.always") - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt index ec249323..434662c3 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodMonitor.kt @@ -1,12 +1,16 @@ package eu.darken.capod.monitor.core +import android.bluetooth.le.ScanSettings import eu.darken.capod.common.bluetooth.BleScanner 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.main.core.GeneralSettings +import eu.darken.capod.main.core.ScannerMode import eu.darken.capod.pods.core.PodDevice import eu.darken.capod.pods.core.PodFactory import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import javax.inject.Inject @@ -16,9 +20,19 @@ import javax.inject.Singleton class PodMonitor @Inject constructor( private val bleScanner: BleScanner, private val podFactory: PodFactory, + private val generalSettings: GeneralSettings, ) { - val pods: Flow> = bleScanner.scan() + val pods: Flow> = generalSettings.scannerMode.flow + .flatMapLatest { + bleScanner.scan( + mode = when (it) { + ScannerMode.LOW_POWER -> ScanSettings.SCAN_MODE_LOW_POWER + ScannerMode.BALANCED -> ScanSettings.SCAN_MODE_BALANCED + ScannerMode.LOW_LATENCY -> ScanSettings.SCAN_MODE_LOW_LATENCY + } + ) + } .map { result -> // For each address we only want the newest result, upstream may batch data result.groupBy { it.device.address } @@ -31,9 +45,9 @@ class PodMonitor @Inject constructor( } .onStart { emptyList() } .map { scanResults -> - val pods = scanResults - .sortedByDescending { it.rssi } - .mapNotNull { podFactory.createPod(it) } + val pods = scanResults + .sortedByDescending { it.rssi } + .mapNotNull { podFactory.createPod(it) } // if (BuildConfigWrap.DEBUG && scanResults.isNotEmpty()) { // val fake1 = AirPodsMax( diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt index d899e142..3ba134d0 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorWorker.kt @@ -16,9 +16,10 @@ import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.common.permissions.Permission import eu.darken.capod.common.permissions.isGrantedOrNotRequired +import eu.darken.capod.main.core.GeneralSettings +import eu.darken.capod.main.core.MonitorMode import eu.darken.capod.monitor.core.MonitorComponent import eu.darken.capod.monitor.core.MonitorCoroutineScope -import eu.darken.capod.monitor.core.MonitorSettings import eu.darken.capod.monitor.ui.MonitorNotifications import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol import kotlinx.coroutines.cancel @@ -34,7 +35,7 @@ class MonitorWorker @AssistedInject constructor( monitorComponentBuilder: MonitorComponent.Builder, private val monitorNotifications: MonitorNotifications, private val notificationManager: NotificationManager, - private val monitorSettings: MonitorSettings, + private val generalSettings: GeneralSettings, ) : CoroutineWorker(context, params) { private val workerScope = MonitorCoroutineScope() @@ -81,11 +82,15 @@ class MonitorWorker @AssistedInject constructor( } .setupCommonEventHandlers(TAG) { "ConnectedDevices" } .flatMapLatest { arePodsConnected -> - log(TAG) { "Monitor mode: ${monitorSettings.mode}" } - when (monitorSettings.mode) { - MonitorSettings.Mode.MANUAL -> emptyFlow() - MonitorSettings.Mode.ALWAYS -> emptyFlow() - MonitorSettings.Mode.AUTOMATIC -> flow { + val mode = generalSettings.monitorMode.value + log(TAG) { "Monitor mode: $mode" } + when (mode) { + MonitorMode.MANUAL -> flow { + // Cancel worker, ui scans manually + workerScope.coroutineContext.cancelChildren() + } + MonitorMode.ALWAYS -> emptyFlow() + MonitorMode.AUTOMATIC -> flow { if (arePodsConnected) { log(TAG) { "Pods are connected, aborting any timeout." } } else { diff --git a/app/src/main/res/drawable/ic_baseline_disabled_visible_24.xml b/app/src/main/res/drawable/ic_baseline_disabled_visible_24.xml new file mode 100644 index 00000000..63835ed5 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_disabled_visible_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_monitor_24.xml b/app/src/main/res/drawable/ic_baseline_monitor_24.xml new file mode 100644 index 00000000..8dfc1c90 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_monitor_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_settings_bluetooth_24.xml b/app/src/main/res/drawable/ic_baseline_settings_bluetooth_24.xml new file mode 100644 index 00000000..890fc992 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_settings_bluetooth_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be8a59d7..8c1e76e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,4 +85,14 @@ Headphones Debug mode Show additional information to troubleshoot issues. + Monitor mode + Under which circumstances this app monitors Bluetooth data. + Scanner mode + Should the Bluetooth Low Energy scanner prioritize performance or conserve energy? + When app is open + When device is connected + Always + Low power + Balanced + Low latency \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_general.xml b/app/src/main/res/xml/preferences_general.xml index ca6cea1e..42a0820a 100644 --- a/app/src/main/res/xml/preferences_general.xml +++ b/app/src/main/res/xml/preferences_general.xml @@ -1,6 +1,18 @@ + + + + ( + 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`() = runBlockingTest { + val testData1 = TestGson(string = "teststring") + val testData2 = TestGson(string = "update") + val moshi = Moshi.Builder().build() + + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = testData1, + moshi = moshi + ).apply { + value shouldBe testData1 + flow.first() shouldBe testData1 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe testData1 + it!!.copy(string = "update") + } + + value shouldBe testData2 + flow.first() shouldBe testData2 + (mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """ + { + "string":"update", + "boolean":true, + "float":1.0, + "int":1, + "long":1 + } + """.toComparableJson() + + update { + it shouldBe testData2 + null + } + value shouldBe testData1 + flow.first() shouldBe testData1 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `enum serialization`() = runBlockingTest { + 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 + } +} diff --git a/app/src/test/java/eu/darken/capod/common/preferences/FlowPreferenceTest.kt b/app/src/test/java/eu/darken/capod/common/preferences/FlowPreferenceTest.kt new file mode 100644 index 00000000..1944804f --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/preferences/FlowPreferenceTest.kt @@ -0,0 +1,159 @@ +package eu.darken.capod.common.preferences + +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.Test +import testhelper.BaseTest +import testhelpers.preferences.MockSharedPreferences + +class FlowPreferenceTest : BaseTest() { + + private val mockPreferences = MockSharedPreferences() + + @Test + fun `reading and writing strings`() = runBlockingTest { + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = "default" + ).apply { + value shouldBe "default" + flow.first() shouldBe "default" + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe "default" + "newvalue" + } + + value shouldBe "newvalue" + flow.first() shouldBe "newvalue" + mockPreferences.dataMapPeek.values.first() shouldBe "newvalue" + + update { + it shouldBe "newvalue" + null + } + value shouldBe "default" + flow.first() shouldBe "default" + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing boolean`() = runBlockingTest { + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = true + ).apply { + value shouldBe true + flow.first() shouldBe true + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe true + false + } + + value shouldBe false + flow.first() shouldBe false + mockPreferences.dataMapPeek.values.first() shouldBe false + + update { + it shouldBe false + null + } + value shouldBe true + flow.first() shouldBe true + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing long`() = runBlockingTest { + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = 9000L + ).apply { + value shouldBe 9000L + flow.first() shouldBe 9000L + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe 9000L + 9001L + } + + value shouldBe 9001L + flow.first() shouldBe 9001L + mockPreferences.dataMapPeek.values.first() shouldBe 9001L + + update { + it shouldBe 9001L + null + } + value shouldBe 9000L + flow.first() shouldBe 9000L + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing integer`() = runBlockingTest { + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = 123 + ).apply { + value shouldBe 123 + flow.first() shouldBe 123 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe 123 + 44 + } + + value shouldBe 44 + flow.first() shouldBe 44 + mockPreferences.dataMapPeek.values.first() shouldBe 44 + + update { + it shouldBe 44 + null + } + value shouldBe 123 + flow.first() shouldBe 123 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing float`() = runBlockingTest { + mockPreferences.createFlowPreference( + key = "testKey", + defaultValue = 3.6f + ).apply { + value shouldBe 3.6f + flow.first() shouldBe 3.6f + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe 3.6f + 15000f + } + + value shouldBe 15000f + flow.first() shouldBe 15000f + mockPreferences.dataMapPeek.values.first() shouldBe 15000f + + update { + it shouldBe 15000f + null + } + value shouldBe 3.6f + flow.first() shouldBe 3.6f + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFlexText.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFlexText.kt index c1199b74..d744b56c 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFlexText.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFlexText.kt @@ -23,4 +23,13 @@ class BeatsFlexText : BaseAirPodsTest() { batteryHeadsetPercent shouldBe 0.4f } } + + @Test + fun `random neighbour`() = runBlockingTest { + create("07 19 01 10 20 0A F6 8F 02 4F 00 95 68 94 9E 99 D6 90 F4 5E 68 3C 58 21 68 9F 0D") { + + batteryHeadsetPercent shouldBe 0.6f + } + } + } \ No newline at end of file diff --git a/app/src/test/java/testhelper/json/JsonExtensions.kt b/app/src/test/java/testhelper/json/JsonExtensions.kt new file mode 100644 index 00000000..13c56eac --- /dev/null +++ b/app/src/test/java/testhelper/json/JsonExtensions.kt @@ -0,0 +1,29 @@ +package testhelper.json + +import com.squareup.moshi.JsonReader +import com.squareup.moshi.Moshi +import okio.Buffer +import okio.ByteString.Companion.encode +import okio.buffer +import okio.sink +import java.io.File + + +fun String.toComparableJson(): String { + val value = Buffer().use { + it.writeUtf8(this) + val reader = JsonReader.of(it) + reader.readJsonValue() + } + + val adapter = Moshi.Builder().build().adapter(Any::class.java).indent(" ") + + return adapter.toJson(value) +} + +fun String.writeToFile(file: File) = encode().let { text -> + require(!file.exists()) + file.parentFile?.mkdirs() + file.createNewFile() + file.sink().buffer().use { it.write(text) } +} \ No newline at end of file