From 0c3ea75cac7af24f82a01a20a54f750920da364d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 10 Oct 2026 00:09:45 +0700 Subject: [PATCH] android: settings as grouped pages (appearance, automation, security, updates, support), theme mode, snackbars instead of toasts --- .../android/ui/compose/SettingsScreen.kt | 775 +++++++++++------- .../android/ui/compose/theme/ShxTheme.kt | 48 +- .../app/src/main/res/values-ru/strings.xml | 6 + .../app/src/main/res/values/strings.xml | 6 + 4 files changed, 535 insertions(+), 300 deletions(-) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt index a56c9ce87..291969ab9 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt @@ -69,6 +69,32 @@ import androidx.preference.PreferenceManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Brightness6 +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.IconButton +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import kotlinx.coroutines.launch +import org.strongswan.android.ui.compose.components.ShxSnackbarHost +import org.strongswan.android.ui.compose.theme.ShxSpacing +import org.strongswan.android.ui.compose.theme.ThemeMode +import androidx.compose.material3.Icon +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import org.strongswan.android.R import org.strongswan.android.data.VpnProfile import org.strongswan.android.logic.StrongSwanApplication @@ -124,6 +150,8 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) val updateState by updateManager.state.collectAsStateWithLifecycle() var showUpdateConfirm by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() val unknownSourcesLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() ) { @@ -133,7 +161,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) } else { - Toast.makeText(context, R.string.update_permission_denied, Toast.LENGTH_LONG).show() + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.update_permission_denied)) } } } val startUpdate: () -> Unit = { @@ -147,331 +175,352 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) } } + var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) } + /* from a sub page "back" returns to the settings list, not out of the tab */ + BackHandler(enabled = pageState != SettingsPage.ROOT) { pageState = SettingsPage.ROOT } + val effects = MaterialTheme.motionScheme.defaultEffectsSpec() + val spatial = MaterialTheme.motionScheme.defaultSpatialSpec() + + Box(Modifier.fillMaxSize()) + { + AnimatedContent( + targetState = pageState, + transitionSpec = { + val forward = targetState != SettingsPage.ROOT + (fadeIn(effects) + slideInHorizontally(spatial) { if (forward) it / 8 else -it / 8 }) togetherWith + (fadeOut(effects) + slideOutHorizontally(spatial) { if (forward) -it / 8 else it / 8 }) + }, + label = "settingsPage", + ) { page -> Column( Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(bottom = 24.dp), + .padding(bottom = ShxSpacing.xl), ) { - if (showTitle) + if (showTitle || page != SettingsPage.ROOT) { TopAppBar( - title = { Text(stringResource(R.string.pref_title), fontWeight = FontWeight.SemiBold) }, + title = { + Text( + stringResource(page.titleRes), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.semantics { heading() }, + ) + }, + navigationIcon = { + if (page != SettingsPage.ROOT) + { + IconButton(onClick = { pageState = SettingsPage.ROOT }) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(android.R.string.cancel)) + } + } + }, colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.background, ), ) } - ShxSection(title = stringResource(R.string.settings_section_general)) + if (page == SettingsPage.ROOT) { - var addedRow = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + SettingsRoot(onOpen = { pageState = it }) + } + + if (page == SettingsPage.APPEARANCE) + { + ShxSection(title = stringResource(R.string.settings_page_appearance)) { - var dynamicColors by remember { ShxThemeSettings.load(context); mutableStateOf(ShxThemeSettings.useDynamicColors.value) } + ThemeModeRow() + var addedRow = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + { + var dynamicColors by remember { ShxThemeSettings.load(context); mutableStateOf(ShxThemeSettings.useDynamicColors.value) } + ShxSettingToggleRow( + icon = Icons.Rounded.Palette, + title = stringResource(R.string.pref_dynamic_colors_title), + subtitle = stringResource(R.string.pref_dynamic_colors_summary), + checked = dynamicColors, + onCheckedChange = { + dynamicColors = it + ShxThemeSettings.setUseDynamicColors(context, it) + }, + ) + addedRow = true + } + } + } + + if (page == SettingsPage.AUTOMATION) + { + ShxSection(title = stringResource(R.string.settings_section_general)) + { + var addedRow = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && managed.isAllowSettingsAccess) + { + if (addedRow) + { + ShxSectionDivider() + } + ShxSettingRow( + icon = Icons.Rounded.History, + title = stringResource(R.string.pref_default_vpn_profile), + subtitle = defaultLabel, + onClick = { showDefaultPicker = true }, + ) + ShxSectionDivider() + ShxSettingToggleRow( + icon = Icons.Rounded.BatteryChargingFull, + title = stringResource(R.string.pref_power_whitelist_title), + subtitle = stringResource(R.string.pref_power_whitelist_summary), + checked = ignorePower, + onCheckedChange = { + ignorePower = it + prefs.edit().putBoolean(Constants.PREF_IGNORE_POWER_WHITELIST, it).apply() + }, + ) + } + if (Build.MANUFACTURER.equals("samsung", ignoreCase = true)) + { /* One UI puts unused apps to sleep even with battery optimizations + * disabled, which breaks watchdog-based recovery of the tunnel */ + ShxSectionDivider() + ShxSettingRow( + icon = Icons.Rounded.Bedtime, + title = stringResource(R.string.samsung_sleep_title), + subtitle = stringResource(R.string.samsung_sleep_summary), + onClick = { openSamsungBatterySettings(context) }, + ) + } + } + } + + if (page == SettingsPage.AUTOMATION) + { + ShxSection(title = stringResource(R.string.settings_section_automation)) + { + ShxSettingRow( + icon = Icons.Rounded.Wifi, + title = stringResource(R.string.auto_connect_title), + subtitle = stringResource(R.string.auto_connect_summary), + onClick = { + context.startActivity(Intent(context, TrustedNetworksActivity::class.java)) + }, + ) + ShxSectionDivider() + ShxSettingRow( + icon = Icons.Rounded.NetworkCheck, + title = stringResource(R.string.health_check_title), + subtitle = stringResource(R.string.health_check_summary), + onClick = { + context.startActivity(Intent(context, ConnectivityCheckActivity::class.java)) + }, + ) + ShxSectionDivider() + var whiteListEnabled by remember { + mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled) + } ShxSettingToggleRow( - icon = Icons.Rounded.Palette, - title = stringResource(R.string.pref_dynamic_colors_title), - subtitle = stringResource(R.string.pref_dynamic_colors_summary), - checked = dynamicColors, + icon = Icons.Rounded.GppMaybe, + title = stringResource(R.string.whitelist_analysis_title), + subtitle = stringResource(R.string.whitelist_analysis_summary), + checked = whiteListEnabled, onCheckedChange = { - dynamicColors = it - ShxThemeSettings.setUseDynamicColors(context, it) + whiteListEnabled = it + WhiteListManager.getInstance(context).updateConfig { config -> config.copy(enabled = it) } + }, + ) + ShxSectionDivider() + var shortcutsEnabled by remember { + mutableStateOf(VpnShortcutManager.getInstance(context).isEnabled()) + } + ShxSettingToggleRow( + icon = Icons.Rounded.Bolt, + title = stringResource(R.string.pref_routines_shortcuts_title), + subtitle = stringResource(R.string.pref_routines_shortcuts_summary), + checked = shortcutsEnabled, + onCheckedChange = { + shortcutsEnabled = it + VpnShortcutManager.getInstance(context).setEnabled(it) + }, + ) + ShxSectionDivider() + var watchdogEnabled by remember { + mutableStateOf(prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true)) + } + ShxSettingToggleRow( + icon = Icons.Rounded.RestartAlt, + title = stringResource(R.string.watchdog_title), + subtitle = stringResource(R.string.watchdog_summary), + checked = watchdogEnabled, + onCheckedChange = { + watchdogEnabled = it + prefs.edit().putBoolean(Constants.PREF_WATCHDOG_ENABLED, it).apply() + if (it) + { /* start watching right away if a connection is expected */ + WatchdogReceiver.schedule(context) + } }, ) - addedRow = true } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && managed.isAllowSettingsAccess) + } + + if (page == SettingsPage.SECURITY) + { + ShxSection(title = stringResource(R.string.settings_section_security)) { - if (addedRow) + KillSwitchRow() + ShxSectionDivider() + var safeIpv6 by remember { + mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false)) + } + ShxSettingToggleRow( + icon = Icons.Rounded.GppGood, + title = stringResource(R.string.safe_ipv6_title), + subtitle = stringResource(R.string.safe_ipv6_summary), + checked = safeIpv6, + onCheckedChange = { + safeIpv6 = it + prefs.edit().putBoolean(Constants.PREF_SAFE_IPV6, it).apply() + }, + ) + } + } + + if (page == SettingsPage.SECURITY) + { + ShxSection(title = stringResource(R.string.settings_section_certs)) + { + ShxSettingRow( + icon = Icons.Rounded.VerifiedUser, + title = stringResource(R.string.trusted_certs_title), + onClick = { + context.startActivity(Intent(context, TrustedCertificatesActivity::class.java)) + }, + ) + ShxSectionDivider() + ShxSettingRow( + icon = Icons.Rounded.Cached, + title = stringResource(R.string.crl_cache), + onClick = { showCrl = true }, + ) + } + } + + if (page == SettingsPage.SUPPORT) + { + if (managed.isAllowProfileImport) + { + ShxSection(title = stringResource(R.string.settings_section_profiles)) + { + ShxSettingRow( + icon = Icons.Rounded.FileDownload, + title = stringResource(R.string.profile_import), + onClick = { + context.startActivity(Intent(context, VpnProfileImportActivity::class.java)) + }, + ) + } + } + } + + if (page == SettingsPage.SUPPORT) + { + ShxSection(title = stringResource(R.string.settings_section_support)) + { + ShxSettingRow( + icon = Icons.Rounded.Description, + title = stringResource(R.string.show_log), + onClick = { + context.startActivity(Intent(context, LogActivity::class.java)) + }, + ) + ShxSectionDivider() + var extendedDiag by remember { + mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false)) + } + ShxSettingToggleRow( + icon = Icons.Rounded.BugReport, + title = stringResource(R.string.extended_diag_title), + subtitle = stringResource(R.string.extended_diag_summary), + checked = extendedDiag, + onCheckedChange = { + extendedDiag = it + prefs.edit().putBoolean(Constants.PREF_EXTENDED_DIAG, it).apply() + }, + ) + } + } + + if (page == SettingsPage.UPDATES) + { + ShxSection(title = stringResource(R.string.settings_section_updates)) + { + var autoUpdate by remember { + mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) + } + ShxSettingToggleRow( + icon = Icons.Rounded.Autorenew, + title = stringResource(R.string.update_auto_check_title), + subtitle = stringResource(R.string.update_auto_check_summary), + checked = autoUpdate, + onCheckedChange = { + autoUpdate = it + prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply() + }, + ) + ShxSectionDivider() + var silentUpdates by remember { + mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) + } + ShxSettingToggleRow( + icon = Icons.Rounded.CloudSync, + title = stringResource(R.string.update_silent_title), + subtitle = stringResource(R.string.update_silent_summary), + checked = silentUpdates, + onCheckedChange = { + silentUpdates = it + prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply() + }, + ) + ShxSectionDivider() + ShxSettingRow( + icon = Icons.Rounded.SystemUpdateAlt, + title = stringResource(R.string.update_check_title), + subtitle = updateStatusText(updateState), + onClick = { + when (updateState) + { + is UpdateState.Checking, is UpdateState.Downloading -> Unit + else -> updateManager.checkNow() + } + }, + ) + val updateInfo = (updateState as? UpdateState.Available)?.info + ?: (updateState as? UpdateState.Downloading)?.info + if (updateInfo != null) { ShxSectionDivider() - } - ShxSettingRow( - icon = Icons.Rounded.History, - title = stringResource(R.string.pref_default_vpn_profile), - subtitle = defaultLabel, - onClick = { showDefaultPicker = true }, - ) - ShxSectionDivider() - ShxSettingToggleRow( - icon = Icons.Rounded.BatteryChargingFull, - title = stringResource(R.string.pref_power_whitelist_title), - subtitle = stringResource(R.string.pref_power_whitelist_summary), - checked = ignorePower, - onCheckedChange = { - ignorePower = it - prefs.edit().putBoolean(Constants.PREF_IGNORE_POWER_WHITELIST, it).apply() - }, - ) - } - if (Build.MANUFACTURER.equals("samsung", ignoreCase = true)) - { /* One UI puts unused apps to sleep even with battery optimizations - * disabled, which breaks watchdog-based recovery of the tunnel */ - ShxSectionDivider() - ShxSettingRow( - icon = Icons.Rounded.Bedtime, - title = stringResource(R.string.samsung_sleep_title), - subtitle = stringResource(R.string.samsung_sleep_summary), - onClick = { openSamsungBatterySettings(context) }, - ) - } - } - - ShxSection(title = stringResource(R.string.settings_section_automation)) - { - ShxSettingRow( - icon = Icons.Rounded.Wifi, - title = stringResource(R.string.auto_connect_title), - subtitle = stringResource(R.string.auto_connect_summary), - onClick = { - context.startActivity(Intent(context, TrustedNetworksActivity::class.java)) - }, - ) - ShxSectionDivider() - ShxSettingRow( - icon = Icons.Rounded.NetworkCheck, - title = stringResource(R.string.health_check_title), - subtitle = stringResource(R.string.health_check_summary), - onClick = { - context.startActivity(Intent(context, ConnectivityCheckActivity::class.java)) - }, - ) - ShxSectionDivider() - var whiteListEnabled by remember { - mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled) - } - ShxSettingToggleRow( - icon = Icons.Rounded.GppMaybe, - title = stringResource(R.string.whitelist_analysis_title), - subtitle = stringResource(R.string.whitelist_analysis_summary), - checked = whiteListEnabled, - onCheckedChange = { - whiteListEnabled = it - WhiteListManager.getInstance(context).updateConfig { config -> config.copy(enabled = it) } - }, - ) - ShxSectionDivider() - var shortcutsEnabled by remember { - mutableStateOf(VpnShortcutManager.getInstance(context).isEnabled()) - } - ShxSettingToggleRow( - icon = Icons.Rounded.Bolt, - title = stringResource(R.string.pref_routines_shortcuts_title), - subtitle = stringResource(R.string.pref_routines_shortcuts_summary), - checked = shortcutsEnabled, - onCheckedChange = { - shortcutsEnabled = it - VpnShortcutManager.getInstance(context).setEnabled(it) - }, - ) - ShxSectionDivider() - var watchdogEnabled by remember { - mutableStateOf(prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true)) - } - ShxSettingToggleRow( - icon = Icons.Rounded.RestartAlt, - title = stringResource(R.string.watchdog_title), - subtitle = stringResource(R.string.watchdog_summary), - checked = watchdogEnabled, - onCheckedChange = { - watchdogEnabled = it - prefs.edit().putBoolean(Constants.PREF_WATCHDOG_ENABLED, it).apply() - if (it) - { /* start watching right away if a connection is expected */ - WatchdogReceiver.schedule(context) - } - }, - ) - } - - ShxSection(title = stringResource(R.string.settings_section_security)) - { - /* re-read on every ON_RESUME so the summary reflects changes made in system settings */ - var lockdownActive by remember { mutableStateOf(context.isVpnLockdownEnabled()) } - var showKillSwitchDialog by remember { mutableStateOf(false) } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) - { - lockdownActive = context.isVpnLockdownEnabled() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - if (showKillSwitchDialog) - { - AlertDialog( - onDismissRequest = { showKillSwitchDialog = false }, - title = { Text(stringResource(R.string.kill_switch_dialog_title)) }, - text = { Text(stringResource(R.string.kill_switch_dialog_text)) }, - confirmButton = { - TextButton(onClick = { - showKillSwitchDialog = false - context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) - }) { Text(stringResource(R.string.kill_switch_dialog_enable)) } - }, - dismissButton = { - TextButton(onClick = { showKillSwitchDialog = false }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) - } - ShxSettingRow( - icon = Icons.Rounded.Security, - title = stringResource(R.string.kill_switch_title), - subtitle = stringResource( - if (lockdownActive) R.string.kill_switch_summary_on - else R.string.kill_switch_summary_off - ), - onClick = { - /* explain what this actually toggles before sending the user to system settings */ - if (lockdownActive) context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) - else showKillSwitchDialog = true - }, - ) - ShxSectionDivider() - var safeIpv6 by remember { - mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false)) - } - ShxSettingToggleRow( - icon = Icons.Rounded.GppGood, - title = stringResource(R.string.safe_ipv6_title), - subtitle = stringResource(R.string.safe_ipv6_summary), - checked = safeIpv6, - onCheckedChange = { - safeIpv6 = it - prefs.edit().putBoolean(Constants.PREF_SAFE_IPV6, it).apply() - }, - ) - } - - ShxSection(title = stringResource(R.string.settings_section_certs)) - { - ShxSettingRow( - icon = Icons.Rounded.VerifiedUser, - title = stringResource(R.string.trusted_certs_title), - onClick = { - context.startActivity(Intent(context, TrustedCertificatesActivity::class.java)) - }, - ) - ShxSectionDivider() - ShxSettingRow( - icon = Icons.Rounded.Cached, - title = stringResource(R.string.crl_cache), - onClick = { showCrl = true }, - ) - } - - if (managed.isAllowProfileImport) - { - ShxSection(title = stringResource(R.string.settings_section_profiles)) - { - ShxSettingRow( - icon = Icons.Rounded.FileDownload, - title = stringResource(R.string.profile_import), - onClick = { - context.startActivity(Intent(context, VpnProfileImportActivity::class.java)) - }, - ) - } - } - - ShxSection(title = stringResource(R.string.settings_section_support)) - { - ShxSettingRow( - icon = Icons.Rounded.Description, - title = stringResource(R.string.show_log), - onClick = { - context.startActivity(Intent(context, LogActivity::class.java)) - }, - ) - ShxSectionDivider() - var extendedDiag by remember { - mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false)) - } - ShxSettingToggleRow( - icon = Icons.Rounded.BugReport, - title = stringResource(R.string.extended_diag_title), - subtitle = stringResource(R.string.extended_diag_summary), - checked = extendedDiag, - onCheckedChange = { - extendedDiag = it - prefs.edit().putBoolean(Constants.PREF_EXTENDED_DIAG, it).apply() - }, - ) - } - - ShxSection(title = stringResource(R.string.settings_section_updates)) - { - var autoUpdate by remember { - mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) - } - ShxSettingToggleRow( - icon = Icons.Rounded.Autorenew, - title = stringResource(R.string.update_auto_check_title), - subtitle = stringResource(R.string.update_auto_check_summary), - checked = autoUpdate, - onCheckedChange = { - autoUpdate = it - prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply() - }, - ) - ShxSectionDivider() - var silentUpdates by remember { - mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) - } - ShxSettingToggleRow( - icon = Icons.Rounded.CloudSync, - title = stringResource(R.string.update_silent_title), - subtitle = stringResource(R.string.update_silent_summary), - checked = silentUpdates, - onCheckedChange = { - silentUpdates = it - prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply() - }, - ) - ShxSectionDivider() - ShxSettingRow( - icon = Icons.Rounded.SystemUpdateAlt, - title = stringResource(R.string.update_check_title), - subtitle = updateStatusText(updateState), - onClick = { - when (updateState) - { - is UpdateState.Checking, is UpdateState.Downloading -> Unit - else -> updateManager.checkNow() - } - }, - ) - val updateInfo = (updateState as? UpdateState.Available)?.info - ?: (updateState as? UpdateState.Downloading)?.info - if (updateInfo != null) - { - ShxSectionDivider() - ShxSettingRow( - icon = Icons.Rounded.CloudDownload, - title = stringResource(R.string.update_download_install), - subtitle = when (val state = updateState) - { - is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress) - else -> stringResource(R.string.update_available_fmt, updateInfo.versionName) - }, - onClick = { - if (updateState !is UpdateState.Downloading) + ShxSettingRow( + icon = Icons.Rounded.CloudDownload, + title = stringResource(R.string.update_download_install), + subtitle = when (val state = updateState) { - showUpdateConfirm = true - } - }, - ) + is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress) + else -> stringResource(R.string.update_available_fmt, updateInfo.versionName) + }, + onClick = { + if (updateState !is UpdateState.Downloading) + { + showUpdateConfirm = true + } + }, + ) + } } } - Text( + if (page == SettingsPage.ROOT || page == SettingsPage.UPDATES) Text( text = stringResource(R.string.app_version_fmt, versionName), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -481,6 +530,12 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) } + } + ShxSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } if (showDefaultPicker) { @@ -534,7 +589,7 @@ fun SettingsScreen(profiles: List, showTitle: Boolean = true) TextButton(onClick = { if (files.isEmpty()) { - Toast.makeText(context, R.string.clear_crl_cache_msg_none, Toast.LENGTH_SHORT).show() + scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.clear_crl_cache_msg_none)) } } else { @@ -654,3 +709,125 @@ private fun openSamsungBatterySettings(context: Context) } } } + +enum class SettingsPage(val titleRes: Int) +{ + ROOT(R.string.pref_title), + APPEARANCE(R.string.settings_page_appearance), + AUTOMATION(R.string.settings_section_automation), + SECURITY(R.string.settings_section_security), + UPDATES(R.string.settings_section_updates), + SUPPORT(R.string.settings_section_support), +} + +/** Settings landing list: two quick rows on top, then the five groups. */ +@Composable +private fun SettingsRoot(onOpen: (SettingsPage) -> Unit) +{ + val context = LocalContext.current + ShxSection(title = stringResource(R.string.settings_quick)) + { + KillSwitchRow() + ShxSectionDivider() + ShxSettingRow( + icon = Icons.Rounded.Wifi, + title = stringResource(R.string.auto_connect_title), + subtitle = stringResource(R.string.auto_connect_summary), + onClick = { context.startActivity(Intent(context, TrustedNetworksActivity::class.java)) }, + ) + } + ShxSection(title = stringResource(R.string.pref_title)) + { + ShxSettingRow(icon = Icons.Rounded.Palette, title = stringResource(R.string.settings_page_appearance), onClick = { onOpen(SettingsPage.APPEARANCE) }) + ShxSectionDivider() + ShxSettingRow(icon = Icons.Rounded.Bolt, title = stringResource(R.string.settings_section_automation), onClick = { onOpen(SettingsPage.AUTOMATION) }) + ShxSectionDivider() + ShxSettingRow(icon = Icons.Rounded.Security, title = stringResource(R.string.settings_section_security), onClick = { onOpen(SettingsPage.SECURITY) }) + ShxSectionDivider() + ShxSettingRow(icon = Icons.Rounded.SystemUpdateAlt, title = stringResource(R.string.settings_section_updates), onClick = { onOpen(SettingsPage.UPDATES) }) + ShxSectionDivider() + ShxSettingRow(icon = Icons.Rounded.Description, title = stringResource(R.string.settings_section_support), onClick = { onOpen(SettingsPage.SUPPORT) }) + } +} + +/** System / light / dark selector, applied immediately through [ShxThemeSettings]. */ +@Composable +private fun ThemeModeRow() +{ + val context = LocalContext.current + val mode by ShxThemeSettings.themeMode.collectAsStateWithLifecycle() + remember { ShxThemeSettings.load(context) } + Column(Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md)) + { + Text(stringResource(R.string.pref_theme_title), style = MaterialTheme.typography.bodyLarge) + Spacer(Modifier.height(ShxSpacing.sm)) + val options = listOf( + ThemeMode.SYSTEM to R.string.theme_system, + ThemeMode.LIGHT to R.string.theme_light, + ThemeMode.DARK to R.string.theme_dark, + ) + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) + { + options.forEachIndexed { index, (value, label) -> + SegmentedButton( + selected = mode == value, + onClick = { ShxThemeSettings.setThemeMode(context, value) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + ) { Text(stringResource(label)) } + } + } + } +} + +/** Kill switch status row; explains and opens the system "Always-on VPN" settings. */ +@Composable +private fun KillSwitchRow() +{ + val context = LocalContext.current + /* re-read on every ON_RESUME so the summary reflects changes made in system settings */ + var lockdownActive by remember { mutableStateOf(context.isVpnLockdownEnabled()) } + var showKillSwitchDialog by remember { mutableStateOf(false) } + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) + { + lockdownActive = context.isVpnLockdownEnabled() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + if (showKillSwitchDialog) + { + AlertDialog( + onDismissRequest = { showKillSwitchDialog = false }, + title = { Text(stringResource(R.string.kill_switch_dialog_title)) }, + text = { Text(stringResource(R.string.kill_switch_dialog_text)) }, + confirmButton = { + TextButton(onClick = { + showKillSwitchDialog = false + context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) + }) { Text(stringResource(R.string.kill_switch_dialog_enable)) } + }, + dismissButton = { + TextButton(onClick = { showKillSwitchDialog = false }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + ShxSettingRow( + icon = Icons.Rounded.Security, + title = stringResource(R.string.kill_switch_title), + subtitle = stringResource( + if (lockdownActive) R.string.kill_switch_summary_on + else R.string.kill_switch_summary_off + ), + onClick = { + /* explain what this actually toggles before sending the user to system settings */ + if (lockdownActive) context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) + else showKillSwitchDialog = true + }, + ) +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt index 4669f593f..161192045 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/theme/ShxTheme.kt @@ -9,6 +9,7 @@ package org.strongswan.android.ui.compose.theme +import android.app.Activity import android.content.Context import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme @@ -27,6 +28,9 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf @@ -205,9 +209,21 @@ val LocalShxStatusColors = staticCompositionLocalOf { LightStatusColors } * Process-wide holder of the "use system colors" preference, so flipping it in * Settings re-themes every open Compose surface without recreating activities. */ +enum class ThemeMode +{ + SYSTEM, + LIGHT, + DARK, +} + object ShxThemeSettings { private const val KEY_DYNAMIC_COLORS = "pref_use_dynamic_colors" + private const val KEY_THEME_MODE = "pref_theme_mode" + + private val _themeMode = MutableStateFlow(ThemeMode.SYSTEM) + + val themeMode: StateFlow = _themeMode.asStateFlow() private val _useDynamicColors = MutableStateFlow(false) @@ -227,10 +243,18 @@ object ShxThemeSettings return } _useDynamicColors.value = prefs(context).getBoolean(KEY_DYNAMIC_COLORS, false) + _themeMode.value = ThemeMode.entries.getOrElse(prefs(context).getInt(KEY_THEME_MODE, 0)) { ThemeMode.SYSTEM } loaded = true } } + fun setThemeMode(context: Context, mode: ThemeMode) + { + load(context) + prefs(context).edit().putInt(KEY_THEME_MODE, mode.ordinal).apply() + _themeMode.value = mode + } + fun setUseDynamicColors(context: Context, enabled: Boolean) { load(context) @@ -268,9 +292,31 @@ private val ShxTypography = buildTypography() @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ShxTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) +fun ShxTheme(content: @Composable () -> Unit) { val context = LocalContext.current + val themeMode by remember { ShxThemeSettings.load(context); ShxThemeSettings.themeMode } + .collectAsStateWithLifecycle() + val darkTheme = when (themeMode) + { + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + ThemeMode.SYSTEM -> isSystemInDarkTheme() + } + /* keep system bar icons readable when the chosen theme differs from the system one */ + val view = LocalView.current + if (!view.isInEditMode) + { + SideEffect { + val window = (view.context as? Activity)?.window + if (window != null) + { + val controller = WindowCompat.getInsetsController(window, view) + controller.isAppearanceLightStatusBars = !darkTheme + controller.isAppearanceLightNavigationBars = !darkTheme + } + } + } val useDynamicColors by remember { ShxThemeSettings.load(context); ShxThemeSettings.useDynamicColors } .collectAsStateWithLifecycle() val colors = when diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index af4628d26..2c56d480f 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -493,4 +493,10 @@ Скрыть детали Дополнительно Исключённые приложения (%1$d) всегда идут мимо VPN, в том числе при обрыве туннеля: kill switch их не блокирует. + Оформление + Быстрые настройки + Тема + Системная + Светлая + Тёмная diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index 062294e9d..bfcad5962 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -498,4 +498,10 @@ Hide details Advanced %1$d excluded app(s) always use the normal network, even when the tunnel drops: the kill switch does not block them. + Appearance + Quick settings + Theme + System + Light + Dark