android: settings as grouped pages (appearance, automation, security, updates, support), theme mode, snackbars instead of toasts
This commit is contained in:
1 parent
a451386e0a
commit
0c3ea75cac
4 files changed
+535
-300
No files matched your search
+476
-299
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
+47
-1
@@ -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>
|
||||
Reference in new issue
Block a user