Skip to content

Commit

Permalink
SettingsManager: use Flows instead of LiveData (#714)
Browse files Browse the repository at this point in the history
* SettingsManager: use flows instead of LiveData

* Fix tests
  • Loading branch information
rfc2822 authored Apr 10, 2024
1 parent 1cd0df1 commit b88c351
Show file tree
Hide file tree
Showing 12 changed files with 104 additions and 109 deletions.
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.fragment)
implementation(libs.androidx.hilt.work)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.base)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.paging)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,15 @@
package at.bitfire.davdroid.settings

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.map
import at.bitfire.davdroid.TestUtils.getOrAwaitValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -62,51 +59,35 @@ class SettingsManagerTest {


@Test
fun test_getBooleanLive_initialValuePostedEvenWhenNull() {
val live = settingsManager.getBooleanLive(SETTING_TEST).map { value ->
value
}
assertNull(live.getOrAwaitValue())

// posts value to main thread, InstantTaskExecutorRule is required to execute it instantly
settingsManager.putBoolean(SETTING_TEST, true)
runBlocking(Dispatchers.Main) { // observeForever can't be run in background thread
assertTrue(live.getOrAwaitValue()!!)
}
}

@Test
fun test_getBooleanLive_getValue() {
val live = settingsManager.getBooleanLive(SETTING_TEST)
assertNull(live.value)

// posts value to main thread, InstantTaskExecutorRule is required to execute it instantly
settingsManager.putBoolean(SETTING_TEST, true)
runBlocking(Dispatchers.Main) { // observeForever can't be run in background thread
assertTrue(live.getOrAwaitValue()!!)
fun test_observerFlow_initialValue() = runBlocking {
var counter = 0
val live = settingsManager.observerFlow {
if (counter++ == 0)
23
else
throw AssertionError("A second value was requested")
}
assertEquals(23, live.first())
}


@Test
fun test_ObserverCalledWhenValueChanges() {
val value = CompletableDeferred<Int>()
val observer = SettingsManager.OnChangeListener {
value.complete(settingsManager.getInt(SETTING_TEST))
}

try {
settingsManager.addOnChangeListener(observer)
settingsManager.putInt(SETTING_TEST, 123)

runBlocking {
// wait until observer is called
assertEquals(123, value.await())
fun test_observerFlow_updatedValue() = runBlocking {
var counter = 0
val live = settingsManager.observerFlow {
when (counter++) {
0 -> {
// update some setting so that we will be called a second time
settingsManager.putBoolean(SETTING_TEST, true)
// and emit initial value
23
}
1 -> 42 // updated value
else -> throw AssertionError()
}

} finally {
settingsManager.removeOnChangeListener(observer)
}

val result = live.take(2).toList()
assertEquals(listOf(23, 42), result)
}

}
64 changes: 32 additions & 32 deletions app/src/main/kotlin/at/bitfire/davdroid/settings/SettingsManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ package at.bitfire.davdroid.settings
import android.content.Context
import android.util.NoSuchPropertyException
import androidx.annotation.AnyThread
import androidx.lifecycle.LiveData
import androidx.annotation.VisibleForTesting
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.SettingsManager.OnChangeListener
import dagger.Module
import dagger.Provides
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import java.io.Writer
import java.lang.ref.WeakReference
import java.util.LinkedList
Expand Down Expand Up @@ -100,11 +104,34 @@ class SettingsManager internal constructor(
}
}

/**
* Returns a Flow that
*
* - always emits the initial value of the setting, and then
* - emits the new value whenever the setting changes.
*
* @param getValue used to determine the current value of the setting
*/
@VisibleForTesting
internal fun<T> observerFlow(getValue: () -> T): Flow<T> = callbackFlow {
// emit value on changes
val listener = OnChangeListener {
trySend(getValue())
}
addOnChangeListener(listener)

// get current value and emit it as first state
trySend(getValue())

// wait and clean up
awaitClose { removeOnChangeListener(listener) }
}


/*** SETTINGS ACCESS ***/

fun containsKey(key: String) = providers.any { it.contains(key) }
fun containsKeyLive(key: String) = SettingLiveData { containsKey(key) }
fun containsKeyFlow(key: String): Flow<Boolean> = observerFlow { containsKey(key) }

private fun<T> getValue(key: String, reader: (SettingsProvider) -> T?): T? {
Logger.log.fine("Looking up setting $key")
Expand All @@ -126,17 +153,17 @@ class SettingsManager internal constructor(

fun getBooleanOrNull(key: String): Boolean? = getValue(key) { provider -> provider.getBoolean(key) }
fun getBoolean(key: String): Boolean = getBooleanOrNull(key) ?: throw NoSuchPropertyException(key)
fun getBooleanLive(key: String): LiveData<Boolean?> = SettingLiveData { getBooleanOrNull(key) }
fun getBooleanFlow(key: String): Flow<Boolean?> = observerFlow { getBooleanOrNull(key) }

fun getIntOrNull(key: String): Int? = getValue(key) { provider -> provider.getInt(key) }
fun getInt(key: String): Int = getIntOrNull(key) ?: throw NoSuchPropertyException(key)
fun getIntLive(key: String): LiveData<Int?> = SettingLiveData { getIntOrNull(key) }
fun getIntFlow(key: String): Flow<Int?> = observerFlow { getIntOrNull(key) }

fun getLongOrNull(key: String): Long? = getValue(key) { provider -> provider.getLong(key) }
fun getLong(key: String) = getLongOrNull(key) ?: throw NoSuchPropertyException(key)

fun getString(key: String) = getValue(key) { provider -> provider.getString(key) }
fun getStringLive(key: String): LiveData<String?> = SettingLiveData { getString(key) }
fun getStringFlow(key: String): Flow<String?> = observerFlow { getString(key) }


fun isWritable(key: String): Boolean {
Expand Down Expand Up @@ -175,33 +202,6 @@ class SettingsManager internal constructor(
fun remove(key: String) = putString(key, null)


inner class SettingLiveData<T>(
val getValueOrNull: () -> T?
): LiveData<T>(), OnChangeListener {
private var hasValue = false

override fun onActive() {
addOnChangeListener(this)
update()
}

override fun onInactive() {
removeOnChangeListener(this)
}

override fun onSettingsChanged() {
update()
}

@Synchronized
private fun update() {
val newValue = getValueOrNull()
if (!hasValue || value != newValue)
postValue(newValue)
}
}


/*** HELPERS ***/

fun dump(writer: Writer) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.preference.PreferenceManager
import at.bitfire.cert4android.CustomCertStore
Expand Down Expand Up @@ -149,16 +151,16 @@ class AppSettingsActivity: AppCompatActivity() {
)

AppSettings_Connection(
proxyType = model.settings.getIntLive(Settings.PROXY_TYPE).observeAsState().value ?: Settings.PROXY_TYPE_NONE,
proxyType = model.settings.getIntFlow(Settings.PROXY_TYPE).collectAsStateWithLifecycle(null).value ?: Settings.PROXY_TYPE_NONE,
onProxyTypeUpdated = { model.settings.putInt(Settings.PROXY_TYPE, it) },
proxyHostName = model.settings.getStringLive(Settings.PROXY_HOST).observeAsState(null).value,
proxyHostName = model.settings.getStringFlow(Settings.PROXY_HOST).collectAsStateWithLifecycle(null).value,
onProxyHostNameUpdated = { model.settings.putString(Settings.PROXY_HOST, it) },
proxyPort = model.settings.getIntLive(Settings.PROXY_PORT).observeAsState(null).value,
proxyPort = model.settings.getIntFlow(Settings.PROXY_PORT).collectAsStateWithLifecycle(null).value,
onProxyPortUpdated = { model.settings.putInt(Settings.PROXY_PORT, it) }
)

AppSettings_Security(
distrustSystemCerts = model.settings.getBooleanLive(Settings.DISTRUST_SYSTEM_CERTIFICATES).observeAsState().value ?: false,
distrustSystemCerts = model.settings.getBooleanFlow(Settings.DISTRUST_SYSTEM_CERTIFICATES).collectAsStateWithLifecycle(null).value ?: false,
onDistrustSystemCertsUpdated = { model.settings.putBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES, it) },
onResetCertificates = {
model.resetCertificates()
Expand All @@ -170,7 +172,7 @@ class AppSettingsActivity: AppCompatActivity() {
)

AppSettings_UserInterface(
theme = model.settings.getIntLive(Settings.PREFERRED_THEME).observeAsState().value ?: Settings.PREFERRED_THEME_DEFAULT,
theme = model.settings.getIntFlow(Settings.PREFERRED_THEME).collectAsStateWithLifecycle(null).value ?: Settings.PREFERRED_THEME_DEFAULT,
onThemeSelected = {
model.settings.putInt(Settings.PREFERRED_THEME, it)
UiUtils.updateTheme(context)
Expand All @@ -184,7 +186,7 @@ class AppSettingsActivity: AppCompatActivity() {
)

AppSettings_Integration(
taskProvider = TaskUtils.currentProviderLive(context).observeAsState().value
taskProvider = TaskUtils.currentProviderFlow(context, lifecycleScope).collectAsStateWithLifecycle().value
)
}
}
Expand Down
8 changes: 5 additions & 3 deletions app/src/main/kotlin/at/bitfire/davdroid/ui/TasksActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.text.HtmlCompat
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
Expand Down Expand Up @@ -103,15 +105,15 @@ class TasksActivity: AppCompatActivity() {

}

val showAgain = settings.getBooleanLive(HINT_OPENTASKS_NOT_INSTALLED)
val showAgain = settings.getBooleanFlow(HINT_OPENTASKS_NOT_INSTALLED)
fun setShowAgain(showAgain: Boolean) {
if (showAgain)
settings.remove(HINT_OPENTASKS_NOT_INSTALLED)
else
settings.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false)
}

val currentProvider = TaskUtils.currentProviderLive(context)
val currentProvider = TaskUtils.currentProviderFlow(context, viewModelScope).asLiveData()
val jtxSelected = currentProvider.map { it == TaskProvider.ProviderName.JtxBoard }
val tasksOrgSelected = currentProvider.map { it == TaskProvider.ProviderName.TasksOrg }
val openTasksSelected = currentProvider.map { it == TaskProvider.ProviderName.OpenTasks }
Expand Down Expand Up @@ -171,7 +173,7 @@ fun TasksCard(
val openTasksInstalled by model.openTasksInstalled.observeAsState(false)
val openTasksSelected by model.openTasksSelected.observeAsState(false)

val showAgain = model.showAgain.observeAsState().value ?: true
val showAgain = model.showAgain.collectAsStateWithLifecycle(null).value ?: false

fun installApp(packageName: String) {
val uri = Uri.parse("market://details?id=$packageName&referrer=" +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
Expand Down Expand Up @@ -134,7 +135,7 @@ class AccountModel @AssistedInject constructor(
)
val addressBooksPager = CollectionPager(db, cardDavSvc, Collection.TYPE_ADDRESSBOOK, showOnlyPersonal)

private val tasksProvider = TaskUtils.currentProviderLive(context)
private val tasksProvider = TaskUtils.currentProviderFlow(context, viewModelScope)
val calDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CALDAV)
val bindableCalendarHomesets = calDavSvc.switchMap { svc ->
if (svc != null)
Expand All @@ -150,7 +151,7 @@ class AccountModel @AssistedInject constructor(
return@switchMap null
RefreshCollectionsWorker.exists(context, RefreshCollectionsWorker.workerName(svc.id))
}
val calDavSyncPending = tasksProvider.switchMap { tasks ->
val calDavSyncPending = tasksProvider.asLiveData().switchMap { tasks ->
BaseSyncWorker.exists(
context,
listOf(WorkInfo.State.ENQUEUED),
Expand All @@ -162,7 +163,7 @@ class AccountModel @AssistedInject constructor(
}
)
}
val calDavSyncing = tasksProvider.switchMap { tasks ->
val calDavSyncing = tasksProvider.asLiveData().switchMap { tasks ->
BaseSyncWorker.exists(
context,
listOf(WorkInfo.State.RUNNING),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.Constants
Expand Down Expand Up @@ -102,15 +103,15 @@ class BatteryOptimizationsPage: IntroPage {
model.checkBatteryOptimizations()
}

val hintBatteryOptimizations by model.hintBatteryOptimizations.observeAsState()
val hintBatteryOptimizations by model.hintBatteryOptimizations.collectAsStateWithLifecycle(false)
val shouldBeExempted by model.shouldBeExempted.observeAsState(false)
val isExempted by model.isExempted.observeAsState(false)
LaunchedEffect(shouldBeExempted, isExempted) {
if (shouldBeExempted && !isExempted)
ignoreBatteryOptimizationsResultLauncher.launch(BuildConfig.APPLICATION_ID)
}

val hintAutostartPermission by model.hintAutostartPermission.observeAsState()
val hintAutostartPermission by model.hintAutostartPermission.collectAsStateWithLifecycle(false)
BatteryOptimizationsContent(
dontShowBattery = hintBatteryOptimizations == false,
onChangeDontShowBattery = {
Expand Down Expand Up @@ -178,14 +179,14 @@ class BatteryOptimizationsPage: IntroPage {

val shouldBeExempted = MutableLiveData<Boolean>()
val isExempted = MutableLiveData<Boolean>()
val hintBatteryOptimizations = settings.getBooleanLive(HINT_BATTERY_OPTIMIZATIONS)
val hintBatteryOptimizations = settings.getBooleanFlow(HINT_BATTERY_OPTIMIZATIONS)
private val batteryOptimizationsReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
checkBatteryOptimizations()
}
}

val hintAutostartPermission = settings.getBooleanLive(HINT_AUTOSTART_PERMISSION)
val hintAutostartPermission = settings.getBooleanFlow(HINT_AUTOSTART_PERMISSION)

init {
val intentFilter = IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED)
Expand Down
Loading

0 comments on commit b88c351

Please sign in to comment.