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.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.R
import org.strongswan.android.data.VpnProfile import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.StrongSwanApplication import org.strongswan.android.logic.StrongSwanApplication
@@ -124,6 +150,8 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
val updateState by updateManager.state.collectAsStateWithLifecycle() val updateState by updateManager.state.collectAsStateWithLifecycle()
var showUpdateConfirm by remember { mutableStateOf(false) } var showUpdateConfirm by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val unknownSourcesLauncher = rememberLauncherForActivityResult( val unknownSourcesLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { ) {
@@ -133,7 +161,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
} }
else 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 = { 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( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(bottom = 24.dp), .padding(bottom = ShxSpacing.xl),
) )
{ {
if (showTitle) if (showTitle || page != SettingsPage.ROOT)
{ {
TopAppBar( 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( colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
), ),
) )
} }
ShxSection(title = stringResource(R.string.settings_section_general)) if (page == SettingsPage.ROOT)
{ {
var addedRow = false SettingsRoot(onOpen = { pageState = it })
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) }
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( ShxSettingToggleRow(
icon = Icons.Rounded.Palette, icon = Icons.Rounded.GppMaybe,
title = stringResource(R.string.pref_dynamic_colors_title), title = stringResource(R.string.whitelist_analysis_title),
subtitle = stringResource(R.string.pref_dynamic_colors_summary), subtitle = stringResource(R.string.whitelist_analysis_summary),
checked = dynamicColors, checked = whiteListEnabled,
onCheckedChange = { onCheckedChange = {
dynamicColors = it whiteListEnabled = it
ShxThemeSettings.setUseDynamicColors(context, 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() ShxSectionDivider()
} ShxSettingRow(
ShxSettingRow( icon = Icons.Rounded.CloudDownload,
icon = Icons.Rounded.History, title = stringResource(R.string.update_download_install),
title = stringResource(R.string.pref_default_vpn_profile), subtitle = when (val state = updateState)
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)
{ {
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), text = stringResource(R.string.app_version_fmt, versionName),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -481,6 +530,12 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
textAlign = androidx.compose.ui.text.style.TextAlign.Center, textAlign = androidx.compose.ui.text.style.TextAlign.Center,
) )
} }
}
ShxSnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
if (showDefaultPicker) if (showDefaultPicker)
{ {
@@ -534,7 +589,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
TextButton(onClick = { TextButton(onClick = {
if (files.isEmpty()) 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 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 package org.strongswan.android.ui.compose.theme
import android.app.Activity
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -27,6 +28,9 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable 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.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf 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 * Process-wide holder of the "use system colors" preference, so flipping it in
* Settings re-themes every open Compose surface without recreating activities. * Settings re-themes every open Compose surface without recreating activities.
*/ */
enum class ThemeMode
{
SYSTEM,
LIGHT,
DARK,
}
object ShxThemeSettings object ShxThemeSettings
{ {
private const val KEY_DYNAMIC_COLORS = "pref_use_dynamic_colors" 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) private val _useDynamicColors = MutableStateFlow(false)
@@ -227,10 +243,18 @@ object ShxThemeSettings
return return
} }
_useDynamicColors.value = prefs(context).getBoolean(KEY_DYNAMIC_COLORS, false) _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 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) fun setUseDynamicColors(context: Context, enabled: Boolean)
{ {
load(context) load(context)
@@ -268,9 +292,31 @@ private val ShxTypography = buildTypography()
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ShxTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) fun ShxTheme(content: @Composable () -> Unit)
{ {
val context = LocalContext.current 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 } val useDynamicColors by remember { ShxThemeSettings.load(context); ShxThemeSettings.useDynamicColors }
.collectAsStateWithLifecycle() .collectAsStateWithLifecycle()
val colors = when val colors = when
@@ -493,4 +493,10 @@
<string name="availability_collapse">Скрыть детали</string> <string name="availability_collapse">Скрыть детали</string>
<string name="routing_advanced">Дополнительно</string> <string name="routing_advanced">Дополнительно</string>
<string name="routing_killswitch_note">Исключённые приложения (%1$d) всегда идут мимо VPN, в том числе при обрыве туннеля: kill switch их не блокирует.</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> </resources>
@@ -498,4 +498,10 @@
<string name="availability_collapse">Hide details</string> <string name="availability_collapse">Hide details</string>
<string name="routing_advanced">Advanced</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="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> </resources>