Use broadcastReceiverFlow instead of BroadcastReceivers (#722)

* [WIP] Use broadcastReceiverFlow and packageReceiverFlow

to make sure that broadcast receivers are unregistered

* Rewrite remaining BroadcastReceivers to use broadcastReceiverFlow

* TasksAppWatcher: minor coroutine changes

* KDoc

* TasksActivity: use Compose state instead of LiveData
This commit is contained in:
Ricki Hirner 2024-04-12 16:52:23 +02:00 committed by GitHub
parent 463c18c4fb
commit c0570549c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 226 additions and 350 deletions

View file

@ -14,6 +14,8 @@ import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.UiUtils
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import java.util.logging.Level
import javax.inject.Inject
import kotlin.concurrent.thread
@ -23,8 +25,6 @@ import kotlin.system.exitProcess
class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provider {
@Inject lateinit var accountsUpdatedListener: AccountsUpdatedListener
@Inject lateinit var storageLowReceiver: StorageLowReceiver
@Inject lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
@ -57,15 +57,13 @@ class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provide
// some current activity and causes an IllegalStateException in rare cases
// don't block UI for some background checks
@OptIn(DelicateCoroutinesApi::class)
thread {
// watch for account changes/deletions
accountsUpdatedListener.listen()
// watch storage because low storage means sync framework stops local content update notifications
storageLowReceiver.listen()
// watch installed/removed tasks apps and update sync settings accordingly
TasksWatcher.watch(this)
// watch installed/removed tasks apps over whole app lifetime and update sync settings accordingly
TasksAppWatcher.watchInstalledTaskApps(this, GlobalScope)
// create/update app shortcuts
UiUtils.updateShortcuts(this)

View file

@ -1,46 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.annotation.MainThread
abstract class PackageChangedReceiver(
val context: Context
): BroadcastReceiver(), AutoCloseable {
/**
* Registers the receiver.
*
* @param whether [onPackageChanged] shall be called immediately after registering
*/
fun register(immediateCall: Boolean = false) {
val filter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply {
addAction(Intent.ACTION_PACKAGE_CHANGED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}
context.registerReceiver(this, filter)
if (immediateCall)
onPackageChanged()
}
override fun close() {
context.unregisterReceiver(this)
}
@MainThread
abstract fun onPackageChanged()
override fun onReceive(context: Context, intent: Intent) {
onPackageChanged()
}
}

View file

@ -1,68 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.lifecycle.MutableLiveData
import at.bitfire.davdroid.log.Logger
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
class StorageLowReceiver private constructor(
val context: Context
): BroadcastReceiver(), AutoCloseable {
@Module
@InstallIn(SingletonComponent::class)
object StorageLowReceiverModule {
@Provides
@Singleton
fun storageLowReceiver(@ApplicationContext context: Context) = StorageLowReceiver(context)
}
val storageLow = MutableLiveData(false)
fun listen() {
Logger.log.fine("Listening for device storage low/OK broadcasts")
val filter = IntentFilter().apply {
addAction(Intent.ACTION_DEVICE_STORAGE_LOW)
addAction(Intent.ACTION_DEVICE_STORAGE_OK)
}
context.registerReceiver(this, filter)
}
override fun close() {
context.unregisterReceiver(this)
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_DEVICE_STORAGE_LOW -> onStorageLow()
Intent.ACTION_DEVICE_STORAGE_OK -> onStorageOk()
}
}
fun onStorageLow() {
Logger.log.warning("Low storage, sync will not be started by Android!")
storageLow.postValue(true)
}
fun onStorageOk() {
Logger.log.info("Storage OK again")
storageLow.postValue(false)
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.content.Context
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.util.packageChangedFlow
import at.bitfire.ical4android.TaskProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Watches whether a tasks app has been installed or uninstalled and updates
* the selected tasks app and task sync settings accordingly.
*/
object TasksAppWatcher {
fun watchInstalledTaskApps(context: Context, externalScope: CoroutineScope) {
externalScope.launch(Dispatchers.Default) {
packageChangedFlow(context).collect {
onPackageChanged(context)
}
}
}
private fun onPackageChanged(context: Context) {
val currentProvider = TaskUtils.currentProvider(context)
Logger.log.info("App launched or package (un)installed; current tasks provider = $currentProvider")
if (currentProvider == null) {
// Iterate through all supported providers and select one, if available.
var providerSelected = false
for (provider in TaskProvider.ProviderName.entries) {
val available = context.packageManager.resolveContentProvider(provider.authority, 0) != null
if (available) {
Logger.log.info("Selecting new tasks provider: $provider")
TaskUtils.selectProvider(context, provider)
providerSelected = true
break
}
}
if (!providerSelected)
// no provider available (anymore), also clear setting and sync
TaskUtils.selectProvider(context, null)
}
}
}

View file

@ -1,56 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid
import android.content.Context
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.ical4android.TaskProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Watches whether a tasks app has been installed or uninstalled and updates
* the selected tasks app and task sync settings accordingly.
*/
class TasksWatcher private constructor(
context: Context
): PackageChangedReceiver(context) {
companion object {
fun watch(context: Context) {
TasksWatcher(context).register(true)
}
}
override fun onPackageChanged() {
CoroutineScope(Dispatchers.Default).launch {
val currentProvider = TaskUtils.currentProvider(context)
Logger.log.info("App launched or package (un)installed; current tasks provider = $currentProvider")
if (currentProvider == null) {
// Iterate through all supported providers and select one, if available.
var providerSelected = false
for (provider in TaskProvider.ProviderName.entries) {
val available = context.packageManager.resolveContentProvider(provider.authority, 0) != null
if (available) {
Logger.log.info("Selecting new tasks provider: $provider")
TaskUtils.selectProvider(context, provider)
providerSelected = true
break
}
}
if (!providerSelected)
// no provider available (anymore), also clear setting and sync
TaskUtils.selectProvider(context, null)
}
}
}
}

View file

@ -73,6 +73,7 @@ import androidx.core.content.getSystemService
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.work.WorkInfo
import androidx.work.WorkManager
@ -196,19 +197,19 @@ class AccountsActivity: AppCompatActivity() {
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
dataSaverActive = warnings.dataSaverEnabled.observeAsState().value == true,
dataSaverActive = warnings.dataSaverEnabled.collectAsStateWithLifecycle().value,
onManageDataSaver = {
val intent = Intent(Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS, Uri.parse("package:$packageName"))
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
batterySaverActive = warnings.batterySaverActive.observeAsState().value == true,
batterySaverActive = warnings.batterySaverActive.collectAsStateWithLifecycle().value,
onManageBatterySaver = {
val intent = Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
lowStorageWarning = warnings.storageLow.observeAsState().value == true,
lowStorageWarning = warnings.storageLow.collectAsStateWithLifecycle().value,
onManageStorage = {
val intent = Intent(Settings.ACTION_INTERNAL_STORAGE_SETTINGS)
if (intent.resolveActivity(packageManager) != null)

View file

@ -6,8 +6,6 @@ package at.bitfire.davdroid.ui
import android.annotation.SuppressLint
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
@ -59,6 +57,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.preference.PreferenceManager
import at.bitfire.cert4android.CustomCertStore
@ -76,9 +75,13 @@ import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPage
import at.bitfire.davdroid.ui.intro.OpenSourcePage
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.util.broadcastReceiverFlow
import at.bitfire.ical4android.TaskProvider
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -136,7 +139,7 @@ class AppSettingsActivity: AppCompatActivity() {
AppSettings_Debugging(
verboseLogging = model.getPrefBoolean(Logger.LOG_TO_FILE).observeAsState().value ?: false,
onUpdateVerboseLogging = { model.putPrefBoolean(Logger.LOG_TO_FILE, it) },
batterySavingExempted = model.getBatterySavingExempted().observeAsState(false).value,
batterySavingExempted = model.batterySavingExempted.collectAsStateWithLifecycle().value,
onExemptFromBatterySaving = {
startActivity(Intent(
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
@ -494,29 +497,10 @@ class AppSettingsActivity: AppCompatActivity() {
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
fun getBatterySavingExempted(): LiveData<Boolean> = object : LiveData<Boolean>() {
val receiver = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
update()
}
}
override fun onActive() {
context.registerReceiver(receiver, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED))
update()
}
override fun onInactive() {
context.unregisterReceiver(receiver)
}
private fun update() {
context.getSystemService<PowerManager>()?.let { powerManager ->
val exempted = powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)
postValue(exempted)
}
}
}
private val powerManager = context.getSystemService<PowerManager>()!!
val batterySavingExempted = broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED))
.map { powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
fun getPrefBoolean(keyToObserve: String): LiveData<Boolean?> =
object : LiveData<Boolean?>(), SharedPreferences.OnSharedPreferenceChangeListener {

View file

@ -5,9 +5,7 @@
package at.bitfire.davdroid.ui
import android.app.Application
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SyncStatusObserver
@ -17,10 +15,14 @@ import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.PowerManager
import androidx.core.content.getSystemService
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import at.bitfire.davdroid.StorageLowReceiver
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.util.broadcastReceiverFlow
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/**
@ -37,12 +39,23 @@ import javax.inject.Inject
*/
@HiltViewModel
class AppWarningsModel @Inject constructor(
context: Application,
storageLowReceiver: StorageLowReceiver
): AndroidViewModel(context), SyncStatusObserver {
val context: Application
): ViewModel(), SyncStatusObserver {
/** whether storage is low (prevents sync framework from running synchronization) */
val storageLow = storageLowReceiver.storageLow
val storageLow =
broadcastReceiverFlow(
context = context,
filter = IntentFilter().apply {
addAction(Intent.ACTION_DEVICE_STORAGE_LOW)
addAction(Intent.ACTION_DEVICE_STORAGE_OK)
}
).map { intent ->
when (intent.action) {
Intent.ACTION_DEVICE_STORAGE_LOW -> true
else -> false
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
/** whether global sync is disabled (sync framework won't run automatic synchronization in this case) */
val globalSyncDisabled = MutableLiveData<Boolean>()
@ -51,14 +64,26 @@ class AppWarningsModel @Inject constructor(
/** whether a usable network connection is available (sync framework won't run synchronization otherwise) */
val networkAvailable = MutableLiveData<Boolean>()
private lateinit var networkCallback: ConnectivityManager.NetworkCallback
private val powerManager = context.getSystemService<PowerManager>()!!
/** whether battery saver is active */
val batterySaverActive =
broadcastReceiverFlow(
context = context,
filter = IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED),
immediate = true
).map { powerManager.isPowerSaveMode }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
private val connectivityManager = context.getSystemService<ConnectivityManager>()!!
val batterySaverActive = MutableLiveData<Boolean>()
private val batterySaverListener: BroadcastReceiver
/** whether data saver is restricting background synchronization ([ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED]) */
val dataSaverEnabled = MutableLiveData<Boolean>()
private val dataSaverChangedListener: BroadcastReceiver
val dataSaverEnabled =
broadcastReceiverFlow(
context = context,
filter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED),
immediate = true
).map { connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
init {
// Automatic Sync
@ -67,56 +92,14 @@ class AppWarningsModel @Inject constructor(
// Network
watchConnectivity()
// Battery saver
batterySaverListener = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
checkBatterySaver()
}
}
val batterySaverListenerFilter = IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
context.registerReceiver(batterySaverListener, batterySaverListenerFilter)
checkBatterySaver()
// Data saver
dataSaverChangedListener = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
checkDataSaver()
}
}
val dataSaverChangedFilter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED)
context.registerReceiver(dataSaverChangedListener, dataSaverChangedFilter)
checkDataSaver()
}
private fun checkBatterySaver() {
batterySaverActive.postValue(
getApplication<Application>().getSystemService<PowerManager>()?.isPowerSaveMode
)
}
private fun checkDataSaver() {
dataSaverEnabled.postValue(
getApplication<Application>().getSystemService<ConnectivityManager>()?.let { connectivityManager ->
connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
}
)
}
override fun onCleared() {
val context = getApplication<Application>()
// Automatic sync
ContentResolver.removeStatusChangeListener(syncStatusObserver)
// Network
connectivityManager.unregisterNetworkCallback(networkCallback)
// Battery saver
context.unregisterReceiver(batterySaverListener)
// Data Saver
context.unregisterReceiver(dataSaverChangedListener)
}
override fun onStatusChanged(which: Int) {

View file

@ -35,15 +35,17 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.PackageChangedReceiver
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.composable.BasicTopAppBar
import at.bitfire.davdroid.ui.composable.CardWithImage
import at.bitfire.davdroid.ui.composable.PermissionSwitchRow
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.packageChangedFlow
import at.bitfire.ical4android.TaskProvider
import kotlinx.coroutines.launch
import java.util.logging.Level
class PermissionsActivity: AppCompatActivity() {
@ -81,19 +83,12 @@ class PermissionsActivity: AppCompatActivity() {
var tasksOrgAvailable by mutableStateOf(false)
var jtxAvailable by mutableStateOf(false)
private val tasksWatcher = object: PackageChangedReceiver(app) {
override fun onPackageChanged() {
checkPermissions()
}
}
init {
tasksWatcher.register()
checkPermissions()
}
override fun onCleared() {
tasksWatcher.close()
viewModelScope.launch {
packageChangedFlow(app).collect {
checkPermissions()
}
}
}
@MainThread

View file

@ -28,9 +28,10 @@ import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
@ -39,15 +40,11 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.PackageChangedReceiver
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
@ -56,10 +53,12 @@ import at.bitfire.davdroid.ui.composable.CardWithImage
import at.bitfire.davdroid.ui.composable.RadioWithSwitch
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.util.packageChangedFlow
import at.bitfire.ical4android.TaskProvider
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -113,29 +112,23 @@ class TasksActivity: AppCompatActivity() {
settings.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false)
}
val currentProvider = TaskUtils.currentProviderFlow(context, viewModelScope).asLiveData()
val currentProvider = TaskUtils.currentProviderFlow(context, viewModelScope)
val jtxSelected = currentProvider.map { it == TaskProvider.ProviderName.JtxBoard }
val tasksOrgSelected = currentProvider.map { it == TaskProvider.ProviderName.TasksOrg }
val openTasksSelected = currentProvider.map { it == TaskProvider.ProviderName.OpenTasks }
val jtxInstalled = MutableLiveData<Boolean>()
val tasksOrgInstalled = MutableLiveData<Boolean>()
val openTasksInstalled = MutableLiveData<Boolean>()
private val pkgChangedReceiver = object: PackageChangedReceiver(context) {
override fun onPackageChanged() {
jtxInstalled.postValue(isInstalled(TaskProvider.ProviderName.JtxBoard.packageName))
tasksOrgInstalled.postValue(isInstalled(TaskProvider.ProviderName.TasksOrg.packageName))
openTasksInstalled.postValue(isInstalled(TaskProvider.ProviderName.OpenTasks.packageName))
}
}
var jtxInstalled by mutableStateOf(false)
var tasksOrgInstalled by mutableStateOf(false)
var openTasksInstalled by mutableStateOf(false)
init {
pkgChangedReceiver.register(true)
}
override fun onCleared() {
pkgChangedReceiver.close()
viewModelScope.launch {
packageChangedFlow(context).collect {
jtxInstalled = isInstalled(TaskProvider.ProviderName.JtxBoard.packageName)
tasksOrgInstalled = isInstalled(TaskProvider.ProviderName.TasksOrg.packageName)
openTasksInstalled = isInstalled(TaskProvider.ProviderName.OpenTasks.packageName)
}
}
}
private fun isInstalled(packageName: String): Boolean =
@ -164,14 +157,14 @@ fun TasksCard(
val snackbarHostState = remember { SnackbarHostState() }
val jtxInstalled by model.jtxInstalled.observeAsState(false)
val jtxSelected by model.jtxSelected.observeAsState(false)
val jtxInstalled = model.jtxInstalled
val jtxSelected by model.jtxSelected.collectAsStateWithLifecycle(false)
val tasksOrgInstalled by model.tasksOrgInstalled.observeAsState(false)
val tasksOrgSelected by model.tasksOrgSelected.observeAsState(false)
val tasksOrgInstalled = model.tasksOrgInstalled
val tasksOrgSelected by model.tasksOrgSelected.collectAsStateWithLifecycle(false)
val openTasksInstalled by model.openTasksInstalled.observeAsState(false)
val openTasksSelected by model.openTasksSelected.observeAsState(false)
val openTasksInstalled = model.openTasksInstalled
val openTasksSelected by model.openTasksSelected.collectAsStateWithLifecycle(false)
val showAgain = model.showAgain.collectAsStateWithLifecycle(null).value ?: false

View file

@ -7,8 +7,6 @@ package at.bitfire.davdroid.ui.account
import android.Manifest
import android.accounts.Account
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
@ -16,7 +14,6 @@ import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.MainThread
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
@ -40,7 +37,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
@ -50,16 +46,21 @@ import androidx.compose.ui.unit.dp
import androidx.core.app.TaskStackBuilder
import androidx.core.content.getSystemService
import androidx.core.location.LocationManagerCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.composable.PermissionSwitchRow
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.broadcastReceiverFlow
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@AndroidEntryPoint
@ -109,7 +110,7 @@ class WifiPermissionsActivity: AppCompatActivity() {
packageManager.backgroundPermissionOptionLabel.toString()
else
stringResource(R.string.wifi_permissions_background_location_permission_label),
locationServiceEnabled = model.isLocationEnabled.observeAsState(false).value
locationServiceEnabled = model.locationEnabled.collectAsStateWithLifecycle().value
)
}
}
@ -129,9 +130,10 @@ class WifiPermissionsActivity: AppCompatActivity() {
backgroundPermissionOptionLabel: String,
locationServiceEnabled: Boolean
) {
Column(Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())) {
Column(
Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())) {
Text(
stringResource(R.string.wifi_permissions_intro),
style = MaterialTheme.typography.body1
@ -264,30 +266,11 @@ class WifiPermissionsActivity: AppCompatActivity() {
context: Application
): ViewModel() {
val isLocationEnabled: LiveData<Boolean> = object: LiveData<Boolean>() {
val locationChangedReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
update()
}
}
private val locationManager = context.getSystemService<LocationManager>()!!
override fun onActive() {
context.registerReceiver(locationChangedReceiver, IntentFilter(LocationManager.MODE_CHANGED_ACTION))
update()
}
override fun onInactive() {
context.unregisterReceiver(locationChangedReceiver)
}
@MainThread
fun update() {
context.getSystemService<LocationManager>()?.let { locationManager ->
val locationEnabled = LocationManagerCompat.isLocationEnabled(locationManager)
value = locationEnabled
}
}
}
val locationEnabled = broadcastReceiverFlow(context, IntentFilter(LocationManager.MODE_CHANGED_ACTION))
.map { LocationManagerCompat.isLocationEnabled(locationManager) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
}

View file

@ -6,7 +6,6 @@ package at.bitfire.davdroid.ui.intro
import android.annotation.SuppressLint
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
@ -47,6 +46,7 @@ import androidx.core.content.getSystemService
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.Constants
@ -57,11 +57,13 @@ import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPage.Model.Companion.HINT_AUTOSTART_PERMISSION
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPage.Model.Companion.HINT_BATTERY_OPTIMIZATIONS
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.broadcastReceiverFlow
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.launch
import org.apache.commons.text.WordUtils
import java.util.Locale
import javax.inject.Inject
@ -180,23 +182,15 @@ class BatteryOptimizationsPage: IntroPage {
val shouldBeExempted = MutableLiveData<Boolean>()
val isExempted = MutableLiveData<Boolean>()
val hintBatteryOptimizations = settings.getBooleanFlow(HINT_BATTERY_OPTIMIZATIONS)
private val batteryOptimizationsReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
checkBatteryOptimizations()
}
}
val hintAutostartPermission = settings.getBooleanFlow(HINT_AUTOSTART_PERMISSION)
init {
val intentFilter = IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED)
context.registerReceiver(batteryOptimizationsReceiver, intentFilter)
checkBatteryOptimizations()
}
override fun onCleared() {
context.unregisterReceiver(batteryOptimizationsReceiver)
viewModelScope.launch {
broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED)).collect {
checkBatteryOptimizations()
}
}
}
fun checkBatteryOptimizations() {

View file

@ -0,0 +1,62 @@
package at.bitfire.davdroid.util
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import at.bitfire.davdroid.log.Logger
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/**
* Creates a flow that emits the respective [Intent] when a broadcast is received.
*
* @param context the context to register the receiver with
* @param filter specifies which broadcasts shall be received
* @param immediate if `true`, send an empty [Intent] as first value
*
* @return cold flow of [Intent]s
*/
@SuppressLint("UnspecifiedRegisterReceiverFlag")
fun broadcastReceiverFlow(context: Context, filter: IntentFilter, immediate: Boolean = true): Flow<Intent> = callbackFlow {
val receiver = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
trySend(intent)
}
}
// register receiver
var filterDump = filter.toString()
filter.dump({ filterDump = it }, "")
Logger.log.fine("Registering broadcast receiver for $filterDump")
context.registerReceiver(receiver, filter)
// send empty Intent as first value, if requested
if (immediate)
trySend(Intent())
// wait until flow is cancelled, then clean up
awaitClose {
Logger.log.fine("Unregistering broadcast receiver for $filterDump")
context.unregisterReceiver(receiver)
}
}
/**
* Creates a flow that emits the Intent when a package is added, changed or removed.
*
* @param context the context to register the receiver with
* @param immediate if `true`, send an empty [Intent] as first value
*
* @return cold flow of [Intent]s
*/
fun packageChangedFlow(context: Context, immediate: Boolean = true): Flow<Intent> {
val filter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply {
addAction(Intent.ACTION_PACKAGE_CHANGED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}
return broadcastReceiverFlow(context, filter, immediate)
}

View file

@ -134,7 +134,7 @@ object TaskUtils {
* Called
*
* - when a user explicitly selects another task app, or
* - when there previously was no (usable) tasks app and [at.bitfire.davdroid.TasksWatcher] detected a new one.
* - when there previously was no (usable) tasks app and [at.bitfire.davdroid.TasksAppWatcher] detected a new one.
*/
fun selectProvider(context: Context, selectedProvider: ProviderName?) {
Logger.log.info("Selecting tasks app: $selectedProvider")