android: settings as grouped pages (appearance, automation, security, updates, support), theme mode, snackbars instead of toasts

This commit is contained in:
Denozordec committed 2026-10-10 00:09:45 +07:00
1 parent a451386e0a
commit 0c3ea75cac
4 files changed
+535 -300

No files matched your search

@@ -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<VpnProfile>, 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<VpnProfile>, 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<VpnProfile>, 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<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
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<VpnProfile>, 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<VpnProfile>, 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
},
)
}
@@ -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> = _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
@@ -493,4 +493,10 @@
<string name="availability_collapse">Скрыть детали</string>
<string name="routing_advanced">Дополнительно</string>
<string name="routing_killswitch_note">Исключённые приложения (%1$d) всегда идут мимо VPN, в том числе при обрыве туннеля: kill switch их не блокирует.</string>
<string name="settings_page_appearance">Оформление</string>
<string name="settings_quick">Быстрые настройки</string>
<string name="pref_theme_title">Тема</string>
<string name="theme_system">Системная</string>
<string name="theme_light">Светлая</string>
<string name="theme_dark">Тёмная</string>
</resources>
@@ -498,4 +498,10 @@
<string name="availability_collapse">Hide details</string>
<string name="routing_advanced">Advanced</string>
<string name="routing_killswitch_note">%1$d excluded app(s) always use the normal network, even when the tunnel drops: the kill switch does not block them.</string>
<string name="settings_page_appearance">Appearance</string>
<string name="settings_quick">Quick settings</string>
<string name="pref_theme_title">Theme</string>
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
</resources>