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.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)

View File

@ -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
@ -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()!!)
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_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()!!)
}
}
@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)
}
}

View File

@ -7,8 +7,9 @@ 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
@ -16,6 +17,9 @@ 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
@ -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")
@ -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 {
@ -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) {

View File

@ -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
@ -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()
@ -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)
@ -184,7 +186,7 @@ class AppSettingsActivity: AppCompatActivity() {
)
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.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
@ -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) {
if (showAgain)
settings.remove(HINT_OPENTASKS_NOT_INSTALLED)
@ -111,7 +113,7 @@ class TasksActivity: AppCompatActivity() {
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 }
@ -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=" +

View File

@ -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
@ -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)
@ -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),
@ -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),

View File

@ -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
@ -102,7 +103,7 @@ 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) {
@ -110,7 +111,7 @@ class BatteryOptimizationsPage: IntroPage {
ignoreBatteryOptimizationsResultLauncher.launch(BuildConfig.APPLICATION_ID)
}
val hintAutostartPermission by model.hintAutostartPermission.observeAsState()
val hintAutostartPermission by model.hintAutostartPermission.collectAsStateWithLifecycle(false)
BatteryOptimizationsContent(
dontShowBattery = hintBatteryOptimizations == false,
onChangeDontShowBattery = {
@ -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)

View File

@ -21,7 +21,6 @@ import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
@ -68,7 +68,7 @@ class OpenSourcePage : IntroPage {
@Composable
private fun Page(model: Model = viewModel()) {
val dontShow by model.dontShow.observeAsState(false)
val dontShow by model.dontShow.collectAsStateWithLifecycle(false)
PageContent(
dontShow = dontShow,
onChangeDontShow = {
@ -147,7 +147,8 @@ class OpenSourcePage : IntroPage {
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) {
if (dontShowAgain) {
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.servicedetection.DavResourceFinder
@ -96,8 +97,9 @@ fun AccountDetailsPage(
val suggestedAccountNames = foundConfig.calDAV?.emails ?: emptyList()
var accountName by remember { mutableStateOf(suggestedAccountNames.firstOrNull() ?: "") }
val forcedGroupMethod by model.forcedGroupMethod.observeAsState()
var groupMethod by remember { mutableStateOf(forcedGroupMethod ?: loginInfo.suggestedGroupMethod) }
var groupMethod by remember { mutableStateOf(loginInfo.suggestedGroupMethod) }
val forcedGroupMethod by model.forcedGroupMethod.collectAsStateWithLifecycle(null)
forcedGroupMethod?.let { groupMethod = it }
AccountDetailsPage_Content(
suggestedAccountNames = suggestedAccountNames,
accountName = accountName,

View File

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

View File

@ -15,8 +15,6 @@ import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Service
@ -34,6 +32,11 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
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 {
@ -57,16 +60,16 @@ object TaskUtils {
/**
* 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()
return settingsManager.getStringLive(Settings.SELECTED_TASKS_PROVIDER).map { preferred ->
return settingsManager.getStringFlow(Settings.SELECTED_TASKS_PROVIDER).map { preferred ->
if (preferred != null)
preferredAuthorityToProviderName(preferred, context.packageManager)
else
null
}
}.stateIn(scope = externalScope, started = SharingStarted.WhileSubscribed(), initialValue = null)
}
private fun preferredAuthorityToProviderName(

View File

@ -15,6 +15,7 @@ androidx-constraintLayout = "2.1.4"
androidx-core = "1.12.0"
androidx-fragment = "1.6.2"
androidx-hilt = "1.2.0"
androidx-lifecycle = "2.7.0"
androidx-paging = "3.2.1"
androidx-preference = "1.2.1"
androidx-security = "1.1.0-alpha06"
@ -23,7 +24,6 @@ androidx-test-core = "1.5.0"
androidx-test-runner = "1.5.2"
androidx-test-rules = "1.5.0"
androidx-test-junit = "1.1.5"
androidx-viewmodel = "2.7.0"
androidx-work = "2.9.0"
appIntro = "7.0.0-beta02"
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-hilt-compiler = { module = "androidx.hilt:hilt-compiler", 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-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-viewmodel" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
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-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }