SettingsManager: use Flows instead of LiveData (#714)

* SettingsManager: use flows instead of LiveData

* Fix tests
This commit is contained in:
Ricki Hirner 2024-04-10 12:10:03 +02:00 committed by GitHub
parent 1cd0df1e6a
commit b88c35169e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 104 additions and 109 deletions

View file

@ -149,6 +149,7 @@ dependencies {
implementation(libs.androidx.core) implementation(libs.androidx.core)
implementation(libs.androidx.fragment) implementation(libs.androidx.fragment)
implementation(libs.androidx.hilt.work) implementation(libs.androidx.hilt.work)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.base) implementation(libs.androidx.lifecycle.viewmodel.base)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.paging) implementation(libs.androidx.paging)

View file

@ -5,18 +5,15 @@
package at.bitfire.davdroid.settings package at.bitfire.davdroid.settings
import androidx.arch.core.executor.testing.InstantTaskExecutorRule 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.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.first
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -62,51 +59,35 @@ class SettingsManagerTest {
@Test @Test
fun test_getBooleanLive_initialValuePostedEvenWhenNull() { fun test_observerFlow_initialValue() = runBlocking {
val live = settingsManager.getBooleanLive(SETTING_TEST).map { value -> var counter = 0
value val live = settingsManager.observerFlow {
} if (counter++ == 0)
assertNull(live.getOrAwaitValue()) 23
else
// posts value to main thread, InstantTaskExecutorRule is required to execute it instantly throw AssertionError("A second value was requested")
settingsManager.putBoolean(SETTING_TEST, true)
runBlocking(Dispatchers.Main) { // observeForever can't be run in background thread
assertTrue(live.getOrAwaitValue()!!)
} }
assertEquals(23, live.first())
} }
@Test @Test
fun test_getBooleanLive_getValue() { fun test_observerFlow_updatedValue() = runBlocking {
val live = settingsManager.getBooleanLive(SETTING_TEST) var counter = 0
assertNull(live.value) val live = settingsManager.observerFlow {
when (counter++) {
// posts value to main thread, InstantTaskExecutorRule is required to execute it instantly 0 -> {
settingsManager.putBoolean(SETTING_TEST, true) // update some setting so that we will be called a second time
runBlocking(Dispatchers.Main) { // observeForever can't be run in background thread settingsManager.putBoolean(SETTING_TEST, true)
assertTrue(live.getOrAwaitValue()!!) // and emit initial value
} 23
} }
1 -> 42 // updated value
else -> throw AssertionError()
@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())
} }
} finally {
settingsManager.removeOnChangeListener(observer)
} }
val result = live.take(2).toList()
assertEquals(listOf(23, 42), result)
} }
} }

View file

@ -7,8 +7,9 @@ package at.bitfire.davdroid.settings
import android.content.Context import android.content.Context
import android.util.NoSuchPropertyException import android.util.NoSuchPropertyException
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.lifecycle.LiveData import androidx.annotation.VisibleForTesting
import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.SettingsManager.OnChangeListener
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
@ -16,6 +17,9 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent 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.io.Writer
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.LinkedList import java.util.LinkedList
@ -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 ***/ /*** SETTINGS ACCESS ***/
fun containsKey(key: String) = providers.any { it.contains(key) } 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? { private fun<T> getValue(key: String, reader: (SettingsProvider) -> T?): T? {
Logger.log.fine("Looking up setting $key") Logger.log.fine("Looking up setting $key")
@ -126,17 +153,17 @@ class SettingsManager internal constructor(
fun getBooleanOrNull(key: String): Boolean? = getValue(key) { provider -> provider.getBoolean(key) } fun getBooleanOrNull(key: String): Boolean? = getValue(key) { provider -> provider.getBoolean(key) }
fun getBoolean(key: String): Boolean = getBooleanOrNull(key) ?: throw NoSuchPropertyException(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 getIntOrNull(key: String): Int? = getValue(key) { provider -> provider.getInt(key) }
fun getInt(key: String): Int = getIntOrNull(key) ?: throw NoSuchPropertyException(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 getLongOrNull(key: String): Long? = getValue(key) { provider -> provider.getLong(key) }
fun getLong(key: String) = getLongOrNull(key) ?: throw NoSuchPropertyException(key) fun getLong(key: String) = getLongOrNull(key) ?: throw NoSuchPropertyException(key)
fun getString(key: String) = getValue(key) { provider -> provider.getString(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 { fun isWritable(key: String): Boolean {
@ -175,33 +202,6 @@ class SettingsManager internal constructor(
fun remove(key: String) = putString(key, null) 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 ***/ /*** HELPERS ***/
fun dump(writer: Writer) { fun dump(writer: Writer) {

View file

@ -57,6 +57,8 @@ import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import at.bitfire.cert4android.CustomCertStore import at.bitfire.cert4android.CustomCertStore
@ -149,16 +151,16 @@ class AppSettingsActivity: AppCompatActivity() {
) )
AppSettings_Connection( 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) }, 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) }, 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) } onProxyPortUpdated = { model.settings.putInt(Settings.PROXY_PORT, it) }
) )
AppSettings_Security( 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) }, onDistrustSystemCertsUpdated = { model.settings.putBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES, it) },
onResetCertificates = { onResetCertificates = {
model.resetCertificates() model.resetCertificates()
@ -170,7 +172,7 @@ class AppSettingsActivity: AppCompatActivity() {
) )
AppSettings_UserInterface( 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 = { onThemeSelected = {
model.settings.putInt(Settings.PREFERRED_THEME, it) model.settings.putInt(Settings.PREFERRED_THEME, it)
UiUtils.updateTheme(context) UiUtils.updateTheme(context)
@ -184,7 +186,7 @@ class AppSettingsActivity: AppCompatActivity() {
) )
AppSettings_Integration( AppSettings_Integration(
taskProvider = TaskUtils.currentProviderLive(context).observeAsState().value taskProvider = TaskUtils.currentProviderFlow(context, lifecycleScope).collectAsStateWithLifecycle().value
) )
} }
} }

View file

@ -41,6 +41,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.map import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@ -103,7 +105,7 @@ class TasksActivity: AppCompatActivity() {
} }
val showAgain = settings.getBooleanLive(HINT_OPENTASKS_NOT_INSTALLED) val showAgain = settings.getBooleanFlow(HINT_OPENTASKS_NOT_INSTALLED)
fun setShowAgain(showAgain: Boolean) { fun setShowAgain(showAgain: Boolean) {
if (showAgain) if (showAgain)
settings.remove(HINT_OPENTASKS_NOT_INSTALLED) settings.remove(HINT_OPENTASKS_NOT_INSTALLED)
@ -111,7 +113,7 @@ class TasksActivity: AppCompatActivity() {
settings.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false) 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 jtxSelected = currentProvider.map { it == TaskProvider.ProviderName.JtxBoard }
val tasksOrgSelected = currentProvider.map { it == TaskProvider.ProviderName.TasksOrg } val tasksOrgSelected = currentProvider.map { it == TaskProvider.ProviderName.TasksOrg }
val openTasksSelected = currentProvider.map { it == TaskProvider.ProviderName.OpenTasks } val openTasksSelected = currentProvider.map { it == TaskProvider.ProviderName.OpenTasks }
@ -171,7 +173,7 @@ fun TasksCard(
val openTasksInstalled by model.openTasksInstalled.observeAsState(false) val openTasksInstalled by model.openTasksInstalled.observeAsState(false)
val openTasksSelected by model.openTasksSelected.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) { fun installApp(packageName: String) {
val uri = Uri.parse("market://details?id=$packageName&referrer=" + val uri = Uri.parse("market://details?id=$packageName&referrer=" +

View file

@ -23,6 +23,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.map import androidx.lifecycle.map
import androidx.lifecycle.switchMap import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -134,7 +135,7 @@ class AccountModel @AssistedInject constructor(
) )
val addressBooksPager = CollectionPager(db, cardDavSvc, Collection.TYPE_ADDRESSBOOK, showOnlyPersonal) 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 calDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CALDAV)
val bindableCalendarHomesets = calDavSvc.switchMap { svc -> val bindableCalendarHomesets = calDavSvc.switchMap { svc ->
if (svc != null) if (svc != null)
@ -150,7 +151,7 @@ class AccountModel @AssistedInject constructor(
return@switchMap null return@switchMap null
RefreshCollectionsWorker.exists(context, RefreshCollectionsWorker.workerName(svc.id)) RefreshCollectionsWorker.exists(context, RefreshCollectionsWorker.workerName(svc.id))
} }
val calDavSyncPending = tasksProvider.switchMap { tasks -> val calDavSyncPending = tasksProvider.asLiveData().switchMap { tasks ->
BaseSyncWorker.exists( BaseSyncWorker.exists(
context, context,
listOf(WorkInfo.State.ENQUEUED), listOf(WorkInfo.State.ENQUEUED),
@ -162,7 +163,7 @@ class AccountModel @AssistedInject constructor(
} }
) )
} }
val calDavSyncing = tasksProvider.switchMap { tasks -> val calDavSyncing = tasksProvider.asLiveData().switchMap { tasks ->
BaseSyncWorker.exists( BaseSyncWorker.exists(
context, context,
listOf(WorkInfo.State.RUNNING), listOf(WorkInfo.State.RUNNING),

View file

@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.Constants import at.bitfire.davdroid.Constants
@ -102,7 +103,7 @@ class BatteryOptimizationsPage: IntroPage {
model.checkBatteryOptimizations() model.checkBatteryOptimizations()
} }
val hintBatteryOptimizations by model.hintBatteryOptimizations.observeAsState() val hintBatteryOptimizations by model.hintBatteryOptimizations.collectAsStateWithLifecycle(false)
val shouldBeExempted by model.shouldBeExempted.observeAsState(false) val shouldBeExempted by model.shouldBeExempted.observeAsState(false)
val isExempted by model.isExempted.observeAsState(false) val isExempted by model.isExempted.observeAsState(false)
LaunchedEffect(shouldBeExempted, isExempted) { LaunchedEffect(shouldBeExempted, isExempted) {
@ -110,7 +111,7 @@ class BatteryOptimizationsPage: IntroPage {
ignoreBatteryOptimizationsResultLauncher.launch(BuildConfig.APPLICATION_ID) ignoreBatteryOptimizationsResultLauncher.launch(BuildConfig.APPLICATION_ID)
} }
val hintAutostartPermission by model.hintAutostartPermission.observeAsState() val hintAutostartPermission by model.hintAutostartPermission.collectAsStateWithLifecycle(false)
BatteryOptimizationsContent( BatteryOptimizationsContent(
dontShowBattery = hintBatteryOptimizations == false, dontShowBattery = hintBatteryOptimizations == false,
onChangeDontShowBattery = { onChangeDontShowBattery = {
@ -178,14 +179,14 @@ class BatteryOptimizationsPage: IntroPage {
val shouldBeExempted = MutableLiveData<Boolean>() val shouldBeExempted = MutableLiveData<Boolean>()
val isExempted = 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() { private val batteryOptimizationsReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
checkBatteryOptimizations() checkBatteryOptimizations()
} }
} }
val hintAutostartPermission = settings.getBooleanLive(HINT_AUTOSTART_PERMISSION) val hintAutostartPermission = settings.getBooleanFlow(HINT_AUTOSTART_PERMISSION)
init { init {
val intentFilter = IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED) val intentFilter = IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED)

View file

@ -21,7 +21,6 @@ import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -31,6 +30,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams import at.bitfire.davdroid.Constants.withStatParams
@ -68,7 +68,7 @@ class OpenSourcePage : IntroPage {
@Composable @Composable
private fun Page(model: Model = viewModel()) { private fun Page(model: Model = viewModel()) {
val dontShow by model.dontShow.observeAsState(false) val dontShow by model.dontShow.collectAsStateWithLifecycle(false)
PageContent( PageContent(
dontShow = dontShow, dontShow = dontShow,
onChangeDontShow = { onChangeDontShow = {
@ -147,7 +147,8 @@ class OpenSourcePage : IntroPage {
const val SETTING_NEXT_DONATION_POPUP = "time_nextDonationPopup" const val SETTING_NEXT_DONATION_POPUP = "time_nextDonationPopup"
} }
val dontShow = settings.containsKeyLive(SETTING_NEXT_DONATION_POPUP) val dontShow = settings.containsKeyFlow(SETTING_NEXT_DONATION_POPUP)
fun setDontShow(dontShowAgain: Boolean) { fun setDontShow(dontShowAgain: Boolean) {
if (dontShowAgain) { if (dontShowAgain) {
val nextReminder = System.currentTimeMillis() + 90*86400000L // 90 days (~ 3 months) val nextReminder = System.currentTimeMillis() + 90*86400000L // 90 days (~ 3 months)

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.R import at.bitfire.davdroid.R
import at.bitfire.davdroid.servicedetection.DavResourceFinder import at.bitfire.davdroid.servicedetection.DavResourceFinder
@ -96,8 +97,9 @@ fun AccountDetailsPage(
val suggestedAccountNames = foundConfig.calDAV?.emails ?: emptyList() val suggestedAccountNames = foundConfig.calDAV?.emails ?: emptyList()
var accountName by remember { mutableStateOf(suggestedAccountNames.firstOrNull() ?: "") } var accountName by remember { mutableStateOf(suggestedAccountNames.firstOrNull() ?: "") }
val forcedGroupMethod by model.forcedGroupMethod.observeAsState() var groupMethod by remember { mutableStateOf(loginInfo.suggestedGroupMethod) }
var groupMethod by remember { mutableStateOf(forcedGroupMethod ?: loginInfo.suggestedGroupMethod) } val forcedGroupMethod by model.forcedGroupMethod.collectAsStateWithLifecycle(null)
forcedGroupMethod?.let { groupMethod = it }
AccountDetailsPage_Content( AccountDetailsPage_Content(
suggestedAccountNames = suggestedAccountNames, suggestedAccountNames = suggestedAccountNames,
accountName = accountName, accountName = accountName,

View file

@ -14,7 +14,6 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R import at.bitfire.davdroid.R
@ -33,6 +32,7 @@ import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.vcard4android.GroupMethod import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import java.util.logging.Level import java.util.logging.Level
@ -45,7 +45,7 @@ class LoginModel @Inject constructor(
val settingsManager: SettingsManager val settingsManager: SettingsManager
): ViewModel() { ): ViewModel() {
val forcedGroupMethod = settingsManager.getStringLive(AccountSettings.KEY_CONTACT_GROUP_METHOD).map { methodName -> val forcedGroupMethod = settingsManager.getStringFlow(AccountSettings.KEY_CONTACT_GROUP_METHOD).map { methodName ->
methodName?.let { methodName?.let {
try { try {
GroupMethod.valueOf(it) GroupMethod.valueOf(it)

View file

@ -15,8 +15,6 @@ import android.graphics.drawable.BitmapDrawable
import android.net.Uri import android.net.Uri
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.db.Service
@ -34,6 +32,11 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
object TaskUtils { object TaskUtils {
@ -57,16 +60,16 @@ object TaskUtils {
/** /**
* Returns the currently selected tasks provider (if it's still available = installed). * Returns the currently selected tasks provider (if it's still available = installed).
* *
* @return the currently selected tasks provider, or null if none is available * @return flow with the currently selected tasks provider
*/ */
fun currentProviderLive(context: Context): LiveData<ProviderName?> { fun currentProviderFlow(context: Context, externalScope: CoroutineScope): StateFlow<ProviderName?> {
val settingsManager = EntryPointAccessors.fromApplication(context, TaskUtilsEntryPoint::class.java).settingsManager() val settingsManager = EntryPointAccessors.fromApplication(context, TaskUtilsEntryPoint::class.java).settingsManager()
return settingsManager.getStringLive(Settings.SELECTED_TASKS_PROVIDER).map { preferred -> return settingsManager.getStringFlow(Settings.SELECTED_TASKS_PROVIDER).map { preferred ->
if (preferred != null) if (preferred != null)
preferredAuthorityToProviderName(preferred, context.packageManager) preferredAuthorityToProviderName(preferred, context.packageManager)
else else
null null
} }.stateIn(scope = externalScope, started = SharingStarted.WhileSubscribed(), initialValue = null)
} }
private fun preferredAuthorityToProviderName( private fun preferredAuthorityToProviderName(

View file

@ -15,6 +15,7 @@ androidx-constraintLayout = "2.1.4"
androidx-core = "1.12.0" androidx-core = "1.12.0"
androidx-fragment = "1.6.2" androidx-fragment = "1.6.2"
androidx-hilt = "1.2.0" androidx-hilt = "1.2.0"
androidx-lifecycle = "2.7.0"
androidx-paging = "3.2.1" androidx-paging = "3.2.1"
androidx-preference = "1.2.1" androidx-preference = "1.2.1"
androidx-security = "1.1.0-alpha06" androidx-security = "1.1.0-alpha06"
@ -23,7 +24,6 @@ androidx-test-core = "1.5.0"
androidx-test-runner = "1.5.2" androidx-test-runner = "1.5.2"
androidx-test-rules = "1.5.0" androidx-test-rules = "1.5.0"
androidx-test-junit = "1.1.5" androidx-test-junit = "1.1.5"
androidx-viewmodel = "2.7.0"
androidx-work = "2.9.0" androidx-work = "2.9.0"
appIntro = "7.0.0-beta02" appIntro = "7.0.0-beta02"
bitfire-cert4android = "f1cc9b9ca3" bitfire-cert4android = "f1cc9b9ca3"
@ -72,8 +72,9 @@ androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-cor
androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" } androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidx-hilt" } androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidx-hilt" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidx-hilt" } androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidx-hilt" }
androidx-lifecycle-viewmodel-base = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-viewmodel" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-viewmodel" } androidx-lifecycle-viewmodel-base = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-paging = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" } androidx-paging = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" } androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }