Refactor Tasks app detection (and settings update when tasks apps change) (#637)

* Refactoring

* Better live handling of (un)installed task apps

* Minor changes

* SettingsManager: explicitly mark possibility of null LiveData values

* Fix tests
This commit is contained in:
Ricki Hirner 2024-03-11 13:51:46 +01:00 committed by GitHub
parent cb56132994
commit 06b4cf9477
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 148 additions and 128 deletions

View file

@ -68,7 +68,7 @@ class SettingsManagerTest {
// 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())
assertTrue(live.getOrAwaitValue()!!)
}
}

View file

@ -10,7 +10,6 @@ import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.syncadapter.AccountsUpdatedListener
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.UiUtils
@ -65,10 +64,8 @@ class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provide
// watch storage because low storage means synchronization is stopped
storageLowReceiver.listen()
// watch installed/removed apps
// watch installed/removed tasks apps and update sync settings accordingly
TasksWatcher.watch(this)
// check whether a tasks app is currently installed
SyncUtils.updateTaskSync(this)
// create/update app shortcuts
UiUtils.updateShortcuts(this)
@ -87,4 +84,4 @@ class App: Application(), Thread.UncaughtExceptionHandler, Configuration.Provide
exitProcess(1)
}
}
}

View file

@ -8,22 +8,39 @@ 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
val context: Context
): BroadcastReceiver(), AutoCloseable {
init {
/**
* 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

@ -5,26 +5,55 @@
package at.bitfire.davdroid
import android.content.Context
import android.content.Intent
import at.bitfire.davdroid.syncadapter.SyncUtils.updateTaskSync
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.syncadapter.SyncUtils
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)
fun watch(context: Context) {
TasksWatcher(context).register(true)
}
}
override fun onReceive(context: Context, intent: Intent) {
override fun onPackageChanged() {
CoroutineScope(Dispatchers.Default).launch {
updateTaskSync(context)
if (TaskUtils.currentProvider(context) == null) {
/* Currently no usable tasks provider.
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, updateSyncSettings = false)
providerSelected = true
break
}
}
if (!providerSelected)
// no provider available, also clear setting
TaskUtils.selectProvider(context, null, updateSyncSettings = false)
}
// update sync settings
SyncUtils.updateTaskSync(context)
}
}
}
}

View file

@ -16,9 +16,6 @@ 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.Dispatchers
import kotlinx.coroutines.launch
object TaskUtils {
@ -28,16 +25,29 @@ object TaskUtils {
fun settingsManager(): SettingsManager
}
/**
* Returns the currently selected tasks provider (if it's still available = installed).
*
* @return the currently selected tasks provider, or null if none is available
*/
fun currentProvider(context: Context): ProviderName? {
val settingsManager = EntryPointAccessors.fromApplication(context, TaskUtilsEntryPoint::class.java).settingsManager()
val preferredAuthority = settingsManager.getString(Settings.PREFERRED_TASKS_PROVIDER) ?: return null
val preferredAuthority = settingsManager.getString(Settings.SELECTED_TASKS_PROVIDER) ?: return null
return preferredAuthorityToProviderName(preferredAuthority, context.packageManager)
}
/**
* Returns the currently selected tasks provider (if it's still available = installed).
*
* @return the currently selected tasks provider, or null if none is available
*/
fun currentProviderLive(context: Context): LiveData<ProviderName?> {
val settingsManager = EntryPointAccessors.fromApplication(context, TaskUtilsEntryPoint::class.java).settingsManager()
return settingsManager.getStringLive(Settings.PREFERRED_TASKS_PROVIDER).map { preferred ->
preferredAuthorityToProviderName(preferred, context.packageManager)
return settingsManager.getStringLive(Settings.SELECTED_TASKS_PROVIDER).map { preferred ->
if (preferred != null)
preferredAuthorityToProviderName(preferred, context.packageManager)
else
null
}
}
@ -56,12 +66,12 @@ object TaskUtils {
fun isAvailable(context: Context) = currentProvider(context) != null
fun setPreferredProvider(context: Context, providerName: ProviderName) {
fun selectProvider(context: Context, providerName: ProviderName?, updateSyncSettings: Boolean = false) {
val settingsManager = EntryPointAccessors.fromApplication(context, TaskUtilsEntryPoint::class.java).settingsManager()
settingsManager.putString(Settings.PREFERRED_TASKS_PROVIDER, providerName.authority)
CoroutineScope(Dispatchers.Default).launch {
SyncUtils.updateTaskSync(context)
}
settingsManager.putString(Settings.SELECTED_TASKS_PROVIDER, providerName?.authority)
// update sync settings
SyncUtils.updateTaskSync(context)
}
}

View file

@ -39,10 +39,13 @@ object Settings {
const val PREFERRED_THEME = "preferred_theme"
const val PREFERRED_THEME_DEFAULT = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
const val LANGUAGE = "language"
const val LANGUAGE_SYSTEM = "language_system"
const val PREFERRED_TASKS_PROVIDER = "preferred_tasks_provider"
/**
* Selected tasks app. When at least one tasks app is installed, this setting is set to its authority.
* In case of multiple available tasks app, the user can choose one and this setting will reflect the selected one.
*
* If no tasks app is installed, this setting is not set.
*/
const val SELECTED_TASKS_PROVIDER = "preferred_tasks_provider"
/** whether collections are automatically selected for synchronization after their initial detection */
const val PRESELECT_COLLECTIONS = "preselect_collections"

View file

@ -125,17 +125,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 getBooleanLive(key: String): LiveData<Boolean?> = SettingLiveData { 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 getIntLive(key: String): LiveData<Int?> = SettingLiveData { 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 getStringLive(key: String): LiveData<String?> = SettingLiveData { getString(key) }
fun isWritable(key: String): Boolean {

View file

@ -114,15 +114,17 @@ object SyncUtils {
// task sync utils
/**
* Sets up sync for the current TaskProvider (and disables sync for unavailable task providers).
*
* In case of missing permissions, a notification is shown.
*/
@WorkerThread
fun updateTaskSync(context: Context) {
val tasksProvider = TaskUtils.currentProvider(context)
Logger.log.info("App launched or other package (un)installed; current tasks provider = $tasksProvider")
val currentProvider = TaskUtils.currentProvider(context)
Logger.log.info("App launched or other package (un)installed; current tasks provider = $currentProvider")
var permissionsRequired = false // whether additional permissions are required
val currentProvider by lazy { // only this provider shall be enabled (null to disable all providers)
TaskUtils.currentProvider(context)
}
// check all accounts and (de)activate task provider(s) if a CalDAV service is defined
val db = EntryPointAccessors.fromApplication(context, SyncUtilsEntryPoint::class.java).appDatabase()

View file

@ -151,7 +151,7 @@ class AppSettingsActivity: AppCompatActivity() {
)
AppSettings_Connection(
proxyType = model.settings.getIntLive(Settings.PROXY_TYPE).observeAsState(Settings.PROXY_TYPE_NONE).value,
proxyType = model.settings.getIntLive(Settings.PROXY_TYPE).observeAsState().value ?: Settings.PROXY_TYPE_NONE,
onProxyTypeUpdated = { model.settings.putInt(Settings.PROXY_TYPE, it) },
proxyHostName = model.settings.getStringLive(Settings.PROXY_HOST).observeAsState(null).value,
onProxyHostNameUpdated = { model.settings.putString(Settings.PROXY_HOST, it) },
@ -160,7 +160,7 @@ class AppSettingsActivity: AppCompatActivity() {
)
AppSettings_Security(
distrustSystemCerts = model.settings.getBooleanLive(Settings.DISTRUST_SYSTEM_CERTIFICATES).observeAsState(false).value,
distrustSystemCerts = model.settings.getBooleanLive(Settings.DISTRUST_SYSTEM_CERTIFICATES).observeAsState().value ?: false,
onDistrustSystemCertsUpdated = { model.settings.putBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES, it) },
onResetCertificates = {
model.resetCertificates()
@ -172,7 +172,7 @@ class AppSettingsActivity: AppCompatActivity() {
)
AppSettings_UserInterface(
theme = model.settings.getIntLive(Settings.PREFERRED_THEME).observeAsState(Settings.PREFERRED_THEME_DEFAULT).value,
theme = model.settings.getIntLive(Settings.PREFERRED_THEME).observeAsState().value ?: Settings.PREFERRED_THEME_DEFAULT,
onThemeSelected = {
model.settings.putInt(Settings.PREFERRED_THEME, it)
UiUtils.updateTheme(context)

View file

@ -6,7 +6,6 @@ package at.bitfire.davdroid.ui
import android.Manifest
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
@ -50,7 +49,6 @@ import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.ical4android.TaskProvider
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.themeadapter.material.MdcTheme
import java.util.logging.Level
class PermissionsActivity: AppCompatActivity() {
@ -82,13 +80,13 @@ class PermissionsActivity: AppCompatActivity() {
val jtxAvailable = MutableLiveData<Boolean>()
private val tasksWatcher = object: PackageChangedReceiver(app) {
@MainThread
override fun onReceive(context: Context?, intent: Intent?) {
override fun onPackageChanged() {
checkPermissions()
}
}
init {
tasksWatcher.register()
checkPermissions()
}

View file

@ -5,14 +5,12 @@
package at.bitfire.davdroid.ui
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.AnyThread
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@ -45,9 +43,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.unit.dp
import androidx.core.text.HtmlCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.PackageChangedReceiver
@ -60,6 +59,7 @@ import at.bitfire.davdroid.ui.widget.RadioWithSwitch
import at.bitfire.ical4android.TaskProvider
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -80,9 +80,9 @@ class TasksActivity: AppCompatActivity() {
@HiltViewModel
class Model @Inject constructor(
application: Application,
val context: Application,
val settings: SettingsManager
) : AndroidViewModel(application), SettingsManager.OnChangeListener {
) : ViewModel() {
companion object {
@ -95,83 +95,46 @@ class TasksActivity: AppCompatActivity() {
}
val currentProvider = MutableLiveData<TaskProvider.ProviderName>()
val openTasksInstalled = MutableLiveData<Boolean>()
val openTasksRequested = MutableLiveData<Boolean>()
val openTasksSelected = MutableLiveData<Boolean>()
val tasksOrgInstalled = MutableLiveData<Boolean>()
val tasksOrgRequested = MutableLiveData<Boolean>()
val tasksOrgSelected = MutableLiveData<Boolean>()
val jtxInstalled = MutableLiveData<Boolean>()
val jtxRequested = MutableLiveData<Boolean>()
val jtxSelected = MutableLiveData<Boolean>()
val dontShow = settings.getBooleanLive(HINT_OPENTASKS_NOT_INSTALLED)
fun setDontShow(dontShow: Boolean) {
settings.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, !dontShow)
}
private val tasksWatcher = object: PackageChangedReceiver(application) {
override fun onReceive(context: Context?, intent: Intent?) {
checkInstalled()
val currentProvider = TaskUtils.currentProviderLive(context)
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))
}
}
val dontShow = MutableLiveData(
settings.getBooleanOrNull(HINT_OPENTASKS_NOT_INSTALLED) == false
)
private val dontShowObserver = Observer<Boolean> { value ->
if (value)
settings.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false)
else
settings.remove(HINT_OPENTASKS_NOT_INSTALLED)
}
init {
checkInstalled()
settings.addOnChangeListener(this)
dontShow.observeForever(dontShowObserver)
pkgChangedReceiver.register(true)
}
override fun onCleared() {
settings.removeOnChangeListener(this)
tasksWatcher.close()
dontShow.removeObserver(dontShowObserver)
}
@AnyThread
fun checkInstalled() {
val taskProvider = TaskUtils.currentProvider(getApplication())
currentProvider.postValue(taskProvider)
val openTasks = isInstalled(TaskProvider.ProviderName.OpenTasks.packageName)
openTasksInstalled.postValue(openTasks)
openTasksRequested.postValue(openTasks)
openTasksSelected.postValue(taskProvider == TaskProvider.ProviderName.OpenTasks)
val tasksOrg = isInstalled(TaskProvider.ProviderName.TasksOrg.packageName)
tasksOrgInstalled.postValue(tasksOrg)
tasksOrgRequested.postValue(tasksOrg)
tasksOrgSelected.postValue(taskProvider == TaskProvider.ProviderName.TasksOrg)
val jtxBoard = isInstalled(TaskProvider.ProviderName.JtxBoard.packageName)
jtxInstalled.postValue(jtxBoard)
jtxRequested.postValue(jtxBoard)
jtxSelected.postValue(taskProvider == TaskProvider.ProviderName.JtxBoard)
pkgChangedReceiver.close()
}
private fun isInstalled(packageName: String): Boolean =
try {
getApplication<Application>().packageManager.getPackageInfo(packageName, 0)
context.packageManager.getPackageInfo(packageName, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
fun selectPreferredProvider(provider: TaskProvider.ProviderName) {
// Changes preferred task app setting, so onSettingsChanged() will be called
TaskUtils.setPreferredProvider(getApplication(), provider)
}
override fun onSettingsChanged() {
checkInstalled()
fun selectProvider(provider: TaskProvider.ProviderName) = viewModelScope.launch(Dispatchers.Default) {
TaskUtils.selectProvider(context, provider)
}
}
@ -190,19 +153,16 @@ fun TasksCard(
val snackbarHostState = remember { SnackbarHostState() }
val jtxInstalled by model.jtxInstalled.observeAsState(initial = false)
val jtxSelected by model.jtxSelected.observeAsState(initial = false)
val jtxRequested by model.jtxRequested.observeAsState(initial = false)
val jtxInstalled by model.jtxInstalled.observeAsState(false)
val jtxSelected by model.jtxSelected.observeAsState(false)
val tasksOrgInstalled by model.tasksOrgInstalled.observeAsState(initial = false)
val tasksOrgSelected by model.tasksOrgSelected.observeAsState(initial = false)
val tasksOrgRequested by model.tasksOrgRequested.observeAsState(initial = false)
val tasksOrgInstalled by model.tasksOrgInstalled.observeAsState(false)
val tasksOrgSelected by model.tasksOrgSelected.observeAsState(false)
val openTasksInstalled by model.openTasksInstalled.observeAsState(initial = false)
val openTasksSelected by model.openTasksSelected.observeAsState(initial = false)
val openTasksRequested by model.openTasksRequested.observeAsState(initial = false)
val openTasksInstalled by model.openTasksInstalled.observeAsState(false)
val openTasksSelected by model.openTasksSelected.observeAsState(false)
val dontShow by model.dontShow.observeAsState(initial = false)
val dontShow = model.dontShow.observeAsState().value ?: false
fun installApp(packageName: String) {
val uri = Uri.parse("market://details?id=$packageName&referrer=" +
@ -221,7 +181,7 @@ fun TasksCard(
fun onProviderSelected(provider: TaskProvider.ProviderName) {
if (model.currentProvider.value != provider)
model.selectPreferredProvider(provider)
model.selectProvider(provider)
}
Scaffold(
@ -249,7 +209,7 @@ fun TasksCard(
Text(stringResource(R.string.intro_tasks_jtx_info))
},
isSelected = jtxSelected,
isToggled = jtxRequested,
isToggled = jtxInstalled,
enabled = jtxInstalled,
onSelected = { onProviderSelected(TaskProvider.ProviderName.JtxBoard) },
onToggled = { toggled ->
@ -280,7 +240,7 @@ fun TasksCard(
)
},
isSelected = tasksOrgSelected,
isToggled = tasksOrgRequested,
isToggled = tasksOrgInstalled,
enabled = tasksOrgInstalled,
onSelected = { onProviderSelected(TaskProvider.ProviderName.TasksOrg) },
onToggled = { toggled ->
@ -297,7 +257,7 @@ fun TasksCard(
Text(stringResource(R.string.intro_tasks_opentasks_info))
},
isSelected = openTasksSelected,
isToggled = openTasksRequested,
isToggled = openTasksInstalled,
enabled = openTasksInstalled,
onSelected = { onProviderSelected(TaskProvider.ProviderName.OpenTasks) },
onToggled = { toggled ->
@ -316,13 +276,13 @@ fun TasksCard(
) {
Checkbox(
checked = dontShow,
onCheckedChange = { model.dontShow.value = it }
onCheckedChange = { model.setDontShow(it) }
)
Text(
text = stringResource(R.string.intro_tasks_dont_show),
modifier = Modifier
.fillMaxWidth()
.clickable { model.dontShow.value = !dontShow }
.clickable { model.setDontShow(!dontShow) }
)
}
}

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@ -121,7 +122,8 @@ class OpenSourcePage : IntroPage {
Text(stringResource(R.string.intro_open_source_details))
}
Row(
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = dontShow,
@ -130,7 +132,9 @@ class OpenSourcePage : IntroPage {
Text(
text = stringResource(R.string.intro_open_source_dont_show),
style = MaterialTheme.typography.body2,
modifier = Modifier.clickable { onChangeDontShow(!dontShow) }
modifier = Modifier
.clickable { onChangeDontShow(!dontShow) }
.weight(1f)
)
}
}