diff --git a/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt b/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt index d885f50f..62ab831f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt @@ -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() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsActivity.kt index aef7ac91..a2d0e554 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsActivity.kt @@ -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(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") } - @Inject lateinit var modelFactory: Model.Factory - val model by viewModels { - object: ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class) = - 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?, - onUpdateOnlyOnSsids: (List) -> 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() - val syncIntervalCalendars = MutableLiveData() - - val tasksProvider = TaskUtils.currentProvider(context) - val syncIntervalTasks = MutableLiveData() - - val syncWifiOnly = MutableLiveData() - val syncWifiOnlySSIDs = MutableLiveData>() - val ignoreVpns = MutableLiveData() - - val credentials = MutableLiveData() - - val timeRangePastDays = MutableLiveData() - val defaultAlarmMinBefore = MutableLiveData() - val manageCalendarColors = MutableLiveData() - val eventColors = MutableLiveData() - - val contactGroupMethod = MutableLiveData() - - - 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?) { - 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) - } - - } - } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt new file mode 100644 index 00000000..62acda5f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsModel.kt @@ -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(null) + var syncIntervalCalendars by mutableStateOf(null) + + private val tasksProvider = TaskUtils.currentProvider(context) + var syncIntervalTasks by mutableStateOf(null) + + var syncWifiOnly by mutableStateOf(false) + var syncWifiOnlySSIDs by mutableStateOf?>(null) + var ignoreVpns by mutableStateOf(false) + + var credentials by mutableStateOf(Credentials()) + + var timeRangePastDays by mutableStateOf(null) + var defaultAlarmMinBefore by mutableStateOf(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?) { + 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) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsScreen.kt new file mode 100644 index 00000000..fa8b5023 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountSettingsScreen.kt @@ -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?, + onUpdateOnlyOnSsids: (List) -> 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?, + onUpdateOnlyOnSsids: (List) -> 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?, + onUpdateOnlyOnSsids: (List) -> 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 = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84d001d3..656cab27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -336,8 +336,6 @@ VPN without underlying validated Internet connection is not enough to run synchronization (recommended) VPN without underlying validated Internet connection is enough to run synchronization Authentication - Re-authenticate - Perform OAuth login again User name Password New password