android: the rest of the UI in the Final 2026 style: setting rows as on the sub pages, kit colours and type everywhere, server picker labels, shortcut picker and remediation screens; the rules server moves to Scenarios, the old Trusted networks screen goes

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 19:51:18 +07:00
1 parent 9ed278b440
commit a39993075e
17 files changed
+163 -605

No files matched your search

@@ -133,10 +133,6 @@
android:name=".ui.SettingsActivity"
android:label="@string/pref_title">
</activity>
<activity
android:name=".ui.TrustedNetworksActivity"
android:label="@string/auto_connect_title">
</activity>
<activity
android:name=".ui.ConnectivityCheckActivity"
android:label="@string/health_check_title">
@@ -44,7 +44,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -58,6 +57,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.ui.compose.design.ShxPageHeader
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.components.PredictiveBackScale
import org.strongswan.android.logic.imc.RemediationInstruction
@@ -83,7 +85,7 @@ class RemediationInstructionsActivity : AppCompatActivity()
setContent {
ShxTheme {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Surface(Modifier.fillMaxSize(), color = LocalShxColors.current.background) {
RemediationUi(
instructions = loadInstructions(),
selected = selected,
@@ -130,25 +132,9 @@ private fun RemediationUi(
{
Column(Modifier.fillMaxSize())
{
TopAppBar(
title = {
Text(
selected?.getTitle() ?: stringResource(R.string.remediation_instructions_title),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
},
navigationIcon = {
IconButton(onClick = { if (selected != null) onSelect(null) else onFinish() }) {
Icon(
ShxIcons.Back,
contentDescription = stringResource(R.string.back),
)
}
},
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
),
ShxPageHeader(
selected?.getTitle() ?: stringResource(R.string.remediation_instructions_title),
onBack = { if (selected != null) onSelect(null) else onFinish() },
)
AnimatedContent(
targetState = selected,
@@ -188,14 +174,14 @@ private fun RemediationUi(
.clickable { onSelect(item) }
.padding(ShxSpacing.lg),
) {
Text(item.getTitle(), style = MaterialTheme.typography.titleMedium)
Text(item.getTitle(), style = ShxType.cardTitle)
if (!item.getDescription().isNullOrEmpty())
{
Spacer(Modifier.height(ShxSpacing.xs))
Text(
item.getDescription(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.note,
color = LocalShxColors.current.textSub,
)
}
}
@@ -214,15 +200,15 @@ private fun RemediationUi(
) {
Text(
instruction.getDescription(),
style = MaterialTheme.typography.bodyMedium,
style = ShxType.note,
modifier = Modifier.padding(top = ShxSpacing.sm),
)
if (!instruction.getHeader().isNullOrEmpty())
{
Text(
instruction.getHeader(),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.section,
color = LocalShxColors.current.textSub,
modifier = Modifier.padding(top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
@@ -230,7 +216,7 @@ private fun RemediationUi(
instruction.getItems().forEachIndexed { index, item ->
Text(
item,
style = MaterialTheme.typography.bodyLarge,
style = ShxType.body,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
if (index < instruction.getItems().lastIndex)
@@ -147,6 +147,7 @@ private fun ScenariosScreen(onBack: () -> Unit)
var config by remember { mutableStateOf(auto.currentConfig) }
val scenarios by remember { ScenarioEngine.flow(context) }.collectAsStateWithLifecycle()
var editing by remember { mutableStateOf<Scenario?>(null) }
var pickRulesServer by remember { mutableStateOf(false) }
var creating by remember { mutableStateOf(false) }
var resumes by remember { mutableIntStateOf(0) }
val owner = LocalLifecycleOwner.current
@@ -208,6 +209,24 @@ private fun ScenariosScreen(onBack: () -> Unit)
{ on -> updateAuto { it.copy(parkOnTrusted = on, connectOnUntrusted = if (on && !it.enabled) true else it.connectOnUntrusted) } }
RuleCard(ShxIcons.Phone, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_untrusted), stringResource(R.string.scenario_do_on), config.enabled && config.connectOnUntrusted, null)
{ on -> updateAuto { it.copy(connectOnUntrusted = on, parkOnTrusted = if (on && !it.enabled) true else it.parkOnTrusted) } }
/* the server the two network rules connect (was the old «Trusted networks» screen) */
if (config.enabled)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(18.dp))
.clickable(role = Role.Button) { pickRulesServer = true }.padding(horizontal = 14.dp, vertical = 12.dp),
)
{
Text(stringResource(R.string.scenarios_rules_server), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
Text(
profiles.firstOrNull { it.getUUID().toString() == config.profileUuid }?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto),
style = ShxType.body, color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false),
)
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
}
scenarios.forEach { s ->
val (icon, bg, fg) = triggerLook(s.trigger)
RuleCard(icon, bg, fg, ScenarioEngine.describeWhen(context, s), stringResource(ScenarioEngine.actionLabel(s.action)), s.enabled, onClick = { editing = s })
@@ -260,6 +279,37 @@ private fun ScenariosScreen(onBack: () -> Unit)
}
}
if (pickRulesServer)
{
ShxSheet(onDismiss = { pickRulesServer = false })
{
Text(stringResource(R.string.scenarios_rules_server_pick), style = ShxType.note.copy(fontWeight = FontWeight.Normal, lineHeight = 19.sp), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp))
ShxPanel()
{
profiles.forEachIndexed { i, p ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable {
updateAuto { it.copy(profileUuid = p.getUUID().toString()) }
pickRulesServer = false
}.padding(horizontal = 16.dp),
)
{
Text(p.name ?: p.gateway, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
if (config.profileUuid == p.getUUID().toString())
{
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
}
}
if (i < profiles.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
}
}
}
if (creating || editing != null)
{
ScenarioSheet(
@@ -1,70 +0,0 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.autoconnect.AutoConnectManager
import org.strongswan.android.logic.autoconnect.SsidReader
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.TrustedNetworksScreen
import org.strongswan.android.ui.compose.theme.ShxTheme
class TrustedNetworksActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
AutoConnectManager.getInstance(this).reloadConfig()
val currentSsid = SsidReader.from(this)
setContent {
ShxTheme {
SecondaryScaffold(title = stringResource(R.string.auto_connect_title), onBack = { finish() }) { padding ->
Box(Modifier.padding(padding)) {
/* profile list comes from SQLite, load off the main thread */
val profiles by produceState<List<VpnProfile>>(emptyList()) {
value = withContext(Dispatchers.IO) {
val source = VpnProfileSource(applicationContext).open()
try
{
source.allVpnProfiles
}
finally
{
source.close()
}
}
}
TrustedNetworksScreen(profiles = profiles, currentSsid = currentSsid)
}
}
}
}
}
}
@@ -17,9 +17,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -33,6 +30,28 @@ import androidx.core.graphics.drawable.IconCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.home.serverCode
import org.strongswan.android.ui.compose.design.ShxPanel
import org.strongswan.android.ui.compose.design.ShxIcons
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.draw.clip
import androidx.compose.ui.Alignment
import androidx.compose.material3.Icon
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.background
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.ui.compose.SecondaryScaffold
@@ -90,20 +109,40 @@ private fun ProfileSelectScreen(
onSelect: (VpnProfile) -> Unit,
)
{
val c = LocalShxColors.current
SecondaryScaffold(title = stringResource(R.string.strongswan_shortcut), onBack = onBack) { padding ->
if (profiles.isEmpty())
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 24.dp))
{
Text(stringResource(R.string.no_profiles), modifier = Modifier.padding(padding).padding(24.dp))
}
else
{
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(profiles, key = { it.getUUID().toString() }) { profile ->
ListItem(
headlineContent = { Text(profile.name ?: "") },
supportingContent = { Text(profile.gateway ?: "") },
modifier = Modifier.clickable { onSelect(profile) },
if (profiles.isEmpty())
{
Text(stringResource(R.string.no_profiles), style = ShxType.body, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp))
return@Column
}
/* the servers as on F_Servers: code circle, name, address */
ShxPanel()
{
profiles.forEachIndexed { i, profile ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().height(68.dp).clickable(role = Role.Button) { onSelect(profile) }.padding(horizontal = 14.dp),
)
{
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(c.tile))
{
Text(serverCode(profile, null), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp))
{
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(profile.gateway ?: "", style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1)
}
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
if (i < profiles.lastIndex)
{
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
}
}
}
}
@@ -25,7 +25,6 @@ import androidx.compose.foundation.verticalScroll
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
@@ -42,6 +41,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import org.strongswan.android.ui.compose.design.ShxDialogButton
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.components.ShxSection
@@ -149,8 +150,8 @@ fun ConnectivityCheckScreen()
{
Text(
stringResource(R.string.health_check_mode),
style = MaterialTheme.typography.bodyLarge,
color = if (config.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.body,
color = if (config.enabled) LocalShxColors.current.text else LocalShxColors.current.textSub,
)
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(top = ShxSpacing.sm))
{
@@ -328,8 +329,8 @@ private fun AttemptsRow(
{
Text(
title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.body,
color = if (enabled) LocalShxColors.current.text else LocalShxColors.current.textSub,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDecrement, enabled = enabled && attempts > 1) {
@@ -337,7 +338,7 @@ private fun AttemptsRow(
}
Text(
text = attempts.toString(),
style = MaterialTheme.typography.titleMedium,
style = ShxType.cardTitle,
modifier = Modifier.padding(horizontal = ShxSpacing.sm),
)
IconButton(onClick = onIncrement, enabled = enabled && attempts < 5) {
@@ -427,7 +427,7 @@ private fun ProfilePickerSheet(
}
ShxSheet(onDismiss = onDismiss)
{
ShxSheetTitle(stringResource(R.string.home_profile_picker_title))
ShxSheetTitle(stringResource(R.string.picker_title))
ShxPanel()
{
ordered.forEachIndexed { index, profile ->
@@ -470,8 +470,8 @@ private fun ProfilePickerSheet(
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.home_manage_profiles), onManage, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.add_profile_cta), onAdd, style = ShxButtonStyle.OUTLINE, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.Plus)
ShxButton(stringResource(R.string.picker_all), onManage, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f))
ShxButton(stringResource(R.string.picker_add), onAdd, style = ShxButtonStyle.OUTLINE, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.Plus)
}
}
}
@@ -127,7 +127,7 @@ fun RoutingScreen()
Scaffold(
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.background,
containerColor = LocalShxColors.current.background,
) { _ ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter)
{
@@ -111,7 +111,6 @@ import org.strongswan.android.logic.updates.UpdateManager
import org.strongswan.android.logic.updates.UpdateState
import org.strongswan.android.ui.LogActivity
import org.strongswan.android.ui.TrustedCertificatesActivity
import org.strongswan.android.ui.TrustedNetworksActivity
import org.strongswan.android.ui.ConnectivityCheckActivity
import org.strongswan.android.ui.VpnProfileImportActivity
import org.strongswan.android.ui.compose.components.ShxSection
@@ -516,8 +515,8 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
if (page == SettingsPage.ROOT || page == SettingsPage.UPDATES) Text(
text = stringResource(R.string.app_version_fmt, versionName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.meta,
color = LocalShxColors.current.textSub,
modifier = Modifier
.fillMaxWidth()
.padding(top = ShxSpacing.lg),
@@ -931,7 +930,7 @@ private fun ThemeModeRow()
remember { ShxThemeSettings.load(context) }
Column(Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md))
{
Text(stringResource(R.string.pref_theme_title), style = MaterialTheme.typography.bodyLarge)
Text(stringResource(R.string.pref_theme_title), style = ShxType.body)
Spacer(Modifier.height(ShxSpacing.sm))
val options = listOf(
ThemeMode.SYSTEM to R.string.theme_system,
@@ -192,7 +192,7 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
navigationSuiteItems = {
ShxNavigationSuiteItems(selected = state.tab, onSelect = viewModel::setTab, colors = navItemColors)
},
containerColor = MaterialTheme.colorScheme.background,
containerColor = LocalShxColors.current.background,
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationBarContainerColor = LocalShxColors.current.tile,
navigationRailContainerColor = LocalShxColors.current.tile,
@@ -200,7 +200,7 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
color = LocalShxColors.current.background,
) {
Box(Modifier.fillMaxSize())
{
@@ -1,456 +0,0 @@
/*
* Copyright (C) 2026 SHX VPN
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* for more details.
*/
package org.strongswan.android.ui.compose
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import org.strongswan.android.ui.compose.design.ShxAlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.material3.Button
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.layout.Arrangement
import org.strongswan.android.R
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.components.ShxSettingRow
import org.strongswan.android.ui.compose.components.ShxSettingToggleRow
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.autoconnect.AutoConnectManager
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Trusted networks settings: when enabled, the VPN is started on cellular and untrusted
* Wi-Fi, and auto sessions are stopped on trusted Wi-Fi.
*/
@Composable
fun TrustedNetworksScreen(
profiles: List<VpnProfile>,
currentSsid: String?,
)
{
val context = LocalContext.current
val manager = remember { AutoConnectManager.getInstance(context) }
var config by remember { mutableStateOf(manager.currentConfig) }
var showProfilePicker by remember { mutableStateOf(false) }
var showAddSsid by remember { mutableStateOf(false) }
var pendingDeleteSsid by remember { mutableStateOf<String?>(null) }
var hasBackgroundLocation by remember { mutableStateOf(isBackgroundLocationGranted(context)) }
var askedBackground by remember { mutableStateOf(false) }
val lifecycleOwner = LocalLifecycleOwner.current
fun refreshPermissions()
{
hasBackgroundLocation = isBackgroundLocationGranted(context)
if (isFineLocationGranted(context))
{
manager.reloadConfig()
}
}
val requestBackground = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { refreshPermissions() }
val requestForeground = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) {
refreshPermissions()
if (isFineLocationGranted(context) && !isBackgroundLocationGranted(context) &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{
askedBackground = true
requestBackground.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
fun requestSsidPermissions()
{
if (!isFineLocationGranted(context))
{
requestForeground.launch(wifiSsidPermissions())
return
}
if (!isBackgroundLocationGranted(context) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{
if (askedBackground)
{
openAppDetails(context)
return
}
askedBackground = true
requestBackground.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
DisposableEffect(lifecycleOwner)
{
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME)
{
refreshPermissions()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val selectedProfile = profiles.firstOrNull { it.getUUID().toString() == config.profileUuid }
val enabled = config.enabled
fun update(transform: (AutoConnectManager.Config) -> AutoConnectManager.Config)
{
val updated = transform(config)
manager.updateConfig { updated }
config = updated
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = ShxSpacing.xl),
)
{
ShxSection(title = stringResource(R.string.settings_section_general))
{
ShxSettingToggleRow(
icon = ShxIcons.Wifi,
title = stringResource(R.string.auto_connect_title),
subtitle = stringResource(R.string.auto_connect_summary),
checked = enabled,
onCheckedChange = { checked ->
/* the permission is requested from the explainer card below, not
* straight from the toggle */
update { it.copy(enabled = checked) }
},
)
ShxSectionDivider()
ShxSettingRow(
icon = ShxIcons.Globe,
title = stringResource(R.string.auto_connect_profile),
subtitle = selectedProfile?.name
?: stringResource(R.string.auto_connect_profile_none),
enabled = enabled,
onClick = { showProfilePicker = true },
)
}
if (enabled && !hasBackgroundLocation)
{
Spacer(Modifier.size(ShxSpacing.md))
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg),
)
{
Column(
modifier = Modifier.padding(ShxSpacing.lg),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{
Icon(ShxIcons.Wifi, contentDescription = null)
Text(
text = stringResource(R.string.auto_connect_location_title),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.semantics { heading() },
)
}
Text(
text = stringResource(R.string.auto_connect_location_why),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.auto_connect_location_banner),
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { requestSsidPermissions() },
modifier = Modifier.align(Alignment.End),
) { Text(stringResource(R.string.auto_connect_location_grant)) }
}
}
}
ShxSection(title = stringResource(R.string.auto_connect_trusted_title))
{
Text(
text = stringResource(R.string.auto_connect_trusted_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
)
if (config.trustedSsids.isEmpty())
{
Text(
text = stringResource(R.string.auto_connect_no_networks),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
}
else
{
val sortedSsids = remember(config.trustedSsids) { config.trustedSsids.sorted() }
sortedSsids.forEachIndexed { index, ssid ->
if (index > 0)
{
ShxSectionDivider()
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg),
)
{
Icon(
ShxIcons.Wifi,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Text(
text = ssid,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.weight(1f)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
IconButton(onClick = { pendingDeleteSsid = ssid }) {
Icon(
ShxIcons.Trash,
contentDescription = stringResource(R.string.delete_profile),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
ShxSectionDivider()
TrustedAddNetworkRow(
currentSsid = currentSsid,
onAddCurrent = { ssid -> update { it.copy(trustedSsids = it.trustedSsids + ssid) } },
onAddManual = { showAddSsid = true },
)
}
}
if (showProfilePicker)
{
ShxAlertDialog(
onDismissRequest = { showProfilePicker = false },
title = { Text(stringResource(R.string.auto_connect_profile)) },
text = {
Column {
profiles.forEach { profile ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
update { it.copy(profileUuid = profile.getUUID().toString()) }
showProfilePicker = false
}
.padding(vertical = 10.dp),
)
{
RadioButton(
selected = config.profileUuid == profile.getUUID().toString(),
onClick = null,
)
Text(profile.name ?: "", modifier = Modifier.padding(start = ShxSpacing.md))
}
}
}
},
confirmButton = {
TextButton(onClick = { showProfilePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
if (showAddSsid)
{
var ssid by remember { mutableStateOf("") }
ShxAlertDialog(
onDismissRequest = { showAddSsid = false },
title = { Text(stringResource(R.string.auto_connect_add_network)) },
text = {
OutlinedTextField(
value = ssid,
onValueChange = { ssid = it },
singleLine = true,
label = { Text(stringResource(R.string.auto_connect_ssid_label)) },
)
},
confirmButton = {
TextButton(
onClick = {
val trimmed = ssid.trim()
if (trimmed.isNotEmpty())
{
update { it.copy(trustedSsids = it.trustedSsids + trimmed) }
}
showAddSsid = false
},
enabled = ssid.isNotBlank(),
) { Text(stringResource(R.string.add_profile_cta)) }
},
dismissButton = {
TextButton(onClick = { showAddSsid = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
pendingDeleteSsid?.let { ssid ->
ShxAlertDialog(
onDismissRequest = { pendingDeleteSsid = null },
title = { Text(stringResource(R.string.auto_connect_remove_network)) },
text = { Text(ssid) },
confirmButton = {
TextButton(onClick = {
update { it.copy(trustedSsids = it.trustedSsids - ssid) }
pendingDeleteSsid = null
}) { Text(stringResource(R.string.delete_profile), color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { pendingDeleteSsid = null }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
@Composable
private fun TrustedAddNetworkRow(
currentSsid: String?,
onAddCurrent: (String) -> Unit,
onAddManual: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
)
{
if (currentSsid != null)
{
TextButton(onClick = { onAddCurrent(currentSsid) })
{
Icon(ShxIcons.Plus, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.auto_connect_add_current, currentSsid))
}
}
else
{
TextButton(onClick = onAddManual)
{
Icon(ShxIcons.Plus, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.auto_connect_add_network))
}
}
}
}
private fun isFineLocationGranted(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
private fun isBackgroundLocationGranted(context: Context): Boolean
{
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
{
return true
}
return ContextCompat.checkSelfPermission(
context, Manifest.permission.ACCESS_BACKGROUND_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
private fun wifiSsidPermissions(): Array<String> = buildList {
add(Manifest.permission.ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
{
add(Manifest.permission.NEARBY_WIFI_DEVICES)
}
}.toTypedArray()
private fun openAppDetails(context: Context)
{
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
)
}
@@ -23,7 +23,6 @@ import org.strongswan.android.ui.compose.design.ShxAlertDialog
import org.strongswan.android.ui.compose.design.ShxFormField
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -185,7 +184,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
}) {
Text(
stringResource(R.string.disconnect),
color = MaterialTheme.colorScheme.error,
color = LocalShxColors.current.error,
)
}
}
@@ -44,6 +44,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.strongswan.android.ui.compose.theme.ShxSpacing
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.heightIn
@@ -103,13 +104,13 @@ private fun RowScope.RowLabels(title: String, subtitle: String?, enabled: Boolea
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.weight(1f)
.padding(horizontal = 14.dp),
.padding(end = 10.dp),
)
{
Text(title, style = ShxType.rowTitle, color = if (enabled) c.text else c.textSub)
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = if (enabled) c.text else c.textSub)
if (subtitle != null)
{
Text(subtitle, style = ShxType.sub, color = c.textSub)
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub)
}
}
}
@@ -117,7 +118,7 @@ private fun RowScope.RowLabels(title: String, subtitle: String?, enabled: Boolea
/** Setting row with a chevron; tap opens a target screen or dialog. */
@Composable
fun ShxSettingRow(
icon: ImageVector,
@Suppress("UNUSED_PARAMETER") icon: ImageVector,
title: String,
subtitle: String? = null,
enabled: Boolean = true,
@@ -130,11 +131,11 @@ fun ShxSettingRow(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.heightIn(min = 76.dp)
.padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
.heightIn(min = 58.dp)
.padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
)
{
ShxIconBadge(icon = icon, tint = if (enabled) c.onAccentContainer else c.textSub)
/* the sub-page rows of the mockup carry no icon (the icon stays in the API) */
RowLabels(title, subtitle, enabled)
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
}
@@ -143,7 +144,7 @@ fun ShxSettingRow(
/** Setting row with the mockup switch; the whole row toggles. */
@Composable
fun ShxSettingToggleRow(
icon: ImageVector,
@Suppress("UNUSED_PARAMETER") icon: ImageVector,
title: String,
subtitle: String? = null,
checked: Boolean,
@@ -162,11 +163,11 @@ fun ShxSettingToggleRow(
role = Role.Switch,
onValueChange = onCheckedChange,
)
.heightIn(min = 76.dp)
.padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
.heightIn(min = 58.dp)
.padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
)
{
ShxIconBadge(icon = icon, tint = if (enabled) c.onAccentContainer else c.textSub)
/* the sub-page rows of the mockup carry no icon (the icon stays in the API) */
RowLabels(title, subtitle, enabled)
ShxSwitch(checked = checked, onCheckedChange = null, enabled = enabled)
}
@@ -9,6 +9,8 @@
package org.strongswan.android.ui.compose.components
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.ui.compose.theme.ShxType
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -32,7 +34,7 @@ fun ShxSectionCard(
{
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
color = LocalShxColors.current.card,
modifier = modifier.fillMaxWidth(),
) {
Column(Modifier.padding(ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
@@ -41,8 +43,8 @@ fun ShxSectionCard(
{
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = ShxType.chip,
color = LocalShxColors.current.textSub,
modifier = Modifier.semantics { heading() },
)
}
@@ -36,6 +36,7 @@ import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.logic.routing.TemplateMatch
@@ -86,7 +87,7 @@ fun AppIcon(packageName: String, size: Dp, modifier: Modifier = Modifier)
modifier
.size(size)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
.background(LocalShxColors.current.tile),
)
}
}
@@ -1332,4 +1332,9 @@
<string name="weekly_text">%1$d%% через VPN · %2$d%% напрямую</string>
<string name="faq_q_bridge">Что такое мост?</string>
<string name="faq_a_bridge">Сервер с российским адресом. Когда оператор включает белые списки, обычные серверы недоступны, а мост — работает. Включите «Мост при белых списках» на странице сервера.</string>
<string name="scenarios_rules_server">Сервер для этих правил</string>
<string name="scenarios_rules_server_pick">К нему подключимся, когда правила включают VPN.</string>
<string name="picker_title">Выберите сервер</string>
<string name="picker_all">Все серверы</string>
<string name="picker_add">Добавить</string>
</resources>
@@ -1318,4 +1318,9 @@
<string name="weekly_text">%1$d%% through the VPN · %2$d%% direct</string>
<string name="faq_q_bridge">What is a bridge?</string>
<string name="faq_a_bridge">A server with a Russian address. When the operator turns on whitelists, ordinary servers are unreachable while a bridge still works. Turn on «Bridge for whitelists» on the server page.</string>
<string name="scenarios_rules_server">Server for these rules</string>
<string name="scenarios_rules_server_pick">Connect to it when the rules turn the VPN on.</string>
<string name="picker_title">Choose a server</string>
<string name="picker_all">All servers</string>
<string name="picker_add">Add</string>
</resources>