diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml index 4c0b0e565..154446118 100644 --- a/src/frontends/android/app/src/main/AndroidManifest.xml +++ b/src/frontends/android/app/src/main/AndroidManifest.xml @@ -133,10 +133,6 @@ android:name=".ui.SettingsActivity" android:label="@string/pref_title"> - - diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RemediationInstructionsActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RemediationInstructionsActivity.kt index fb9751d9c..6b831f8e8 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RemediationInstructionsActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/RemediationInstructionsActivity.kt @@ -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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ScenariosActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ScenariosActivity.kt index 9091592d4..d6ef6acd2 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ScenariosActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/ScenariosActivity.kt @@ -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(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( diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/TrustedNetworksActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/TrustedNetworksActivity.kt deleted file mode 100644 index 945a437d4..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/TrustedNetworksActivity.kt +++ /dev/null @@ -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 . - * - * 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>(emptyList()) { - value = withContext(Dispatchers.IO) { - val source = VpnProfileSource(applicationContext).open() - try - { - source.allVpnProfiles - } - finally - { - source.close() - } - } - } - TrustedNetworksScreen(profiles = profiles, currentSsid = currentSsid) - } - } - } - } - } -} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileSelectActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileSelectActivity.kt index c56f7a9b2..5270ff524 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileSelectActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileSelectActivity.kt @@ -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)) + } } } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ConnectivityCheckScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ConnectivityCheckScreen.kt index 4ec31717e..6b5d27147 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ConnectivityCheckScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ConnectivityCheckScreen.kt @@ -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) { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt index 019ec7119..4f195695f 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt @@ -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) } } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt index d6b9d0398..1b88dfe0c 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt @@ -127,7 +127,7 @@ fun RoutingScreen() Scaffold( snackbarHost = { ShxSnackbarHost(snackbarHostState) }, - containerColor = MaterialTheme.colorScheme.background, + containerColor = LocalShxColors.current.background, ) { _ -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt index c030a8e6b..8887bd235 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt @@ -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, 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, diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt index 269eadeb2..4688f6fb4 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ShxApp.kt @@ -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()) { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/TrustedNetworksScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/TrustedNetworksScreen.kt deleted file mode 100644 index c42a43ec8..000000000 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/TrustedNetworksScreen.kt +++ /dev/null @@ -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 . - * - * 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, - 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(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 = 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) - } - ) -} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/VpnDialogs.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/VpnDialogs.kt index 5e40f76af..cf95d92a4 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/VpnDialogs.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/VpnDialogs.kt @@ -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, ) } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt index 7810991a6..0e8ddca9f 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt @@ -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) } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt index 02cfc6224..2006a1494 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt @@ -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() }, ) } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingUiUtil.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingUiUtil.kt index 992e15df2..dd8f54469 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingUiUtil.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/routing/RoutingUiUtil.kt @@ -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), ) } } diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index cda47a3e2..339bfb16b 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -1332,4 +1332,9 @@ %1$d%% через VPN · %2$d%% напрямую Что такое мост? Сервер с российским адресом. Когда оператор включает белые списки, обычные серверы недоступны, а мост — работает. Включите «Мост при белых списках» на странице сервера. + Сервер для этих правил + К нему подключимся, когда правила включают VPN. + Выберите сервер + Все серверы + Добавить diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index 6adf858cd..043afca2c 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1318,4 +1318,9 @@ %1$d%% through the VPN · %2$d%% direct What is 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. + Server for these rules + Connect to it when the rules turn the VPN on. + Choose a server + All servers + Add