Rewrite AppSettingsActivity to M3 (#792)

* Extract composables

* Extract model and companion object

* Switch to M3

* Linting

* Drop previews for sub composables

* Minor adjustments for readability

* Minor changes

- use manual URL from Constants
- use M3 in some Composables

* Create PreferenceRepository (for now only for verbose logging)

* Move actual settings to model; M3 Composables

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2024-05-16 13:00:20 +02:00 committed by GitHub
parent e11d511971
commit 6f02669832
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 709 additions and 554 deletions

View File

@ -24,6 +24,8 @@ object Constants {
val MANUAL_URL = "https://manual.davx5.com".toUri()
const val MANUAL_PATH_ACCOUNTS_COLLECTIONS = "accounts_collections.html"
const val MANUAL_FRAGMENT_SERVICE_DISCOVERY = "how-does-service-discovery-work"
const val MANUAL_PATH_SETTINGS = "settings.html"
const val MANUAL_FRAGMENT_APP_SETTINGS = "app-wide-settings"
const val MANUAL_PATH_WEBDAV_MOUNTS = "webdav_mounts.html"
val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions".toUri()

View File

@ -0,0 +1,59 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.app.Application
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.preference.PreferenceManager
import at.bitfire.davdroid.log.Logger
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject
/**
* Repository to access preferences. Preferences are stored in a shared preferences file
* and reflect settings that are very low-level and are therefore not covered by
* [at.bitfire.davdroid.settings.SettingsManager].
*/
class PreferenceRepository @Inject constructor(
context: Application
) {
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
/**
* Updates the "log to file" (verbose logging") setting.
*/
fun logToFile(logToFile: Boolean) {
preferences
.edit()
.putBoolean(Logger.LOG_TO_FILE, logToFile)
.apply()
}
/**
* Gets the "log to file" (verbose logging) setting as a live value.
*/
fun logToFileFlow(): Flow<Boolean> = observeAsFlow(Logger.LOG_TO_FILE) {
preferences.getBoolean(Logger.LOG_TO_FILE, false)
}
private fun<T> observeAsFlow(keyToObserve: String, getValue: () -> T): Flow<T> =
callbackFlow {
val listener = OnSharedPreferenceChangeListener { _, key ->
if (key == keyToObserve) {
trySend(getValue())
}
}
preferences.registerOnSharedPreferenceChangeListener(listener)
awaitClose {
preferences.unregisterOnSharedPreferenceChangeListener(listener)
}
}
}

View File

@ -5,546 +5,56 @@
package at.bitfire.davdroid.ui
import android.annotation.SuppressLint
import android.app.Application
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Adb
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.InvertColors
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.SyncProblem
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.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringArrayResource
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.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.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.preference.PreferenceManager
import at.bitfire.cert4android.CustomCertStore
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.composable.EditTextInputDialog
import at.bitfire.davdroid.ui.composable.MultipleChoiceInputDialog
import at.bitfire.davdroid.ui.composable.Setting
import at.bitfire.davdroid.ui.composable.SettingsHeader
import at.bitfire.davdroid.ui.composable.SwitchSetting
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPageModel
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 javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@AndroidEntryPoint
class AppSettingsActivity: AppCompatActivity() {
companion object {
const val APP_SETTINGS_HELP_URL = "https://manual.davx5.com/settings.html#app-wide-settings"
}
@SuppressLint("BatteryLife")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
M2Theme {
AppSettings()
}
}
}
@SuppressLint("BatteryLife")
@Composable
fun AppSettings(model: Model = viewModel()) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onSupportNavigateUp() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up))
}
},
title = { Text(stringResource(R.string.app_settings)) },
actions = {
IconButton(onClick = {
uriHandler.openUri(APP_SETTINGS_HELP_URL)
}) {
Icon(Icons.AutoMirrored.Filled.Help, stringResource(R.string.help))
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Column(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
Column(Modifier.padding(8.dp)) {
AppSettings_Debugging(
verboseLogging = model.getPrefBoolean(Logger.LOG_TO_FILE).observeAsState().value ?: false,
onUpdateVerboseLogging = { model.putPrefBoolean(Logger.LOG_TO_FILE, it) },
batterySavingExempted = model.batterySavingExempted.collectAsStateWithLifecycle().value,
onExemptFromBatterySaving = {
startActivity(Intent(
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:" + BuildConfig.APPLICATION_ID)
))
},
onBatterySavingSettings = {
startActivity(Intent(
android.provider.Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
))
}
)
AppSettings_Connection(
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.getStringFlow(Settings.PROXY_HOST).collectAsStateWithLifecycle(null).value,
onProxyHostNameUpdated = { model.settings.putString(Settings.PROXY_HOST, it) },
proxyPort = model.settings.getIntFlow(Settings.PROXY_PORT).collectAsStateWithLifecycle(null).value,
onProxyPortUpdated = { model.settings.putInt(Settings.PROXY_PORT, it) }
)
AppSettings_Security(
distrustSystemCerts = model.settings.getBooleanFlow(Settings.DISTRUST_SYSTEM_CERTIFICATES).collectAsStateWithLifecycle(null).value ?: false,
onDistrustSystemCertsUpdated = { model.settings.putBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES, it) },
onResetCertificates = {
model.resetCertificates()
coroutineScope.launch {
snackbarHostState.showSnackbar(getString(R.string.app_settings_reset_certificates_success))
}
}
)
AppSettings_UserInterface(
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)
},
onResetHints = {
model.resetHints()
coroutineScope.launch {
snackbarHostState.showSnackbar(getString(R.string.app_settings_reset_hints_success))
}
}
)
AppSettings_Integration(
taskProvider = TaskUtils.currentProviderFlow(context, lifecycleScope).collectAsStateWithLifecycle().value
)
}
}
}
}
@Composable
fun AppSettings_Debugging(
verboseLogging: Boolean,
onUpdateVerboseLogging: (Boolean) -> Unit,
batterySavingExempted: Boolean,
onExemptFromBatterySaving: () -> Unit,
onBatterySavingSettings: () -> Unit
) {
val context = LocalContext.current
SettingsHeader {
Text(stringResource(R.string.app_settings_debug))
}
Setting(
icon = Icons.Default.BugReport,
name = stringResource(R.string.app_settings_show_debug_info),
summary = stringResource(R.string.app_settings_show_debug_info_details)
) {
context.startActivity(Intent(context, DebugInfoActivity::class.java))
}
SwitchSetting(
icon = Icons.Default.Adb,
checked = verboseLogging,
name = stringResource(R.string.app_settings_logging),
summaryOn = stringResource(R.string.app_settings_logging_on),
summaryOff = stringResource(R.string.app_settings_logging_off)
) {
onUpdateVerboseLogging(it)
}
SwitchSetting(
checked = batterySavingExempted,
icon = Icons.Default.SyncProblem.takeUnless { batterySavingExempted },
name = stringResource(R.string.app_settings_battery_optimization),
summaryOn = stringResource(R.string.app_settings_battery_optimization_exempted),
summaryOff = stringResource(R.string.app_settings_battery_optimization_optimized)
) {
if (batterySavingExempted)
onBatterySavingSettings()
else
onExemptFromBatterySaving()
}
}
@Composable
@Preview
fun AppSettings_Debugging_Preview() {
Column {
AppSettings_Debugging(
verboseLogging = false,
onUpdateVerboseLogging = {},
batterySavingExempted = true,
onExemptFromBatterySaving = {},
onBatterySavingSettings = {}
)
}
}
@Composable
fun AppSettings_Connection(
proxyType: Int,
onProxyTypeUpdated: (Int) -> Unit = {},
proxyHostName: String? = null,
onProxyHostNameUpdated: (String) -> Unit = {},
proxyPort: Int? = null,
onProxyPortUpdated: (Int) -> Unit = {}
) {
SettingsHeader(divider = true) {
Text(stringResource(R.string.app_settings_connection))
}
val proxyTypeNames = stringArrayResource(R.array.app_settings_proxy_types)
val proxyTypeValues = stringArrayResource(R.array.app_settings_proxy_type_values).map { it.toInt() }
var showProxyTypeInputDialog by remember { mutableStateOf(false) }
Setting(
name = stringResource(R.string.app_settings_proxy),
summary = proxyTypeNames[proxyTypeValues.indexOf(proxyType)]
) {
showProxyTypeInputDialog = true
}
if (showProxyTypeInputDialog)
MultipleChoiceInputDialog(
title = stringResource(R.string.app_settings_proxy),
namesAndValues = proxyTypeNames.zip(proxyTypeValues.map { it.toString() }),
initialValue = proxyType.toString(),
onValueSelected = { newValue ->
onProxyTypeUpdated(newValue.toInt())
AppSettingsScreen(
onNavDebugInfo = {
startActivity(Intent(this, DebugInfoActivity::class.java))
},
onDismiss = { showProxyTypeInputDialog = false }
)
if (proxyType !in listOf(Settings.PROXY_TYPE_SYSTEM, Settings.PROXY_TYPE_NONE)) {
var showProxyHostNameInputDialog by remember { mutableStateOf(false) }
Setting(
name = stringResource(R.string.app_settings_proxy_host),
summary = proxyHostName
) {
showProxyHostNameInputDialog = true
}
if (showProxyHostNameInputDialog)
EditTextInputDialog(
title = stringResource(R.string.app_settings_proxy_host),
initialValue = proxyHostName,
keyboardType = KeyboardType.Uri,
onValueEntered = onProxyHostNameUpdated,
onDismiss = { showProxyHostNameInputDialog = false }
)
var showProxyPortInputDialog by remember { mutableStateOf(false) }
Setting(
name = stringResource(R.string.app_settings_proxy_port),
summary = proxyPort?.toString()
) {
showProxyPortInputDialog = true
}
if (showProxyPortInputDialog)
EditTextInputDialog(
title = stringResource(R.string.app_settings_proxy_port),
initialValue = proxyPort?.toString(),
keyboardType = KeyboardType.Number,
onValueEntered = {
try {
val newPort = it.toInt()
if (newPort in 1..65535)
onProxyPortUpdated(newPort)
} catch (_: NumberFormatException) {
// user entered invalid port number
}
},
onDismiss = { showProxyPortInputDialog = false }
)
}
}
@Composable
@Preview
fun AppSettings_Connection_Preview() {
Column {
AppSettings_Connection(
proxyType = Settings.PROXY_TYPE_HTTP
)
}
}
@Composable
fun AppSettings_Security(
distrustSystemCerts: Boolean,
onDistrustSystemCertsUpdated: (Boolean) -> Unit = {},
onResetCertificates: () -> Unit = {}
) {
val context = LocalContext.current
SettingsHeader(divider = true) {
Text(stringResource(R.string.app_settings_security))
}
SwitchSetting(
checked = distrustSystemCerts,
name = stringResource(R.string.app_settings_distrust_system_certs),
summaryOn = stringResource(R.string.app_settings_distrust_system_certs_on),
summaryOff = stringResource(R.string.app_settings_distrust_system_certs_off)
) {
onDistrustSystemCertsUpdated(it)
}
Setting(
name = stringResource(R.string.app_settings_reset_certificates),
summary = stringResource(R.string.app_settings_reset_certificates_summary),
onClick = onResetCertificates
)
Setting(
name = stringResource(R.string.app_settings_security_app_permissions),
summary = stringResource(R.string.app_settings_security_app_permissions_summary),
onClick = {
context.startActivity(Intent(context, PermissionsActivity::class.java))
}
)
}
@Composable
@Preview
fun AppSettings_Security_Preview() {
Column {
AppSettings_Security(
distrustSystemCerts = false
)
}
}
@Composable
fun AppSettings_UserInterface(
theme: Int,
onThemeSelected: (Int) -> Unit = {},
onResetHints: () -> Unit = {}
) {
SettingsHeader(divider = true) {
Text(stringResource(R.string.app_settings_user_interface))
}
if (Build.VERSION.SDK_INT >= 26)
Setting(
icon = Icons.Default.Notifications,
name = stringResource(R.string.app_settings_notification_settings),
summary = stringResource(R.string.app_settings_notification_settings_summary)
) {
val intent = Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
}
startActivity(intent)
}
val themeNames = stringArrayResource(R.array.app_settings_theme_names)
val themeValues = stringArrayResource(R.array.app_settings_theme_values).map { it.toInt() }
var showThemeDialog by remember { mutableStateOf(false) }
val themeValueIdx = themeValues.indexOf(theme).takeIf { it != -1 }
Setting(
icon = Icons.Default.InvertColors,
name = stringResource(R.string.app_settings_theme_title),
summary = themeValueIdx?.let { themeNames[it] }
) {
showThemeDialog = true
}
if (showThemeDialog)
MultipleChoiceInputDialog(
title = stringResource(R.string.app_settings_theme_title),
namesAndValues = themeNames.zip(themeValues.map { it.toString() }),
initialValue = theme.toString(),
onValueSelected = {
onThemeSelected(it.toInt())
onExemptFromBatterySaving = {
startActivity(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:" + BuildConfig.APPLICATION_ID)
)
)
},
onDismiss = { showThemeDialog = false }
)
Setting(
name = stringResource(R.string.app_settings_reset_hints),
summary = stringResource(R.string.app_settings_reset_hints_summary),
onClick = onResetHints
)
}
@Composable
@Preview
fun AppSettings_UserInterface_Preview() {
Column {
AppSettings_UserInterface(
theme = Settings.PREFERRED_THEME_DEFAULT
onBatterySavingSettings = {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
},
onNavTasksScreen = {
startActivity(Intent(this, TasksActivity::class.java))
},
onShowNotificationSettings = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
startActivity(
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
}
)
},
onNavPermissionsScreen = {
startActivity(Intent(this, PermissionsActivity::class.java))
},
onNavUp = ::onSupportNavigateUp
)
}
}
@Composable
fun AppSettings_Integration(
taskProvider: TaskProvider.ProviderName? = null
) {
val context = LocalContext.current
SettingsHeader(divider = true) {
Text(stringResource(R.string.app_settings_integration))
}
val pm = context.packageManager
val appInfo = taskProvider?.packageName?.let { pkgName ->
pm.getApplicationInfo(pkgName, 0)
}
val appName = appInfo?.loadLabel(pm)?.toString()
Setting(
name = {
Text(stringResource(R.string.app_settings_tasks_provider))
},
icon = {
if (appInfo != null) {
val icon = appInfo.loadIcon(pm)
Image(icon.toBitmap().asImageBitmap(), appName)
}
},
summary = appName ?: stringResource(R.string.app_settings_tasks_provider_none)
) {
context.startActivity(Intent(context, TasksActivity::class.java))
}
}
@Composable
@Preview
fun AppSettings_Integration_Preview() {
Column {
AppSettings_Integration()
}
}
@HiltViewModel
class Model @Inject constructor(
val context: Application,
val settings: SettingsManager
) : ViewModel() {
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
private val powerManager = context.getSystemService<PowerManager>()!!
val batterySavingExempted = broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED), immediate = true)
.map { powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
fun getPrefBoolean(keyToObserve: String): LiveData<Boolean?> =
object : LiveData<Boolean?>(), SharedPreferences.OnSharedPreferenceChangeListener {
override fun onActive() {
preferences.registerOnSharedPreferenceChangeListener(this)
update()
}
override fun onInactive() {
preferences.unregisterOnSharedPreferenceChangeListener(this)
}
private fun update() {
if (preferences.contains(keyToObserve))
postValue(preferences.getBoolean(keyToObserve, false))
else
postValue(null)
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
if (key == keyToObserve)
update()
}
}
fun putPrefBoolean(key: String, value: Boolean) {
preferences
.edit()
.putBoolean(key, value)
.apply()
}
fun resetCertificates() {
CustomCertStore.getInstance(context).clearUserDecisions()
}
fun resetHints() {
settings.remove(BatteryOptimizationsPageModel.HINT_BATTERY_OPTIMIZATIONS)
settings.remove(BatteryOptimizationsPageModel.HINT_AUTOSTART_PERMISSION)
settings.remove(TasksModel.HINT_OPENTASKS_NOT_INSTALLED)
settings.remove(OpenSourcePage.Model.SETTING_NEXT_DONATION_POPUP)
}
}
}

View File

@ -0,0 +1,104 @@
package at.bitfire.davdroid.ui
import android.app.Application
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.PowerManager
import androidx.core.content.getSystemService
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.cert4android.CustomCertStore
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.repository.PreferenceRepository
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPageModel
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class AppSettingsModel @Inject constructor(
val context: Application,
private val preference: PreferenceRepository,
private val settings: SettingsManager
) : ViewModel() {
// debugging
private val powerManager = context.getSystemService<PowerManager>()!!
val batterySavingExempted = broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED), immediate = true)
.map { powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
fun verboseLogging() = preference.logToFileFlow()
fun updateVerboseLogging(verbose: Boolean) {
preference.logToFile(verbose)
}
// connection
fun proxyType() = settings.getIntFlow(Settings.PROXY_TYPE)
fun updateProxyType(type: Int) {
settings.putInt(Settings.PROXY_TYPE, type)
}
fun proxyHostName() = settings.getStringFlow(Settings.PROXY_HOST)
fun updateProxyHostName(host: String) {
settings.putString(Settings.PROXY_HOST, host)
}
fun proxyPort() = settings.getIntFlow(Settings.PROXY_PORT)
fun updateProxyPort(port: Int) {
settings.putInt(Settings.PROXY_PORT, port)
}
// security
fun distrustSystemCertificates() = settings.getBooleanFlow(Settings.DISTRUST_SYSTEM_CERTIFICATES)
fun updateDistrustSystemCertificates(distrust: Boolean) {
settings.putBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES, distrust)
}
fun resetCertificates() {
CustomCertStore.getInstance(context).clearUserDecisions()
}
// user interface
fun theme() = settings.getIntFlow(Settings.PREFERRED_THEME)
fun updateTheme(theme: Int) {
settings.putInt(Settings.PREFERRED_THEME, theme)
UiUtils.updateTheme(context)
}
fun resetHints() {
settings.remove(BatteryOptimizationsPageModel.HINT_BATTERY_OPTIMIZATIONS)
settings.remove(BatteryOptimizationsPageModel.HINT_AUTOSTART_PERMISSION)
settings.remove(TasksModel.HINT_OPENTASKS_NOT_INSTALLED)
settings.remove(OpenSourcePage.Model.SETTING_NEXT_DONATION_POPUP)
}
// tasks
val pm: PackageManager = context.packageManager
private val appInfoFlow = TaskUtils.currentProviderFlow(context, viewModelScope).map { tasksProvider ->
tasksProvider?.packageName?.let { pkgName ->
pm.getApplicationInfo(pkgName, 0)
}
}
val appName = appInfoFlow.map { it?.loadLabel(pm)?.toString() }
val icon = appInfoFlow.map { it?.loadIcon(pm) }
}

View File

@ -0,0 +1,485 @@
package at.bitfire.davdroid.ui
import android.annotation.SuppressLint
import android.graphics.drawable.Drawable
import android.os.Build
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Adb
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.InvertColors
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringArrayResource
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.core.graphics.drawable.toBitmap
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.ui.composable.EditTextInputDialog
import at.bitfire.davdroid.ui.composable.MultipleChoiceInputDialog
import at.bitfire.davdroid.ui.composable.Setting
import at.bitfire.davdroid.ui.composable.SettingsHeader
import at.bitfire.davdroid.ui.composable.SwitchSetting
import kotlinx.coroutines.launch
@Composable
fun AppSettingsScreen(
onNavDebugInfo: () -> Unit,
onExemptFromBatterySaving: () -> Unit,
onBatterySavingSettings: () -> Unit,
onNavPermissionsScreen: () -> Unit,
onShowNotificationSettings: () -> Unit,
onNavTasksScreen: () -> Unit,
onNavUp: () -> Unit,
model: AppSettingsModel = viewModel()
) {
AppTheme {
AppSettingsScreen(
onNavDebugInfo = onNavDebugInfo,
verboseLogging = model.verboseLogging().collectAsStateWithLifecycle(false).value,
onUpdateVerboseLogging = model::updateVerboseLogging,
batterySavingExempted = model.batterySavingExempted.collectAsStateWithLifecycle().value,
onExemptFromBatterySaving = onExemptFromBatterySaving,
onBatterySavingSettings = onBatterySavingSettings,
onNavUp = onNavUp,
// Connection
proxyType = model.proxyType().collectAsStateWithLifecycle(null).value ?: Settings.PROXY_TYPE_NONE,
onProxyTypeUpdated = model::updateProxyType,
proxyHostName = model.proxyHostName().collectAsStateWithLifecycle(null).value,
onProxyHostNameUpdated = model::updateProxyHostName,
proxyPort = model.proxyPort().collectAsStateWithLifecycle(null).value,
onProxyPortUpdated = model::updateProxyPort,
// Security
distrustSystemCerts = model.distrustSystemCertificates().collectAsStateWithLifecycle(null).value ?: false,
onDistrustSystemCertsUpdated = model::updateDistrustSystemCertificates,
onResetCertificates = model::resetCertificates,
onNavPermissionsScreen = onNavPermissionsScreen,
// User interface
onShowNotificationSettings = onShowNotificationSettings,
theme = model.theme().collectAsStateWithLifecycle(null).value ?: Settings.PREFERRED_THEME_DEFAULT,
onThemeSelected = model::updateTheme,
onResetHints = model::resetHints,
// Integration (Tasks)
tasksAppName = model.appName.collectAsStateWithLifecycle(null).value ?: stringResource(R.string.app_settings_tasks_provider_none),
tasksAppIcon = model.icon.collectAsStateWithLifecycle(null).value,
onNavTasksScreen = onNavTasksScreen
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("BatteryLife")
@Composable
fun AppSettingsScreen(
onNavDebugInfo: () -> Unit,
verboseLogging: Boolean,
onUpdateVerboseLogging: (Boolean) -> Unit,
batterySavingExempted: Boolean,
onExemptFromBatterySaving: () -> Unit,
onBatterySavingSettings: () -> Unit,
// AppSettings connection
proxyType: Int,
onProxyTypeUpdated: (Int) -> Unit,
proxyHostName: String?,
onProxyHostNameUpdated: (String) -> Unit,
proxyPort: Int?,
onProxyPortUpdated: (Int) -> Unit,
// AppSettings security
distrustSystemCerts: Boolean,
onDistrustSystemCertsUpdated: (Boolean) -> Unit,
onResetCertificates: () -> Unit,
onNavPermissionsScreen: () -> Unit,
// AppSettings UserInterface
theme: Int,
onThemeSelected: (Int) -> Unit,
onResetHints: () -> Unit,
// AppSettings Integration
tasksAppName: String,
tasksAppIcon: Drawable?,
onNavTasksScreen: () -> Unit,
onShowNotificationSettings: () -> Unit,
onNavUp: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onNavUp) {
Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up))
}
},
title = { Text(stringResource(R.string.app_settings)) },
actions = {
IconButton(onClick = {
val settingsUri = Constants.MANUAL_URL.buildUpon()
.appendPath(Constants.MANUAL_PATH_SETTINGS)
.fragment(Constants.MANUAL_FRAGMENT_APP_SETTINGS)
.build()
uriHandler.openUri(settingsUri.toString())
}) {
Icon(Icons.AutoMirrored.Filled.Help, stringResource(R.string.help))
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Column(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
Column(Modifier.padding(8.dp)) {
AppSettings_Debugging(
onNavDebugInfo = onNavDebugInfo,
verboseLogging = verboseLogging,
onUpdateVerboseLogging = onUpdateVerboseLogging,
batterySavingExempted = batterySavingExempted,
onExemptFromBatterySaving = onExemptFromBatterySaving,
onBatterySavingSettings = onBatterySavingSettings
)
AppSettings_Connection(
proxyType = proxyType,
onProxyTypeUpdated = onProxyTypeUpdated,
proxyHostName = proxyHostName,
onProxyHostNameUpdated = onProxyHostNameUpdated,
proxyPort = proxyPort,
onProxyPortUpdated = onProxyPortUpdated,
)
val resetCertificatesSuccessMessage = stringResource(R.string.app_settings_reset_certificates_success)
AppSettings_Security(
distrustSystemCerts = distrustSystemCerts,
onDistrustSystemCertsUpdated = onDistrustSystemCertsUpdated,
onResetCertificates = {
onResetCertificates()
coroutineScope.launch {
snackbarHostState.showSnackbar(resetCertificatesSuccessMessage)
}
},
onNavPermissionsScreen = onNavPermissionsScreen
)
val resetHintsSuccessMessage = stringResource(R.string.app_settings_reset_hints_success)
AppSettings_UserInterface(
theme = theme,
onThemeSelected = onThemeSelected,
onResetHints = {
onResetHints()
coroutineScope.launch {
snackbarHostState.showSnackbar(resetHintsSuccessMessage)
}
},
onShowNotificationSettings = onShowNotificationSettings
)
AppSettings_Integration(
appName = tasksAppName,
icon = tasksAppIcon,
onNavTasksScreen = onNavTasksScreen
)
}
}
}
}
@Composable
@Preview
fun AppSettingsScreen_Preview() {
AppTheme {
AppSettingsScreen(
onNavDebugInfo = {},
verboseLogging = true,
batterySavingExempted = true,
proxyType = 0,
proxyHostName = "true",
proxyPort = 0,
distrustSystemCerts = true,
theme = 0,
onUpdateVerboseLogging = {},
onProxyHostNameUpdated = {},
onExemptFromBatterySaving = {},
onBatterySavingSettings = {},
onShowNotificationSettings = {},
onNavUp = {},
onProxyTypeUpdated = {},
onProxyPortUpdated = {},
onDistrustSystemCertsUpdated = {},
onResetCertificates = {},
onNavPermissionsScreen = {},
onThemeSelected = {},
onResetHints = {},
tasksAppName = "No tasks app",
tasksAppIcon = null,
onNavTasksScreen = {}
)
}
}
@Composable
fun AppSettings_Debugging(
onNavDebugInfo: () -> Unit,
verboseLogging: Boolean,
onUpdateVerboseLogging: (Boolean) -> Unit,
batterySavingExempted: Boolean,
onExemptFromBatterySaving: () -> Unit,
onBatterySavingSettings: () -> Unit
) {
SettingsHeader {
Text(stringResource(R.string.app_settings_debug))
}
Setting(
icon = Icons.Default.BugReport,
name = stringResource(R.string.app_settings_show_debug_info),
summary = stringResource(R.string.app_settings_show_debug_info_details)
) {
onNavDebugInfo()
}
SwitchSetting(
icon = Icons.Default.Adb,
checked = verboseLogging,
name = stringResource(R.string.app_settings_logging),
summaryOn = stringResource(R.string.app_settings_logging_on),
summaryOff = stringResource(R.string.app_settings_logging_off)
) {
onUpdateVerboseLogging(it)
}
SwitchSetting(
checked = batterySavingExempted,
icon = Icons.Default.SyncProblem.takeUnless { batterySavingExempted },
name = stringResource(R.string.app_settings_battery_optimization),
summaryOn = stringResource(R.string.app_settings_battery_optimization_exempted),
summaryOff = stringResource(R.string.app_settings_battery_optimization_optimized)
) {
if (batterySavingExempted)
onBatterySavingSettings()
else
onExemptFromBatterySaving()
}
}
@Composable
fun AppSettings_Connection(
proxyType: Int,
onProxyTypeUpdated: (Int) -> Unit = {},
proxyHostName: String? = null,
onProxyHostNameUpdated: (String) -> Unit = {},
proxyPort: Int? = null,
onProxyPortUpdated: (Int) -> Unit = {}
) {
SettingsHeader(divider = true) {
Text(stringResource(R.string.app_settings_connection))
}
val proxyTypeNames = stringArrayResource(R.array.app_settings_proxy_types)
val proxyTypeValues = stringArrayResource(R.array.app_settings_proxy_type_values).map { it.toInt() }
var showProxyTypeInputDialog by remember { mutableStateOf(false) }
Setting(
name = stringResource(R.string.app_settings_proxy),
summary = proxyTypeNames[proxyTypeValues.indexOf(proxyType)]
) {
showProxyTypeInputDialog = true
}
if (showProxyTypeInputDialog)
MultipleChoiceInputDialog(
title = stringResource(R.string.app_settings_proxy),
namesAndValues = proxyTypeNames.zip(proxyTypeValues.map { it.toString() }),
initialValue = proxyType.toString(),
onValueSelected = { newValue ->
onProxyTypeUpdated(newValue.toInt())
},
onDismiss = { showProxyTypeInputDialog = false }
)
if (proxyType !in listOf(Settings.PROXY_TYPE_SYSTEM, Settings.PROXY_TYPE_NONE)) {
var showProxyHostNameInputDialog by remember { mutableStateOf(false) }
Setting(
name = stringResource(R.string.app_settings_proxy_host),
summary = proxyHostName
) {
showProxyHostNameInputDialog = true
}
if (showProxyHostNameInputDialog)
EditTextInputDialog(
title = stringResource(R.string.app_settings_proxy_host),
initialValue = proxyHostName,
keyboardType = KeyboardType.Uri,
onValueEntered = onProxyHostNameUpdated,
onDismiss = { showProxyHostNameInputDialog = false }
)
var showProxyPortInputDialog by remember { mutableStateOf(false) }
Setting(
name = stringResource(R.string.app_settings_proxy_port),
summary = proxyPort?.toString()
) {
showProxyPortInputDialog = true
}
if (showProxyPortInputDialog)
EditTextInputDialog(
title = stringResource(R.string.app_settings_proxy_port),
initialValue = proxyPort?.toString(),
keyboardType = KeyboardType.Number,
onValueEntered = {
try {
val newPort = it.toInt()
if (newPort in 1..65535)
onProxyPortUpdated(newPort)
} catch (_: NumberFormatException) {
// user entered invalid port number
}
},
onDismiss = { showProxyPortInputDialog = false }
)
}
}
@Composable
fun AppSettings_Security(
distrustSystemCerts: Boolean,
onDistrustSystemCertsUpdated: (Boolean) -> Unit,
onResetCertificates: () -> Unit,
onNavPermissionsScreen: () -> Unit
) {
SettingsHeader(divider = true) {
Text(stringResource(R.string.app_settings_security))
}
SwitchSetting(
checked = distrustSystemCerts,
name = stringResource(R.string.app_settings_distrust_system_certs),
summaryOn = stringResource(R.string.app_settings_distrust_system_certs_on),
summaryOff = stringResource(R.string.app_settings_distrust_system_certs_off)
) {
onDistrustSystemCertsUpdated(it)
}
Setting(
name = stringResource(R.string.app_settings_reset_certificates),
summary = stringResource(R.string.app_settings_reset_certificates_summary),
onClick = onResetCertificates
)
Setting(
name = stringResource(R.string.app_settings_security_app_permissions),
summary = stringResource(R.string.app_settings_security_app_permissions_summary),
onClick = onNavPermissionsScreen
)
}
@Composable
fun AppSettings_UserInterface(
theme: Int,
onThemeSelected: (Int) -> Unit = {},
onResetHints: () -> Unit = {},
onShowNotificationSettings: () -> Unit = {}
) {
SettingsHeader(divider = true) {
Text(stringResource(R.string.app_settings_user_interface))
}
if (Build.VERSION.SDK_INT >= 26)
Setting(
icon = Icons.Default.Notifications,
name = stringResource(R.string.app_settings_notification_settings),
summary = stringResource(R.string.app_settings_notification_settings_summary),
onClick = onShowNotificationSettings
)
val themeNames = stringArrayResource(R.array.app_settings_theme_names)
val themeValues = stringArrayResource(R.array.app_settings_theme_values).map { it.toInt() }
var showThemeDialog by remember { mutableStateOf(false) }
val themeValueIdx = themeValues.indexOf(theme).takeIf { it != -1 }
Setting(
icon = Icons.Default.InvertColors,
name = stringResource(R.string.app_settings_theme_title),
summary = themeValueIdx?.let { themeNames[it] }
) {
showThemeDialog = true
}
if (showThemeDialog)
MultipleChoiceInputDialog(
title = stringResource(R.string.app_settings_theme_title),
namesAndValues = themeNames.zip(themeValues.map { it.toString() }),
initialValue = theme.toString(),
onValueSelected = {
onThemeSelected(it.toInt())
},
onDismiss = { showThemeDialog = false }
)
Setting(
name = stringResource(R.string.app_settings_reset_hints),
summary = stringResource(R.string.app_settings_reset_hints_summary),
onClick = onResetHints
)
}
@Composable
fun AppSettings_Integration(
appName: String,
icon: Drawable? = null,
onNavTasksScreen: () -> Unit = {}
) {
SettingsHeader(divider = true) {
Text(stringResource(R.string.app_settings_integration))
}
Setting(
name = {
Text(stringResource(R.string.app_settings_tasks_provider))
},
icon = {
icon?.let {
Image(icon.toBitmap().asImageBitmap(), appName)
}
},
summary = appName,
onClick = onNavTasksScreen
)
}

View File

@ -6,20 +6,23 @@ package at.bitfire.davdroid.ui.composable
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import at.bitfire.davdroid.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Deprecated("Directly use TopAppBar instead.", replaceWith = ReplaceWith("TopAppBar"))
fun BasicTopAppBar(
@StringRes titleStringRes: Int,
actions: @Composable () -> Unit = {},
onNavigateUp: () -> Unit
) {
TopAppBar(
@ -32,12 +35,4 @@ fun BasicTopAppBar(
}
}
)
}
@Composable
fun AppCompatActivity.BasicTopAppBar(@StringRes titleStringRes: Int) {
BasicTopAppBar(
titleStringRes = titleStringRes,
onNavigateUp = ::onSupportNavigateUp
)
}

View File

@ -12,13 +12,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.AlertDialog
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextField
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -59,7 +59,7 @@ fun EditTextInputDialog(
title = {
Text(
title,
style = MaterialTheme.typography.body1
style = MaterialTheme.typography.bodyLarge
)
},
text = {
@ -148,7 +148,7 @@ fun MultipleChoiceInputDialog(
Column {
Text(
title,
style = MaterialTheme.typography.body1,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
@ -173,7 +173,7 @@ fun MultipleChoiceInputDialog(
)
Text(
name,
style = MaterialTheme.typography.body1,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.weight(1f)
.clickable {

View File

@ -11,15 +11,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProvideTextStyle
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
@ -41,8 +41,8 @@ fun SettingsHeader(divider: Boolean = false, content: @Composable () -> Unit) {
.fillMaxWidth()
) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.body1.copy(
color = MaterialTheme.colors.secondary
LocalTextStyle provides MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.secondary
)
) {
content()
@ -94,12 +94,12 @@ fun Setting(
.padding(start = 8.dp)
.weight(1f)
) {
ProvideTextStyle(MaterialTheme.typography.body1) {
ProvideTextStyle(MaterialTheme.typography.bodyLarge) {
name()
}
if (summary != null)
Text(summary, style = MaterialTheme.typography.body2)
Text(summary, style = MaterialTheme.typography.bodyMedium)
}
end()
@ -120,7 +120,7 @@ fun Setting(
Icon(icon, contentDescription = name)
},
name = {
Text(name, style = MaterialTheme.typography.body1)
Text(name, style = MaterialTheme.typography.bodyLarge)
},
summary = summary,
enabled = enabled,