Rewrite AccountSettingsActivity to M3 (#795)

* Extract composables

* Drop sub component previews and minor adjustment

* Fix preview

* Extract view model

* Switch to M3

* Extract URI to Constant

* Minor changes

* We alway have AccountSettings

* Replace LiveData by State

* Use Snapshot.withMutableSnapshot in reload

* Don't show empty OAuth setting

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2024-05-16 17:34:11 +02:00 committed by GitHub
parent 5cc29fc58a
commit e2bfa8c56b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 943 additions and 795 deletions

View File

@ -26,6 +26,7 @@ object Constants {
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_FRAGMENT_ACCOUNT_SETTINGS = "account-settings"
const val MANUAL_PATH_WEBDAV_MOUNTS = "webdav_mounts.html"
val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions".toUri()

View File

@ -5,152 +5,39 @@
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Bundle
import android.provider.CalendarContract
import android.security.KeyChain
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
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.SnackbarResult
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.AccountCircle
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Password
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.outlined.Task
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.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.TaskStackBuilder
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import at.bitfire.davdroid.syncadapter.Syncer
import at.bitfire.davdroid.ui.M2Theme
import at.bitfire.davdroid.ui.composable.ActionCard
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.util.PermissionUtils
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.openid.appauth.AuthState
import javax.inject.Inject
@AndroidEntryPoint
class AccountSettingsActivity: AppCompatActivity() {
companion object {
const val EXTRA_ACCOUNT = "account"
const val ACCOUNT_SETTINGS_HELP_URL = "https://manual.davx5.com/settings.html#account-settings"
}
private val account by lazy {
intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set")
}
@Inject lateinit var modelFactory: Model.Factory
val model by viewModels<Model> {
object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>) =
modelFactory.create(account) as T
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
title = account.name
setContent {
M2Theme {
val uriHandler = LocalUriHandler.current
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onSupportNavigateUp() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = stringResource(R.string.navigate_up))
}
},
title = { Text(account.name) },
actions = {
IconButton(onClick = {
uriHandler.openUri(ACCOUNT_SETTINGS_HELP_URL)
}) {
Icon(Icons.AutoMirrored.Filled.Help, stringResource(R.string.help))
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(Modifier
.padding(padding)
.verticalScroll(rememberScrollState())) {
AccountSettings_FromModel(
snackbarHostState = snackbarHostState,
model = model
)
}
}
}
AccountSettingsScreen(
account = account,
onNavWifiPermissionsScreen = {
val intent = Intent(this, WifiPermissionsActivity::class.java)
intent.putExtra(WifiPermissionsActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
},
onNavUp = ::onSupportNavigateUp,
)
}
}
@ -160,675 +47,4 @@ class AccountSettingsActivity: AppCompatActivity() {
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
}
@Composable
fun AccountSettings_FromModel(
snackbarHostState: SnackbarHostState,
model: Model
) {
Column(Modifier.padding(8.dp)) {
SyncSettings(
contactsSyncInterval = model.syncIntervalContacts.observeAsState().value,
onUpdateContactsSyncInterval = { model.updateSyncInterval(getString(R.string.address_books_authority), it) },
calendarSyncInterval = model.syncIntervalCalendars.observeAsState().value,
onUpdateCalendarSyncInterval = { model.updateSyncInterval(CalendarContract.AUTHORITY, it) },
taskSyncInterval = model.syncIntervalTasks.observeAsState().value,
onUpdateTaskSyncInterval = { interval ->
model.tasksProvider?.let { model.updateSyncInterval(it.authority, interval) }
},
syncOnlyOnWifi = model.syncWifiOnly.observeAsState(false).value,
onUpdateSyncOnlyOnWifi = { model.updateSyncWifiOnly(it) },
onlyOnSsids = model.syncWifiOnlySSIDs.observeAsState().value,
onUpdateOnlyOnSsids = { model.updateSyncWifiOnlySSIDs(it) },
ignoreVpns = model.ignoreVpns.observeAsState(false).value,
onUpdateIgnoreVpns = { model.updateIgnoreVpns(it) }
)
val credentials by model.credentials.observeAsState()
credentials?.let {
AuthenticationSettings(
snackbarHostState = snackbarHostState,
credentials = it,
onUpdateCredentials = { model.updateCredentials(it) }
)
}
CalDavSettings(
timeRangePastDays = model.timeRangePastDays.observeAsState().value,
onUpdateTimeRangePastDays = { model.updateTimeRangePastDays(it) },
defaultAlarmMinBefore = model.defaultAlarmMinBefore.observeAsState().value,
onUpdateDefaultAlarmMinBefore = { model.updateDefaultAlarm(it) },
manageCalendarColors = model.manageCalendarColors.observeAsState().value ?: false,
onUpdateManageCalendarColors = { model.updateManageCalendarColors(it) },
eventColors = model.eventColors.observeAsState().value ?: false,
onUpdateEventColors = { model.updateEventColors(it) }
)
CardDavSettings(
contactGroupMethod = model.contactGroupMethod.observeAsState(GroupMethod.GROUP_VCARDS).value,
onUpdateContactGroupMethod = { model.updateContactGroupMethod(it) }
)
}
}
@Composable
fun SyncSettings(
contactsSyncInterval: Long?,
onUpdateContactsSyncInterval: ((Long) -> Unit) = {},
calendarSyncInterval: Long?,
onUpdateCalendarSyncInterval: ((Long) -> Unit) = {},
taskSyncInterval: Long?,
onUpdateTaskSyncInterval: ((Long) -> Unit) = {},
syncOnlyOnWifi: Boolean,
onUpdateSyncOnlyOnWifi: (Boolean) -> Unit = {},
onlyOnSsids: List<String>?,
onUpdateOnlyOnSsids: (List<String>) -> Unit = {},
ignoreVpns: Boolean,
onUpdateIgnoreVpns: (Boolean) -> Unit = {}
) {
val context = LocalContext.current
Column {
SettingsHeader(false) {
Text(stringResource(R.string.settings_sync))
}
if (contactsSyncInterval != null)
SyncIntervalSetting(
icon = Icons.Default.Contacts,
name = R.string.settings_sync_interval_contacts,
syncInterval = contactsSyncInterval,
onUpdateSyncInterval = onUpdateContactsSyncInterval
)
if (calendarSyncInterval != null)
SyncIntervalSetting(
icon = Icons.Default.Event,
name = R.string.settings_sync_interval_calendars,
syncInterval = calendarSyncInterval,
onUpdateSyncInterval = onUpdateCalendarSyncInterval
)
if (taskSyncInterval != null)
SyncIntervalSetting(
icon = Icons.Outlined.Task,
name = R.string.settings_sync_interval_tasks,
syncInterval = taskSyncInterval,
onUpdateSyncInterval = onUpdateTaskSyncInterval
)
SwitchSetting(
icon = Icons.Default.Wifi,
name = stringResource(R.string.settings_sync_wifi_only),
summaryOn = stringResource(R.string.settings_sync_wifi_only_on),
summaryOff = stringResource(R.string.settings_sync_wifi_only_off),
checked = syncOnlyOnWifi,
onCheckedChange = onUpdateSyncOnlyOnWifi
)
var showWifiOnlySsidsDialog by remember { mutableStateOf(false) }
Setting(
icon = null,
name = stringResource(R.string.settings_sync_wifi_only_ssids),
enabled = syncOnlyOnWifi,
summary =
if (onlyOnSsids != null)
stringResource(R.string.settings_sync_wifi_only_ssids_on, onlyOnSsids.joinToString(", "))
else
stringResource(R.string.settings_sync_wifi_only_ssids_off),
onClick = {
showWifiOnlySsidsDialog = true
}
)
if (showWifiOnlySsidsDialog)
EditTextInputDialog(
title = stringResource(R.string.settings_sync_wifi_only_ssids_message),
initialValue = onlyOnSsids?.joinToString(", ") ?: "",
onValueEntered = { newValue ->
val newSsids = newValue.split(',')
.map { it.trim() }
.distinct()
onUpdateOnlyOnSsids(newSsids)
showWifiOnlySsidsDialog = false
},
onDismiss = { showWifiOnlySsidsDialog = false }
)
val canAccessWifiSsid by PermissionUtils.rememberCanAccessWifiSsid()
if (LocalInspectionMode.current || (onlyOnSsids != null && !canAccessWifiSsid))
ActionCard(
icon = Icons.Default.SyncProblem,
actionText = stringResource(R.string.settings_sync_wifi_only_ssids_permissions_action),
onAction = {
val intent = Intent(context, WifiPermissionsActivity::class.java)
intent.putExtra(WifiPermissionsActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
}
) {
Text(stringResource(R.string.settings_sync_wifi_only_ssids_permissions_required))
}
SwitchSetting(
icon = null,
name = stringResource(R.string.settings_ignore_vpns),
summaryOn = stringResource(R.string.settings_ignore_vpns_on),
summaryOff = stringResource(R.string.settings_ignore_vpns_off),
checked = ignoreVpns,
onCheckedChange = onUpdateIgnoreVpns
)
}
}
@Composable
fun SyncIntervalSetting(
icon: ImageVector,
@StringRes name: Int,
syncInterval: Long,
onUpdateSyncInterval: (Long) -> Unit
) {
var showSyncIntervalDialog by remember { mutableStateOf(false) }
Setting(
icon = icon,
name = stringResource(name),
summary =
if (syncInterval == AccountSettings.SYNC_INTERVAL_MANUALLY)
stringResource(R.string.settings_sync_summary_manually)
else
stringResource(R.string.settings_sync_summary_periodically, syncInterval / 60),
onClick = {
showSyncIntervalDialog = true
}
)
if (showSyncIntervalDialog) {
val syncIntervalNames = stringArrayResource(R.array.settings_sync_interval_names)
val syncIntervalSeconds = stringArrayResource(R.array.settings_sync_interval_seconds)
MultipleChoiceInputDialog(
title = stringResource(name),
namesAndValues = syncIntervalNames.zip(syncIntervalSeconds),
initialValue = syncInterval.toString(),
onValueSelected = { newValue ->
try {
val seconds = newValue.toLong()
onUpdateSyncInterval(seconds)
} catch (_: NumberFormatException) {
}
showSyncIntervalDialog = false
},
onDismiss = {
showSyncIntervalDialog = false
}
)
}
}
@Composable
@Preview
fun SyncSettings_Preview() {
SyncSettings(
contactsSyncInterval = 60*60,
calendarSyncInterval = 4*60*60,
taskSyncInterval = 2*60*60,
syncOnlyOnWifi = false,
onlyOnSsids = listOf("SSID1", "SSID2"),
ignoreVpns = true
)
}
@Composable
fun AuthenticationSettings(
credentials: Credentials,
snackbarHostState: SnackbarHostState = SnackbarHostState(),
onUpdateCredentials: (Credentials) -> Unit = {}
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(Modifier.padding(8.dp)) {
SettingsHeader(false) {
Text(stringResource(R.string.settings_authentication))
}
if (credentials.authState != null) { // OAuth
Setting(
icon = Icons.Default.Password,
name = stringResource(R.string.settings_oauth),
summary = stringResource(R.string.settings_oauth_summary),
onClick = {
// GoogleLoginFragment replacement
}
)
} else { // username/password
if (credentials.username != null) {
var showUsernameDialog by remember { mutableStateOf(false) }
Setting(
icon = Icons.Default.AccountCircle,
name = stringResource(R.string.settings_username),
summary = credentials.username,
onClick = {
showUsernameDialog = true
}
)
if (showUsernameDialog)
EditTextInputDialog(
title = stringResource(R.string.settings_username),
initialValue = credentials.username,
onValueEntered = { newValue ->
onUpdateCredentials(credentials.copy(username = newValue))
},
onDismiss = { showUsernameDialog = false }
)
}
if (credentials.password != null) {
var showPasswordDialog by remember { mutableStateOf(false) }
Setting(
icon = Icons.Default.Password,
name = stringResource(R.string.settings_password),
summary = stringResource(R.string.settings_password_summary),
onClick = {
showPasswordDialog = true
}
)
if (showPasswordDialog)
EditTextInputDialog(
title = stringResource(R.string.settings_password),
inputLabel = stringResource(R.string.settings_new_password),
initialValue = null, // Do not show the existing password
passwordField = true,
onValueEntered = { newValue ->
onUpdateCredentials(credentials.copy(password = newValue))
},
onDismiss = { showPasswordDialog = false }
)
}
// client certificate
Setting(
icon = null,
name = stringResource(R.string.settings_certificate_alias),
summary = credentials.certificateAlias ?: stringResource(R.string.settings_certificate_alias_empty),
onClick = {
val activity = context as Activity
KeyChain.choosePrivateKeyAlias(activity, { newAlias ->
if (newAlias != null)
onUpdateCredentials(credentials.copy(certificateAlias = newAlias))
else
scope.launch {
if (snackbarHostState.showSnackbar(
context.getString(R.string.settings_certificate_alias_empty),
actionLabel = context.getString(R.string.settings_certificate_install).uppercase()
) == SnackbarResult.ActionPerformed) {
val intent = KeyChain.createInstallIntent()
if (intent.resolveActivity(context.packageManager) != null)
context.startActivity(intent)
}
}
}, null, null, null, -1, credentials.certificateAlias)
}
)
}
}
}
@Composable
@Preview
fun AuthenticationSettings_Preview_ClientCertificate() {
AuthenticationSettings(
credentials = Credentials(certificateAlias = "alias")
)
}
@Composable
@Preview
fun AuthenticationSettings_Preview_OAuth() {
AuthenticationSettings(
credentials = Credentials(authState = AuthState())
)
}
@Composable
@Preview
fun AuthenticationSettings_Preview_UsernamePassword() {
AuthenticationSettings(
credentials = Credentials(username = "user", password = "password")
)
}
@Composable
@Preview
fun AuthenticationSettings_Preview_UsernamePassword_ClientCertificate() {
AuthenticationSettings(
credentials = Credentials(username = "user", password = "password", certificateAlias = "alias")
)
}
@Composable
fun CalDavSettings(
timeRangePastDays: Int?,
onUpdateTimeRangePastDays: (Int?) -> Unit = {},
defaultAlarmMinBefore: Int?,
onUpdateDefaultAlarmMinBefore: (Int?) -> Unit = {},
manageCalendarColors: Boolean,
onUpdateManageCalendarColors: (Boolean) -> Unit = {},
eventColors: Boolean,
onUpdateEventColors: (Boolean) -> Unit = {}
) {
Column {
SettingsHeader {
Text(stringResource(R.string.settings_caldav))
}
var showTimeRangePastDialog by remember { mutableStateOf(false) }
Setting(
icon = Icons.Default.History,
name = stringResource(R.string.settings_sync_time_range_past),
summary =
if (timeRangePastDays != null)
pluralStringResource(R.plurals.settings_sync_time_range_past_days, timeRangePastDays, timeRangePastDays)
else
stringResource(R.string.settings_sync_time_range_past_none),
onClick = {
showTimeRangePastDialog = true
}
)
if (showTimeRangePastDialog)
EditTextInputDialog(
title = stringResource(R.string.settings_sync_time_range_past_message),
initialValue = timeRangePastDays?.toString() ?: "",
onValueEntered = { newValue ->
val days = try {
newValue.toInt()
} catch (_: NumberFormatException) {
null
}
onUpdateTimeRangePastDays(days)
showTimeRangePastDialog = false
},
onDismiss = { showTimeRangePastDialog = false }
)
var showDefaultAlarmDialog by remember { mutableStateOf(false) }
Setting(
icon = null,
name = stringResource(R.string.settings_default_alarm),
summary =
if (defaultAlarmMinBefore != null)
pluralStringResource(R.plurals.settings_default_alarm_on, defaultAlarmMinBefore, defaultAlarmMinBefore)
else
stringResource(R.string.settings_default_alarm_off),
onClick = {
showDefaultAlarmDialog = true
}
)
if (showDefaultAlarmDialog)
EditTextInputDialog(
title = stringResource(R.string.settings_default_alarm_message),
initialValue = defaultAlarmMinBefore?.toString() ?: "",
onValueEntered = { newValue ->
val minBefore = try {
newValue.toInt()
} catch (_: NumberFormatException) {
null
}
onUpdateDefaultAlarmMinBefore(minBefore)
showDefaultAlarmDialog = false
},
onDismiss = { showDefaultAlarmDialog = false }
)
SwitchSetting(
icon = null,
name = stringResource(R.string.settings_manage_calendar_colors),
summaryOn = stringResource(R.string.settings_manage_calendar_colors_on),
summaryOff = stringResource(R.string.settings_manage_calendar_colors_off),
checked = manageCalendarColors,
onCheckedChange = onUpdateManageCalendarColors
)
SwitchSetting(
icon = null,
name = stringResource(R.string.settings_event_colors),
summaryOn = stringResource(R.string.settings_event_colors_on),
summaryOff = stringResource(R.string.settings_event_colors_off),
checked = eventColors,
onCheckedChange = onUpdateEventColors
)
}
}
@Composable
@Preview
fun CalDavSettings_Preview() {
CalDavSettings(
timeRangePastDays = 30,
defaultAlarmMinBefore = 10,
manageCalendarColors = true,
eventColors = true
)
}
@Composable
fun CardDavSettings(
contactGroupMethod: GroupMethod,
onUpdateContactGroupMethod: (GroupMethod) -> Unit = {}
) {
Column {
SettingsHeader {
Text(stringResource(R.string.settings_carddav))
}
val groupMethodNames = stringArrayResource(R.array.settings_contact_group_method_entries)
val groupMethodValues = stringArrayResource(R.array.settings_contact_group_method_values)
var showGroupMethodDialog by remember { mutableStateOf(false) }
Setting(
icon = Icons.Default.Contacts,
name = stringResource(R.string.settings_contact_group_method),
summary = groupMethodNames[groupMethodValues.indexOf(contactGroupMethod.name)],
onClick = {
showGroupMethodDialog = true
}
)
if (showGroupMethodDialog)
MultipleChoiceInputDialog(
title = stringResource(R.string.settings_contact_group_method),
namesAndValues = groupMethodNames.zip(groupMethodValues),
initialValue = contactGroupMethod.name,
onValueSelected = { newValue ->
onUpdateContactGroupMethod(GroupMethod.valueOf(newValue))
showGroupMethodDialog = false
},
onDismiss = { showGroupMethodDialog = false }
)
}
}
@Composable
@Preview
fun CardDavSettings_Preview() {
CardDavSettings(
contactGroupMethod = GroupMethod.GROUP_VCARDS
)
}
class Model @AssistedInject constructor(
val context: Application,
val settings: SettingsManager,
@Assisted val account: Account
): ViewModel(), SettingsManager.OnChangeListener {
@AssistedFactory
interface Factory {
fun create(account: Account): Model
}
private var accountSettings: AccountSettings? = null
// settings
val syncIntervalContacts = MutableLiveData<Long>()
val syncIntervalCalendars = MutableLiveData<Long>()
val tasksProvider = TaskUtils.currentProvider(context)
val syncIntervalTasks = MutableLiveData<Long>()
val syncWifiOnly = MutableLiveData<Boolean>()
val syncWifiOnlySSIDs = MutableLiveData<List<String>>()
val ignoreVpns = MutableLiveData<Boolean>()
val credentials = MutableLiveData<Credentials>()
val timeRangePastDays = MutableLiveData<Int>()
val defaultAlarmMinBefore = MutableLiveData<Int>()
val manageCalendarColors = MutableLiveData<Boolean>()
val eventColors = MutableLiveData<Boolean>()
val contactGroupMethod = MutableLiveData<GroupMethod>()
init {
accountSettings = AccountSettings(context, account)
settings.addOnChangeListener(this)
reload()
}
override fun onCleared() {
super.onCleared()
settings.removeOnChangeListener(this)
}
override fun onSettingsChanged() {
Logger.log.info("Settings changed")
reload()
}
fun reload() {
val accountSettings = accountSettings ?: return
syncIntervalContacts.postValue(
accountSettings.getSyncInterval(context.getString(R.string.address_books_authority))
)
syncIntervalCalendars.postValue(accountSettings.getSyncInterval(CalendarContract.AUTHORITY))
syncIntervalTasks.postValue(tasksProvider?.let { accountSettings.getSyncInterval(it.authority) })
syncWifiOnly.postValue(accountSettings.getSyncWifiOnly())
syncWifiOnlySSIDs.postValue(accountSettings.getSyncWifiOnlySSIDs())
ignoreVpns.postValue(accountSettings.getIgnoreVpns())
credentials.postValue(accountSettings.credentials())
timeRangePastDays.postValue(accountSettings.getTimeRangePastDays())
defaultAlarmMinBefore.postValue(accountSettings.getDefaultAlarm())
manageCalendarColors.postValue(accountSettings.getManageCalendarColors())
eventColors.postValue(accountSettings.getEventColors())
contactGroupMethod.postValue(accountSettings.getGroupMethod())
}
fun updateSyncInterval(authority: String, syncInterval: Long) {
CoroutineScope(Dispatchers.Default).launch {
accountSettings?.setSyncInterval(authority, syncInterval)
reload()
}
}
fun updateSyncWifiOnly(wifiOnly: Boolean) {
accountSettings?.setSyncWiFiOnly(wifiOnly)
reload()
}
fun updateSyncWifiOnlySSIDs(ssids: List<String>?) {
accountSettings?.setSyncWifiOnlySSIDs(ssids)
reload()
}
fun updateIgnoreVpns(ignoreVpns: Boolean) {
accountSettings?.setIgnoreVpns(ignoreVpns)
reload()
}
fun updateCredentials(credentials: Credentials) {
accountSettings?.credentials(credentials)
reload()
}
fun updateTimeRangePastDays(days: Int?) {
accountSettings?.setTimeRangePastDays(days)
reload()
/* If the new setting is a certain number of days, no full resync is required,
because every sync will cause a REPORT calendar-query with the given number of days.
However, if the new setting is "all events", collection sync may/should be used, so
the last sync-token has to be reset, which is done by setting fullResync=true.
*/
resyncCalendars(fullResync = days == null, tasks = false)
}
fun updateDefaultAlarm(minBefore: Int?) {
accountSettings?.setDefaultAlarm(minBefore)
reload()
resyncCalendars(fullResync = true, tasks = false)
}
fun updateManageCalendarColors(manage: Boolean) {
accountSettings?.setManageCalendarColors(manage)
reload()
resyncCalendars(fullResync = false, tasks = true)
}
fun updateEventColors(manageColors: Boolean) {
accountSettings?.setEventColors(manageColors)
reload()
resyncCalendars(fullResync = true, tasks = false)
}
fun updateContactGroupMethod(groupMethod: GroupMethod) {
accountSettings?.setGroupMethod(groupMethod)
reload()
resync(
authority = context.getString(R.string.address_books_authority),
fullResync = true
)
}
/**
* Initiates calendar re-synchronization.
*
* @param fullResync whether sync shall download all events again
* (_true_: sets [Syncer.SYNC_EXTRAS_FULL_RESYNC],
* _false_: sets [Syncer.SYNC_EXTRAS_RESYNC])
* @param tasks whether tasks shall be synchronized, too (false: only events, true: events and tasks)
*/
private fun resyncCalendars(fullResync: Boolean, tasks: Boolean) {
resync(CalendarContract.AUTHORITY, fullResync)
if (tasks)
resync(TaskProvider.ProviderName.OpenTasks.authority, fullResync)
}
/**
* Initiates re-synchronization for given authority.
*
* @param authority authority to re-sync
* @param fullResync whether sync shall download all events again
* (_true_: sets [Syncer.SYNC_EXTRAS_FULL_RESYNC],
* _false_: sets [Syncer.SYNC_EXTRAS_RESYNC])
*/
private fun resync(authority: String, fullResync: Boolean) {
val resync =
if (fullResync)
OneTimeSyncWorker.FULL_RESYNC
else
OneTimeSyncWorker.RESYNC
OneTimeSyncWorker.enqueue(context, account, authority, resync = resync)
}
}
}

View File

@ -0,0 +1,219 @@
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.app.Application
import android.provider.CalendarContract
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.lifecycle.ViewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import at.bitfire.davdroid.syncadapter.Syncer
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@HiltViewModel(assistedFactory = AccountSettingsModel.Factory::class)
class AccountSettingsModel @AssistedInject constructor(
val context: Application,
val settings: SettingsManager,
@Assisted val account: Account
): ViewModel(), SettingsManager.OnChangeListener {
@AssistedFactory
interface Factory {
fun create(account: Account): AccountSettingsModel
}
private val accountSettings = AccountSettings(context, account)
// settings
var syncIntervalContacts by mutableStateOf<Long?>(null)
var syncIntervalCalendars by mutableStateOf<Long?>(null)
private val tasksProvider = TaskUtils.currentProvider(context)
var syncIntervalTasks by mutableStateOf<Long?>(null)
var syncWifiOnly by mutableStateOf(false)
var syncWifiOnlySSIDs by mutableStateOf<List<String>?>(null)
var ignoreVpns by mutableStateOf(false)
var credentials by mutableStateOf(Credentials())
var timeRangePastDays by mutableStateOf<Int?>(null)
var defaultAlarmMinBefore by mutableStateOf<Int?>(null)
var manageCalendarColors by mutableStateOf(false)
var eventColors by mutableStateOf(false)
var contactGroupMethod by mutableStateOf(GroupMethod.GROUP_VCARDS)
init {
settings.addOnChangeListener(this)
reload()
}
override fun onCleared() {
super.onCleared()
settings.removeOnChangeListener(this)
}
override fun onSettingsChanged() {
reload()
}
private fun reload() {
Logger.log.info("Reloading settings")
Snapshot.withMutableSnapshot {
syncIntervalContacts = accountSettings.getSyncInterval(context.getString(R.string.address_books_authority))
syncIntervalCalendars = accountSettings.getSyncInterval(CalendarContract.AUTHORITY)
syncIntervalTasks = tasksProvider?.let { accountSettings.getSyncInterval(it.authority) }
syncWifiOnly = accountSettings.getSyncWifiOnly()
syncWifiOnlySSIDs = accountSettings.getSyncWifiOnlySSIDs()
ignoreVpns = accountSettings.getIgnoreVpns()
credentials = accountSettings.credentials()
timeRangePastDays = accountSettings.getTimeRangePastDays()
defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
manageCalendarColors = accountSettings.getManageCalendarColors()
eventColors = accountSettings.getEventColors()
contactGroupMethod = accountSettings.getGroupMethod()
}
}
fun updateContactsSyncInterval(syncInterval: Long) {
CoroutineScope(Dispatchers.Default).launch {
accountSettings.setSyncInterval(context.getString(R.string.address_books_authority), syncInterval)
reload()
}
}
fun updateCalendarSyncInterval(syncInterval: Long) {
CoroutineScope(Dispatchers.Default).launch {
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, syncInterval)
reload()
}
}
fun updateTasksSyncInterval(syncInterval: Long) {
CoroutineScope(Dispatchers.Default).launch {
tasksProvider?.authority?.let { tasksAuthority ->
accountSettings.setSyncInterval(tasksAuthority, syncInterval)
reload()
}
}
}
fun updateSyncWifiOnly(wifiOnly: Boolean) {
accountSettings.setSyncWiFiOnly(wifiOnly)
reload()
}
fun updateSyncWifiOnlySSIDs(ssids: List<String>?) {
accountSettings.setSyncWifiOnlySSIDs(ssids)
reload()
}
fun updateIgnoreVpns(ignoreVpns: Boolean) {
accountSettings.setIgnoreVpns(ignoreVpns)
reload()
}
fun updateCredentials(credentials: Credentials) {
accountSettings.credentials(credentials)
reload()
}
fun updateTimeRangePastDays(days: Int?) {
accountSettings.setTimeRangePastDays(days)
reload()
/* If the new setting is a certain number of days, no full resync is required,
because every sync will cause a REPORT calendar-query with the given number of days.
However, if the new setting is "all events", collection sync may/should be used, so
the last sync-token has to be reset, which is done by setting fullResync=true.
*/
resyncCalendars(fullResync = days == null, tasks = false)
}
fun updateDefaultAlarm(minBefore: Int?) {
accountSettings.setDefaultAlarm(minBefore)
reload()
resyncCalendars(fullResync = true, tasks = false)
}
fun updateManageCalendarColors(manage: Boolean) {
accountSettings.setManageCalendarColors(manage)
reload()
resyncCalendars(fullResync = false, tasks = true)
}
fun updateEventColors(manageColors: Boolean) {
accountSettings.setEventColors(manageColors)
reload()
resyncCalendars(fullResync = true, tasks = false)
}
fun updateContactGroupMethod(groupMethod: GroupMethod) {
accountSettings.setGroupMethod(groupMethod)
reload()
resync(
authority = context.getString(R.string.address_books_authority),
fullResync = true
)
}
/**
* Initiates calendar re-synchronization.
*
* @param fullResync whether sync shall download all events again
* (_true_: sets [Syncer.SYNC_EXTRAS_FULL_RESYNC],
* _false_: sets [Syncer.SYNC_EXTRAS_RESYNC])
* @param tasks whether tasks shall be synchronized, too (false: only events, true: events and tasks)
*/
private fun resyncCalendars(fullResync: Boolean, tasks: Boolean) {
resync(CalendarContract.AUTHORITY, fullResync)
if (tasks)
resync(TaskProvider.ProviderName.OpenTasks.authority, fullResync)
}
/**
* Initiates re-synchronization for given authority.
*
* @param authority authority to re-sync
* @param fullResync whether sync shall download all events again
* (_true_: sets [Syncer.SYNC_EXTRAS_FULL_RESYNC],
* _false_: sets [Syncer.SYNC_EXTRAS_RESYNC])
*/
private fun resync(authority: String, fullResync: Boolean) {
val resync =
if (fullResync)
OneTimeSyncWorker.FULL_RESYNC
else
OneTimeSyncWorker.RESYNC
OneTimeSyncWorker.enqueue(context, account, authority, resync = resync)
}
}

View File

@ -0,0 +1,714 @@
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.app.Activity
import android.security.KeyChain
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
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.AccountCircle
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Password
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.outlined.Task
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.SnackbarResult
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.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.composable.ActionCard
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.util.PermissionUtils
import at.bitfire.vcard4android.GroupMethod
import kotlinx.coroutines.launch
@Composable
fun AccountSettingsScreen(
onNavUp: () -> Unit,
account: Account,
onNavWifiPermissionsScreen: () -> Unit,
) {
val model = hiltViewModel { factory: AccountSettingsModel.Factory ->
factory.create(account)
}
val canAccessWifiSsid by PermissionUtils.rememberCanAccessWifiSsid()
AppTheme {
AccountSettingsScreen(
accountName = account.name,
onNavUp = onNavUp,
// Sync settings
canAccessWifiSsid = canAccessWifiSsid,
onSyncWifiOnlyPermissionsAction = onNavWifiPermissionsScreen,
contactsSyncInterval = model.syncIntervalContacts,
onUpdateContactsSyncInterval = model::updateContactsSyncInterval,
calendarSyncInterval = model.syncIntervalCalendars,
onUpdateCalendarSyncInterval = model::updateCalendarSyncInterval,
tasksSyncInterval = model.syncIntervalTasks,
onUpdateTasksSyncInterval = model::updateTasksSyncInterval,
syncOnlyOnWifi = model.syncWifiOnly,
onUpdateSyncOnlyOnWifi = model::updateSyncWifiOnly,
onlyOnSsids = model.syncWifiOnlySSIDs,
onUpdateOnlyOnSsids = model::updateSyncWifiOnlySSIDs,
ignoreVpns = model.ignoreVpns,
onUpdateIgnoreVpns = model::updateIgnoreVpns,
// Authentication Settings
credentials = model.credentials,
onUpdateCredentials = model::updateCredentials,
// CalDav Settings
timeRangePastDays = model.timeRangePastDays,
onUpdateTimeRangePastDays = model::updateTimeRangePastDays,
defaultAlarmMinBefore = model.defaultAlarmMinBefore,
onUpdateDefaultAlarmMinBefore = model::updateDefaultAlarm,
manageCalendarColors = model.manageCalendarColors,
onUpdateManageCalendarColors = model::updateManageCalendarColors,
eventColors = model.eventColors,
onUpdateEventColors = model::updateEventColors,
// CardDav Settings
contactGroupMethod = model.contactGroupMethod,
onUpdateContactGroupMethod = model::updateContactGroupMethod,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSettingsScreen(
onNavUp: () -> Unit,
accountName: String,
// Sync settings
canAccessWifiSsid: Boolean,
onSyncWifiOnlyPermissionsAction: () -> Unit,
contactsSyncInterval: Long?,
onUpdateContactsSyncInterval: ((Long) -> Unit) = {},
calendarSyncInterval: Long?,
onUpdateCalendarSyncInterval: ((Long) -> Unit) = {},
tasksSyncInterval: Long?,
onUpdateTasksSyncInterval: ((Long) -> Unit) = {},
syncOnlyOnWifi: Boolean,
onUpdateSyncOnlyOnWifi: (Boolean) -> Unit = {},
onlyOnSsids: List<String>?,
onUpdateOnlyOnSsids: (List<String>) -> Unit = {},
ignoreVpns: Boolean,
onUpdateIgnoreVpns: (Boolean) -> Unit = {},
// Authentication Settings
credentials: Credentials?,
onUpdateCredentials: (Credentials) -> Unit = {},
// CalDav Settings
timeRangePastDays: Int?,
onUpdateTimeRangePastDays: (Int?) -> Unit = {},
defaultAlarmMinBefore: Int?,
onUpdateDefaultAlarmMinBefore: (Int?) -> Unit = {},
manageCalendarColors: Boolean,
onUpdateManageCalendarColors: (Boolean) -> Unit = {},
eventColors: Boolean,
onUpdateEventColors: (Boolean) -> Unit = {},
// CardDav Settings
contactGroupMethod: GroupMethod,
onUpdateContactGroupMethod: (GroupMethod) -> Unit = {},
) {
val uriHandler = LocalUriHandler.current
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onNavUp) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
contentDescription = stringResource(R.string.navigate_up)
)
}
},
title = { Text(accountName) },
actions = {
IconButton(onClick = {
val settingsUri = Constants.MANUAL_URL.buildUpon()
.appendPath(Constants.MANUAL_PATH_SETTINGS)
.fragment(Constants.MANUAL_FRAGMENT_ACCOUNT_SETTINGS)
.build()
uriHandler.openUri(settingsUri.toString())
}) {
Icon(Icons.AutoMirrored.Filled.Help, stringResource(R.string.help))
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
AccountSettings_FromModel(
snackbarHostState = snackbarHostState,
// Sync settings
canAccessWifiSsid = canAccessWifiSsid,
onSyncWifiOnlyPermissionsAction = onSyncWifiOnlyPermissionsAction,
contactsSyncInterval = contactsSyncInterval,
onUpdateContactsSyncInterval = onUpdateContactsSyncInterval,
calendarSyncInterval = calendarSyncInterval,
onUpdateCalendarSyncInterval = onUpdateCalendarSyncInterval,
taskSyncInterval = tasksSyncInterval,
onUpdateTaskSyncInterval = onUpdateTasksSyncInterval,
syncOnlyOnWifi = syncOnlyOnWifi,
onUpdateSyncOnlyOnWifi = onUpdateSyncOnlyOnWifi,
onlyOnSsids = onlyOnSsids,
onUpdateOnlyOnSsids = onUpdateOnlyOnSsids,
ignoreVpns = ignoreVpns,
onUpdateIgnoreVpns = onUpdateIgnoreVpns,
// Authentication Settings
credentials = credentials,
onUpdateCredentials = onUpdateCredentials,
// CalDav Settings
timeRangePastDays = timeRangePastDays,
onUpdateTimeRangePastDays = onUpdateTimeRangePastDays,
defaultAlarmMinBefore = defaultAlarmMinBefore,
onUpdateDefaultAlarmMinBefore = onUpdateDefaultAlarmMinBefore,
manageCalendarColors = manageCalendarColors,
onUpdateManageCalendarColors = onUpdateManageCalendarColors,
eventColors = eventColors,
onUpdateEventColors = onUpdateEventColors,
// CardDav Settings
contactGroupMethod = contactGroupMethod,
onUpdateContactGroupMethod = onUpdateContactGroupMethod,
)
}
}
}
@Composable
fun AccountSettings_FromModel(
snackbarHostState: SnackbarHostState,
// Sync settings
canAccessWifiSsid: Boolean,
onSyncWifiOnlyPermissionsAction: () -> Unit,
contactsSyncInterval: Long?,
onUpdateContactsSyncInterval: ((Long) -> Unit) = {},
calendarSyncInterval: Long?,
onUpdateCalendarSyncInterval: ((Long) -> Unit) = {},
taskSyncInterval: Long?,
onUpdateTaskSyncInterval: ((Long) -> Unit) = {},
syncOnlyOnWifi: Boolean,
onUpdateSyncOnlyOnWifi: (Boolean) -> Unit = {},
onlyOnSsids: List<String>?,
onUpdateOnlyOnSsids: (List<String>) -> Unit = {},
ignoreVpns: Boolean,
onUpdateIgnoreVpns: (Boolean) -> Unit = {},
// Authentication Settings
credentials: Credentials?,
onUpdateCredentials: (Credentials) -> Unit = {},
// CalDav Settings
timeRangePastDays: Int?,
onUpdateTimeRangePastDays: (Int?) -> Unit = {},
defaultAlarmMinBefore: Int?,
onUpdateDefaultAlarmMinBefore: (Int?) -> Unit = {},
manageCalendarColors: Boolean,
onUpdateManageCalendarColors: (Boolean) -> Unit = {},
eventColors: Boolean,
onUpdateEventColors: (Boolean) -> Unit = {},
// CardDav Settings
contactGroupMethod: GroupMethod,
onUpdateContactGroupMethod: (GroupMethod) -> Unit = {},
) {
Column(Modifier.padding(8.dp)) {
SyncSettings(
canAccessWifiSsid = canAccessWifiSsid,
onSyncWifiOnlyPermissionsAction = onSyncWifiOnlyPermissionsAction,
contactsSyncInterval = contactsSyncInterval,
onUpdateContactsSyncInterval = onUpdateContactsSyncInterval,
calendarSyncInterval = calendarSyncInterval,
onUpdateCalendarSyncInterval = onUpdateCalendarSyncInterval,
taskSyncInterval = taskSyncInterval,
onUpdateTaskSyncInterval = onUpdateTaskSyncInterval,
syncOnlyOnWifi = syncOnlyOnWifi,
onUpdateSyncOnlyOnWifi = onUpdateSyncOnlyOnWifi,
onlyOnSsids = onlyOnSsids,
onUpdateOnlyOnSsids = onUpdateOnlyOnSsids,
ignoreVpns = ignoreVpns,
onUpdateIgnoreVpns = onUpdateIgnoreVpns
)
credentials?.let {
AuthenticationSettings(
snackbarHostState = snackbarHostState,
credentials = credentials,
onUpdateCredentials = onUpdateCredentials
)
}
CalDavSettings(
timeRangePastDays = timeRangePastDays,
onUpdateTimeRangePastDays = onUpdateTimeRangePastDays,
defaultAlarmMinBefore = defaultAlarmMinBefore,
onUpdateDefaultAlarmMinBefore = onUpdateDefaultAlarmMinBefore,
manageCalendarColors = manageCalendarColors,
onUpdateManageCalendarColors = onUpdateManageCalendarColors,
eventColors = eventColors,
onUpdateEventColors = onUpdateEventColors,
)
CardDavSettings(
contactGroupMethod = contactGroupMethod,
onUpdateContactGroupMethod = onUpdateContactGroupMethod
)
}
}
@Composable
fun SyncSettings(
canAccessWifiSsid: Boolean,
onSyncWifiOnlyPermissionsAction: () -> Unit,
contactsSyncInterval: Long?,
onUpdateContactsSyncInterval: ((Long) -> Unit) = {},
calendarSyncInterval: Long?,
onUpdateCalendarSyncInterval: ((Long) -> Unit) = {},
taskSyncInterval: Long?,
onUpdateTaskSyncInterval: ((Long) -> Unit) = {},
syncOnlyOnWifi: Boolean,
onUpdateSyncOnlyOnWifi: (Boolean) -> Unit = {},
onlyOnSsids: List<String>?,
onUpdateOnlyOnSsids: (List<String>) -> Unit = {},
ignoreVpns: Boolean,
onUpdateIgnoreVpns: (Boolean) -> Unit = {}
) {
Column {
SettingsHeader(false) {
Text(stringResource(R.string.settings_sync))
}
if (contactsSyncInterval != null)
SyncIntervalSetting(
icon = Icons.Default.Contacts,
name = R.string.settings_sync_interval_contacts,
syncInterval = contactsSyncInterval,
onUpdateSyncInterval = onUpdateContactsSyncInterval
)
if (calendarSyncInterval != null)
SyncIntervalSetting(
icon = Icons.Default.Event,
name = R.string.settings_sync_interval_calendars,
syncInterval = calendarSyncInterval,
onUpdateSyncInterval = onUpdateCalendarSyncInterval
)
if (taskSyncInterval != null)
SyncIntervalSetting(
icon = Icons.Outlined.Task,
name = R.string.settings_sync_interval_tasks,
syncInterval = taskSyncInterval,
onUpdateSyncInterval = onUpdateTaskSyncInterval
)
SwitchSetting(
icon = Icons.Default.Wifi,
name = stringResource(R.string.settings_sync_wifi_only),
summaryOn = stringResource(R.string.settings_sync_wifi_only_on),
summaryOff = stringResource(R.string.settings_sync_wifi_only_off),
checked = syncOnlyOnWifi,
onCheckedChange = onUpdateSyncOnlyOnWifi
)
var showWifiOnlySsidsDialog by remember { mutableStateOf(false) }
Setting(
icon = null,
name = stringResource(R.string.settings_sync_wifi_only_ssids),
enabled = syncOnlyOnWifi,
summary =
if (onlyOnSsids != null)
stringResource(R.string.settings_sync_wifi_only_ssids_on, onlyOnSsids.joinToString(", "))
else
stringResource(R.string.settings_sync_wifi_only_ssids_off),
onClick = {
showWifiOnlySsidsDialog = true
}
)
if (showWifiOnlySsidsDialog)
EditTextInputDialog(
title = stringResource(R.string.settings_sync_wifi_only_ssids_message),
initialValue = onlyOnSsids?.joinToString(", ") ?: "",
onValueEntered = { newValue ->
val newSsids = newValue.split(',')
.map { it.trim() }
.distinct()
onUpdateOnlyOnSsids(newSsids)
showWifiOnlySsidsDialog = false
},
onDismiss = { showWifiOnlySsidsDialog = false }
)
if (LocalInspectionMode.current || (onlyOnSsids != null && !canAccessWifiSsid))
ActionCard(
icon = Icons.Default.SyncProblem,
actionText = stringResource(R.string.settings_sync_wifi_only_ssids_permissions_action),
onAction = onSyncWifiOnlyPermissionsAction
) {
Text(stringResource(R.string.settings_sync_wifi_only_ssids_permissions_required))
}
SwitchSetting(
icon = null,
name = stringResource(R.string.settings_ignore_vpns),
summaryOn = stringResource(R.string.settings_ignore_vpns_on),
summaryOff = stringResource(R.string.settings_ignore_vpns_off),
checked = ignoreVpns,
onCheckedChange = onUpdateIgnoreVpns
)
}
}
@Composable
fun SyncIntervalSetting(
icon: ImageVector,
@StringRes name: Int,
syncInterval: Long,
onUpdateSyncInterval: (Long) -> Unit
) {
var showSyncIntervalDialog by remember { mutableStateOf(false) }
Setting(
icon = icon,
name = stringResource(name),
summary =
if (syncInterval == AccountSettings.SYNC_INTERVAL_MANUALLY)
stringResource(R.string.settings_sync_summary_manually)
else
stringResource(R.string.settings_sync_summary_periodically, syncInterval / 60),
onClick = {
showSyncIntervalDialog = true
}
)
if (showSyncIntervalDialog) {
val syncIntervalNames = stringArrayResource(R.array.settings_sync_interval_names)
val syncIntervalSeconds = stringArrayResource(R.array.settings_sync_interval_seconds)
MultipleChoiceInputDialog(
title = stringResource(name),
namesAndValues = syncIntervalNames.zip(syncIntervalSeconds),
initialValue = syncInterval.toString(),
onValueSelected = { newValue ->
try {
val seconds = newValue.toLong()
onUpdateSyncInterval(seconds)
} catch (_: NumberFormatException) {
}
showSyncIntervalDialog = false
},
onDismiss = {
showSyncIntervalDialog = false
}
)
}
}
@Composable
fun AuthenticationSettings(
credentials: Credentials,
snackbarHostState: SnackbarHostState = SnackbarHostState(),
onUpdateCredentials: (Credentials) -> Unit = {}
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
if (credentials.authState != null || credentials.username != null || credentials.password != null || credentials.certificateAlias != null)
Column {
SettingsHeader(false) {
Text(stringResource(R.string.settings_authentication))
}
if (credentials.username != null || credentials.password != null) {
var showUsernameDialog by remember { mutableStateOf(false) }
Setting(
icon = Icons.Default.AccountCircle,
name = stringResource(R.string.settings_username),
summary = credentials.username,
onClick = {
showUsernameDialog = true
}
)
if (showUsernameDialog)
EditTextInputDialog(
title = stringResource(R.string.settings_username),
initialValue = credentials.username,
onValueEntered = { newValue ->
onUpdateCredentials(credentials.copy(username = newValue))
},
onDismiss = { showUsernameDialog = false }
)
var showPasswordDialog by remember { mutableStateOf(false) }
Setting(
icon = Icons.Default.Password,
name = stringResource(R.string.settings_password),
summary = stringResource(R.string.settings_password_summary),
onClick = {
showPasswordDialog = true
}
)
if (showPasswordDialog)
EditTextInputDialog(
title = stringResource(R.string.settings_password),
inputLabel = stringResource(R.string.settings_new_password),
initialValue = null, // Do not show the existing password
passwordField = true,
onValueEntered = { newValue ->
onUpdateCredentials(credentials.copy(password = newValue))
},
onDismiss = { showPasswordDialog = false }
)
}
// client certificate
Setting(
icon = null,
name = stringResource(R.string.settings_certificate_alias),
summary = credentials.certificateAlias ?: stringResource(R.string.settings_certificate_alias_empty),
onClick = {
val activity = context as Activity
KeyChain.choosePrivateKeyAlias(activity, { newAlias ->
if (newAlias != null)
onUpdateCredentials(credentials.copy(certificateAlias = newAlias))
else
scope.launch {
if (snackbarHostState.showSnackbar(
context.getString(R.string.settings_certificate_alias_empty),
actionLabel = context.getString(R.string.settings_certificate_install)
) == SnackbarResult.ActionPerformed) {
val intent = KeyChain.createInstallIntent()
if (intent.resolveActivity(context.packageManager) != null)
context.startActivity(intent)
}
}
}, null, null, null, -1, credentials.certificateAlias)
}
)
}
}
@Composable
fun CalDavSettings(
timeRangePastDays: Int?,
onUpdateTimeRangePastDays: (Int?) -> Unit = {},
defaultAlarmMinBefore: Int?,
onUpdateDefaultAlarmMinBefore: (Int?) -> Unit = {},
manageCalendarColors: Boolean,
onUpdateManageCalendarColors: (Boolean) -> Unit = {},
eventColors: Boolean,
onUpdateEventColors: (Boolean) -> Unit = {}
) {
Column {
SettingsHeader {
Text(stringResource(R.string.settings_caldav))
}
var showTimeRangePastDialog by remember { mutableStateOf(false) }
Setting(
icon = Icons.Default.History,
name = stringResource(R.string.settings_sync_time_range_past),
summary =
if (timeRangePastDays != null)
pluralStringResource(R.plurals.settings_sync_time_range_past_days, timeRangePastDays, timeRangePastDays)
else
stringResource(R.string.settings_sync_time_range_past_none),
onClick = {
showTimeRangePastDialog = true
}
)
if (showTimeRangePastDialog)
EditTextInputDialog(
title = stringResource(R.string.settings_sync_time_range_past_message),
initialValue = timeRangePastDays?.toString() ?: "",
onValueEntered = { newValue ->
val days = try {
newValue.toInt()
} catch (_: NumberFormatException) {
null
}
onUpdateTimeRangePastDays(days)
showTimeRangePastDialog = false
},
onDismiss = { showTimeRangePastDialog = false }
)
var showDefaultAlarmDialog by remember { mutableStateOf(false) }
Setting(
icon = null,
name = stringResource(R.string.settings_default_alarm),
summary =
if (defaultAlarmMinBefore != null)
pluralStringResource(R.plurals.settings_default_alarm_on, defaultAlarmMinBefore, defaultAlarmMinBefore)
else
stringResource(R.string.settings_default_alarm_off),
onClick = {
showDefaultAlarmDialog = true
}
)
if (showDefaultAlarmDialog)
EditTextInputDialog(
title = stringResource(R.string.settings_default_alarm_message),
initialValue = defaultAlarmMinBefore?.toString() ?: "",
onValueEntered = { newValue ->
val minBefore = try {
newValue.toInt()
} catch (_: NumberFormatException) {
null
}
onUpdateDefaultAlarmMinBefore(minBefore)
showDefaultAlarmDialog = false
},
onDismiss = { showDefaultAlarmDialog = false }
)
SwitchSetting(
icon = null,
name = stringResource(R.string.settings_manage_calendar_colors),
summaryOn = stringResource(R.string.settings_manage_calendar_colors_on),
summaryOff = stringResource(R.string.settings_manage_calendar_colors_off),
checked = manageCalendarColors,
onCheckedChange = onUpdateManageCalendarColors
)
SwitchSetting(
icon = null,
name = stringResource(R.string.settings_event_colors),
summaryOn = stringResource(R.string.settings_event_colors_on),
summaryOff = stringResource(R.string.settings_event_colors_off),
checked = eventColors,
onCheckedChange = onUpdateEventColors
)
}
}
@Composable
fun CardDavSettings(
contactGroupMethod: GroupMethod,
onUpdateContactGroupMethod: (GroupMethod) -> Unit = {}
) {
Column {
SettingsHeader {
Text(stringResource(R.string.settings_carddav))
}
val groupMethodNames = stringArrayResource(R.array.settings_contact_group_method_entries)
val groupMethodValues = stringArrayResource(R.array.settings_contact_group_method_values)
var showGroupMethodDialog by remember { mutableStateOf(false) }
Setting(
icon = Icons.Default.Contacts,
name = stringResource(R.string.settings_contact_group_method),
summary = groupMethodNames[groupMethodValues.indexOf(contactGroupMethod.name)],
onClick = {
showGroupMethodDialog = true
}
)
if (showGroupMethodDialog)
MultipleChoiceInputDialog(
title = stringResource(R.string.settings_contact_group_method),
namesAndValues = groupMethodNames.zip(groupMethodValues),
initialValue = contactGroupMethod.name,
onValueSelected = { newValue ->
onUpdateContactGroupMethod(GroupMethod.valueOf(newValue))
showGroupMethodDialog = false
},
onDismiss = { showGroupMethodDialog = false }
)
}
}
@Composable
@Preview
fun AccountSettingsScreen_Preview() {
AppTheme {
AccountSettingsScreen(
accountName = "Account Name Here",
onNavUp = {},
// Sync settings
canAccessWifiSsid = true,
onSyncWifiOnlyPermissionsAction = {},
contactsSyncInterval = 80000L,
onUpdateContactsSyncInterval = {},
calendarSyncInterval = 50000L,
onUpdateCalendarSyncInterval = {},
tasksSyncInterval = 900000L,
onUpdateTasksSyncInterval = {},
syncOnlyOnWifi = true,
onUpdateSyncOnlyOnWifi = {},
onlyOnSsids = listOf("HeyWifi", "Another"),
onUpdateOnlyOnSsids = {},
ignoreVpns = true,
onUpdateIgnoreVpns = {},
// Authentication Settings
credentials = Credentials(username = "test", password = "test"),
onUpdateCredentials = {},
// CalDav Settings
timeRangePastDays = 365,
onUpdateTimeRangePastDays = {},
defaultAlarmMinBefore = 585,
onUpdateDefaultAlarmMinBefore = {},
manageCalendarColors = false,
onUpdateManageCalendarColors = {},
eventColors = false,
onUpdateEventColors = {},
// CardDav Settings
contactGroupMethod = GroupMethod.GROUP_VCARDS,
onUpdateContactGroupMethod = {}
)
}
}

View File

@ -336,8 +336,6 @@
<string name="settings_ignore_vpns_on">VPN without underlying validated Internet connection is not enough to run synchronization (recommended)</string>
<string name="settings_ignore_vpns_off">VPN without underlying validated Internet connection is enough to run synchronization</string>
<string name="settings_authentication">Authentication</string>
<string name="settings_oauth">Re-authenticate</string>
<string name="settings_oauth_summary">Perform OAuth login again</string>
<string name="settings_username">User name</string>
<string name="settings_password">Password</string>
<string name="settings_new_password">New password</string>