Rewrite AccountSettingsActivity to Compose (#646)

* AccountSettings: rewrite Sync settings
* Authentication
* CalDAV, CardDAV settings
This commit is contained in:
Ricki Hirner 2024-03-12 20:48:06 +01:00 committed by GitHub
parent 45d6b33023
commit 2e780a890b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 878 additions and 801 deletions

View file

@ -153,12 +153,13 @@
android:label="@string/create_calendar"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.SettingsActivity"
android:name=".ui.account.AccountSettingsActivity"
android:theme="@style/AppTheme.NoActionBar"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.WifiPermissionsActivity"
android:theme="@style/AppTheme.NoActionBar"
android:parentActivityName=".ui.account.SettingsActivity" />
android:parentActivityName=".ui.account.AccountSettingsActivity" />
<activity
android:name=".ui.webdav.WebdavMountsActivity"

View file

@ -61,7 +61,7 @@ import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
import at.bitfire.davdroid.ui.account.SettingsActivity
import at.bitfire.davdroid.ui.account.AccountSettingsActivity
import at.bitfire.davdroid.util.DavUtils.parent
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@ -217,8 +217,8 @@ class RefreshCollectionsWorker @AssistedInject constructor(
} catch (e: UnauthorizedException) {
Logger.log.log(Level.SEVERE, "Not authorized (anymore)", e)
// notify that we need to re-authenticate in the account settings
val settingsIntent = Intent(applicationContext, SettingsActivity::class.java)
.putExtra(SettingsActivity.EXTRA_ACCOUNT, account)
val settingsIntent = Intent(applicationContext, AccountSettingsActivity::class.java)
.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
notifyRefreshError(
applicationContext.getString(R.string.sync_error_authentication_failed),
settingsIntent

View file

@ -51,7 +51,7 @@ import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
import at.bitfire.davdroid.ui.account.SettingsActivity
import at.bitfire.davdroid.ui.account.AccountSettingsActivity
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.ical4android.Ical4Android
import at.bitfire.ical4android.TaskProvider
@ -779,8 +779,8 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
val contentIntent: Intent
var viewItemAction: NotificationCompat.Action? = null
if (e is UnauthorizedException) {
contentIntent = Intent(context, SettingsActivity::class.java)
contentIntent.putExtra(SettingsActivity.EXTRA_ACCOUNT,
contentIntent = Intent(context, AccountSettingsActivity::class.java)
contentIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT,
if (authority == ContactsContract.AUTHORITY)
mainAccount
else

View file

@ -92,9 +92,9 @@ class AboutActivity: AppCompatActivity() {
super.onCreate(savedInstanceState)
setContent {
val uriHandler = LocalUriHandler.current
AppTheme {
val uriHandler = LocalUriHandler.current
Scaffold(
topBar = {
TopAppBar(

View file

@ -195,8 +195,8 @@ class AccountActivity : AppCompatActivity() {
SyncWorker.enqueueAllAuthorities(this, model.account)
},
onAccountSettings = {
val intent = Intent(this, SettingsActivity::class.java)
intent.putExtra(SettingsActivity.EXTRA_ACCOUNT, model.account)
val intent = Intent(this, AccountSettingsActivity::class.java)
intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, model.account)
startActivity(intent, null)
},
onRenameAccount = { newName ->

View file

@ -0,0 +1,830 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
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.resource.TaskUtils
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.syncadapter.Syncer
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.widget.ActionCard
import at.bitfire.davdroid.ui.widget.EditTextInputDialog
import at.bitfire.davdroid.ui.widget.MultipleChoiceInputDialog
import at.bitfire.davdroid.ui.widget.Setting
import at.bitfire.davdroid.ui.widget.SettingsHeader
import at.bitfire.davdroid.ui.widget.SwitchSetting
import at.bitfire.davdroid.util.PermissionUtils
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 {
AppTheme {
val uriHandler = LocalUriHandler.current
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onNavigateUp() }) {
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
)
}
}
}
}
}
override fun supportShouldUpRecreateTask(targetIntent: Intent) = true
override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) {
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 }
)
// TODO make canAccessWifiSsid live-capable
val canAccessWifiSsid =
if (LocalInspectionMode.current)
false
else
PermissionUtils.canAccessWifiSsid(context)
if (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 = 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),
initialValue = credentials.password,
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>()
// TODO tasksProvider LiveData
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) SyncWorker.FULL_RESYNC else SyncWorker.RESYNC
SyncWorker.enqueue(context, account, authority, expedited = true, resync = resync)
}
}
}

View file

@ -1,617 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.annotation.SuppressLint
import android.app.Application
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.CalendarContract
import android.security.KeyChain
import android.text.InputType
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.TaskStackBuilder
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceGroup
import androidx.preference.SwitchPreferenceCompat
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.syncadapter.Syncer
import at.bitfire.davdroid.ui.UiUtils
import at.bitfire.davdroid.ui.setup.GoogleLoginFragment
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import com.google.android.material.snackbar.Snackbar
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 org.apache.commons.lang3.StringUtils
import javax.inject.Inject
@AndroidEntryPoint
class SettingsActivity: AppCompatActivity() {
companion object {
const val EXTRA_ACCOUNT = "account"
}
private val account by lazy { intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
title = getString(R.string.settings_title, account.name)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
// TODO add help button that leads to manual
if (savedInstanceState == null)
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, DialogFragment.instantiate(this, AccountSettingsFragment::class.java.name, intent.extras))
.commit()
}
override fun supportShouldUpRecreateTask(targetIntent: Intent) = true
override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) {
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
}
@AndroidEntryPoint
class AccountSettingsFragment : PreferenceFragmentCompat() {
private val account by lazy { requireArguments().getParcelable<Account>(EXTRA_ACCOUNT)!! }
@Inject lateinit var settings: SettingsManager
@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 onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.settings_account)
findPreference<EditTextPreference>(getString(R.string.settings_password_key))!!.setOnBindEditTextListener { password ->
password.inputType = InputType.TYPE_CLASS_TEXT.or(InputType.TYPE_TEXT_VARIATION_PASSWORD)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
try {
initSettings()
} catch (e: InvalidAccountException) {
Toast.makeText(context, R.string.account_invalid, Toast.LENGTH_LONG).show()
requireActivity().finish()
}
}
override fun onResume() {
super.onResume()
checkWifiPermissions()
}
private fun initSettings() {
// preference group: sync
findPreference<ListPreference>(getString(R.string.settings_sync_interval_contacts_key))!!.let {
model.syncIntervalContacts.observe(viewLifecycleOwner) { interval: Long? ->
if (interval != null) {
it.isEnabled = true
it.isVisible = true
it.value = interval.toString()
if (interval == AccountSettings.SYNC_INTERVAL_MANUALLY)
it.setSummary(R.string.settings_sync_summary_manually)
else
it.summary = getString(R.string.settings_sync_summary_periodically, interval / 60)
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue ->
pref.isEnabled = false // disable until updated setting is read from system again
model.updateSyncInterval(getString(R.string.address_books_authority), (newValue as String).toLong())
false
}
} else
it.isVisible = false
}
}
findPreference<ListPreference>(getString(R.string.settings_sync_interval_calendars_key))!!.let {
model.syncIntervalCalendars.observe(viewLifecycleOwner) { interval: Long? ->
if (interval != null) {
it.isEnabled = true
it.isVisible = true
it.value = interval.toString()
if (interval == AccountSettings.SYNC_INTERVAL_MANUALLY)
it.setSummary(R.string.settings_sync_summary_manually)
else
it.summary = getString(R.string.settings_sync_summary_periodically, interval / 60)
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue ->
pref.isEnabled = false
model.updateSyncInterval(CalendarContract.AUTHORITY, (newValue as String).toLong())
false
}
} else
it.isVisible = false
}
}
findPreference<ListPreference>(getString(R.string.settings_sync_interval_tasks_key))!!.let {
model.syncIntervalTasks.observe(viewLifecycleOwner) { interval: Long? ->
val provider = model.tasksProvider
if (provider != null && interval != null) {
it.isEnabled = true
it.isVisible = true
it.value = interval.toString()
if (interval == AccountSettings.SYNC_INTERVAL_MANUALLY)
it.setSummary(R.string.settings_sync_summary_manually)
else
it.summary = getString(R.string.settings_sync_summary_periodically, interval / 60)
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { pref, newValue ->
pref.isEnabled = false
model.updateSyncInterval(provider.authority, (newValue as String).toLong())
false
}
} else
it.isVisible = false
}
}
findPreference<SwitchPreferenceCompat>(getString(R.string.settings_sync_wifi_only_key))!!.let {
model.syncWifiOnly.observe(viewLifecycleOwner) { wifiOnly ->
it.isEnabled = !settings.containsKey(AccountSettings.KEY_WIFI_ONLY)
it.isChecked = wifiOnly
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly ->
model.updateSyncWifiOnly(wifiOnly as Boolean)
false
}
}
}
findPreference<EditTextPreference>(getString(R.string.settings_sync_wifi_only_ssids_key))!!.let {
model.syncWifiOnly.observe(viewLifecycleOwner) { wifiOnly ->
it.isEnabled = wifiOnly && settings.isWritable(AccountSettings.KEY_WIFI_ONLY_SSIDS)
}
model.syncWifiOnlySSIDs.observe(viewLifecycleOwner) { onlySSIDs ->
checkWifiPermissions()
if (onlySSIDs != null) {
it.text = onlySSIDs.joinToString(", ")
it.summary = getString(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
R.string.settings_sync_wifi_only_ssids_on_location_services
else R.string.settings_sync_wifi_only_ssids_on, onlySSIDs.joinToString(", "))
} else {
it.text = ""
it.setSummary(R.string.settings_sync_wifi_only_ssids_off)
}
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val newOnlySSIDs = (newValue as String)
.split(',')
.mapNotNull { StringUtils.trimToNull(it) }
.distinct()
model.updateSyncWifiOnlySSIDs(newOnlySSIDs)
false
}
}
}
findPreference<SwitchPreferenceCompat>(getString(R.string.settings_ignore_vpns_key))!!.let {
model.ignoreVpns.observe(viewLifecycleOwner) { ignoreVpns ->
it.isEnabled = true
it.isChecked = ignoreVpns
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, prefValue ->
model.updateIgnoreVpns(prefValue as Boolean)
false
}
}
}
// preference group: authentication
val prefUserName = findPreference<EditTextPreference>(getString(R.string.settings_username_key))!!
val prefPassword = findPreference<EditTextPreference>(getString(R.string.settings_password_key))!!
val prefCertAlias = findPreference<Preference>(getString(R.string.settings_certificate_alias_key))!!
val prefOAuth = findPreference<Preference>(getString(R.string.settings_oauth_key))!!
model.credentials.observe(viewLifecycleOwner) { credentials ->
if (credentials.authState != null) {
// using OAuth, hide other settings
prefOAuth.isVisible = true
prefUserName.isVisible = false
prefPassword.isVisible = false
prefCertAlias.isVisible = false
prefOAuth.setOnPreferenceClickListener {
parentFragmentManager.beginTransaction()
.replace(android.R.id.content, GoogleLoginFragment(account.name), null)
.addToBackStack(null)
.commit()
true
}
} else {
// not using OAuth, hide OAuth setting, show the others
prefOAuth.isVisible = false
prefUserName.isVisible = true
prefPassword.isVisible = true
prefCertAlias.isVisible = true
prefUserName.summary = credentials.userName
prefUserName.text = credentials.userName
prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newUserName ->
val newUserNameOrNull = StringUtils.trimToNull(newUserName as String)
model.updateCredentials(Credentials(
userName = newUserNameOrNull,
password = credentials.password,
certificateAlias = credentials.certificateAlias)
)
false
}
if (credentials.userName != null) {
prefPassword.isVisible = true
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newPassword ->
model.updateCredentials(Credentials(credentials.userName, newPassword as String, credentials.certificateAlias))
false
}
} else
prefPassword.isVisible = false
prefCertAlias.summary = credentials.certificateAlias ?: getString(R.string.settings_certificate_alias_empty)
prefCertAlias.setOnPreferenceClickListener {
KeyChain.choosePrivateKeyAlias(requireActivity(), { newAlias ->
model.updateCredentials(Credentials(credentials.userName, credentials.password, newAlias))
}, null, null, null, -1, credentials.certificateAlias)
true
}
}
}
// preference group: CalDAV
model.hasCalDav.observe(viewLifecycleOwner) { hasCalDav ->
if (!hasCalDav)
findPreference<PreferenceGroup>(getString(R.string.settings_caldav_key))!!.isVisible = false
else {
findPreference<PreferenceGroup>(getString(R.string.settings_caldav_key))!!.isVisible = true
// when model.hasCalDav is available, model.syncInterval* are also available
// (because hasCalDav is calculated from them)
val hasCalendars = model.syncIntervalCalendars.value != null
findPreference<EditTextPreference>(getString(R.string.settings_sync_time_range_past_key))!!.let { pref ->
if (hasCalendars)
model.timeRangePastDays.observe(viewLifecycleOwner) { pastDays ->
if (model.syncIntervalCalendars.value != null) {
pref.isVisible = true
if (pastDays != null) {
pref.text = pastDays.toString()
pref.summary = resources.getQuantityString(R.plurals.settings_sync_time_range_past_days, pastDays, pastDays)
} else {
pref.text = null
pref.setSummary(R.string.settings_sync_time_range_past_none)
}
pref.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val days = try {
(newValue as String).toInt()
} catch(e: NumberFormatException) {
-1
}
model.updateTimeRangePastDays(if (days < 0) null else days)
false
}
} else
pref.isVisible = false
}
else
pref.isVisible = false
}
findPreference<EditTextPreference>(getString(R.string.settings_key_default_alarm))!!.let { pref ->
if (hasCalendars)
model.defaultAlarmMinBefore.observe(viewLifecycleOwner) { minBefore ->
pref.isVisible = true
if (minBefore != null) {
pref.text = minBefore.toString()
pref.summary = resources.getQuantityString(R.plurals.settings_default_alarm_on, minBefore, minBefore)
} else {
pref.text = null
pref.summary = getString(R.string.settings_default_alarm_off)
}
pref.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
val minBefore = try {
(newValue as String).toInt()
} catch (e: java.lang.NumberFormatException) {
null
}
model.updateDefaultAlarm(minBefore)
false
}
}
else
pref.isVisible = false
}
findPreference<SwitchPreferenceCompat>(getString(R.string.settings_manage_calendar_colors_key))!!.let {
model.manageCalendarColors.observe(viewLifecycleOwner) { manageCalendarColors ->
it.isEnabled = !settings.containsKey(AccountSettings.KEY_MANAGE_CALENDAR_COLORS)
it.isChecked = manageCalendarColors
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
model.updateManageCalendarColors(newValue as Boolean)
false
}
}
}
findPreference<SwitchPreferenceCompat>(getString(R.string.settings_event_colors_key))!!.let { pref ->
if (hasCalendars)
model.eventColors.observe(viewLifecycleOwner) { eventColors ->
pref.isVisible = true
pref.isEnabled = !settings.containsKey(AccountSettings.KEY_EVENT_COLORS)
pref.isChecked = eventColors
pref.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
model.updateEventColors(newValue as Boolean)
false
}
}
else
pref.isVisible = false
}
}
}
// preference group: CardDAV
model.syncIntervalContacts.observe(viewLifecycleOwner) { contactsSyncInterval ->
val hasCardDav = contactsSyncInterval != null
if (!hasCardDav)
findPreference<PreferenceGroup>(getString(R.string.settings_carddav_key))!!.isVisible = false
else {
findPreference<PreferenceGroup>(getString(R.string.settings_carddav_key))!!.isVisible = true
findPreference<ListPreference>(getString(R.string.settings_contact_group_method_key))!!.let {
model.contactGroupMethod.observe(viewLifecycleOwner) { groupMethod ->
if (model.syncIntervalContacts.value != null) {
it.isVisible = true
it.value = groupMethod.name
it.summary = it.entry
if (settings.containsKey(AccountSettings.KEY_CONTACT_GROUP_METHOD))
it.isEnabled = false
else {
it.isEnabled = true
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod ->
model.updateContactGroupMethod(GroupMethod.valueOf(groupMethod as String))
false
}
}
} else
it.isVisible = false
}
}
}
}
}
@SuppressLint("WrongConstant")
private fun checkWifiPermissions() {
if (model.syncWifiOnlySSIDs.value != null && !PermissionUtils.canAccessWifiSsid(requireActivity()))
Snackbar.make(requireView(), R.string.settings_sync_wifi_only_ssids_permissions_required, UiUtils.SNACKBAR_LENGTH_VERY_LONG)
.setAction(R.string.settings_sync_wifi_only_ssids_permissions_action) {
val intent = Intent(requireActivity(), WifiPermissionsActivity::class.java)
intent.putExtra(WifiPermissionsActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
}
.show()
}
}
class Model @AssistedInject constructor(
application: Application,
val settings: SettingsManager,
@Assisted val account: Account
): AndroidViewModel(application), 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(application)
val syncIntervalTasks = MutableLiveData<Long>()
val hasCalDav = object: MediatorLiveData<Boolean>() {
init {
addSource(syncIntervalCalendars) { calculate() }
addSource(syncIntervalTasks) { calculate() }
}
private fun calculate() {
value = syncIntervalCalendars.value != null || syncIntervalTasks.value != null
}
}
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(application, 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(getApplication<Application>().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 = getApplication<Application>().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) SyncWorker.FULL_RESYNC else SyncWorker.RESYNC
SyncWorker.enqueue(getApplication(), account, authority, expedited = true, resync = resync)
}
}
}

View file

@ -120,7 +120,7 @@ class WifiPermissionsActivity: AppCompatActivity() {
override fun supportShouldUpRecreateTask(targetIntent: Intent) = true
override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) {
builder.editIntentAt(builder.intentCount - 1)?.putExtra(SettingsActivity.EXTRA_ACCOUNT, account)
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
}

View file

@ -42,7 +42,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@ -131,7 +130,6 @@ class AddWebdavMountActivity : AppCompatActivity() {
password: String = "",
onPasswordChange: (String) -> Unit = {}
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val snackbarHostState = remember { SnackbarHostState() }

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProvideTextStyle
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
@ -35,16 +36,21 @@ fun ActionCard(
.fillMaxWidth()
.then(modifier)
) {
Column(Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp)) {
if (icon != null)
Row {
Icon(icon, "", Modifier
.align(Alignment.CenterVertically)
.padding(end = 8.dp))
Column(Modifier
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
.fillMaxWidth(),
) {
ProvideTextStyle(MaterialTheme.typography.body1) {
if (icon != null)
Row {
Icon(icon, "", Modifier
.align(Alignment.CenterVertically)
.padding(end = 8.dp))
content()
}
else
content()
}
else
content()
}
if (actionText != null)
TextButton(onClick = onAction) {

View file

@ -50,7 +50,7 @@ fun EditTextInputDialog(
title = {
Text(
title,
style = MaterialTheme.typography.h6
style = MaterialTheme.typography.body1
)
},
text = {
@ -123,7 +123,7 @@ fun MultipleChoiceInputDialog(
title = {
Text(
title,
style = MaterialTheme.typography.h6
style = MaterialTheme.typography.body1
)
},
text = {

View file

@ -11,6 +11,7 @@ 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
@ -19,9 +20,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.android.material.color.MaterialColors
@Composable
fun SettingsHeader(divider: Boolean = false, content: @Composable () -> Unit) {
@ -64,8 +67,10 @@ fun Setting(
onClick: () -> Unit = {}
) {
var modifier = Modifier.fillMaxWidth()
if (enabled)
modifier = modifier.clickable(onClick = onClick)
modifier = if (enabled)
modifier.clickable(onClick = onClick)
else
modifier.alpha(MaterialColors.ALPHA_DISABLED)
Row(
verticalAlignment = Alignment.CenterVertically,
@ -85,7 +90,9 @@ fun Setting(
.padding(start = 8.dp)
.weight(1f)
) {
name()
ProvideTextStyle(MaterialTheme.typography.body1) {
name()
}
if (summary != null)
Text(summary, style = MaterialTheme.typography.body2)
@ -100,6 +107,7 @@ fun Setting(
name: String,
summary: String? = null,
icon: ImageVector? = null,
enabled: Boolean = true,
onClick: () -> Unit = {}
) {
Setting(
@ -111,6 +119,7 @@ fun Setting(
Text(name, style = MaterialTheme.typography.body1)
},
summary = summary,
enabled = enabled,
onClick = onClick
)
}
@ -163,6 +172,7 @@ fun SwitchSetting(
fun SwitchSetting_Sample() {
SwitchSetting(
name = "Some Switched Setting",
checked = true
checked = true,
summaryOn = "Currently on"
)
}

View file

@ -310,13 +310,10 @@
<!-- AccountSettingsActivity -->
<string name="settings_title">Settings: %s</string>
<string name="settings_sync">Synchronization</string>
<string name="settings_sync_interval_contacts_key" translatable="false">sync_interval_contacts</string>
<string name="settings_sync_interval_contacts">Contacts sync. interval</string>
<string name="settings_sync_summary_manually">Only manually</string>
<string name="settings_sync_summary_periodically" tools:ignore="PluralsCandidate">Every %d minutes + immediately on local changes</string>
<string name="settings_sync_interval_calendars_key" translatable="false">sync_interval_calendars</string>
<string name="settings_sync_interval_calendars">Calendars sync. interval</string>
<string name="settings_sync_interval_tasks_key" translatable="false">sync_interval_tasks</string>
<string name="settings_sync_interval_tasks">Tasks sync. interval</string>
<string-array name="settings_sync_interval_seconds" translatable="false">
<item>-1</item>
@ -336,39 +333,30 @@
<item>Every 4 hours</item>
<item>Once a day</item>
</string-array>
<string name="settings_sync_wifi_only_key" translatable="false">sync_wifi_only</string>
<string name="settings_sync_wifi_only">Sync over WiFi only</string>
<string name="settings_sync_wifi_only_on">Synchronization is restricted to WiFi connections</string>
<string name="settings_sync_wifi_only_off">Connection type is not taken into consideration</string>
<string name="settings_sync_wifi_only_ssids_key" translatable="false">sync_wifi_only_ssids</string>
<string name="settings_sync_wifi_only_ssids">WiFi SSID restriction</string>
<string name="settings_sync_wifi_only_ssids_on">Will only sync over %s</string>
<string name="settings_sync_wifi_only_ssids_on_location_services">Will only sync over %s (requires active location services)</string>
<string name="settings_sync_wifi_only_ssids_off">All WiFi connections will be used</string>
<string name="settings_sync_wifi_only_ssids_message">Comma-separated names (SSIDs) of allowed WiFi networks (leave blank for all)</string>
<string name="settings_sync_wifi_only_ssids_permissions_required">WiFi SSID restriction requires further settings</string>
<string name="settings_sync_wifi_only_ssids_permissions_action">Manage</string>
<string name="settings_ignore_vpns_key" translatable="false">ignore_vpns</string>
<string name="settings_ignore_vpns">VPN requires underlying Internet</string>
<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_key" translatable="false">oauth</string>
<string name="settings_oauth">Re-authenticate</string>
<string name="settings_oauth_summary">Perform OAuth login again</string>
<string name="settings_username_key">username</string>
<string name="settings_username">User name</string>
<string name="settings_enter_username">Enter user name:</string>
<string name="settings_password_key" translatable="false">password</string>
<string name="settings_password">Password</string>
<string name="settings_password_summary">Update the password according to your server.</string>
<string name="settings_enter_password">Enter your password:</string>
<string name="settings_certificate_alias_key" translatable="false">certificate_alias</string>
<string name="settings_certificate_alias">Client certificate alias</string>
<string name="settings_certificate_alias_empty">No certificate selected</string>
<string name="settings_caldav_key" translatable="false">caldav</string>
<string name="settings_certificate_alias">Client certificate</string>
<string name="settings_certificate_alias_empty">No certificate available or selected</string>
<string name="settings_certificate_install">Install certificate</string>
<string name="settings_caldav">CalDAV</string>
<string name="settings_sync_time_range_past_key" translatable="false">time_range_past_days</string>
<string name="settings_sync_time_range_past">Past event time limit</string>
<string name="settings_sync_time_range_past_none">All events will be synchronized</string>
<plurals name="settings_sync_time_range_past_days">
@ -384,17 +372,13 @@
</plurals>
<string name="settings_default_alarm_off">No default reminders are created</string>
<string name="settings_default_alarm_message">If default reminders shall be created for events without reminder: the desired number of minutes before the event. Leave blank to disable default reminders.</string>
<string name="settings_manage_calendar_colors_key" translatable="false">manage_calendar_colors</string>
<string name="settings_manage_calendar_colors">Manage calendar colors</string>
<string name="settings_manage_calendar_colors_on">Calendar colors are reset at each sync</string>
<string name="settings_manage_calendar_colors_off">Calendar colors can be set by other apps</string>
<string name="settings_event_colors_key" translatable="false">event_colors</string>
<string name="settings_event_colors">Event color support</string>
<string name="settings_event_colors_on">Event colors are synced</string>
<string name="settings_event_colors_off">Event colors are not synced</string>
<string name="settings_carddav_key" translatable="false">carddav</string>
<string name="settings_carddav">CardDAV</string>
<string name="settings_contact_group_method_key" translatable="false">contact_group_method</string>
<string name="settings_contact_group_method">Contact group method</string>
<string-array name="settings_contact_group_method_values" translatable="false">
<item>GROUP_VCARDS</item>

View file

@ -1,135 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory
android:key="sync"
android:title="@string/settings_sync">
<ListPreference
android:key="@string/settings_sync_interval_contacts_key"
android:persistent="false"
android:title="@string/settings_sync_interval_contacts"
android:icon="@drawable/ic_contacts"
android:entries="@array/settings_sync_interval_names"
android:entryValues="@array/settings_sync_interval_seconds"/>
<ListPreference
android:key="@string/settings_sync_interval_calendars_key"
android:persistent="false"
android:title="@string/settings_sync_interval_calendars"
android:icon="@drawable/ic_today"
android:entries="@array/settings_sync_interval_names"
android:entryValues="@array/settings_sync_interval_seconds"/>
<ListPreference
android:key="@string/settings_sync_interval_tasks_key"
android:persistent="false"
android:title="@string/settings_sync_interval_tasks"
android:icon="@drawable/ic_playlist_add_check"
android:entries="@array/settings_sync_interval_names"
android:entryValues="@array/settings_sync_interval_seconds"/>
<SwitchPreferenceCompat
android:key="@string/settings_sync_wifi_only_key"
android:persistent="false"
android:title="@string/settings_sync_wifi_only"
android:icon="@drawable/ic_network_wifi"
android:summaryOn="@string/settings_sync_wifi_only_on"
android:summaryOff="@string/settings_sync_wifi_only_off" />
<EditTextPreference
android:key="@string/settings_sync_wifi_only_ssids_key"
android:persistent="false"
android:title="@string/settings_sync_wifi_only_ssids"
android:dialogMessage="@string/settings_sync_wifi_only_ssids_message"/>
<SwitchPreferenceCompat
android:key="@string/settings_ignore_vpns_key"
android:persistent="false"
android:title="@string/settings_ignore_vpns"
android:summaryOn="@string/settings_ignore_vpns_on"
android:summaryOff="@string/settings_ignore_vpns_off" />
</PreferenceCategory>
<PreferenceCategory
android:key="authentication"
android:title="@string/settings_authentication">
<EditTextPreference
android:key="@string/settings_username_key"
android:title="@string/settings_username"
android:icon="@drawable/ic_login"
android:persistent="false"
android:dialogTitle="@string/settings_enter_username" />
<EditTextPreference
android:key="@string/settings_password_key"
android:title="@string/settings_password"
android:persistent="false"
android:summary="@string/settings_password_summary"
android:dialogTitle="@string/settings_enter_password"/>
<Preference
android:key="@string/settings_certificate_alias_key"
android:title="@string/settings_certificate_alias"
android:persistent="false"/>
<Preference
android:key="@string/settings_oauth_key"
android:title="@string/settings_oauth"
android:summary="@string/settings_oauth_summary"
android:icon="@drawable/ic_login" />
</PreferenceCategory>
<PreferenceCategory
android:key="@string/settings_caldav_key"
android:title="@string/settings_caldav">
<EditTextPreference
android:key="@string/settings_sync_time_range_past_key"
android:persistent="false"
android:title="@string/settings_sync_time_range_past"
android:icon="@drawable/ic_date_range"
android:dialogMessage="@string/settings_sync_time_range_past_message"
android:inputType="number"/>
<EditTextPreference
android:key="@string/settings_key_default_alarm"
android:persistent="false"
android:title="@string/settings_default_alarm"
android:dialogMessage="@string/settings_default_alarm_message"
android:inputType="number"/>
<SwitchPreferenceCompat
android:key="@string/settings_manage_calendar_colors_key"
android:persistent="false"
android:title="@string/settings_manage_calendar_colors"
android:summaryOn="@string/settings_manage_calendar_colors_on"
android:summaryOff="@string/settings_manage_calendar_colors_off"/>
<SwitchPreferenceCompat
android:key="@string/settings_event_colors_key"
android:persistent="false"
android:title="@string/settings_event_colors"
android:summaryOn="@string/settings_event_colors_on"
android:summaryOff="@string/settings_event_colors_off"/>
</PreferenceCategory>
<PreferenceCategory
android:key="@string/settings_carddav_key"
android:title="@string/settings_carddav">
<ListPreference
android:key="@string/settings_contact_group_method_key"
android:persistent="false"
android:title="@string/settings_contact_group_method"
android:icon="@drawable/ic_group"
android:entries="@array/settings_contact_group_method_entries"
android:entryValues="@array/settings_contact_group_method_values"/>
</PreferenceCategory>
</androidx.preference.PreferenceScreen>