Compare commits

28 Commits
Author SHA1 Message Date
DenozordecandClaude Opus 5.5 6a8528fc98 android: 2.15.0 - Material 3 Expressive redesign
Android / pre-check (push) Failing after 6s
Android / android (push) Skipped
CIFuzz / pre-check (push) Failing after 4s
CIFuzz / fuzzing (address) (push) Skipped
CIFuzz / fuzzing (undefined) (push) Skipped
CodeQL / pre-check (push) Failing after 3s
CodeQL / analyze (python) (push) Skipped
CodeQL / analyze (ruby) (push) Skipped
CodeQL / analyze-cpp (push) Skipped
FreeBSD / pre-check (push) Failing after 4s
FreeBSD / freebsd (14.4) (push) Skipped
FreeBSD / freebsd (15.0) (push) Skipped
Linux / pre-check (push) Failing after 3s
Linux / latest (apidoc) (push) Skipped
Linux / latest (clang, no, all) (push) Skipped
Linux / latest (clang, no, default) (push) Skipped
Linux / latest (clang, no, printf-builtin) (push) Skipped
Linux / latest (clang, no-dbg) (push) Skipped
Linux / latest (clang, no-testable-ke) (push) Skipped
Linux / latest (clang, yes, all) (push) Skipped
Linux / latest (clang, yes, default) (push) Skipped
Linux / latest (clang, yes, fuzzing) (push) Skipped
Linux / latest (coverage) (push) Skipped
Linux / latest (dist) (push) Skipped
Linux / latest (gcc, no, all) (push) Skipped
Linux / latest (gcc, no, default) (push) Skipped
Linux / latest (gcc, no, printf-builtin) (push) Skipped
Linux / latest (gcc, yes, all) (push) Skipped
Linux / latest (gcc, yes, default) (push) Skipped
Linux / latest (nm) (push) Skipped
Linux / latest (no-dbg) (push) Skipped
Linux / latest (no-testable-ke) (push) Skipped
Linux / latest (yes, ld) (push) Skipped
Linux / crypto (ubuntu-22.04, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, botan) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-3) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-4) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-awslc) (push) Skipped
Linux / crypto (ubuntu-latest, openssl-sys) (push) Skipped
Linux / crypto (ubuntu-latest, wolfssl) (push) Skipped
Linux / older (clang, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, all) (push) Skipped
Linux / older (gcc, ubuntu-22.04, nm) (push) Skipped
Linux / alpine (push) Skipped
macOS / pre-check (push) Failing after 4s
macOS / macos (macos-14) (push) Skipped
macOS / macos (macos-latest) (push) Skipped
SonarCloud / pre-check (push) Failing after 3s
SonarCloud / sonarcloud (push) Skipped
TKM / pre-check (push) Failing after 3s
TKM / tkm (push) Skipped
Windows / pre-check (push) Failing after 3s
Windows / cross-compile (win32) (push) Skipped
Windows / cross-compile (win64) (push) Skipped
Windows / native (i686, mingw32, win32) (push) Skipped
Windows / native (x86_64, mingw64, win64) (push) Skipped
Toolchain AGP 9.1 / Gradle 9.3 / Kotlin 2.2 / compileSdk 37, minSdk 24,
material3 1.5.0-beta01. Expressive theme and motion, redesigned Connect,
Profiles, Routing and Settings screens, adaptive layouts, home-screen
widget, Live Update download progress, predictive back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:38:00 +07:00
Denozordec 8ef7c0a53b docs: ui plan status - deferred items completed 2026-10-10 00:33:20 +07:00
Denozordec dc6e1e32fb android: predictive back scale for settings pages and remediation detail 2026-10-10 00:33:20 +07:00
Denozordec 2681233449 android: route check results as cards with status pills 2026-10-10 00:32:24 +07:00
Denozordec 7a7673119e android: profile import as an Expressive bottom sheet in a transparent host 2026-10-10 00:32:00 +07:00
Denozordec 8deafeae8e android: trusted networks - inline location explainer card 2026-10-10 00:31:32 +07:00
Denozordec 59a31d6a1d android: app picker - hide system apps by default with a toggle 2026-10-10 00:30:41 +07:00
Denozordec 8bedf432e2 android: log screen - level filter chips, snackbar instead of toast 2026-10-10 00:30:27 +07:00
Denozordec 42cd361c58 android: QS tile - connectivity category, toggleable 2026-10-10 00:29:39 +07:00
Denozordec 87c3d73fbc android: update download progress notification (ProgressStyle / Live Update on API 36+) 2026-10-10 00:29:30 +07:00
Denozordec 564b8aaa8d android: profiles list-detail on expanded width with in-pane profile form 2026-10-10 00:28:47 +07:00
Denozordec 7cc6343ba1 android: build.gradle comment cleanup 2026-10-10 00:27:29 +07:00
Denozordec c9048143cc android: minSdk 24 (material3 1.5 requirement), drop overrideLibrary workaround 2026-10-10 00:27:24 +07:00
Denozordec 203a67ee86 android: a11y (headings, switch role), form snackbar, German strings, smoke tests, docs 2026-10-10 00:21:31 +07:00
Denozordec 3c7c121c7d android: expanded width - two-column Connect, 640dp settings 2026-10-10 00:13:51 +07:00
Denozordec 0e5e28b4da android: spacing tokens - migrate padding/spacing literals and ScreenPadding to ShxSpacing 2026-10-10 00:13:08 +07:00
Denozordec 22e4082825 android: home screen widget (Glance), tile subtitle with profile name 2026-10-10 00:13:07 +07:00
Denozordec 0c3ea75cac android: settings as grouped pages (appearance, automation, security, updates, support), theme mode, snackbars instead of toasts 2026-10-10 00:09:45 +07:00
Denozordec a451386e0a android: split RoutingScreen into routing/{RoutingHero,RuleSetList,AdvancedRouting} 2026-10-10 00:09:44 +07:00
Denozordec 8bd65d535e android: routing - advanced block (RU details, GeoIP, force prefixes), kill switch vs excluded apps note 2026-10-10 00:07:12 +07:00
Denozordec c5585df135 android: profiles - swipe to delete with undo, search only for long lists 2026-10-10 00:06:05 +07:00
DenozordecandClaude Opus 5.5 385819caf0 android: redesigned Connect screen - morphing hero, profile chip + picker sheet, traffic tiles, one banner slot, collapsed availability
Split HomeScreen into home/{HeroConnect,TrafficTiles,AvailabilityCard,HomeBanners}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:05:34 +07:00
DenozordecandClaude Opus 5.5 3f7bd94a69 android: expressive motion - fade-through tabs, back to Connect, LoadingIndicator, wavy progress, snackbar messages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:01:17 +07:00
DenozordecandClaude Opus 5.5 6fd03f37f7 android: toolchain AGP 9.1.1 / Gradle 9.3.1 / Kotlin 2.2.21 / compileSdk 37, BOM 2026.09.00 + material3 1.5.0-beta01
Built-in Kotlin (kotlin-android removed), Java 11 compile options (compose 1.12),
proguard-android-optimize.txt (the non-optimize file is rejected by AGP 9),
ExposedDropdownMenu import, minSdk 23 kept via tools:overrideLibrary for
material3 1.5 libs that declare minSdk 24. -PshortNdkCommands for deep worktree paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:59:09 +07:00
DenozordecandClaude Opus 5.5 65989b3a5a android: shared ui components (SectionCard, ListRow, StatusPill, SnackbarHost)
Replace the duplicate FormSection, ActiveBadge and SnackbarHost call sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:39:35 +07:00
DenozordecandClaude Opus 5.5 5f6726f173 android: drop reui palette, align splash/window/system bars with Compose surface
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:39:35 +07:00
DenozordecandClaude Opus 5.5 fda3ec158c android: expressive theme, tonal palette, ShxSpacing/ShxShapes tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:35:59 +07:00
DenozordecandClaude Opus 5.5 c872246b8c android: compose BOM 2026.06.01 + material3 1.5.0-alpha18 (expressive API)
Drop unused navigation-compose and material3-window-size-class.
alpha19+/beta01 need AGP 9.1 + compileSdk 37, so alpha18 is pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:35:59 +07:00
54 changed files with 4653 additions and 1953 deletions

No files matched your search

+27 -21
View File
@@ -1,5 +1,4 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
/* Optional .env next to this project: S3/update settings and signing (see .env.example).
@@ -31,7 +30,7 @@ def keystoreKeyPassword = envOr('KEY_PASSWORD', '')
android {
namespace = 'org.strongswan.android'
/* minSdk 23 flipped AGP's default to uncompressed .so (extractNativeLibs=false):
/* minSdk >= 23 flips AGP's default to uncompressed .so (extractNativeLibs=false):
* the APK grew 12 -> 23 MB. Updates are downloaded over cellular, so keep
* the libraries compressed as before. */
packaging {
@@ -42,12 +41,12 @@ android {
defaultConfig {
applicationId "one.shx.strongswan.ext"
compileSdk = 36
minSdkVersion 23
targetSdkVersion 36
compileSdk = 37
minSdk = 24
targetSdk = 36
versionCode 159
versionName "2.14.6"
versionCode 160
versionName "2.15.0"
/* Base URL of the update storage, trailing slash included */
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
@@ -78,6 +77,10 @@ android {
externalNativeBuild {
ndkBuild {
arguments '-j' + Runtime.runtime.availableProcessors()
/* deep checkout paths (e.g. git worktrees) overflow the Windows command line; opt in with -PshortNdkCommands */
if (project.hasProperty('shortNdkCommands')) {
arguments 'APP_SHORT_COMMANDS=true', 'LOCAL_SHORT_COMMANDS=true'
}
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
}
}
@@ -114,7 +117,7 @@ android {
Smoke-test a release build on a device before publishing. */
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.txt'
if (keystoreFile) {
signingConfig signingConfigs.release
} else {
@@ -131,11 +134,8 @@ android {
}
}
compileOptions {
targetCompatibility 1.8
sourceCompatibility 1.8
}
kotlinOptions {
jvmTarget = '1.8'
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_11
}
buildFeatures {
compose true
@@ -155,18 +155,24 @@ dependencies {
implementation 'androidx.work:work-runtime-ktx:2.10.1'
implementation 'com.google.android.material:material:1.13.0'
/* BOM 2025.12.01 (compose 1.10, Expressive-тема) — отдельная задача после
стабилизации; 2025.09.01 = material3 1.4.0 на compose 1.9. */
def composeBom = platform('androidx.compose:compose-bom:2025.09.01')
/* BOM 2026.09.00 = compose 1.12. The Expressive API (MaterialExpressiveTheme,
MotionScheme.expressive, LoadingIndicator, MaterialShapes) lives only in
material3 1.5.0-alpha/beta, so material3 is pinned explicitly (needs AGP 9.1,
compileSdk 37, minSdk 24). Unpin once 1.5.0 is stable. */
def composeBom = platform('androidx.compose:compose-bom:2026.09.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material3:material3-window-size-class'
/* adaptive-navigation-suite (NavigationSuiteScaffold): BOM 1.4.0, требует minSdk 23 */
implementation 'androidx.compose.material3:material3-adaptive-navigation-suite'
implementation 'androidx.compose.material3:material3:1.5.0-beta01'
/* adaptive-navigation-suite (NavigationSuiteScaffold) requires minSdk 23+ (material3 1.5 requires 24) */
implementation 'androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-beta01'
/* list-detail layout for Profiles on expanded widths */
implementation 'androidx.compose.material3.adaptive:adaptive-layout:1.3.0'
implementation 'androidx.compose.material3.adaptive:adaptive-navigation:1.3.0'
implementation 'androidx.compose.material:material-icons-extended'
/* home screen widget (status + connect button) */
implementation 'androidx.glance:glance-appwidget:1.2.0'
implementation 'androidx.glance:glance-material3:1.2.0'
implementation 'androidx.activity:activity-compose:1.11.0'
implementation 'androidx.navigation:navigation-compose:2.9.4'
debugImplementation 'androidx.compose.ui:ui-tooling'
implementation 'androidx.compose.ui:ui-tooling-preview'
@@ -0,0 +1,81 @@
/*
* 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 androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.ShxTheme
/** Smoke tests for the redesigned Profiles, Routing and Settings screens. */
class ScreensSmokeTest
{
@get:Rule
val composeRule = createComposeRule()
private fun string(id: Int): String =
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
@Test
fun profilesShowsEmptyState()
{
composeRule.setContent {
ShxTheme {
ProfilesScreen(
state = HomeUiState(),
onConnect = {},
onDisconnect = {},
onEdit = {},
onDelete = {},
onRestore = {},
onShare = {},
onAdd = {},
)
}
}
composeRule.onNodeWithText(string(R.string.no_profiles)).assertIsDisplayed()
}
@Test
fun routingShowsHeroAndAdvancedBlock()
{
composeRule.setContent {
ShxTheme {
RoutingScreen()
}
}
composeRule.onNodeWithText(string(R.string.routing_title)).assertIsDisplayed()
/* advanced block is collapsed until tapped */
composeRule.onNodeWithText(string(R.string.routing_advanced).uppercase()).assertIsDisplayed()
}
@Test
fun settingsOpensSecurityPageAndGoesBack()
{
composeRule.setContent {
ShxTheme {
SettingsScreen(profiles = emptyList())
}
}
composeRule.onNodeWithText(string(R.string.settings_section_security)).performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText(string(R.string.safe_ipv6_title)).assertIsDisplayed()
}
}
@@ -30,6 +30,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
<!-- required to install downloaded updates of the app itself -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- lets the app install its own updates in the background without the
@@ -148,7 +149,7 @@
<activity
android:name=".ui.VpnProfileImportActivity"
android:label="@string/profile_import"
android:theme="@style/ApplicationTheme.ActionBar"
android:theme="@style/TransparentActivity"
android:taskAffinity=""
android:excludeFromRecents="true"
android:exported="true">
@@ -242,8 +243,26 @@
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.TILE_CATEGORY"
android:value="android.service.quicksettings.CATEGORY_CONNECTIVITY" />
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>
<receiver
android:name=".ui.widget.ShxWidgetReceiver"
android:label="@string/widget_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/shx_widget_info" />
</receiver>
<provider
android:name=".data.LogContentProvider"
android:authorities="one.shx.strongswan.ext.content.log"
@@ -31,6 +31,7 @@ import android.util.Log;
import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.ui.widget.ShxWidgetState;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnType;
import org.strongswan.android.logic.imc.ImcState;
@@ -592,6 +593,11 @@ public class VpnStateService extends Service
{
listener.stateChanged();
}
/* the home screen widget mirrors the state */
VpnProfile widgetProfile = mProfile;
ShxWidgetState.publish(VpnStateService.this, mState, getErrorState(),
widgetProfile != null ? widgetProfile.getName() : null,
widgetProfile != null ? widgetProfile.getUUID().toString() : null);
}
}
catch (Exception e)
@@ -14,6 +14,7 @@
package org.strongswan.android.logic.updates
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
@@ -113,6 +114,7 @@ class UpdateManager private constructor(private val context: Context)
private const val READ_TIMEOUT_MS = 10_000
private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
private const val UPDATES_NOTIFICATION_ID = 1001
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
private var mInstance: UpdateManager? = null
@@ -323,6 +325,73 @@ class UpdateManager private constructor(private val context: Context)
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && UpdateInstaller.canInstall(context)
}
private fun ensureUpdatesChannel(manager: NotificationManager)
{
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
{
manager.createNotificationChannel(
NotificationChannel(UPDATES_CHANNEL, context.getString(R.string.updates_channel_name),
NotificationManager.IMPORTANCE_DEFAULT))
}
}
/**
* Shows the download progress. Android 16+ gets Notification.ProgressStyle, promoted
* to a Live Update (POST_PROMOTED_NOTIFICATIONS is a normal permission, granted at
* install, so there is no extra prompt); older versions get the classic progress bar.
*/
private fun postDownloadProgress(info: UpdateInfo, progress: Int)
{
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
ensureUpdatesChannel(manager)
val title = context.getString(R.string.update_downloading_title, info.versionName)
val intent = Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
{
Notification.Builder(context, UPDATES_CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText("$progress%")
.setShortCriticalText("$progress%")
.setStyle(Notification.ProgressStyle()
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
.setProgress(progress))
.setRequestPromotedOngoing(true)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(pending)
.build()
}
else
{
NotificationCompat.Builder(context, UPDATES_CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText("$progress%")
.setProgress(100, progress, false)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(pending)
.build()
}
try
{
manager.notify(UPDATES_PROGRESS_NOTIFICATION_ID, notification)
}
catch (e: SecurityException)
{ /* POST_NOTIFICATIONS denied - progress stays visible in the settings */
Log.w(TAG, "Cannot post the update progress notification", e)
}
}
private fun cancelDownloadProgress()
{
(context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager)
?.cancel(UPDATES_PROGRESS_NOTIFICATION_ID)
}
/** Posts a notification that the downloaded update is ready to install */
private fun notifyUpdateReady(info: UpdateInfo)
{
@@ -507,6 +576,7 @@ class UpdateManager private constructor(private val context: Context)
{
lastProgress = progress
mState.value = UpdateState.Downloading(info, progress)
postDownloadProgress(info, progress)
}
}
}
@@ -527,6 +597,7 @@ class UpdateManager private constructor(private val context: Context)
finally
{
conn.disconnect()
cancelDownloadProgress()
}
}
@@ -46,11 +46,19 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import org.strongswan.android.R
import org.strongswan.android.data.LogContentProvider
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
import java.io.BufferedReader
import java.io.File
@@ -58,13 +66,15 @@ import java.io.FileReader
class LogActivity : AppCompatActivity()
{
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ShxTheme {
LogScreen(onBack = { finish() }, onSend = { sendLog() })
LogScreen(onBack = { finish() }, onSend = { sendLog() }, messages = messages)
}
}
}
@@ -79,7 +89,7 @@ class LogActivity : AppCompatActivity()
).filter { it.exists() && it.length() > 0L }
if (files.isEmpty())
{
Toast.makeText(this, R.string.empty_log, Toast.LENGTH_SHORT).show()
messages.tryEmit(R.string.empty_log)
return
}
var version = ""
@@ -164,17 +174,47 @@ class LogViewModel : ViewModel()
}
}
private enum class LogLevelFilter(val labelRes: Int)
{
ALL(R.string.log_filter_all),
WARN(R.string.log_filter_warn),
ERROR(R.string.log_filter_error),
}
private fun String.isErrorLine() = contains(" ERROR ")
private fun String.isWarnLine() = contains(" WARN ") || contains(" WARNING ")
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
private fun LogScreen(onBack: () -> Unit, onSend: () -> Unit, viewModel: LogViewModel = viewModel())
private fun LogScreen(
onBack: () -> Unit,
onSend: () -> Unit,
messages: kotlinx.coroutines.flow.Flow<Int>,
viewModel: LogViewModel = viewModel(),
)
{
val context = LocalContext.current
val lines by viewModel.lines.collectAsStateWithLifecycle()
val allLines by viewModel.lines.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
var filter by rememberSaveable { mutableStateOf(LogLevelFilter.ALL) }
val lines = remember(allLines, filter) {
when (filter)
{
LogLevelFilter.ALL -> allLines
LogLevelFilter.WARN -> allLines.filter { it.isErrorLine() || it.isWarnLine() }
LogLevelFilter.ERROR -> allLines.filter { it.isErrorLine() }
}
}
LaunchedEffect(Unit) {
viewModel.start(File(context.filesDir, CharonVpnService.LOG_FILE))
}
LaunchedEffect(lines.size) {
LaunchedEffect(messages) {
messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
}
LaunchedEffect(lines.size, filter) {
if (lines.isNotEmpty())
{
listState.scrollToItem(lines.lastIndex)
@@ -189,36 +229,57 @@ private fun LogScreen(onBack: () -> Unit, onSend: () -> Unit, viewModel: LogView
Icon(Icons.Default.Share, contentDescription = stringResource(R.string.send_log))
}
},
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
) { padding ->
if (lines.isEmpty())
Column(Modifier.fillMaxSize().padding(padding))
{
Text(
stringResource(R.string.empty_log),
modifier = Modifier.padding(padding).padding(24.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
Row(
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
)
}
else
{
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 12.dp),
) {
items(lines.size) { index ->
val line = lines[index]
val isError = line.contains(" ERROR ", ignoreCase = false)
Text(
line,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
),
color = if (isError) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(vertical = 2.dp),
{
LogLevelFilter.entries.forEach { level ->
androidx.compose.material3.FilterChip(
selected = filter == level,
onClick = { filter = level },
label = { Text(stringResource(level.labelRes)) },
)
}
}
if (lines.isEmpty())
{
Text(
stringResource(R.string.empty_log),
modifier = Modifier.padding(ShxSpacing.xl),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else
{
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(horizontal = ShxSpacing.md),
) {
items(lines.size) { index ->
val line = lines[index]
val color = when
{
line.isErrorLine() -> MaterialTheme.colorScheme.error
line.isWarnLine() -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
}
Text(
line,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
),
color = color,
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
}
}
}
@@ -61,11 +61,12 @@ 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.components.PredictiveBackScale
import org.strongswan.android.logic.imc.RemediationInstruction
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.ScreenPadding
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
/**
@@ -127,7 +128,8 @@ private fun RemediationUi(
onFinish: () -> Unit,
)
{
BackHandler(enabled = selected != null) { onSelect(null) }
PredictiveBackScale(enabled = selected != null, onBack = { onSelect(null) }, modifier = Modifier.fillMaxSize())
{
Column(Modifier.fillMaxSize())
{
TopAppBar(
@@ -167,7 +169,7 @@ private fun RemediationUi(
ShxEmptyState(
icon = Icons.Rounded.CheckCircle,
message = stringResource(R.string.remediation_instructions_title),
modifier = Modifier.padding(ScreenPadding),
modifier = Modifier.padding(ShxSpacing.lg),
)
}
else
@@ -179,19 +181,19 @@ private fun RemediationUi(
itemsIndexed(instructions) { index, item ->
ShxCard(
modifier = Modifier
.padding(horizontal = ScreenPadding, vertical = 6.dp)
.padding(horizontal = ShxSpacing.lg, vertical = 6.dp)
.animateItem(),
) {
Column(
Modifier
.fillMaxWidth()
.clickable { onSelect(item) }
.padding(16.dp),
.padding(ShxSpacing.lg),
) {
Text(item.getTitle(), style = MaterialTheme.typography.titleMedium)
if (!item.getDescription().isNullOrEmpty())
{
Spacer(Modifier.height(4.dp))
Spacer(Modifier.height(ShxSpacing.xs))
Text(
item.getDescription(),
style = MaterialTheme.typography.bodyMedium,
@@ -210,12 +212,12 @@ private fun RemediationUi(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = ScreenPadding),
.padding(horizontal = ShxSpacing.lg),
) {
Text(
instruction.getDescription(),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
modifier = Modifier.padding(top = ShxSpacing.sm),
)
if (!instruction.getHeader().isNullOrEmpty())
{
@@ -223,15 +225,15 @@ private fun RemediationUi(
instruction.getHeader(),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 20.dp, bottom = 8.dp),
modifier = Modifier.padding(top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
Column(Modifier.padding(vertical = 4.dp)) {
Column(Modifier.padding(vertical = ShxSpacing.xs)) {
instruction.getItems().forEachIndexed { index, item ->
Text(
item,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
if (index < instruction.getItems().lastIndex)
{
@@ -241,9 +243,10 @@ private fun RemediationUi(
}
}
}
Spacer(Modifier.height(24.dp))
Spacer(Modifier.height(ShxSpacing.xl))
}
}
}
}
}
}
@@ -7,6 +7,8 @@
* option) any later version.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui
import android.Manifest
@@ -25,7 +27,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -49,6 +52,7 @@ import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.ui.adapter.SelectedApplicationEntry
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
import java.util.TreeSet
@@ -146,9 +150,17 @@ private fun SelectedAppsScreen(
onChange(selected)
}
val filtered = remember(apps, query) {
if (query.isBlank()) apps
else apps.filter { it.toString().contains(query, ignoreCase = true) || it.info.packageName.contains(query, ignoreCase = true) }
/* pure system apps are hidden by default (they are rarely worth excluding); a selected one always shows */
var showSystem by remember { mutableStateOf(false) }
val filtered = remember(apps, query, showSystem, selected) {
apps.filter { entry ->
val flags = entry.info.flags
val system = (flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 &&
(flags and android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) == 0
(showSystem || !system || selected.contains(entry.info.packageName)) &&
(query.isBlank() || entry.toString().contains(query, ignoreCase = true) ||
entry.info.packageName.contains(query, ignoreCase = true))
}
}
SecondaryScaffold(title = stringResource(R.string.profile_select_apps), onBack = onBack) { padding ->
@@ -158,11 +170,22 @@ private fun SelectedAppsScreen(
onValueChange = { query = it },
label = { Text(stringResource(R.string.search)) },
singleLine = true,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm).fillMaxWidth(),
)
androidx.compose.foundation.layout.Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { showSystem = !showSystem }
.padding(horizontal = ShxSpacing.lg),
)
{
Text(stringResource(R.string.apps_show_system), modifier = Modifier.weight(1f))
androidx.compose.material3.Switch(checked = showSystem, onCheckedChange = null)
}
if (loading)
{
CircularProgressIndicator(Modifier.padding(24.dp).size(32.dp))
LoadingIndicator(Modifier.padding(ShxSpacing.xl).size(32.dp))
}
else
{
@@ -55,6 +55,8 @@ class VpnProfileDetailActivity : AppCompatActivity()
}
}
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
@@ -80,13 +82,14 @@ class VpnProfileDetailActivity : AppCompatActivity()
state = formViewModel.state,
onChange = { formViewModel.state = it },
onCancel = { finish() },
messages = messages,
onSave = {
val state = formViewModel.state
val checked = state.validate(this)
formViewModel.state = checked
if (!checked.isValid())
{
Toast.makeText(this, R.string.alert_text_no_input_gateway, Toast.LENGTH_SHORT).show()
messages.tryEmit(R.string.alert_text_no_input_gateway)
return@ProfileFormScreen
}
val profile = checked.toProfile(existing)
@@ -269,7 +269,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
}
if (profile != null && !isSecure())
{
tile.setLabel(profile.getName());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
{ /* keep the action label, show the profile as the tile subtitle */
tile.setSubtitle(profile.getName());
}
else
{
tile.setLabel(profile.getName());
}
}
tile.updateTile();
}
@@ -61,7 +61,7 @@ import org.strongswan.android.logic.connectivity.HealthCheckManager
import org.strongswan.android.logic.connectivity.HealthConfig
import org.strongswan.android.logic.connectivity.ProbeTarget
import org.strongswan.android.logic.connectivity.ServerCheckMode
import org.strongswan.android.ui.compose.theme.ScreenPadding
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Connectivity check settings: what to probe, how often, and what to do when
@@ -86,7 +86,7 @@ fun ConnectivityCheckScreen()
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
.padding(bottom = ShxSpacing.xl),
)
{
ShxSection(title = stringResource(R.string.settings_section_general))
@@ -153,14 +153,14 @@ fun ConnectivityCheckScreen()
ShxSection(title = stringResource(R.string.health_check_server_down_title))
{
Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp))
Column(Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp))
{
Text(
stringResource(R.string.health_check_mode),
style = MaterialTheme.typography.bodyLarge,
color = if (config.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(top = 8.dp))
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(top = ShxSpacing.sm))
{
ServerCheckMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
@@ -270,7 +270,7 @@ fun ConnectivityCheckScreen()
)
{
RadioButton(selected = config.intervalSec == seconds, onClick = null)
Text(intervalLabel(seconds, false), modifier = Modifier.padding(start = 12.dp))
Text(intervalLabel(seconds, false), modifier = Modifier.padding(start = ShxSpacing.md))
}
}
}
@@ -331,7 +331,7 @@ private fun AttemptsRow(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
{
Text(
@@ -346,7 +346,7 @@ private fun AttemptsRow(
Text(
text = attempts.toString(),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 8.dp),
modifier = Modifier.padding(horizontal = ShxSpacing.sm),
)
IconButton(onClick = onIncrement, enabled = enabled && attempts < 5) {
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.health_check_attempt_plus))
File diff suppressed because it is too large. Load diff
@@ -37,7 +37,9 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
@@ -125,6 +127,10 @@ data class HomeUiState(
class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.VpnStateListener
{
/* one-shot user messages (string resource ids), shown as snackbars by ShxApp */
private val _messages = MutableSharedFlow<Int>(extraBufferCapacity = 4)
val messages: SharedFlow<Int> = _messages
private val _ui = MutableStateFlow(HomeUiState())
val ui: StateFlow<HomeUiState> = _ui
@@ -379,7 +385,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
withContext(Dispatchers.Main) {
if (uri == null)
{
Toast.makeText(context, R.string.share_profile_failed, Toast.LENGTH_LONG).show()
_messages.tryEmit(R.string.share_profile_failed)
return@withContext
}
val intent = Intent(Intent.ACTION_SEND)
@@ -391,7 +397,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
}
catch (_: ActivityNotFoundException)
{
Toast.makeText(context, R.string.share_profile_failed, Toast.LENGTH_LONG).show()
_messages.tryEmit(R.string.share_profile_failed)
}
}
}
@@ -7,6 +7,8 @@
* option) any later version.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose
import androidx.activity.ComponentActivity
@@ -29,7 +31,8 @@ import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.FileDownload
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.VpnKey
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -49,9 +52,11 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import org.strongswan.android.R
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.theme.ScreenPadding
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
/**
@@ -86,19 +91,28 @@ class ImportUiModel
var hideImport by mutableStateOf(false)
}
/** Renders the import screen in the transparent import activity. */
/** Renders the import UI as a bottom sheet in the transparent import activity. */
@OptIn(ExperimentalMaterial3Api::class)
fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnable, onClose: Runnable,
onImportPkcs12: Runnable, onSelectUserCert: Runnable)
{
setContent {
ShxTheme {
ImportScreen(
model = model,
onImport = onImport::run,
onClose = onClose::run,
onImportPkcs12 = onImportPkcs12::run,
onSelectUserCert = onSelectUserCert::run,
/* the activity is a transparent host: the import UI is an Expressive bottom sheet */
ModalBottomSheet(
onDismissRequest = onClose::run,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = MaterialTheme.colorScheme.background,
)
{
ImportScreen(
model = model,
onImport = onImport::run,
onClose = onClose::run,
onImportPkcs12 = onImportPkcs12::run,
onSelectUserCert = onSelectUserCert::run,
)
}
}
}
}
@@ -138,7 +152,7 @@ fun ImportScreen(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
LoadingIndicator()
}
return
}
@@ -146,8 +160,8 @@ fun ImportScreen(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = ScreenPadding)
.padding(bottom = 24.dp),
.padding(horizontal = ShxSpacing.lg)
.padding(bottom = ShxSpacing.xl),
)
{
if (model.existsWarning != null)
@@ -158,7 +172,7 @@ fun ImportScreen(
content = MaterialTheme.colorScheme.onErrorContainer,
)
}
ShxCard(modifier = Modifier.padding(top = 12.dp))
ShxCard(modifier = Modifier.padding(top = ShxSpacing.md))
{
ImportInfoRow(Icons.Rounded.Language, stringResource(R.string.profile_name_label_simple), model.name)
ImportInfoRow(Icons.Rounded.Dns, stringResource(R.string.profile_gateway_label), model.gateway)
@@ -171,10 +185,10 @@ fun ImportScreen(
stringResource(R.string.profile_username_label).uppercase().replace(":", ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 8.dp),
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
Column(Modifier.padding(16.dp)) {
Column(Modifier.padding(ShxSpacing.lg)) {
OutlinedTextField(
value = model.username,
onValueChange = { model.username = it },
@@ -184,7 +198,7 @@ fun ImportScreen(
isError = model.usernameError,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(ShxSpacing.md))
OutlinedTextField(
value = model.password,
onValueChange = { model.password = it },
@@ -202,7 +216,7 @@ fun ImportScreen(
text = stringResource(R.string.profile_import_shared_secret),
container = MaterialTheme.colorScheme.surfaceContainerHigh,
content = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
modifier = Modifier.padding(top = ShxSpacing.sm),
)
}
}
@@ -213,7 +227,7 @@ fun ImportScreen(
stringResource(R.string.profile_user_certificate_label).uppercase().replace(":", ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 8.dp),
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
Column {
@@ -222,7 +236,7 @@ fun ImportScreen(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onSelectUserCert)
.padding(horizontal = 16.dp, vertical = 14.dp),
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
) {
Column(Modifier.weight(1f)) {
Text(
@@ -240,14 +254,14 @@ fun ImportScreen(
}
if (model.userCertLoading)
{
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
LoadingIndicator(Modifier.size(18.dp))
}
}
if (model.showImportUserCert)
{
TextButton(onClick = onImportPkcs12, modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)) {
TextButton(onClick = onImportPkcs12, modifier = Modifier.padding(start = ShxSpacing.sm, bottom = ShxSpacing.sm)) {
Icon(Icons.Rounded.FileDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.profile_cert_import))
}
}
@@ -261,7 +275,7 @@ fun ImportScreen(
stringResource(R.string.profile_ca_label).uppercase().replace(":", ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 8.dp),
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
)
ShxCard {
ImportInfoRow(Icons.Rounded.Dns, model.remoteCertPrimary ?: "", model.remoteCertSecondary ?: "", divider = false)
@@ -285,12 +299,12 @@ private fun ImportBanner(
contentColor = content,
modifier = modifier
.fillMaxWidth()
.padding(top = 8.dp),
.padding(top = ShxSpacing.sm),
) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
}
}
@@ -303,7 +317,7 @@ private fun ImportInfoRow(icon: ImageVector, label: String, value: String, divid
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
) {
Icon(
icon,
@@ -311,7 +325,7 @@ private fun ImportInfoRow(icon: ImageVector, label: String, value: String, divid
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Column(Modifier.padding(start = 16.dp)) {
Column(Modifier.padding(start = ShxSpacing.lg)) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Searchable list of the built-in presets shown inside the rule set editor
@@ -71,7 +72,7 @@ fun PresetPickerContent(
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp),
modifier = Modifier.padding(start = ShxSpacing.md, end = ShxSpacing.md, bottom = ShxSpacing.xs),
)
{
IconButton(onClick = onBack)
@@ -103,7 +104,7 @@ fun PresetPickerContent(
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
.padding(horizontal = ShxSpacing.xl),
shape = MaterialTheme.shapes.medium,
)
@@ -111,7 +112,7 @@ fun PresetPickerContent(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 460.dp)
.padding(top = 4.dp),
.padding(top = ShxSpacing.xs),
)
{
items(filtered, key = { it.name })
@@ -123,7 +124,7 @@ fun PresetPickerContent(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggle(preset) }
.padding(horizontal = 24.dp, vertical = 6.dp),
.padding(horizontal = ShxSpacing.xl, vertical = 6.dp),
)
{
Column(
@@ -151,7 +152,7 @@ fun PresetPickerContent(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.lg),
)
}
}
@@ -0,0 +1,80 @@
/*
* 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.
*/
package org.strongswan.android.ui.compose
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.flow.MutableSharedFlow
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.ui.ProfileFormViewModel
import org.strongswan.android.utils.Constants
/**
* The profile form hosted inside the detail pane of the Profiles list-detail layout
* (expanded width). Same save logic as VpnProfileDetailActivity, which is still used
* on phones.
*/
@Composable
fun ProfileEditorPane(
profile: VpnProfile?,
onClose: () -> Unit,
modifier: Modifier = Modifier,
)
{
val context = LocalContext.current
val dataSource = remember { VpnProfileSource(context).open() as VpnProfileDataSource }
DisposableEffect(dataSource) { onDispose { dataSource.close() } }
val messages = remember { MutableSharedFlow<Int>(extraBufferCapacity = 1) }
val key = profile?.getUUID()?.toString() ?: "new"
val formViewModel: ProfileFormViewModel = viewModel(
key = "profile-editor-$key",
factory = object : ViewModelProvider.Factory
{
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ProfileFormViewModel(profile?.toFormState() ?: ProfileFormState()) as T
},
)
ProfileFormScreen(
title = profile?.name ?: stringResource(R.string.add_profile),
state = formViewModel.state,
onChange = { formViewModel.state = it },
onCancel = onClose,
onSave = {
val checked = formViewModel.state.validate(context)
formViewModel.state = checked
if (!checked.isValid())
{
messages.tryEmit(R.string.alert_text_no_input_gateway)
return@ProfileFormScreen
}
val saved = checked.toProfile(profile)
if (profile != null) dataSource.updateVpnProfile(saved)
else dataSource.insertProfile(saved)
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
intent.putExtra(Constants.VPN_PROFILES_SINGLE, saved.getUUID().toString())
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
onClose()
},
messages = messages,
)
}
@@ -9,6 +9,7 @@
package org.strongswan.android.ui.compose
import org.strongswan.android.ui.compose.components.ShxSectionCard
import android.app.Activity
import android.content.Intent
import android.security.KeyChain
@@ -46,9 +47,10 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenu
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -89,6 +91,7 @@ import org.strongswan.android.data.VpnType
import org.strongswan.android.data.VpnType.VpnTypeFeature
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.TrustedCertificatesActivity
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.utils.Constants
import org.strongswan.android.utils.IPRangeSet
import org.strongswan.android.utils.Utils
@@ -294,9 +297,16 @@ fun ProfileFormScreen(
onChange: (ProfileFormState) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit,
/* one-shot messages (string resource ids) shown as a snackbar */
messages: kotlinx.coroutines.flow.Flow<Int>? = null,
)
{
val context = LocalContext.current
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
androidx.compose.runtime.LaunchedEffect(messages)
{
messages?.collect { snackbarHostState.showSnackbar(context.getString(it)) }
}
val types = stringArrayResource(R.array.vpn_types)
val appsHandling = stringArrayResource(R.array.apps_handling)
var typeExpanded by remember { mutableStateOf(false) }
@@ -321,6 +331,7 @@ fun ProfileFormScreen(
}
Scaffold(
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
),
@@ -354,8 +365,8 @@ fun ProfileFormScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
.padding(horizontal = 20.dp, vertical = ShxSpacing.md),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
) {
OutlinedButton(
onClick = onCancel,
@@ -389,7 +400,7 @@ fun ProfileFormScreen(
{
Text(stringResource(R.string.alert_text_vpn_profile_read_only), color = MaterialTheme.colorScheme.error)
}
FormSection(title = stringResource(R.string.profile_section_basic))
ShxSectionCard(title = stringResource(R.string.profile_section_basic))
{
OutlinedTextField(
value = state.gateway,
@@ -410,7 +421,7 @@ fun ProfileFormScreen(
enabled = enabled,
label = { Text(stringResource(R.string.profile_vpn_type_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable),
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false }) {
VpnType.values().forEachIndexed { index, type ->
@@ -514,7 +525,7 @@ fun ProfileFormScreen(
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) {
OutlinedTextField(state.remoteId, { onChange(state.copy(remoteId = it)) },
label = { Text(stringResource(R.string.profile_remote_id_label)) },
supportingText = { Text(stringResource(R.string.profile_remote_id_hint)) },
@@ -574,7 +585,7 @@ fun ProfileFormScreen(
enabled = enabled,
label = { Text(stringResource(R.string.profile_select_apps)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(appsExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable),
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = appsExpanded, onDismissRequest = { appsExpanded = false }) {
SelectedAppsHandling.values().forEachIndexed { index, handling ->
@@ -625,26 +636,6 @@ fun ProfileFormScreen(
}
}
@Composable
private fun FormSection(title: String, content: @Composable () -> Unit)
{
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
{
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
content()
}
}
}
@Composable
private fun SwitchRow(
label: String,
@@ -9,6 +9,7 @@
package org.strongswan.android.ui.compose
import org.strongswan.android.ui.compose.components.ShxConnectedPill
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
@@ -37,6 +38,7 @@ import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
@Composable
fun ProfileAvatar(icon: ImageVector, connected: Boolean, modifier: Modifier = Modifier)
@@ -80,13 +82,13 @@ fun ShxProfileRow(
onLongClick = onLongClick,
onLongClickLabel = onLongClickLabel,
)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
) {
ProfileAvatar(icon = leadingIcon, connected = active)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
.padding(horizontal = ShxSpacing.md),
) {
Text(
text = profile.name ?: "",
@@ -117,19 +119,7 @@ fun ShxProfileRow(
@Composable
fun ActiveBadge(modifier: Modifier = Modifier)
{
val status = LocalShxStatusColors.current
Surface(
color = status.connectedContainer,
contentColor = status.connected,
shape = CircleShape,
modifier = modifier,
) {
Text(
text = stringResource(R.string.badge_connected),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
ShxConnectedPill(text = stringResource(R.string.badge_connected), modifier = modifier)
}
@Composable
@@ -139,7 +129,7 @@ fun ShxDivider(modifier: Modifier = Modifier, indent: Boolean = true)
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
modifier = modifier
.fillMaxWidth()
.padding(start = if (indent) 68.dp else 0.dp, end = if (indent) 16.dp else 0.dp)
.padding(start = if (indent) 68.dp else 0.dp, end = if (indent) ShxSpacing.lg else 0.dp)
.height(1.dp),
) {}
}
@@ -9,6 +9,7 @@
package org.strongswan.android.ui.compose
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -38,6 +39,11 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.foundation.background
import org.strongswan.android.ui.compose.theme.ShxSpacing
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
@@ -55,19 +61,91 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.saveable.rememberSaveable
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ScreenPadding
/**
* Profiles tab. On phones it is just the list (the form opens as its own screen);
* on expanded widths it becomes a list-detail layout with the profile form in the detail pane.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ProfilesScreen(
state: HomeUiState,
onConnect: (VpnProfile) -> Unit,
onDisconnect: () -> Unit,
onEdit: (VpnProfile) -> Unit,
onDelete: (VpnProfile) -> Unit,
onRestore: (VpnProfile) -> Unit,
onShare: (VpnProfile) -> Unit,
onAdd: () -> Unit,
)
{
if (LocalConfiguration.current.screenWidthDp < 840)
{
ProfilesList(state, onConnect, onDisconnect, onEdit, onDelete, onRestore, onShare, onAdd)
return
}
/* null = nothing open, "" = new profile, otherwise the edited profile's uuid */
var editing by rememberSaveable { mutableStateOf<String?>(null) }
val navigator = rememberListDetailPaneScaffoldNavigator<Nothing>()
LaunchedEffect(editing)
{
navigator.navigateTo(if (editing != null) ListDetailPaneScaffoldRole.Detail else ListDetailPaneScaffoldRole.List)
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
ProfilesList(
state = state,
onConnect = onConnect,
onDisconnect = onDisconnect,
onEdit = { editing = it.getUUID().toString() },
onDelete = onDelete,
onRestore = onRestore,
onShare = onShare,
onAdd = { editing = "" },
)
}
},
detailPane = {
AnimatedPane {
val target = editing
if (target != null)
{
val profile = state.profiles.firstOrNull { it.getUUID().toString() == target }
ProfileEditorPane(profile = profile, onClose = { editing = null })
}
else
{
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center)
{
ShxEmptyState(icon = Icons.Rounded.Language, message = stringResource(R.string.profile_detail_placeholder))
}
}
}
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfilesScreen(
private fun ProfilesList(
state: HomeUiState,
onConnect: (VpnProfile) -> Unit,
onDisconnect: () -> Unit,
@@ -111,7 +189,7 @@ fun ProfilesScreen(
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.background,
) { _ ->
Column(Modifier.fillMaxSize())
@@ -130,7 +208,7 @@ fun ProfilesScreen(
SearchBar(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ScreenPadding, vertical = 4.dp),
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.xs),
inputField = {
SearchBarDefaults.InputField(
query = query,
@@ -164,7 +242,7 @@ fun ProfilesScreen(
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ScreenPadding, vertical = 12.dp),
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
) {
LazyColumn {
itemsIndexed(filtered, key = { _, p -> p.getUUID().toString() }) { index, profile ->
@@ -261,13 +339,15 @@ fun ProfilesScreen(
}
}
private const val SEARCH_THRESHOLD = 5
@Composable
private fun EmptyProfilesState(query: String, onAdd: () -> Unit)
{
ShxCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ScreenPadding, vertical = 12.dp),
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
if (query.isBlank())
@@ -33,10 +33,15 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import org.strongswan.android.ui.compose.components.ShxStatusPill
import org.strongswan.android.ui.compose.components.ShxSectionCard
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.R
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.RouteCheck
import org.strongswan.android.ui.compose.theme.ScreenPadding
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Diagnostic: how a domain resolves and which route its addresses take. */
@Composable
@@ -52,7 +57,7 @@ fun RouteCheckScreen()
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = ScreenPadding, vertical = 12.dp),
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
Text(
@@ -98,50 +103,68 @@ fun RouteCheckScreen()
@Composable
private fun RouteCheckResult(report: RouteCheck.Report)
{
Column(modifier = Modifier.padding(top = 12.dp))
val status = LocalShxStatusColors.current
Column(
modifier = Modifier.padding(top = ShxSpacing.lg),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.md),
)
{
if (!report.vpnNetworkFound)
{
Text(
ShxStatusPill(
text = stringResource(R.string.route_check_no_vpn),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
containerColor = status.disconnectedContainer,
contentColor = status.onDisconnectedContainer,
shape = MaterialTheme.shapes.small,
style = MaterialTheme.typography.labelMedium,
)
}
RouteCheckRow(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
RouteCheckRow(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
Text(
text = stringResource(R.string.route_check_routes),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 10.dp),
)
for (route in report.routes)
ShxSectionCard(title = stringResource(R.string.route_check_dns_section))
{
val label = stringResource(
when (route.kind)
RouteCheckRow(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
RouteCheckRow(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
}
ShxSectionCard(title = stringResource(R.string.route_check_routes))
{
for (route in report.routes)
{
val label = stringResource(
when (route.kind)
{
RouteCheck.RouteKind.TUNNEL -> R.string.route_verdict_tunnel
RouteCheck.RouteKind.DIRECT_RU -> R.string.route_verdict_direct
RouteCheck.RouteKind.TUNNEL_DNS -> R.string.route_verdict_dns
},
)
val direct = route.kind == RouteCheck.RouteKind.DIRECT_RU
Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
)
{
RouteCheck.RouteKind.TUNNEL -> R.string.route_verdict_tunnel
RouteCheck.RouteKind.DIRECT_RU -> R.string.route_verdict_direct
RouteCheck.RouteKind.TUNNEL_DNS -> R.string.route_verdict_dns
},
)
Text(
text = "${route.ip} — $label",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(top = 2.dp),
)
}
if (report.routes.isEmpty())
{
Text(
text = stringResource(R.string.route_check_resolve_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Text(
text = route.ip,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
)
ShxStatusPill(
text = label,
containerColor = if (direct) status.connectingContainer else status.connectedContainer,
contentColor = if (direct) status.onConnectingContainer else status.onConnectedContainer,
)
}
}
if (report.routes.isEmpty())
{
Text(
text = stringResource(R.string.route_check_resolve_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
Spacer(Modifier.height(8.dp))
}
@Composable
@@ -150,7 +173,7 @@ private fun RouteCheckRow(label: String, ips: List<String>)
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = ips.joinToString(", ").ifEmpty { stringResource(R.string.route_check_none) },
@@ -7,8 +7,11 @@
* option) any later version.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
@@ -52,11 +55,18 @@ import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -114,7 +124,18 @@ import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ScreenPadding
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.routing.RoutingHeroCard
import org.strongswan.android.ui.compose.routing.RuToggleRow
import org.strongswan.android.ui.compose.routing.RuModeSelector
import org.strongswan.android.ui.compose.routing.RuDetailsDisclosure
import org.strongswan.android.ui.compose.routing.GeoipChipsRow
import org.strongswan.android.ui.compose.routing.RfActionRow
import org.strongswan.android.ui.compose.routing.RuleSetRow
import org.strongswan.android.ui.compose.routing.AddRuleRow
import org.strongswan.android.ui.compose.routing.ForcePrefixRow
import org.strongswan.android.ui.compose.routing.AddForcePrefixRow
import org.strongswan.android.ui.compose.routing.ForcePrefixDialog
@Composable
fun RoutingScreen()
@@ -122,7 +143,7 @@ fun RoutingScreen()
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.background,
) { _ ->
RoutingContent(snackbarHostState)
@@ -214,7 +235,7 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
.padding(bottom = ShxSpacing.xl),
)
{
TopAppBar(
@@ -239,9 +260,22 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
setCount = config.ruleSets.count { it.enabled },
appCount = excludedApps,
onToggle = { manager.setGlobalEnabled(it) },
modifier = Modifier.padding(horizontal = ScreenPadding),
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
)
/* kill switch + excluded apps: excluded apps are added to the VPN's disallowed list
* (CharonVpnService.addDisallowedApplication), so the system lockdown does not
* cover them - on a tunnel drop they keep using the normal network */
if (config.enabled && excludedApps > 0 && remember { context.isVpnLockdownEnabled() })
{
Text(
text = stringResource(R.string.routing_killswitch_note, excludedApps),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs, vertical = ShxSpacing.sm),
)
}
/* ---- dead direct destinations the engine keeps refusing ---- */
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>())
{
@@ -267,16 +301,16 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
text = stringResource(R.string.dead_targets_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
)
deadTargets.forEachIndexed { index, target ->
ShxSectionDivider()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
) {
Box(
modifier = Modifier
@@ -318,55 +352,6 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
onCheckedChange = { ruList.setEnabled(it) },
onUpdate = { refreshGeoip() },
)
if (ruState.enabled)
{
ShxSectionDivider()
RuModeSelector(
mode = ruState.mode,
onModeChange = { ruList.setMode(it) },
)
if (ruState.status == RuIpListStatus.UPDATING)
{
LinearProgressIndicator(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
GeoipChipsRow(state = ruState)
RuDetailsDisclosure(mode = ruState.mode)
}
}
/* ---- force-tunnel prefixes: always through the tunnel ---- */
ShxSection(title = stringResource(R.string.routing_force_section))
{
if (config.forceTunnelPrefixes.isEmpty())
{
Text(
text = stringResource(R.string.routing_force_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
else
{
config.forceTunnelPrefixes.forEachIndexed { index, entry ->
ForcePrefixRow(
entry = entry,
onToggle = { manager.setForcePrefixEnabled(entry.id, it) },
onEdit = { forceDialogId = entry.id },
onDelete = { manager.deleteForcePrefix(entry.id) },
)
if (index < config.forceTunnelPrefixes.lastIndex)
{
ShxSectionDivider()
}
}
}
ShxSectionDivider()
AddForcePrefixRow(onClick = { forceDialogId = "" })
}
/* ---- rule sets ---- */
@@ -406,6 +391,89 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
AddRuleRow(onClick = { addNewSet() })
}
}
/* ---- advanced: userspace mode, GeoIP list, force-tunnel prefixes ---- */
var advancedOpen by rememberSaveable { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { advancedOpen = !advancedOpen }
.padding(horizontal = ShxSpacing.lg + ShxSpacing.lg, vertical = ShxSpacing.lg),
)
{
Text(
text = stringResource(R.string.routing_advanced).uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f).semantics { heading() },
)
Icon(
if (advancedOpen) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(visible = advancedOpen)
{
Column {
if (ruState.enabled)
{
ShxSection(title = stringResource(R.string.routing_section_ru))
{
if (ruState.enabled)
{
RuModeSelector(
mode = ruState.mode,
onModeChange = { ruList.setMode(it) },
)
if (ruState.status == RuIpListStatus.UPDATING)
{
LinearWavyProgressIndicator(
Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.xs),
)
}
GeoipChipsRow(state = ruState)
RuDetailsDisclosure(mode = ruState.mode)
}
}
}
/* ---- force-tunnel prefixes: always through the tunnel ---- */
ShxSection(title = stringResource(R.string.routing_force_section))
{
if (config.forceTunnelPrefixes.isEmpty())
{
Text(
text = stringResource(R.string.routing_force_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
)
}
else
{
config.forceTunnelPrefixes.forEachIndexed { index, entry ->
ForcePrefixRow(
entry = entry,
onToggle = { manager.setForcePrefixEnabled(entry.id, it) },
onEdit = { forceDialogId = entry.id },
onDelete = { manager.deleteForcePrefix(entry.id) },
)
if (index < config.forceTunnelPrefixes.lastIndex)
{
ShxSectionDivider()
}
}
}
ShxSectionDivider()
AddForcePrefixRow(onClick = { forceDialogId = "" })
}
}
}
}
/* ---- rule set editor, hosted outside the scrolling column ---- */
@@ -490,490 +558,3 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
)
}
}
/** Hero card: master switch plus a live summary of what the rules currently affect. */
@Composable
private fun RoutingHeroCard(
enabled: Boolean,
setCount: Int,
appCount: Int,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
)
{
Surface(
shape = MaterialTheme.shapes.large,
color = if (enabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow,
contentColor = if (enabled) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
border = if (enabled) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier.fillMaxWidth(),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = 8.dp),
)
{
Icon(
Icons.Rounded.AltRoute,
contentDescription = null,
tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f))
{
Text(
text = stringResource(if (enabled) R.string.routing_hero_on else R.string.routing_hero_off),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = if (enabled)
{
stringResource(R.string.routing_hero_summary, setCount, appCount)
}
else
{
stringResource(R.string.routing_hero_off_summary)
},
style = MaterialTheme.typography.bodySmall,
color = if (enabled)
{
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
}
else
{
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Switch(checked = enabled, onCheckedChange = onToggle)
Spacer(Modifier.width(8.dp))
}
}
}
/** Geoip toggle row with a refresh/update slot in front of the switch. */
@Composable
private fun RuToggleRow(
title: String,
subtitle: String,
checked: Boolean,
updating: Boolean,
onCheckedChange: (Boolean) -> Unit,
onUpdate: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) }
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f))
{
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (updating)
{
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(12.dp))
}
else
{
IconButton(onClick = onUpdate)
{
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.rusplit_update),
)
}
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
/**
* Selector for how the RU split is routed: OS routes with the coarsened
* complement (as before) or per-packet classification in native code.
*/
@Composable
private fun RuModeSelector(mode: String, onModeChange: (String) -> Unit)
{
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
)
{
SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_SYSTEM,
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
)
{
Text(stringResource(R.string.rusplit_mode_system))
}
SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_USERSPACE,
onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
)
{
Text(stringResource(R.string.rusplit_mode_userspace_short))
}
}
}
/** Mode description, list source and beta note behind a "Details" disclosure. */
@Composable
private fun RuDetailsDisclosure(mode: String)
{
var expanded by rememberSaveable { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(horizontal = 16.dp, vertical = 10.dp),
)
{
Text(
text = stringResource(if (expanded) R.string.routing_details_less else R.string.routing_details_more),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(4.dp))
Icon(
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) +
fadeIn(tween(120)),
exit = shrinkVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) +
fadeOut(tween(90)),
)
{
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp))
{
Text(
text = stringResource(
if (mode == Constants.RUSPLIT_MODE_USERSPACE)
{
R.string.rusplit_mode_userspace_desc
}
else
{
R.string.rusplit_mode_system_desc
}
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.rusplit_source),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = stringResource(R.string.rusplit_beta_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
/** Freshness/count chips of the geoip list. */
@Composable
private fun GeoipChipsRow(state: RuIpListState)
{
val status = LocalShxStatusColors.current
val fresh = state.lastUpdatedMs > 0L &&
System.currentTimeMillis() - state.lastUpdatedMs < 26 * 60 * 60 * 1000L
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
)
{
InfoChip(text = stringResource(R.string.routing_geoip_ipv4, state.ipv4Ranges))
InfoChip(text = stringResource(R.string.routing_geoip_ipv6, state.ipv6Ranges))
val freshnessText = when
{
state.lastUpdatedMs > 0L ->
stringResource(
R.string.routing_geoip_updated,
DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(),
)
else -> stringResource(R.string.routing_geoip_never)
}
InfoChip(
text = freshnessText,
highlight = if (state.lastUpdatedMs > 0L && fresh) status.connected else null,
)
}
}
/** Small rounded info pill used across the screen. */
@Composable
private fun InfoChip(text: String, highlight: Color? = null)
{
Surface(
shape = RoundedCornerShape(8.dp),
color = if (highlight != null) highlight.copy(alpha = 0.14f) else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = highlight ?: MaterialTheme.colorScheme.onSurfaceVariant,
)
{
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
/** Quick action: create/enable rule sets for all RF bundle presets. */
@Composable
private fun RfActionRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f))
{
Text(
stringResource(R.string.routing_rf_quick_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
stringResource(R.string.routing_rf_quick_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** Collapsed rule set row: tap opens the editor sheet, the switch toggles in place. */
@Composable
private fun RuleSetRow(
ruleSet: RuleSet,
onToggle: (Boolean) -> Unit,
onOpen: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen)
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
ShxIconBadge(
Icons.Rounded.Rule,
tint = if (ruleSet.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f))
{
Text(
ruleSet.name.ifBlank { stringResource(R.string.routing_rule_name) },
style = MaterialTheme.typography.bodyLarge,
color = if (ruleSet.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(
R.string.routing_rule_subtitle,
ruleSet.packages.size,
ruleSet.prefixes.size,
ruleSet.keywords.size,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = ruleSet.enabled, onCheckedChange = onToggle)
}
}
/** Trailing "add rule set" row of the rule sets card. */
@Composable
private fun AddRuleRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(16.dp))
Text(
stringResource(R.string.routing_add_rule),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
/** One force-tunnel prefix: tap edits, switch toggles, trash deletes. */
@Composable
private fun ForcePrefixRow(
entry: ForceTunnelPrefix,
onToggle: (Boolean) -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onEdit)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
{
ShxIconBadge(
Icons.Rounded.Lan,
tint = if (entry.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(16.dp))
Text(
entry.prefix,
style = MaterialTheme.typography.bodyLarge,
color = if (entry.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDelete)
{
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.routing_force_delete),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = entry.enabled, onCheckedChange = onToggle)
}
}
/** Trailing "add force-tunnel prefix" row. */
@Composable
private fun AddForcePrefixRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(16.dp))
Text(
stringResource(R.string.routing_force_add),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
/** Add/edit dialog for a force-tunnel prefix; shows an error until valid. */
@Composable
private fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, onDismiss: () -> Unit)
{
var text by remember { mutableStateOf(initial) }
var error by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.routing_force_dialog_title)) },
text = {
OutlinedTextField(
value = text,
onValueChange = {
text = it
error = false
},
singleLine = true,
isError = error,
label = { Text(stringResource(R.string.routing_force_hint)) },
supportingText = if (error)
{
{ Text(stringResource(R.string.routing_force_invalid)) }
}
else
{
null
},
)
},
confirmButton = {
TextButton(onClick = {
if (onConfirm(text.trim()))
{
onDismiss()
}
else
{
error = true
}
})
{
Text(stringResource(R.string.routing_force_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss)
{
Text(stringResource(android.R.string.cancel))
}
},
)
}
@@ -68,6 +68,7 @@ import org.strongswan.android.R
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingPresets
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Editor of one rule set as a modal bottom sheet. The preset picker is a
@@ -161,12 +162,12 @@ private fun EditorContent(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
.padding(bottom = ShxSpacing.xl),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 24.dp, end = 12.dp, bottom = 8.dp),
modifier = Modifier.padding(start = ShxSpacing.xl, end = ShxSpacing.md, bottom = ShxSpacing.sm),
)
{
Text(
@@ -188,7 +189,7 @@ private fun EditorContent(
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(horizontal = ShxSpacing.xl)
.focusRequester(nameFocusRequester),
shape = MaterialTheme.shapes.medium,
)
@@ -232,7 +233,7 @@ private fun EditorContent(
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.md),
)
{
Icon(
@@ -240,7 +241,7 @@ private fun EditorContent(
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Spacer(Modifier.width(ShxSpacing.sm))
Text(stringResource(R.string.routing_delete))
}
}
@@ -262,14 +263,14 @@ private fun ChipGroup(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 24.dp, top = 16.dp),
modifier = Modifier.padding(start = ShxSpacing.xl, top = ShxSpacing.lg),
)
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.xs),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.xs),
)
{
values.forEach { value ->
@@ -294,11 +295,11 @@ private fun EditorRow(icon: ImageVector, title: String, subtitle: String, onClic
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 14.dp),
.padding(horizontal = ShxSpacing.xl, vertical = 14.dp),
)
{
ShxIconBadge(icon)
Spacer(Modifier.width(16.dp))
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(title, style = MaterialTheme.typography.bodyLarge)
@@ -346,7 +347,7 @@ private fun AddChipField(
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 4.dp),
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.xs),
shape = MaterialTheme.shapes.medium,
)
}
@@ -33,10 +33,12 @@ fun SecondaryScaffold(
title: String,
onBack: () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
)
{
Scaffold(
snackbarHost = snackbarHost,
topBar = {
TopAppBar(
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold) },
@@ -69,6 +69,34 @@ 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 androidx.compose.foundation.layout.widthIn
import org.strongswan.android.ui.compose.components.PredictiveBackScale
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.StrongSwanApplication
@@ -124,6 +152,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 +163,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,340 +177,373 @@ 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 */
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter)
{
PredictiveBackScale(
enabled = pageState != SettingsPage.ROOT,
onBack = { pageState = SettingsPage.ROOT },
modifier = Modifier.widthIn(max = 640.dp).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,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
.padding(top = ShxSpacing.lg),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
ShxSnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
if (showDefaultPicker)
{
@@ -505,7 +568,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
)
{
RadioButton(selected = defaultUuid == value, onClick = null)
Text(label, modifier = Modifier.padding(start = 12.dp))
Text(label, modifier = Modifier.padding(start = ShxSpacing.md))
}
}
}
@@ -534,7 +597,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
{
@@ -612,7 +675,7 @@ private fun updateStatusText(state: UpdateState): String
* Checks whether the system-level lockdown ("Block connections without VPN") is
* active for our package. Returns false if not determinable.
*/
private fun Context.isVpnLockdownEnabled(): Boolean
internal fun Context.isVpnLockdownEnabled(): Boolean
{
return try
{
@@ -654,3 +717,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
},
)
}
@@ -11,14 +11,14 @@ package org.strongswan.android.ui.compose
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -44,7 +44,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.Box
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
@@ -62,7 +66,6 @@ import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import org.strongswan.android.R
@@ -72,6 +75,7 @@ import org.strongswan.android.logic.StrongSwanApplication
import org.strongswan.android.logic.TrustedCertificateManager
import org.strongswan.android.ui.LogActivity
import org.strongswan.android.ui.VpnProfileDetailActivity
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
fun ComponentActivity.installShxUi()
@@ -105,6 +109,12 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
val shareProfile: (VpnProfile) -> Unit = { pendingShare = it }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(viewModel)
{
viewModel.messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
}
/* the home list keeps its scroll state across tab switches */
val homeListState = rememberLazyListState()
@@ -112,6 +122,13 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
* and on tablets (Stage 3 of ui-ux-plan). Item colors are built here in a
* composable context - the items lambda itself does not compose. */
val navItemColors = shxNavigationItemColors()
val fadeSpec = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val scaleSpec = MaterialTheme.motionScheme.defaultSpatialSpec<Float>()
/* from any other tab "back" returns to Connect instead of leaving the app */
BackHandler(enabled = state.tab != ShxTab.HOME) {
viewModel.setTab(ShxTab.HOME)
}
NavigationSuiteScaffold(
navigationSuiteItems = {
ShxNavigationSuiteItems(selected = state.tab, onSelect = viewModel::setTab, colors = navItemColors)
@@ -122,13 +139,16 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Box(Modifier.fillMaxSize())
{
AnimatedContent(
targetState = state.tab,
transitionSpec = {
val forward = targetState.ordinal >= initialState.ordinal
val direction = if (forward) 1 else -1
(slideInHorizontally(tween(240)) { it / 6 * direction } + fadeIn(tween(240))) togetherWith
(slideOutHorizontally(tween(180)) { -it / 6 * direction } + fadeOut(tween(160)))
/* tabs are not spatially ordered: fade-through (outgoing fades out
* first, incoming fades in with a slight scale), driven by the
* expressive motion scheme */
(fadeIn(fadeSpec) + scaleIn(scaleSpec, initialScale = 0.96f)) togetherWith
fadeOut(tween(90))
},
label = "tabContent",
) { tab ->
@@ -159,6 +179,11 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
ShxTab.ROUTING -> RoutingScreen()
ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles)
}
}
ShxSnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
pendingShare?.let { profile ->
ShareProfileDialog(
@@ -184,13 +209,13 @@ private fun ShareProfileDialog(profile: VpnProfile, onDismiss: () -> Unit, onCon
text = {
Column {
Text(stringResource(R.string.share_profile_password_warning), style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(ShxSpacing.md))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { includePassword = !includePassword },
) {
Checkbox(checked = includePassword, onCheckedChange = { includePassword = it })
Spacer(Modifier.width(4.dp))
Spacer(Modifier.width(ShxSpacing.xs))
Text(stringResource(R.string.share_profile_include_password))
}
}
@@ -62,6 +62,10 @@ 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.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
@@ -69,7 +73,7 @@ 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.ScreenPadding
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Trusted networks settings: when enabled, the VPN is started on cellular and untrusted
@@ -159,7 +163,7 @@ fun TrustedNetworksScreen(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(bottom = 24.dp),
.padding(bottom = ShxSpacing.xl),
)
{
ShxSection(title = stringResource(R.string.settings_section_general))
@@ -170,10 +174,8 @@ fun TrustedNetworksScreen(
subtitle = stringResource(R.string.auto_connect_summary),
checked = enabled,
onCheckedChange = { checked ->
if (checked)
{
requestSsidPermissions()
}
/* the permission is requested from the explainer card below, not
* straight from the toggle */
update { it.copy(enabled = checked) }
},
)
@@ -190,30 +192,42 @@ fun TrustedNetworksScreen(
if (enabled && !hasBackgroundLocation)
{
Spacer(Modifier.size(12.dp))
Spacer(Modifier.size(ShxSpacing.md))
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.errorContainer,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ScreenPadding)
.clickable { requestSsidPermissions() },
.padding(horizontal = ShxSpacing.lg),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
Column(
modifier = Modifier.padding(ShxSpacing.lg),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
)
{
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{
Icon(Icons.Rounded.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,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { requestSsidPermissions() }) {
Text(stringResource(R.string.auto_connect_location_grant))
}
Button(
onClick = { requestSsidPermissions() },
modifier = Modifier.align(Alignment.End),
) { Text(stringResource(R.string.auto_connect_location_grant)) }
}
}
}
@@ -226,7 +240,7 @@ fun TrustedNetworksScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
)
if (config.trustedSsids.isEmpty())
{
@@ -236,7 +250,7 @@ fun TrustedNetworksScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
}
else
@@ -251,7 +265,7 @@ fun TrustedNetworksScreen(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
.padding(horizontal = ShxSpacing.lg),
)
{
Icon(
@@ -265,7 +279,7 @@ fun TrustedNetworksScreen(
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp, vertical = 14.dp),
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
IconButton(onClick = { pendingDeleteSsid = ssid }) {
Icon(
@@ -309,7 +323,7 @@ fun TrustedNetworksScreen(
selected = config.profileUuid == profile.getUUID().toString(),
onClick = null,
)
Text(profile.name ?: "", modifier = Modifier.padding(start = 12.dp))
Text(profile.name ?: "", modifier = Modifier.padding(start = ShxSpacing.md))
}
}
}
@@ -389,7 +403,7 @@ private fun TrustedAddNetworkRow(
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
)
{
if (currentSsid != null)
@@ -397,7 +411,7 @@ private fun TrustedAddNetworkRow(
TextButton(onClick = { onAddCurrent(currentSsid) })
{
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.auto_connect_add_current, currentSsid))
}
}
@@ -406,7 +420,7 @@ private fun TrustedAddNetworkRow(
TextButton(onClick = onAddManual)
{
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Spacer(Modifier.size(ShxSpacing.sm))
Text(stringResource(R.string.auto_connect_add_network))
}
}
@@ -40,12 +40,12 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.strongswan.android.R
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
/**
@@ -147,7 +147,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
title = { Text(dialog.title) },
text = { Text(stringResource(dialog.message)) },
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs)) {
if (dialog.reconnect)
{
TextButton(onClick = {
@@ -193,7 +193,7 @@ private fun LoginUi(dialog: VpnDialog.Login)
onDismissRequest = ::cancelDialog,
title = { Text(stringResource(R.string.login_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) {
OutlinedTextField(
value = username,
onValueChange = { username = it },
@@ -0,0 +1,65 @@
/*
* 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.
*/
package org.strongswan.android.ui.compose.components
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import kotlin.coroutines.cancellation.CancellationException
/**
* Handles the system back gesture for in-screen navigation (a sub page that returns
* to its parent): while the user drags, the content shrinks slightly and rounds its
* corners, then [onBack] runs when the gesture is committed.
*/
@Composable
fun PredictiveBackScale(
enabled: Boolean,
onBack: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
)
{
var progress by remember { mutableFloatStateOf(0f) }
PredictiveBackHandler(enabled = enabled) { events ->
try
{
events.collect { progress = it.progress }
onBack()
progress = 0f
}
catch (e: CancellationException)
{
progress = 0f
throw e
}
}
Box(
modifier
.graphicsLayer {
val scale = 1f - 0.08f * progress
scaleX = scale
scaleY = scale
}
.clip(RoundedCornerShape((progress * 28f).dp)),
)
{
content()
}
}
@@ -12,6 +12,10 @@ package org.strongswan.android.ui.compose.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -40,7 +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 org.strongswan.android.ui.compose.theme.ScreenPadding
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Uppercase section header + grouped card, the standard grouped-list building block. */
@Composable
@@ -52,9 +56,10 @@ fun ShxSection(title: String, content: @Composable () -> Unit)
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(start = ScreenPadding + 16.dp, top = 20.dp, bottom = 8.dp),
.padding(start = ShxSpacing.lg + ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm)
.semantics { heading() },
)
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ScreenPadding)) {
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) {
Column { content() }
}
}
@@ -83,7 +88,7 @@ fun ShxIconBadge(icon: ImageVector, tint: Color = MaterialTheme.colorScheme.prim
contentDescription = null,
tint = tint,
modifier = Modifier
.padding(8.dp)
.padding(ShxSpacing.sm)
.size(20.dp),
)
}
@@ -95,7 +100,7 @@ private fun RowScope.RowLabels(title: String, subtitle: String?, enabled: Boolea
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp),
.padding(horizontal = ShxSpacing.lg),
)
{
Text(
@@ -129,7 +134,7 @@ fun ShxSettingRow(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
ShxIconBadge(icon = icon, tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
@@ -157,13 +162,18 @@ fun ShxSettingToggleRow(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled) { onCheckedChange(!checked) }
.padding(horizontal = 16.dp, vertical = 14.dp),
.toggleable(
value = checked,
enabled = enabled,
role = Role.Switch,
onValueChange = onCheckedChange,
)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
ShxIconBadge(icon = icon, tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
RowLabels(title, subtitle, enabled)
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
Switch(checked = checked, onCheckedChange = null, enabled = enabled)
}
}
@@ -198,7 +208,7 @@ fun ShxEmptyState(
Column(
modifier = modifier
.fillMaxWidth()
.padding(24.dp),
.padding(ShxSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(0.dp),
)
@@ -225,7 +235,7 @@ fun ShxEmptyState(
)
if (actionLabel != null && onAction != null)
{
Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(ShxSpacing.lg))
OutlinedButton(onClick = onAction, shape = MaterialTheme.shapes.medium) { Text(actionLabel) }
}
}
@@ -0,0 +1,80 @@
/*
* 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.
*/
package org.strongswan.android.ui.compose.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* Generic list row: optional leading slot, headline + supporting text, optional trailing slot.
* Minimum height 56dp keeps touch targets above 48dp. Clickable when [onClick] is set.
*/
@Composable
fun ShxListRow(
headline: String,
modifier: Modifier = Modifier,
supporting: String? = null,
enabled: Boolean = true,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: (() -> Unit)? = null,
)
{
val clickable = if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) else Modifier
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.then(clickable)
.heightIn(min = 56.dp)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
if (leading != null)
{
leading()
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = if (leading != null || trailing != null) ShxSpacing.lg else 0.dp),
)
{
Text(
headline,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (supporting != null)
{
Text(
supporting,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (trailing != null)
{
trailing()
}
}
}
@@ -0,0 +1,49 @@
/*
* 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.
*/
package org.strongswan.android.ui.compose.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Card with an uppercase caption and a padded content column (form groups, detail blocks). */
@Composable
fun ShxSectionCard(
title: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
)
{
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = modifier.fillMaxWidth(),
) {
Column(Modifier.padding(ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
{
Text(
text = title.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.semantics { heading() },
)
content()
}
}
}
@@ -0,0 +1,25 @@
/*
* 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.
*/
package org.strongswan.android.ui.compose.components
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* The single snackbar host used by every screen scaffold, so error/undo messages
* look the same everywhere (replaces Toast).
*/
@Composable
fun ShxSnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier)
{
SnackbarHost(hostState = hostState, modifier = modifier)
}
@@ -0,0 +1,62 @@
/*
* 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.
*/
package org.strongswan.android.ui.compose.components
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Small rounded label with explicit colors, e.g. "Connected". */
@Composable
fun ShxStatusPill(
text: String,
containerColor: Color,
contentColor: Color,
modifier: Modifier = Modifier,
shape: Shape = CircleShape,
style: TextStyle = MaterialTheme.typography.labelSmall,
)
{
Surface(
color = containerColor,
contentColor = contentColor,
shape = shape,
modifier = modifier,
) {
Text(
text = text,
style = style,
modifier = Modifier.padding(horizontal = 10.dp, vertical = ShxSpacing.xs),
)
}
}
/** Pill in the "connected" status palette (independent of dynamic color). */
@Composable
fun ShxConnectedPill(text: String, modifier: Modifier = Modifier)
{
val status = LocalShxStatusColors.current
ShxStatusPill(
text = text,
containerColor = status.connectedContainer,
contentColor = status.connected,
modifier = modifier,
)
}
@@ -0,0 +1,575 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
package org.strongswan.android.ui.compose.home
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.SignalCellularAlt
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.StateFlow
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.ErrorState
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.logic.connectivity.ProbeResult
import org.strongswan.android.logic.connectivity.ProbeTarget
import org.strongswan.android.logic.connectivity.WhiteListState
import org.strongswan.android.logic.connectivity.WhiteListStatus
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ScreenPadding
import org.strongswan.android.ui.compose.DeadTargetUi
import org.strongswan.android.ui.compose.ExitUi
import org.strongswan.android.ui.compose.ShxDivider
import org.strongswan.android.ui.compose.ProfileAvatar
import org.strongswan.android.ui.compose.TrafficUi
import org.strongswan.android.ui.compose.formatBytes
import org.strongswan.android.ui.compose.formatDuration
import org.strongswan.android.ui.compose.formatRate
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.runtime.saveable.rememberSaveable
import org.strongswan.android.ui.compose.theme.ShxSpacing
/* ---------------- availability card ---------------- */
internal val PROBE_ORDER = listOf(ProbeTarget.SERVER, ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE)
/** Server first, then external targets in fixed order. */
internal fun Map<ProbeTarget, ProbeResult>.sortedForDisplay(): List<ProbeResult> =
PROBE_ORDER.filter { containsKey(it) }.mapNotNull { this[it] }
internal fun ProbeTarget.labelRes(): Int = when (this)
{
ProbeTarget.SERVER -> R.string.probe_target_server
ProbeTarget.GOOGLE -> R.string.probe_target_google
ProbeTarget.YOUTUBE -> R.string.probe_target_youtube
ProbeTarget.CLOUDFLARE -> R.string.probe_target_cloudflare
}
@Composable
internal fun ProbeResult.rttText(): String = when
{
ok && rttMs != null -> stringResource(R.string.probe_rtt_fmt, rttMs)
else -> stringResource(R.string.value_no_data)
}
/** A dead direct destination: address, the app hammering it, refusal stats,
* and a one-tap action to force the host through the tunnel. */
@Composable
internal fun DeadTargetRow(target: DeadTargetUi, onForceTunnel: (String) -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, top = 6.dp, bottom = 2.dp),
) {
Column(Modifier.weight(1f)) {
Text(
text = target.dst,
style = MaterialTheme.typography.bodyMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
)
Text(
text = listOfNotNull(
target.owner,
stringResource(R.string.dead_targets_fails_fmt, target.fails),
stringResource(R.string.dead_targets_cooldown_fmt, formatDuration(target.leftMs / 1000)),
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (target.viaTunnel)
{
Text(
text = stringResource(R.string.dead_targets_via_tunnel),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
else
{
TextButton(
onClick = { onForceTunnel(target.dst) },
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
) {
Text(
text = stringResource(R.string.dead_targets_force),
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
@Composable
internal fun DirectProbeRow(label: String, ok: Boolean?, rttMs: Long?)
{ Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
)
{
val status = LocalShxStatusColors.current
/* tri-state: null = not checked yet, false = checked and unreachable */
val dotColor = when (ok)
{
true -> status.connected
false -> status.disconnected
null -> MaterialTheme.colorScheme.outlineVariant
}
Box(
modifier = Modifier
.size(8.dp)
.background(dotColor, CircleShape),
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
)
Text(
text = when
{
ok == true && rttMs != null -> stringResource(R.string.probe_rtt_fmt, rttMs)
ok == false -> stringResource(R.string.value_unreachable)
else -> stringResource(R.string.value_no_data)
},
style = MaterialTheme.typography.bodyMedium,
color = when (ok)
{
true -> MaterialTheme.colorScheme.onSurface
false -> status.disconnected
null -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
@Composable
internal fun ProbeRow(result: ProbeResult, measuring: Boolean, history: List<ProbeResult?> = emptyList())
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
)
{
val status = LocalShxStatusColors.current
val dotColor = when
{
!result.ok -> status.disconnected
result.rttMs != null && result.rttMs < WARN_RTT_MS -> status.connected
result.rttMs != null && result.rttMs < FAIL_RTT_MS -> status.connecting
else -> status.disconnected
}
Box(
modifier = Modifier
.size(8.dp)
.background(dotColor, CircleShape),
)
Text(
text = stringResource(result.target.labelRes()),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
)
RttSparkline(samples = history)
Spacer(Modifier.size(8.dp))
if (result.ok && result.rttMs == null && measuring)
{
LoadingIndicator(Modifier.size(12.dp))
}
else
{
Text(
text = result.rttText(),
style = MaterialTheme.typography.bodyMedium,
color = if (result.ok) MaterialTheme.colorScheme.onSurface else status.disconnected,
)
}
}
}
internal const val RTT_HISTORY = 16
/** Tiny bar chart of the last RTT samples; missing samples render as dim ticks. */
@Composable
internal fun RttSparkline(samples: List<ProbeResult?>)
{
val status = LocalShxStatusColors.current
val idle = MaterialTheme.colorScheme.outlineVariant
Canvas(modifier = Modifier.size(width = 52.dp, height = 16.dp))
{
val barW = size.width / RTT_HISTORY
val barActual = barW * 0.6f
val shown = samples.takeLast(RTT_HISTORY)
shown.forEachIndexed { index, sample ->
val x = size.width - (shown.size - index) * barW
val rtt = sample?.rttMs
if (sample == null || rtt == null)
{
drawRoundRect(
color = idle.copy(alpha = 0.5f),
topLeft = Offset(x + (barW - barActual) / 2, size.height - 2.dp.toPx()),
size = Size(barActual, 2.dp.toPx()),
cornerRadius = CornerRadius(1.dp.toPx()),
)
}
else
{
val fraction = (rtt.toFloat() / FAIL_RTT_MS).coerceIn(0.12f, 1f)
val barH = size.height * fraction
val color = when
{
!sample.ok -> status.disconnected
rtt < WARN_RTT_MS -> status.connected
else -> status.connecting
}
drawRoundRect(
color = color,
topLeft = Offset(x + (barW - barActual) / 2, size.height - barH),
size = Size(barActual, barH),
cornerRadius = CornerRadius(2.dp.toPx()),
)
}
}
}
}
internal const val WARN_RTT_MS = 150L
internal const val FAIL_RTT_MS = 400L
/**
* Availability probes. Collapsed by default into one summary row (colored dots + worst
* RTT); expands in place with the per-target rows, dead-host list and white-list probes.
*/
@Composable
internal fun AvailabilityCard(
modifier: Modifier = Modifier,
probes: Map<ProbeTarget, ProbeResult>,
probing: Boolean,
whiteList: WhiteListState,
history: Map<ProbeTarget, List<ProbeResult?>>,
deadTargets: List<DeadTargetUi>,
onOpenRouting: () -> Unit,
onForceTunnel: (String) -> Unit,
onRefresh: () -> Unit,
)
{
var expanded by rememberSaveable { mutableStateOf(false) }
val rows = probes.sortedForDisplay()
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg)
.animateContentSize(MaterialTheme.motionScheme.defaultSpatialSpec()),
)
{
PullToRefreshBox(isRefreshing = probing, onRefresh = onRefresh)
{
Column(Modifier.padding(ShxSpacing.lg))
{
val toggleLabel = stringResource(if (expanded) R.string.availability_collapse else R.string.availability_expand)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClickLabel = toggleLabel) { expanded = !expanded },
)
{
Text(
text = stringResource(R.string.availability_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (probing)
{
LoadingIndicator(Modifier.size(24.dp))
Spacer(Modifier.size(ShxSpacing.sm))
}
if (!expanded)
{
AvailabilitySummary(rows = rows, deadCount = deadTargets.size)
}
else
{
IconButton(onClick = onRefresh, enabled = !probing) {
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.availability_refresh),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AnimatedVisibility(visible = expanded)
{
Column {
Spacer(Modifier.height(ShxSpacing.xs))
if (rows.isEmpty())
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(top = ShxSpacing.xs),
)
{
LoadingIndicator(Modifier.size(24.dp))
Text(
text = stringResource(R.string.availability_measuring),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else
{
rows.forEachIndexed { index, result ->
ProbeRow(result = result, measuring = probing, history = history[result.target].orEmpty())
if (index < rows.lastIndex)
{
ShxDivider()
}
}
}
if (deadTargets.isNotEmpty())
{
DeadTargetsSection(deadTargets, onOpenRouting, onForceTunnel)
}
if (whiteList.config.enabled)
{
WhiteListProbes(whiteList)
}
}
}
}
}
}
}
@Composable
private fun AvailabilitySummary(rows: List<ProbeResult>, deadCount: Int)
{
val status = LocalShxStatusColors.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs))
{
rows.forEach { result ->
val dot = when
{
!result.ok -> status.disconnected
result.rttMs != null && result.rttMs < WARN_RTT_MS -> status.connected
result.rttMs != null && result.rttMs < FAIL_RTT_MS -> status.connecting
else -> status.disconnected
}
Box(Modifier.size(8.dp).background(dot, CircleShape))
}
if (deadCount > 0)
{
Box(Modifier.size(8.dp).background(MaterialTheme.colorScheme.error, CircleShape))
}
val worst = rows.filter { it.ok }.mapNotNull { it.rttMs }.maxOrNull()
val anyFailed = rows.any { !it.ok }
Text(
text = when
{
anyFailed -> stringResource(R.string.value_unreachable)
worst != null -> stringResource(R.string.probe_rtt_fmt, worst)
else -> stringResource(R.string.value_no_data)
},
style = MaterialTheme.typography.bodyMedium,
color = if (anyFailed) status.disconnected else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = ShxSpacing.xs),
)
}
}
@Composable
private fun DeadTargetsSection(deadTargets: List<DeadTargetUi>, onOpenRouting: () -> Unit, onForceTunnel: (String) -> Unit)
{
Spacer(Modifier.height(ShxSpacing.sm))
ShxDivider()
Spacer(Modifier.height(ShxSpacing.sm))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpenRouting)
.padding(vertical = ShxSpacing.xs),
) {
Box(Modifier.size(8.dp).background(MaterialTheme.colorScheme.error, CircleShape))
Text(
text = stringResource(R.string.dead_targets_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Rounded.ChevronRight,
contentDescription = stringResource(R.string.dead_targets_open),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
deadTargets.take(3).forEach { target ->
DeadTargetRow(target = target, onForceTunnel = onForceTunnel)
}
if (deadTargets.size > 3)
{
Text(
text = stringResource(R.string.dead_targets_more_fmt, deadTargets.size - 3),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 18.dp, top = ShxSpacing.xs),
)
}
}
@Composable
private fun WhiteListProbes(whiteList: WhiteListState)
{
Spacer(Modifier.height(ShxSpacing.sm))
ShxDivider()
Spacer(Modifier.height(ShxSpacing.sm))
Text(
text = stringResource(R.string.whitelist_direct_section),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
DirectProbeRow(
label = stringResource(R.string.whitelist_foreign_direct),
ok = whiteList.foreignDirectOk,
rttMs = whiteList.foreignDirectRttMs,
)
ShxDivider()
DirectProbeRow(
label = stringResource(R.string.whitelist_domestic_direct),
ok = whiteList.domesticDirectOk,
rttMs = whiteList.domesticDirectRttMs,
)
}
@@ -0,0 +1,244 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose.home
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PowerSettingsNew
import androidx.compose.material.icons.rounded.UnfoldMore
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.graphics.shapes.Morph
import androidx.graphics.shapes.toPath
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.ui.compose.ExitUi
import org.strongswan.android.ui.compose.ProfileAvatar
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/**
* The main connect control: a large expressive shape that morphs between a circle
* (disconnected), a scalloped cookie (connecting) and the full cookie (connected),
* with the state color and a loading indicator while the tunnel is coming up.
*/
@Composable
internal fun HeroConnect(
state: State,
enabled: Boolean,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
)
{
val status = LocalShxStatusColors.current
val connected = state == State.CONNECTED
val connecting = state == State.CONNECTING || state == State.DISCONNECTING
val haptics = LocalHapticFeedback.current
val container by animateColorAsState(
targetValue = when
{
connected -> status.connectedContainer
connecting -> status.connectingContainer
else -> MaterialTheme.colorScheme.primary
},
animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
label = "heroContainer",
)
val content by animateColorAsState(
targetValue = when
{
connected -> status.connected
connecting -> status.connecting
else -> MaterialTheme.colorScheme.onPrimary
},
animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
label = "heroContent",
)
val progress by animateFloatAsState(
targetValue = when
{
connected -> 1f
connecting -> 0.5f
else -> 0f
},
animationSpec = MaterialTheme.motionScheme.slowSpatialSpec(),
label = "heroMorph",
)
val morph = remember { Morph(MaterialShapes.Circle, MaterialShapes.Cookie9Sided) }
val shape = remember(progress) { MorphShape(morph, progress) }
val label = stringResource(
when
{
connected -> R.string.disconnect
connecting -> android.R.string.cancel
else -> R.string.connect
}
)
Surface(
onClick = {
when
{
connected ->
{
haptics.performHapticFeedback(HapticFeedbackType.Reject)
onDisconnect()
}
connecting -> onCancel()
else ->
{
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
onConnect()
}
}
},
enabled = enabled || connected || connecting,
shape = shape,
color = container,
contentColor = content,
modifier = modifier
.size(176.dp)
.semantics {
contentDescription = label
role = Role.Button
},
)
{
Box(contentAlignment = Alignment.Center)
{
if (connecting)
{
LoadingIndicator(Modifier.size(72.dp), color = content)
}
else
{
Icon(
Icons.Rounded.PowerSettingsNew,
contentDescription = null,
modifier = Modifier.size(72.dp),
)
}
}
}
}
/** Current profile as a chip: name, and exit country + IP while connected; tap opens the picker. */
@Composable
internal fun ProfileChip(
profile: VpnProfile?,
exit: ExitUi?,
connected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
)
{
val secondary = when
{
connected && exit?.ip != null -> exit.country?.let { "$it · ${exit.ip}" } ?: exit.ip
else -> profile?.gateway ?: ""
}
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = modifier.fillMaxWidth(),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
)
{
ProfileAvatar(icon = Icons.Rounded.Language, connected = connected)
Column(Modifier.weight(1f))
{
Text(
text = profile?.name ?: stringResource(R.string.no_profiles),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!secondary.isNullOrEmpty())
{
Text(
text = secondary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Icon(
Icons.Rounded.UnfoldMore,
contentDescription = stringResource(R.string.home_profile_picker_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** Compose Shape drawing a [Morph] at [progress], scaled to fill the layout bounds. */
private class MorphShape(private val morph: Morph, private val progress: Float) : Shape
{
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline
{
val bounds = morph.calculateBounds()
val width = bounds[2] - bounds[0]
val height = bounds[3] - bounds[1]
val matrix = android.graphics.Matrix()
matrix.setScale(size.width / width, size.height / height)
matrix.preTranslate(-bounds[0], -bounds[1])
val path = morph.toPath(progress)
path.transform(matrix)
return Outline.Generic(path.asComposePath())
}
}
@@ -0,0 +1,234 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
package org.strongswan.android.ui.compose.home
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.GppMaybe
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material.icons.rounded.SignalCellularAlt
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.StateFlow
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
import org.strongswan.android.logic.VpnStateService.ErrorState
import org.strongswan.android.logic.VpnStateService.State
import org.strongswan.android.logic.connectivity.ProbeResult
import org.strongswan.android.logic.connectivity.ProbeTarget
import org.strongswan.android.logic.connectivity.WhiteListState
import org.strongswan.android.logic.connectivity.WhiteListStatus
import org.strongswan.android.ui.compose.components.ShxCard
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ScreenPadding
import org.strongswan.android.ui.compose.DeadTargetUi
import org.strongswan.android.ui.compose.ExitUi
import org.strongswan.android.ui.compose.ShxDivider
import org.strongswan.android.ui.compose.ProfileAvatar
import org.strongswan.android.ui.compose.TrafficUi
import org.strongswan.android.ui.compose.formatBytes
import org.strongswan.android.ui.compose.formatDuration
import org.strongswan.android.ui.compose.formatRate
import androidx.compose.ui.unit.IntSize
import org.strongswan.android.ui.compose.theme.ShxSpacing
@Composable
internal fun ErrorBanner(modifier: Modifier = Modifier, onRetry: () -> Unit, onShowLog: () -> Unit)
{
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.connection_failed),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
maxLines = 2,
)
TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
TextButton(onClick = onShowLog) { Text(stringResource(R.string.show_log)) }
}
}
}
@Composable
internal fun WhiteListBanner(modifier: Modifier = Modifier)
{
val status = LocalShxStatusColors.current
Surface(
shape = MaterialTheme.shapes.large,
color = status.connectingContainer,
contentColor = status.onConnectingContainer,
modifier = modifier.fillMaxWidth(),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp),
)
{
Icon(Icons.Rounded.GppMaybe, contentDescription = null)
Spacer(Modifier.size(12.dp))
Column {
Text(
text = stringResource(R.string.whitelist_active_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(R.string.whitelist_active_subtitle),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
/** At most one status banner at a time: connection error beats the white-list notice. */
@Composable
internal fun HomeBannerSlot(
error: Boolean,
whiteListActive: Boolean,
onRetry: () -> Unit,
onShowLog: () -> Unit,
modifier: Modifier = Modifier,
)
{
val kind = when
{
error -> 1
whiteListActive -> 2
else -> 0
}
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
val fastEffects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<IntSize>()
val fastSpatial = MaterialTheme.motionScheme.fastSpatialSpec<IntSize>()
AnimatedContent(
targetState = kind,
modifier = modifier,
transitionSpec = {
(fadeIn(effects) + expandVertically(spatial)) togetherWith
(fadeOut(fastEffects) + shrinkVertically(fastSpatial))
},
label = "homeBanner",
) { k ->
when (k)
{
1 -> ErrorBanner(Modifier.padding(horizontal = ShxSpacing.lg), onRetry = onRetry, onShowLog = onShowLog)
2 -> WhiteListBanner(Modifier.padding(horizontal = ShxSpacing.lg))
else -> Spacer(Modifier.height(0.dp))
}
}
}
@@ -0,0 +1,108 @@
/*
* 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.
*/
package org.strongswan.android.ui.compose.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.TrafficUi
import org.strongswan.android.ui.compose.formatBytes
import org.strongswan.android.ui.compose.formatRate
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Two compact tiles (down / up) with total and live rate; tap opens the connection details. */
@Composable
internal fun TrafficTiles(traffic: TrafficUi, onClick: () -> Unit, modifier: Modifier = Modifier)
{
Column(modifier = modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg))
{
Row(
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
)
{
TrafficTile(down = true, total = traffic.bytesIn, rate = traffic.rateIn, onClick = onClick, modifier = Modifier.weight(1f))
TrafficTile(down = false, total = traffic.bytesOut, rate = traffic.rateOut, onClick = onClick, modifier = Modifier.weight(1f))
}
/* one-glance confirmation that the RU split is working */
traffic.split?.takeIf { it.directBytes > 0 }?.let { split ->
Text(
text = stringResource(R.string.traffic_direct_share_fmt, split.directShare),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = ShxSpacing.sm),
)
}
}
}
@Composable
private fun TrafficTile(down: Boolean, total: Long, rate: Long, onClick: () -> Unit, modifier: Modifier = Modifier)
{
val label = if (down) stringResource(R.string.traffic_down) else stringResource(R.string.traffic_up)
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = modifier,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
modifier = Modifier.padding(ShxSpacing.md),
)
{
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainerHigh)
{
Icon(
imageVector = if (down) Icons.Rounded.ArrowDownward else Icons.Rounded.ArrowUpward,
contentDescription = label,
modifier = Modifier.padding(ShxSpacing.sm - 2.dp).size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column {
Text(
text = formatBytes(total),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = formatRate(rate),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,451 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose.routing
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.utils.Constants
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Geoip toggle row with a refresh/update slot in front of the switch. */
@Composable
internal fun RuToggleRow(
title: String,
subtitle: String,
checked: Boolean,
updating: Boolean,
onCheckedChange: (Boolean) -> Unit,
onUpdate: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) }
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (updating)
{
LoadingIndicator(Modifier.size(14.dp))
Spacer(Modifier.width(ShxSpacing.md))
}
else
{
IconButton(onClick = onUpdate)
{
Icon(
Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.rusplit_update),
)
}
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
/**
* Selector for how the RU split is routed: OS routes with the coarsened
* complement (as before) or per-packet classification in native code.
*/
@Composable
internal fun RuModeSelector(mode: String, onModeChange: (String) -> Unit)
{
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
{
SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_SYSTEM,
onClick = { onModeChange(Constants.RUSPLIT_MODE_SYSTEM) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
)
{
Text(stringResource(R.string.rusplit_mode_system))
}
SegmentedButton(
selected = mode == Constants.RUSPLIT_MODE_USERSPACE,
onClick = { onModeChange(Constants.RUSPLIT_MODE_USERSPACE) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
)
{
Text(stringResource(R.string.rusplit_mode_userspace_short))
}
}
}
/** Mode description, list source and beta note behind a "Details" disclosure. */
@Composable
internal fun RuDetailsDisclosure(mode: String)
{
var expanded by rememberSaveable { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
)
{
Text(
text = stringResource(if (expanded) R.string.routing_details_less else R.string.routing_details_more),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(ShxSpacing.xs))
Icon(
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)) +
fadeIn(tween(120)),
exit = shrinkVertically(spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMedium)) +
fadeOut(tween(90)),
)
{
Column(Modifier.padding(start = ShxSpacing.lg, end = ShxSpacing.lg, bottom = ShxSpacing.md))
{
Text(
text = stringResource(
if (mode == Constants.RUSPLIT_MODE_USERSPACE)
{
R.string.rusplit_mode_userspace_desc
}
else
{
R.string.rusplit_mode_system_desc
}
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.rusplit_source),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = stringResource(R.string.rusplit_beta_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = ShxSpacing.xs),
)
}
}
}
/** Freshness/count chips of the geoip list. */
@Composable
internal fun GeoipChipsRow(state: RuIpListState)
{
val status = LocalShxStatusColors.current
val fresh = state.lastUpdatedMs > 0L &&
System.currentTimeMillis() - state.lastUpdatedMs < 26 * 60 * 60 * 1000L
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = ShxSpacing.lg, end = ShxSpacing.lg, bottom = ShxSpacing.xs),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
verticalArrangement = Arrangement.spacedBy(ShxSpacing.xs),
)
{
InfoChip(text = stringResource(R.string.routing_geoip_ipv4, state.ipv4Ranges))
InfoChip(text = stringResource(R.string.routing_geoip_ipv6, state.ipv6Ranges))
val freshnessText = when
{
state.lastUpdatedMs > 0L ->
stringResource(
R.string.routing_geoip_updated,
DateUtils.getRelativeTimeSpanString(state.lastUpdatedMs).toString(),
)
else -> stringResource(R.string.routing_geoip_never)
}
InfoChip(
text = freshnessText,
highlight = if (state.lastUpdatedMs > 0L && fresh) status.connected else null,
)
}
}
/** Small rounded info pill used across the screen. */
@Composable
internal fun InfoChip(text: String, highlight: Color? = null)
{
Surface(
shape = RoundedCornerShape(8.dp),
color = if (highlight != null) highlight.copy(alpha = 0.14f) else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = highlight ?: MaterialTheme.colorScheme.onSurfaceVariant,
)
{
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
/** One force-tunnel prefix: tap edits, switch toggles, trash deletes. */
@Composable
internal fun ForcePrefixRow(
entry: ForceTunnelPrefix,
onToggle: (Boolean) -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onEdit)
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
)
{
ShxIconBadge(
Icons.Rounded.Lan,
tint = if (entry.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(ShxSpacing.lg))
Text(
entry.prefix,
style = MaterialTheme.typography.bodyLarge,
color = if (entry.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDelete)
{
Icon(
Icons.Rounded.Delete,
contentDescription = stringResource(R.string.routing_force_delete),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = entry.enabled, onCheckedChange = onToggle)
}
}
/** Trailing "add force-tunnel prefix" row. */
@Composable
internal fun AddForcePrefixRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(ShxSpacing.lg))
Text(
stringResource(R.string.routing_force_add),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
/** Add/edit dialog for a force-tunnel prefix; shows an error until valid. */
@Composable
internal fun ForcePrefixDialog(initial: String, onConfirm: (String) -> Boolean, onDismiss: () -> Unit)
{
var text by remember { mutableStateOf(initial) }
var error by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.routing_force_dialog_title)) },
text = {
OutlinedTextField(
value = text,
onValueChange = {
text = it
error = false
},
singleLine = true,
isError = error,
label = { Text(stringResource(R.string.routing_force_hint)) },
supportingText = if (error)
{
{ Text(stringResource(R.string.routing_force_invalid)) }
}
else
{
null
},
)
},
confirmButton = {
TextButton(onClick = {
if (onConfirm(text.trim()))
{
onDismiss()
}
else
{
error = true
}
})
{
Text(stringResource(R.string.routing_force_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss)
{
Text(stringResource(android.R.string.cancel))
}
},
)
}
@@ -0,0 +1,219 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose.routing
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.utils.Constants
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Hero card: master switch plus a live summary of what the rules currently affect. */
@Composable
internal fun RoutingHeroCard(
enabled: Boolean,
setCount: Int,
appCount: Int,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier,
)
{
Surface(
shape = MaterialTheme.shapes.large,
color = if (enabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow,
contentColor = if (enabled) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
border = if (enabled) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier.fillMaxWidth(),
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = ShxSpacing.lg, top = 14.dp, bottom = 14.dp, end = ShxSpacing.sm),
)
{
Icon(
Icons.Rounded.AltRoute,
contentDescription = null,
tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(
text = stringResource(if (enabled) R.string.routing_hero_on else R.string.routing_hero_off),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = if (enabled)
{
stringResource(R.string.routing_hero_summary, setCount, appCount)
}
else
{
stringResource(R.string.routing_hero_off_summary)
},
style = MaterialTheme.typography.bodySmall,
color = if (enabled)
{
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
}
else
{
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Switch(checked = enabled, onCheckedChange = onToggle)
Spacer(Modifier.width(ShxSpacing.sm))
}
}
}
/** Quick action: create/enable rule sets for all RF bundle presets. */
@Composable
internal fun RfActionRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
ShxIconBadge(Icons.Rounded.Block)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(
stringResource(R.string.routing_rf_quick_title),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
)
Text(
stringResource(R.string.routing_rf_quick_summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,198 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.strongswan.android.ui.compose.routing
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import android.app.Activity
import android.content.Intent
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material.icons.rounded.Block
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Route
import androidx.compose.material.icons.rounded.Rule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.routing.ForceTunnelPrefix
import org.strongswan.android.logic.routing.RuleSet
import org.strongswan.android.logic.routing.RoutingManager
import org.strongswan.android.logic.routing.RuIpListManager
import org.strongswan.android.logic.routing.RuIpListState
import org.strongswan.android.logic.routing.RuIpListStatus
import org.strongswan.android.utils.Constants
import org.strongswan.android.ui.RouteCheckActivity
import org.strongswan.android.ui.SelectedApplicationsActivity
import org.strongswan.android.ui.compose.components.ShxEmptyState
import org.strongswan.android.ui.compose.components.ShxIconBadge
import org.strongswan.android.ui.compose.components.ShxSection
import org.strongswan.android.ui.compose.components.ShxSectionDivider
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing
/** Collapsed rule set row: tap opens the editor sheet, the switch toggles in place. */
@Composable
internal fun RuleSetRow(
ruleSet: RuleSet,
onToggle: (Boolean) -> Unit,
onOpen: () -> Unit,
)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOpen)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
ShxIconBadge(
Icons.Rounded.Rule,
tint = if (ruleSet.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(ShxSpacing.lg))
Column(Modifier.weight(1f))
{
Text(
ruleSet.name.ifBlank { stringResource(R.string.routing_rule_name) },
style = MaterialTheme.typography.bodyLarge,
color = if (ruleSet.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(
R.string.routing_rule_subtitle,
ruleSet.packages.size,
ruleSet.prefixes.size,
ruleSet.keywords.size,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = ruleSet.enabled, onCheckedChange = onToggle)
}
}
/** Trailing "add rule set" row of the rule sets card. */
@Composable
internal fun AddRuleRow(onClick: () -> Unit)
{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
)
{
Icon(
Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
Spacer(Modifier.width(ShxSpacing.lg))
Text(
stringResource(R.string.routing_add_rule),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
}
@@ -9,13 +9,18 @@
package org.strongswan.android.ui.compose.theme
import android.app.Activity
import android.content.Context
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.toShape
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
@@ -23,10 +28,14 @@ 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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -38,8 +47,41 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
val ConnectionCardRadius = 16.dp
val ScreenPadding = 16.dp
/** Spacing scale; use these instead of ad-hoc .dp literals. */
object ShxSpacing
{
val xs = 4.dp
val sm = 8.dp
val md = 12.dp
val lg = 16.dp
val xl = 24.dp
val xxl = 32.dp
}
/** Shape tokens. [scheme] feeds MaterialTheme.shapes; the rest are named radii. */
object ShxShapes
{
val cardRadius = 16.dp
val scheme = Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(10.dp),
medium = RoundedCornerShape(14.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(22.dp),
)
/** Scalloped "cookie" used by the hero connect button (Expressive MaterialShapes). */
@ExperimentalMaterial3ExpressiveApi
val hero: Shape
@Composable get() = MaterialShapes.Cookie9Sided.toShape()
}
@Deprecated("Use ShxShapes.cardRadius", ReplaceWith("ShxShapes.cardRadius"))
val ConnectionCardRadius = ShxShapes.cardRadius
@Deprecated("Use ShxSpacing.lg", ReplaceWith("ShxSpacing.lg"))
val ScreenPadding = ShxSpacing.lg
val ProtonPurpleLight = Color(0xFF6D4AFF)
val ProtonPurpleDark = Color(0xFF9C7CFF)
@@ -49,34 +91,34 @@ val SuccessGreenDark = Color(0xFF28A671)
private val LightColors = lightColorScheme(
primary = ProtonPurpleLight,
onPrimary = Color.White,
primaryContainer = Color(0xFFEDE7FF),
onPrimaryContainer = Color(0xFF2E1A7A),
primaryContainer = Color(0xFFE7DFFF),
onPrimaryContainer = Color(0xFF1F0A6B),
inversePrimary = ProtonPurpleDark,
secondary = Color(0xFF625B71),
secondary = Color(0xFF5E5A7D),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE8DEF8),
onSecondaryContainer = Color(0xFF1D192B),
secondaryContainer = Color(0xFFE4E0F9),
onSecondaryContainer = Color(0xFF1A1833),
tertiary = SuccessGreenLight,
onTertiary = Color.White,
tertiaryContainer = Color(0xFFC8F2DA),
onTertiaryContainer = Color(0xFF07391D),
background = Color(0xFFF7F7F8),
onBackground = Color(0xFF1B1B20),
surface = Color(0xFFF7F7F8),
onSurface = Color(0xFF1B1B20),
surfaceVariant = Color(0xFFEBEBEF),
onSurfaceVariant = Color(0xFF57575F),
surfaceDim = Color(0xFFDADAE0),
surfaceBright = Color(0xFFF7F7F8),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF2F2F4),
surfaceContainer = Color.White,
surfaceContainerHigh = Color(0xFFF2F2F4),
surfaceContainerHighest = Color(0xFFEBEBEF),
outline = Color(0xFF797981),
outlineVariant = Color(0xFFD0D0D6),
inverseSurface = Color(0xFF2F2F35),
inverseOnSurface = Color(0xFFF2F2F4),
background = Color(0xFFFCF8FF),
onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFCF8FF),
onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFE5E1EC),
onSurfaceVariant = Color(0xFF48454E),
surfaceDim = Color(0xFFDCD8E0),
surfaceBright = Color(0xFFFCF8FF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF6F2FA),
surfaceContainer = Color(0xFFF0ECF4),
surfaceContainerHigh = Color(0xFFEAE6EE),
surfaceContainerHighest = Color(0xFFE5E1E9),
outline = Color(0xFF79767F),
outlineVariant = Color(0xFFC9C5D0),
inverseSurface = Color(0xFF313036),
inverseOnSurface = Color(0xFFF3EFF7),
error = Color(0xFFD8303C),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
@@ -90,31 +132,31 @@ private val DarkColors = darkColorScheme(
primaryContainer = Color(0xFF3B2C88),
onPrimaryContainer = Color(0xFFEDE7FF),
inversePrimary = ProtonPurpleLight,
secondary = Color(0xFFCCC2DC),
onSecondary = Color(0xFF332D41),
secondaryContainer = Color(0xFF4A4458),
onSecondaryContainer = Color(0xFFE8DEF8),
secondary = Color(0xFFCBC4E8),
onSecondary = Color(0xFF322F4D),
secondaryContainer = Color(0xFF484564),
onSecondaryContainer = Color(0xFFE4E0F9),
tertiary = SuccessGreenDark,
onTertiary = Color(0xFF00391B),
tertiaryContainer = Color(0xFF005228),
onTertiaryContainer = Color(0xFFC8F2DA),
background = Color(0xFF17171A),
onBackground = Color(0xFFE6E6E9),
surface = Color(0xFF17171A),
onSurface = Color(0xFFE6E6E9),
surfaceVariant = Color(0xFF2C2C31),
onSurfaceVariant = Color(0xFFC0C0C8),
surfaceDim = Color(0xFF17171A),
surfaceBright = Color(0xFF3B3B41),
surfaceContainerLowest = Color(0xFF121214),
surfaceContainerLow = Color(0xFF1F1F23),
surfaceContainer = Color(0xFF242428),
surfaceContainerHigh = Color(0xFF2C2C31),
surfaceContainerHighest = Color(0xFF36363B),
outline = Color(0xFF8B8B93),
outlineVariant = Color(0xFF3D3D43),
inverseSurface = Color(0xFFE6E6E9),
inverseOnSurface = Color(0xFF2C2C31),
background = Color(0xFF141318),
onBackground = Color(0xFFE5E1E9),
surface = Color(0xFF141318),
onSurface = Color(0xFFE5E1E9),
surfaceVariant = Color(0xFF48454E),
onSurfaceVariant = Color(0xFFC9C5D0),
surfaceDim = Color(0xFF141318),
surfaceBright = Color(0xFF3A383E),
surfaceContainerLowest = Color(0xFF0F0D13),
surfaceContainerLow = Color(0xFF1C1B20),
surfaceContainer = Color(0xFF201F24),
surfaceContainerHigh = Color(0xFF2B292F),
surfaceContainerHighest = Color(0xFF36343A),
outline = Color(0xFF938F99),
outlineVariant = Color(0xFF48454E),
inverseSurface = Color(0xFFE5E1E9),
inverseOnSurface = Color(0xFF313036),
error = Color(0xFFFF8A8C),
onError = Color(0xFF490005),
errorContainer = Color(0xFF68000E),
@@ -163,21 +205,25 @@ private val DarkStatusColors = ShxStatusColors(
* semantics must stay green/purple/red regardless of the user's wallpaper. */
val LocalShxStatusColors = staticCompositionLocalOf { LightStatusColors }
private val ShxShapes = Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(10.dp),
medium = RoundedCornerShape(14.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(22.dp),
)
/**
* 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)
@@ -197,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)
@@ -236,10 +290,33 @@ private fun buildTypography(): Typography
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
@@ -251,11 +328,10 @@ fun ShxTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable ()
}
val statusColors = if (darkTheme) DarkStatusColors else LightStatusColors
CompositionLocalProvider(LocalShxStatusColors provides statusColors) {
/* MaterialExpressiveTheme/MotionScheme недоступны: в material3 1.4.0 они internal,
а 1.5.0-alpha требует compose 1.10+ (minSdk 23). Вернуться при подъеме minSdk. */
MaterialTheme(
MaterialExpressiveTheme(
colorScheme = colors,
shapes = ShxShapes,
motionScheme = MotionScheme.expressive(),
shapes = ShxShapes.scheme,
typography = ShxTypography,
content = content,
)
@@ -0,0 +1,210 @@
/*
* 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.
*/
package org.strongswan.android.ui.widget
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.Button
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfileSource
import org.strongswan.android.logic.VpnStateService
import org.strongswan.android.ui.VpnProfileControlActivity
import org.strongswan.android.utils.DefaultVpnProfile
/** What the widget shows; persisted by [ShxWidgetState] so it survives process death. */
internal data class WidgetSnapshot(
val state: String,
val error: Boolean,
val profileName: String?,
val profileUuid: String?,
)
/**
* Bridges the VPN state (VpnStateService) to the home screen widget. The widget
* itself never binds the service: the service publishes every change here.
*/
object ShxWidgetState
{
private const val KEY_STATE = "widget_state"
private const val KEY_ERROR = "widget_error"
private const val KEY_NAME = "widget_profile_name"
private const val KEY_UUID = "widget_profile_uuid"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@JvmStatic
fun publish(
context: Context,
state: VpnStateService.State,
error: VpnStateService.ErrorState,
profileName: String?,
profileUuid: String?,
)
{
val app = context.applicationContext
val editor = PreferenceManager.getDefaultSharedPreferences(app).edit()
.putString(KEY_STATE, state.name)
.putBoolean(KEY_ERROR, error != VpnStateService.ErrorState.NO_ERROR)
if (profileUuid != null)
{
editor.putString(KEY_NAME, profileName).putString(KEY_UUID, profileUuid)
}
editor.apply()
scope.launch {
try
{
if (GlanceAppWidgetManager(app).getGlanceIds(ShxWidget::class.java).isNotEmpty())
{
ShxWidget().updateAll(app)
}
}
catch (_: Exception)
{
}
}
}
internal suspend fun snapshot(context: Context): WidgetSnapshot = withContext(Dispatchers.IO)
{
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
var name = prefs.getString(KEY_NAME, null)
var uuid = prefs.getString(KEY_UUID, null)
if (uuid == null)
{ /* never connected yet: fall back to the configured default profile */
val source = VpnProfileSource(context)
source.open()
try
{
source.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(context))?.let {
name = it.name
uuid = it.uuid.toString()
}
}
finally
{
source.close()
}
}
val state = prefs.getString(KEY_STATE, null) ?: VpnStateService.State.DISABLED.name
WidgetSnapshot(state, prefs.getBoolean(KEY_ERROR, false), name, uuid)
}
}
/** 2x1 widget: protection status, current profile and a connect / disconnect button. */
class ShxWidget : GlanceAppWidget()
{
override val sizeMode = SizeMode.Single
override suspend fun provideGlance(context: Context, id: GlanceId)
{
val snapshot = ShxWidgetState.snapshot(context)
provideContent {
GlanceTheme {
WidgetContent(context, snapshot)
}
}
}
}
@Composable
private fun WidgetContent(context: Context, snapshot: WidgetSnapshot)
{
val connected = snapshot.state == VpnStateService.State.CONNECTED.name && !snapshot.error
val connecting = snapshot.state == VpnStateService.State.CONNECTING.name && !snapshot.error
val status = context.getString(
when
{
connected -> R.string.status_protected
connecting -> R.string.status_connecting
else -> R.string.status_unprotected
}
)
val buttonLabel = context.getString(if (connected || connecting) R.string.disconnect else R.string.connect)
/* same entry point as the quick settings tile */
val intent = Intent(context, VpnProfileControlActivity::class.java)
if (connected || connecting)
{
intent.action = VpnProfileControlActivity.DISCONNECT
}
else
{
intent.action = VpnProfileControlActivity.START_PROFILE
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, snapshot.profileUuid)
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
Row(
modifier = GlanceModifier
.fillMaxSize()
.appWidgetBackground()
.background(GlanceTheme.colors.widgetBackground)
.cornerRadius(24.dp)
.padding(12.dp),
verticalAlignment = Alignment.Vertical.CenterVertically,
)
{
Column(modifier = GlanceModifier.defaultWeight())
{
Text(
text = status,
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = GlanceTheme.colors.onSurface,
),
maxLines = 1,
)
Text(
text = snapshot.profileName ?: context.getString(R.string.status_choose_profile),
style = TextStyle(fontSize = 12.sp, color = GlanceTheme.colors.onSurfaceVariant),
maxLines = 1,
)
}
Button(
text = buttonLabel,
onClick = actionStartActivity(intent),
)
}
}
class ShxWidgetReceiver : GlanceAppWidgetReceiver()
{
override val glanceAppWidget: GlanceAppWidget = ShxWidget()
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" />
@@ -273,4 +273,244 @@
<string name="tile_connect">VPN verbinden</string>
<string name="tile_disconnect">VPN trennen</string>
<string name="pref_dynamic_colors_title">Systemfarben</string>
<string name="probe_rtt_fmt">%1$d ms</string>
<string name="value_no_data">—</string>
<string name="value_unreachable">✕</string>
<string name="profile_actions">Profilaktionen</string>
<string name="action_clear_search">Suche löschen</string>
<string name="action_undo">Rückgängig</string>
<string name="action_done">Fertig</string>
<string name="action_back">Zurück</string>
<string name="profile_deleted_snackbar">Profil gelöscht</string>
<string name="action_more_options">Weitere Optionen</string>
<string name="back">Zurück</string>
<string name="pref_dynamic_colors_summary">Farben des Hintergrundbilds übernehmen (Android 12+)</string>
<string name="watchdog_title">Verbindungs-Watchdog</string>
<string name="watchdog_summary">Die VPN-Verbindung nach dem Beenden oder Absturz des App-Prozesses automatisch wiederherstellen</string>
<string name="samsung_sleep_title">Von Ruhe-Apps ausnehmen (Samsung)</string>
<string name="samsung_sleep_summary">One UI kann ungenutzte Apps auch bei deaktivierter Akkuoptimierung in den Ruhezustand versetzen. Im Ruhezustand kann die App die VPN-Verbindung nicht selbst wiederherstellen. Öffnen Sie die Systemeinstellungen → Akku → Einschränkungen für Hintergrundnutzung und fügen Sie diese App unter „Apps, die nie in den Ruhezustand versetzt werden“ hinzu.</string>
<string name="update_silent_title">Stille Updates</string>
<string name="update_silent_summary">Updates über WLAN vorab herunterladen und automatisch installieren (Android 12+). Andernfalls benachrichtigen, sobald das Update bereit ist</string>
<string name="updates_channel_name">App-Updates</string>
<string name="update_ready_title">Update zur Installation bereit</string>
<string name="update_ready_text">Version %1$s wurde heruntergeladen. Tippen Sie, um die App zu aktualisieren.</string>
<string name="profile_split_tunnelingv6_hint">Wenn der Server kein IPv6 hat, verhindert dies, dass IPv6 in Dual-Stack-Netzen am Tunnel vorbei austritt</string>
<string name="share_profile">VPN-Profil teilen</string>
<string name="share_profile_include_password">Passwort in Datei einschließen</string>
<string name="share_profile_password_warning">Die Profildatei enthält die Verbindungseinstellungen. Das Passwort wird unverschlüsselt in der Datei gespeichert, und ein in der App gespeichertes Zertifikat wird mit seinem privaten Schlüssel exportiert. Senden Sie die Datei nur an vertrauenswürdige Personen.</string>
<string name="share_profile_failed">VPN-Profil konnte nicht exportiert werden</string>
<string name="whitelist_analysis_title">Analyse der ISP-Whitelist</string>
<string name="whitelist_analysis_summary">Erkennt eine ISP-Whitelist und pausiert die Wiederverbindung, solange ausländische Hosts direkt nicht erreichbar sind</string>
<string name="whitelist_active_title">ISP-Whitelist ist aktiv</string>
<string name="whitelist_active_subtitle">Prüfungen laufen direkt außerhalb des Tunnels. Die Wiederverbindung ist pausiert, bis die Whitelist aufgehoben wird</string>
<string name="whitelist_direct_section">Direkt (außerhalb des Tunnels)</string>
<string name="whitelist_foreign_direct">Ausländische Hosts (direkt)</string>
<string name="whitelist_domestic_direct">Yandex (direkt)</string>
<string name="nav_routing">Routing</string>
<string name="routing_title">Routing</string>
<string name="routing_section_ru">RU-Netze direkt</string>
<string name="routing_rf_quick_title">Apps von RF-Unternehmen ausnehmen</string>
<string name="routing_rf_quick_summary">Alle ru.*-Apps, Yandex, VK, Gosuslugi, große Banken, Handel, Telekommunikation, Verkehr und weitere russische Dienste</string>
<string name="routing_force_section">Immer über VPN</string>
<string name="routing_force_summary">Adressen und Subnetze, die immer durch den Tunnel geleitet werden, auch wenn sie in RU-Bereiche fallen (Userspace-Modus)</string>
<string name="routing_force_add">Adresse hinzufügen</string>
<string name="routing_force_dialog_title">Adresse oder Subnetz</string>
<string name="routing_force_hint">z. B. 130.49.213.176 oder 10.8.0.0/24</string>
<string name="routing_force_invalid">Ungültige Adresse oder ungültiges Subnetz</string>
<string name="routing_force_save">Speichern</string>
<string name="routing_force_delete">Löschen</string>
<string name="routing_rules_section">Regelsätze</string>
<string name="routing_add_rule">Regelsatz hinzufügen</string>
<string name="routing_rule_name">Name des Regelsatzes</string>
<string name="routing_rule_subtitle">Apps: %1$d · Präfixe: %2$d · Schlüsselwörter: %3$d</string>
<string name="routing_pick_apps">Anwendungen auswählen</string>
<string name="routing_apps_count">Ausgewählt: %1$d</string>
<string name="routing_editor_title">Regelsatz</string>
<string name="routing_prefixes_label">Paket-Präfixe</string>
<string name="routing_keywords_label">Schlüsselwörter</string>
<string name="routing_details_more">Details</string>
<string name="routing_details_less">Details ausblenden</string>
<string name="routing_presets_label">Vorlagen</string>
<string name="routing_presets_selected">Vorlagen · %1$d ausgewählt</string>
<string name="routing_presets_hint">Fertige Gruppen von Diensten</string>
<string name="routing_presets_search">Vorlagen durchsuchen</string>
<string name="routing_presets_none">Nichts gefunden</string>
<string name="routing_preset_prefixes">%1$d Präfixe</string>
<string name="routing_preset_keywords">%1$d Schlüsselwörter</string>
<string name="routing_preset_no_patterns">keine Muster</string>
<string name="routing_empty">Noch keine Regelsätze vorhanden. Legen Sie einen an oder nutzen Sie die Schnellaktion oben.</string>
<string name="routing_delete">Regelsatz löschen</string>
<string name="routing_delete_snackbar">Regelsatz gelöscht</string>
<string name="routing_add_prefix">Paket-Präfix hinzufügen</string>
<string name="routing_add_keyword">Schlüsselwort hinzufügen</string>
<string name="routing_hero_on">Routing aktiv</string>
<string name="routing_hero_off">Routing inaktiv</string>
<string name="routing_hero_summary">Sets: %1$d · Apps direkt: %2$d</string>
<string name="routing_hero_off_summary">Gesamter Datenverkehr läuft über das VPN</string>
<string name="routing_geoip_ipv4">IPv4 · %1$d</string>
<string name="routing_geoip_ipv6">IPv6 · %1$d</string>
<string name="routing_geoip_updated">Aktualisiert %1$s</string>
<string name="routing_geoip_never">Nie aktualisiert</string>
<string name="rusplit_toggle_title">RU-Netze direkt verbinden</string>
<string name="rusplit_toggle_summary">Datenverkehr zu russischen IP-Adressen umgeht den VPN-Tunnel und nutzt die direkte Verbindung (geoip:ru)</string>
<string name="rusplit_update">Liste aktualisieren</string>
<string name="rusplit_updating">Wird aktualisiert…</string>
<string name="rusplit_updated">Liste aktualisiert: %1$d Bereiche</string>
<string name="rusplit_update_failed">Aktualisierung nicht möglich, gespeicherte Version wird verwendet</string>
<string name="rusplit_source">Quelle: github.com/MetaCubeX/meta-rules-dat</string>
<string name="rusplit_beta_note">Die Liste ist statisch und wird täglich von GitHub aktualisiert. Die Zuordnung erfolgt über IP-Adressen. Dienste hinter ausländischen CDNs sind möglicherweise nicht in der Liste enthalten. Änderungen werden nach einer erneuten Verbindung wirksam.</string>
<string name="rusplit_mode_system">Betriebssystem-Routen</string>
<string name="rusplit_mode_userspace">Userspace-Routing (Beta)</string>
<string name="rusplit_mode_userspace_short">Userspace (Beta)</string>
<string name="rusplit_mode_system_desc">Das Komplement der RU-Liste wird als Systemrouten installiert und auf das Routenlimit gerundet. Bewährtes Verfahren, die Liste wird dabei jedoch vergröbert, und Aktualisierungen erfordern eine erneute Verbindung.</string>
<string name="rusplit_mode_userspace_desc">Der gesamte Datenverkehr geht in das VPN. Die Entscheidung Direkt/Tunnel wird pro Paket anhand der genauen Liste getroffen. Listenaktualisierungen werden sofort übernommen. TCP und UDP gehen direkt. Ping zu RU-Adressen funktioniert in diesem Modus nicht direkt, anderes ICMP wird verworfen.</string>
<string name="route_check_title">Routenprüfung</string>
<string name="route_check_hint">Geben Sie eine Domain ein, um zu sehen, auf welche Adressen sie über den DNS des Tunnels und den DNS der direkten Verbindung aufgelöst wird und ob jede Adresse unter den angewendeten Ausnahmen durch den Tunnel läuft oder ihn umgeht.</string>
<string name="route_check_field">Domain, z. B. inet.ya.ru</string>
<string name="route_check_button">Prüfen</string>
<string name="route_check_checking">Wird geprüft…</string>
<string name="route_check_no_vpn">Der Tunnel ist nicht verbunden. Die Routenansicht ist nicht verfügbar</string>
<string name="route_check_via_vpn_dns">Über den DNS des Tunnels aufgelöst</string>
<string name="route_check_via_direct_dns">Über den DNS der direkten Verbindung aufgelöst</string>
<string name="route_check_routes">Routen</string>
<string name="route_verdict_tunnel">über den Tunnel</string>
<string name="route_verdict_direct">direkt (RU-Ausnahme)</string>
<string name="route_verdict_dns">direkt (privater DNS-Server)</string>
<string name="route_check_resolve_failed">Die Domain konnte auf keinem der beiden Pfade aufgelöst werden</string>
<string name="route_check_none">keine</string>
<string name="vpn_connection_title">Verbindung</string>
<string name="vpn_profiles_title">Profile</string>
<string name="login_show_password">Passwort anzeigen</string>
<string name="login_hide_password">Passwort ausblenden</string>
<string name="nav_connect">Verbinden</string>
<string name="nav_profiles">Profile</string>
<string name="nav_settings">Einstellungen</string>
<string name="status_unprotected">Ungeschützt</string>
<string name="status_protected">Geschützt</string>
<string name="status_connecting">Verbindung wird hergestellt…</string>
<string name="status_choose_profile">Profil auswählen</string>
<string name="badge_connected">Verbunden</string>
<string name="connection_failed">Verbindung fehlgeschlagen</string>
<string name="profiles_title">Profile</string>
<string name="search_profiles">Profile durchsuchen</string>
<string name="nothing_found">Nichts gefunden</string>
<string name="settings_section_general">Allgemein</string>
<string name="settings_section_automation">Automatisierung</string>
<string name="settings_section_certs">Zertifikate</string>
<string name="settings_section_profiles">Profile</string>
<string name="settings_section_support">Support</string>
<string name="settings_section_security">Sicherheit</string>
<string name="auto_connect_title">Vertrauenswürdige Netzwerke</string>
<string name="auto_connect_summary">Über Mobilfunk und nicht vertrauenswürdiges WLAN verbinden</string>
<string name="auto_connect_profile">Profil für automatische Verbindung</string>
<string name="auto_connect_profile_none">Nicht ausgewählt</string>
<string name="auto_connect_trusted_title">Vertrauenswürdige WLAN-Netzwerke</string>
<string name="auto_connect_trusted_hint">In diesen Netzwerken wird das VPN nur dann automatisch beendet, wenn es durch die Automatisierung gestartet wurde. Eine manuell gestartete Verbindung bleibt bestehen, bis Sie sie trennen. Wenn Sie zu Mobilfunk oder in ein anderes WLAN wechseln, wird das VPN erneut gestartet.</string>
<string name="auto_connect_no_networks">Noch keine vertrauenswürdigen Netzwerke</string>
<string name="auto_connect_add_network">Netzwerk hinzufügen</string>
<string name="auto_connect_add_current">Aktuelles hinzufügen (%1$s)</string>
<string name="auto_connect_ssid_label">Netzwerkname (SSID)</string>
<string name="auto_connect_remove_network">Vertrauenswürdiges Netzwerk entfernen</string>
<string name="auto_connect_location_banner">Damit das VPN in vertrauenswürdigen WLAN-Netzwerken im Hintergrund pausiert werden kann, erlauben Sie den Standortzugriff immer.</string>
<string name="auto_connect_location_grant">Erlauben</string>
<string name="kill_switch_title">Kill-Switch</string>
<string name="kill_switch_summary_on">Always-on-VPN mit Lockdown ist aktiv</string>
<string name="kill_switch_summary_off">Nicht aktiviert. Datenverkehr kann außerhalb des Tunnels austreten</string>
<string name="safe_ipv6_title">Sicheres IPv6</string>
<string name="safe_ipv6_summary">Den IPv6-Verkehr blockieren, wenn der Tunnel kein IPv6 bereitstellt, statt ihn am VPN vorbeizulassen</string>
<string name="kill_switch_open_settings">Systemeinstellungen für VPN öffnen</string>
<string name="kill_switch_dialog_title">Kill-Switch aktivieren?</string>
<string name="kill_switch_dialog_text">Der Kill-Switch ist die Systemoption „Always-on VPN“ mit „Verbindungen ohne VPN blockieren“ für diese App. Fällt der Tunnel aus, wird der gesamte Datenverkehr blockiert, statt auszutreten. Er kann nur in den Systemeinstellungen aktiviert werden. Hinweis: „Always-on VPN“ steht im Konflikt mit der automatischen Verbindung in vertrauenswürdigen Netzwerken, da das System den Tunnel wiederherstellen würde, den die automatische Verbindung im vertrauenswürdigen WLAN pausiert. Verwenden Sie nur eins von beidem.</string>
<string name="kill_switch_dialog_enable">Einstellungen öffnen</string>
<string name="availability_title">Erreichbarkeit</string>
<string name="availability_refresh">Erreichbarkeit erneut prüfen</string>
<string name="availability_measuring">Wird gemessen…</string>
<string name="probe_target_server">Server (direkt)</string>
<string name="probe_target_google">Google</string>
<string name="probe_target_youtube">YouTube</string>
<string name="probe_target_cloudflare">Cloudflare</string>
<string name="health_check_title">Verbindungsprüfung</string>
<string name="health_check_summary">Tunnel überwachen und automatisch neu verbinden</string>
<string name="health_check_interval">Prüfintervall</string>
<string name="health_check_interval_seconds">Alle %1$d s</string>
<string name="health_check_interval_minutes">Alle %1$d Min.</string>
<string name="health_check_interval_aggressive">Alle 5 s (aggressiver Modus)</string>
<string name="health_check_aggressive_title">Aggressiver Modus</string>
<string name="health_check_aggressive_summary">Alle 5 s prüfen und ohne Bestätigung neu verbinden. Schnellere Wiederherstellung, aber höherer Akkuverbrauch und die Gefahr von Fehlverbindungen</string>
<string name="health_check_adaptive_title">Adaptiver Akkumodus</string>
<string name="health_check_adaptive_summary">Seltener prüfen bei ausgeschaltetem Bildschirm, im Energiesparmodus oder bei niedrigem Akkustand</string>
<string name="health_check_targets_title">Prüfziele</string>
<string name="health_check_server_title">VPN-Server prüfen</string>
<string name="health_check_server_summary">Server direkt außerhalb des Tunnels anpingen</string>
<string name="health_check_server_down_title">Server nicht erreichbar</string>
<string name="health_check_tunnel_dead_title">Internet über VPN nicht erreichbar</string>
<string name="health_check_partial_title">Einige Ziele nicht erreichbar</string>
<string name="health_check_mode">Wann prüfen</string>
<string name="health_check_mode_always">Immer</string>
<string name="health_check_mode_with_tunnel">Nur wenn der Tunnel ausgefallen ist</string>
<string name="health_check_mode_off">Nie</string>
<string name="health_check_mode_short_always">Immer</string>
<string name="health_check_mode_short_tunnel_down">Bei Tunnelausfall</string>
<string name="health_check_mode_short_never">Nie</string>
<string name="health_check_action">Aktion</string>
<string name="health_action_reconnect">Neu verbinden</string>
<string name="health_action_ignore">Nichts tun</string>
<string name="health_check_max_attempts">Verbindungsversuche</string>
<string name="health_check_attempt_minus">Versuche verringern</string>
<string name="health_check_attempt_plus">Versuche erhöhen</string>
<string name="profile_section_basic">Grundlagen</string>
<string name="app_version_fmt">SHX VPN %1$s</string>
<string name="recents_title">Zuletzt verwendet</string>
<string name="add_profile_cta">Profil hinzufügen</string>
<string name="connection_details">Verbindungsdetails</string>
<string name="traffic_down">Empfang</string>
<string name="traffic_up">Senden</string>
<string name="traffic_idle">—</string>
<string name="duration_label">Dauer</string>
<string name="traffic_stat_down">Empfangen %1$s, %2$s</string>
<string name="traffic_stat_up">Gesendet %1$s, %2$s</string>
<string name="traffic_split_tunnel">Über VPN</string>
<string name="traffic_split_direct">Direkt (RU)</string>
<string name="traffic_direct_share_fmt">%1$d%% direkt</string>
<string name="exit_ip_label">Exit-IP</string>
<string name="network_label">Netzwerk</string>
<string name="network_wifi">WLAN</string>
<string name="network_cellular">Mobile Daten</string>
<string name="dead_targets_title">Nicht erreichbare direkte Hosts</string>
<string name="dead_targets_summary">Apps verbinden sich weiterhin mit diesen Hosts, doch sie antworten nie. Die Engine lehnt sie für ein Cooldown-Fenster ab. Lange nicht erreichbare Hosts werden bis zum Netzwerkwechsel automatisch über den VPN-Tunnel geleitet. Prüfen Sie die Server oder entfernen Sie die Hosts aus der App, die sie verwendet.</string>
<string name="dead_targets_fails_fmt">%1$d fehlgeschlagene Verbindungen</string>
<string name="dead_targets_cooldown_fmt">Cooldown %1$s</string>
<string name="dead_targets_more_fmt">+%1$d weitere</string>
<string name="dead_targets_open">Routing öffnen</string>
<string name="dead_targets_via_tunnel">über Tunnel</string>
<string name="dead_targets_force">Über VPN</string>
<string name="profile_advanced_hint">Kennungen, DNS, MTU, Split-Tunneling</string>
<string name="import_profile_title">Profil importieren</string>
<string name="extended_diag_title">Erweiterte Diagnose</string>
<string name="extended_diag_summary">Regelmäßige Snapshots der Routing-Diagnose während der Verbindung (alle 5 Min.). Erhöht den Akkuverbrauch</string>
<string name="home_profile_picker_title">Profil auswählen</string>
<string name="home_manage_profiles">Profile verwalten</string>
<string name="availability_expand">Details anzeigen</string>
<string name="availability_collapse">Details ausblenden</string>
<string name="routing_advanced">Erweitert</string>
<string name="routing_killswitch_note">%1$d ausgeschlossene App(s) nutzen immer das normale Netzwerk, auch wenn der Tunnel ausfällt. Der Kill-Switch blockiert sie nicht.</string>
<string name="settings_page_appearance">Darstellung</string>
<string name="settings_quick">Schnelleinstellungen</string>
<string name="pref_theme_title">Design</string>
<string name="theme_system">System</string>
<string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string>
<string name="widget_label">SHX VPN</string>
<string name="widget_description">Schutzstatus und Verbinden mit einem Tipp</string>
<string name="profile_detail_placeholder">Profil auswählen oder hinzufügen</string>
<string name="update_downloading_title">Update %1$s wird heruntergeladen</string>
<string name="log_filter_all">Alle</string>
<string name="log_filter_warn">Warnungen</string>
<string name="log_filter_error">Fehler</string>
<string name="apps_show_system">Systemapps anzeigen</string>
<string name="auto_connect_location_title">Standortzugriff erforderlich</string>
<string name="auto_connect_location_why">Android gibt den WLAN-Namen nur an Apps mit Standortzugriff weiter. Er dient ausschließlich dem Erkennen Ihrer vertrauenswürdigen Netzwerke; der Standort selbst wird nie gelesen oder gesendet.</string>
<string name="route_check_dns_section">DNS</string>
</resources>
@@ -1,21 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- ReUI-like semantic tokens (dark). -->
<color name="reui_background">#0A0A0A</color>
<color name="splash_background">#17171A</color>
<color name="reui_foreground">#FAFAFA</color>
<color name="reui_muted">#A3A3A3</color>
<color name="reui_muted_bg">#262626</color>
<color name="reui_border">#333333</color>
<color name="reui_frame">#171717</color>
<color name="reui_primary">#FAFAFA</color>
<color name="reui_on_primary">#171717</color>
<color name="reui_success">#4ADE80</color>
<color name="reui_success_bg">#14532D</color>
<color name="reui_warning">#FBBF24</color>
<color name="reui_warning_bg">#78350F</color>
<color name="reui_destructive">#F87171</color>
<color name="reui_destructive_bg">#7F1D1D</color>
<color name="reui_info">#60A5FA</color>
<color name="reui_info_bg">#1E3A8A</color>
<!-- Mirrors the Compose scheme in ui/compose/theme/ShxTheme.kt (dark). -->
<color name="shx_primary">#9C7CFF</color>
<color name="shx_on_primary">#150F35</color>
<color name="shx_surface">#141318</color>
<color name="shx_on_surface">#E5E1E9</color>
<color name="shx_outline">#48454E</color>
<color name="shx_error">#FF8A8C</color>
<color name="shx_on_error">#490005</color>
<color name="splash_background">@color/shx_surface</color>
<color name="error_text">#F87171</color>
<color name="warning_text">#FBBF24</color>
<color name="success_text">#28A671</color>
</resources>
@@ -487,4 +487,27 @@
<string name="extended_diag_title">Расширенная диагностика</string>
<string name="extended_diag_summary">Периодические снимки диагностики маршрутизации во время подключения (каждые 5 минут); увеличивает расход батареи</string>
<string name="home_profile_picker_title">Выбор профиля</string>
<string name="home_manage_profiles">Управление профилями</string>
<string name="availability_expand">Показать детали</string>
<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>
<string name="widget_label">SHX VPN</string>
<string name="widget_description">Статус защиты и подключение одним касанием</string>
<string name="profile_detail_placeholder">Выберите или добавьте профиль</string>
<string name="update_downloading_title">Загрузка обновления %1$s</string>
<string name="log_filter_all">Все</string>
<string name="log_filter_warn">Предупреждения</string>
<string name="log_filter_error">Ошибки</string>
<string name="apps_show_system">Показывать системные приложения</string>
<string name="auto_connect_location_title">Нужен доступ к геолокации</string>
<string name="auto_connect_location_why">Android сообщает имя Wi-Fi сети только приложениям с доступом к геолокации. Оно нужно лишь для распознавания доверенных сетей; сама геолокация не читается и не передаётся.</string>
<string name="route_check_dns_section">DNS</string>
</resources>
@@ -1,32 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- ReUI-like semantic tokens (light). Aliases keep existing resource names. -->
<color name="reui_background">#FAFAFA</color>
<color name="splash_background">#F7F7F8</color>
<color name="reui_foreground">#0A0A0A</color>
<color name="reui_muted">#737373</color>
<color name="reui_muted_bg">#F5F5F5</color>
<color name="reui_border">#E5E5E5</color>
<color name="reui_frame">#FFFFFF</color>
<color name="reui_primary">#171717</color>
<color name="reui_on_primary">#FAFAFA</color>
<color name="reui_success">#15803D</color>
<color name="reui_success_bg">#DCFCE7</color>
<color name="reui_warning">#B45309</color>
<color name="reui_warning_bg">#FEF3C7</color>
<color name="reui_destructive">#DC2626</color>
<color name="reui_destructive_bg">#FEE2E2</color>
<color name="reui_info">#1D4ED8</color>
<color name="reui_info_bg">#DBEAFE</color>
<!-- Mirrors the Compose scheme in ui/compose/theme/ShxTheme.kt (light), so the
splash, window background and system bars match the first Compose frame. -->
<color name="shx_primary">#6D4AFF</color>
<color name="shx_on_primary">#FFFFFF</color>
<color name="shx_surface">#FCF8FF</color>
<color name="shx_on_surface">#1C1B20</color>
<color name="shx_outline">#C9C5D0</color>
<color name="shx_error">#D8303C</color>
<color name="shx_on_error">#FFFFFF</color>
<color name="splash_background">@color/shx_surface</color>
<color name="accent">@color/reui_primary</color>
<color name="primary">@color/reui_primary</color>
<color name="primary_dark">@color/reui_foreground</color>
<color name="error_text">@color/reui_destructive</color>
<color name="warning_text">@color/reui_warning</color>
<color name="success_text">@color/reui_success</color>
<color name="panel_background">@color/reui_frame</color>
<color name="panel_separator">@color/reui_border</color>
<color name="checked">@color/reui_muted_bg</color>
<color name="pressed">@color/reui_muted_bg</color>
<!-- Notification accent colors (CharonVpnService) -->
<color name="error_text">#DC2626</color>
<color name="warning_text">#B45309</color>
<color name="success_text">#148A45</color>
</resources>
@@ -492,4 +492,27 @@
<string name="extended_diag_title">Extended diagnostics</string>
<string name="extended_diag_summary">Periodic routing diagnostics snapshots while connected (every 5 min); increases battery use</string>
<string name="home_profile_picker_title">Choose profile</string>
<string name="home_manage_profiles">Manage profiles</string>
<string name="availability_expand">Show details</string>
<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>
<string name="widget_label">SHX VPN</string>
<string name="widget_description">Protection status and one-tap connect</string>
<string name="profile_detail_placeholder">Select or add a profile</string>
<string name="update_downloading_title">Downloading update %1$s</string>
<string name="log_filter_all">All</string>
<string name="log_filter_warn">Warnings</string>
<string name="log_filter_error">Errors</string>
<string name="apps_show_system">Show system apps</string>
<string name="auto_connect_location_title">Location access needed</string>
<string name="auto_connect_location_why">Android only reveals the Wi-Fi network name to apps with location access. The app uses it solely to recognize your trusted networks; the location itself is never read or sent.</string>
<string name="route_check_dns_section">DNS</string>
</resources>
@@ -2,39 +2,39 @@
<resources xmlns:android="http://schemas.android.com/apk/res/android">
<style name="ApplicationTheme" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/reui_primary</item>
<item name="colorOnPrimary">@color/reui_on_primary</item>
<item name="colorSecondary">@color/reui_muted</item>
<item name="colorOnSecondary">@color/reui_foreground</item>
<item name="colorSurface">@color/reui_background</item>
<item name="colorOnSurface">@color/reui_foreground</item>
<item name="colorError">@color/reui_destructive</item>
<item name="colorOnError">@color/reui_on_primary</item>
<item name="android:colorBackground">@color/reui_background</item>
<item name="colorOnBackground">@color/reui_foreground</item>
<item name="colorOutline">@color/reui_border</item>
<item name="android:statusBarColor">@color/reui_background</item>
<item name="android:navigationBarColor">@color/reui_background</item>
<item name="android:windowBackground">@color/reui_background</item>
<item name="colorAccent">@color/reui_primary</item>
<item name="colorPrimaryDark">@color/reui_foreground</item>
<item name="colorPrimary">@color/shx_primary</item>
<item name="colorOnPrimary">@color/shx_on_primary</item>
<item name="colorSecondary">@color/shx_primary</item>
<item name="colorOnSecondary">@color/shx_on_primary</item>
<item name="colorSurface">@color/shx_surface</item>
<item name="colorOnSurface">@color/shx_on_surface</item>
<item name="colorError">@color/shx_error</item>
<item name="colorOnError">@color/shx_on_error</item>
<item name="android:colorBackground">@color/shx_surface</item>
<item name="colorOnBackground">@color/shx_on_surface</item>
<item name="colorOutline">@color/shx_outline</item>
<item name="android:statusBarColor">@color/shx_surface</item>
<item name="android:navigationBarColor">@color/shx_surface</item>
<item name="android:windowBackground">@color/shx_surface</item>
<item name="colorAccent">@color/shx_primary</item>
<item name="colorPrimaryDark">@color/shx_on_surface</item>
<item name="android:alertDialogTheme">@style/AlertDialogTheme</item>
<item name="preferenceTheme">@style/PreferenceThemeOverlay</item>
<item name="android:windowLightStatusBar">@bool/window_light_status_bar</item>
</style>
<style name="ApplicationTheme.ActionBar" parent="Theme.Material3.DayNight">
<item name="colorPrimary">@color/reui_primary</item>
<item name="colorOnPrimary">@color/reui_on_primary</item>
<item name="android:statusBarColor">@color/reui_background</item>
<item name="android:navigationBarColor">@color/reui_background</item>
<item name="android:windowBackground">@color/reui_background</item>
<item name="colorPrimary">@color/shx_primary</item>
<item name="colorOnPrimary">@color/shx_on_primary</item>
<item name="android:statusBarColor">@color/shx_surface</item>
<item name="android:navigationBarColor">@color/shx_surface</item>
<item name="android:windowBackground">@color/shx_surface</item>
<item name="android:windowLightStatusBar">@bool/window_light_status_bar</item>
<item name="android:alertDialogTheme">@style/AlertDialogTheme</item>
</style>
<style name="AlertDialogTheme" parent="ThemeOverlay.Material3.MaterialAlertDialog">
<item name="colorAccent">@color/reui_primary</item>
<item name="colorAccent">@color/shx_primary</item>
</style>
<style name="ShxSplashTheme" parent="Theme.SplashScreen">
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:minWidth="110dp"
android:minHeight="40dp"
android:targetCellWidth="2"
android:targetCellHeight="1"
android:updatePeriodMillis="0"
android:resizeMode="horizontal"
android:widgetCategory="home_screen"
android:initialLayout="@layout/shx_widget_loading" />
+3 -3
View File
@@ -4,9 +4,9 @@ buildscript {
google()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20'
classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.1.20'
classpath 'com.android.tools.build:gradle:9.1.1'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.21'
classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.2.21'
}
}
+16
View File
@@ -130,3 +130,19 @@
5. Авторизованная проверка `aws s3 cp s3://shx-app/latest.json -` — совпадение versionCode и sha256. Публичный `GET` = `AccessDenied` — норма.
Релизы: №1 после этапа 2 (versionCode 117, 2.10.0), финальный после этапов 3–4 (118).
## Редизайн 2.15 (ветка interface-ux-ui-2026): статус по факту
Этапы 0–4 выше устарели в части ограничений: minSdk поднят до 24, Expressive-тема включена. Что сделано:
- **Тулчейн:** AGP 9.1.1, Gradle 9.3.1, Kotlin 2.2.21 (built-in Kotlin, `kotlin-android` убран), compileSdk 37 (targetSdk 36), Java 11, BOM 2026.09.00 (compose 1.12), **material3 1.5.0-beta01**. Библиотеки material3 1.5 требуют minSdk 24, поэтому **minSdk поднят до 24** (Android 7.0); обходные `tools:overrideLibrary` не нужны. `proguard-android-optimize.txt` вместо non-optimize (AGP 9 отвергает старый файл).
- **Тема (этап 1) ✅:** `MaterialExpressiveTheme` + `MotionScheme.expressive()`, тональная палитра с `surfaceContainer*`, токены `ShxSpacing`/`ShxShapes`, выбор темы системная/светлая/тёмная (`ShxThemeSettings.themeMode`), сплэш/окно/системные панели совпадают с Compose-поверхностью (`shx_*` в `colors.xml`).
- **Компоненты ✅:** `ShxSectionCard`, `ShxListRow`, `ShxStatusPill`/`ShxConnectedPill`, `ShxEmptyState`, `ShxSnackbarHost`; переходы между вкладками — fade-through на motion scheme, «назад» с любой вкладки ведёт на «Подключение», `LoadingIndicator`/`LinearWavyProgressIndicator`, Snackbar вместо Toast в `ShxApp`, Настройках, форме профиля; хаптика подключения/отключения.
- **«Подключение» ✅:** hero-кнопка с морфингом формы (круг → cookie), чип профиля с exit-IP + выбор профиля в bottom sheet (дублирующий список профилей убран), плитки трафика, один слот баннера (ошибка приоритетнее белого списка), «Доступность» свёрнута в сводку. Файл разбит на `home/*`. На ширине ≥ 840dp две колонки.
- **«Профили» ✅:** свайп-удаление с Undo, поиск только при > 5 профилей. На ширине ≥ 840dp — list-detail (`ListDetailPaneScaffold`), форма профиля в detail-панели (`ProfileEditorPane`); на телефоне форма остаётся отдельной Activity.
- **«Маршрутизация» ✅:** режим/GeoIP/force-префиксы в блоке «Дополнительно»; строка про kill switch и исключённые приложения (исключённые идут мимо VPN, lockdown их не блокирует: они в `addDisallowedApplication`). Разбито на `routing/*`.
- **«Настройки» ✅:** корень (быстрые: kill switch, автоподключение) + 5 страниц (оформление, автоматизация, безопасность, обновления, поддержка), ширина ≤ 640dp.
- **Системные поверхности:** виджет Glance 2×1 (`ui/widget/ShxWidget.kt`, состояние публикует `VpnStateService`, кнопка идёт через `VpnProfileControlActivity`, как плитка); плитка QS показывает профиль в subtitle, категория `CATEGORY_CONNECTIVITY`, toggleable. Загрузка обновления — уведомление `Notification.ProgressStyle` + Live Update на API 36+ (`POST_PROMOTED_NOTIFICATIONS` — обычное разрешение, без запроса), на старых версиях обычный прогресс-бар.
- **Доступность/тексты:** `heading()` у заголовков секций, `toggleable(role = Switch)` у строк-переключателей, liveRegion у статуса подключения, недостающие строки `values-de`.
- **Вторичные экраны ✅:** импорт профиля — Expressive bottom sheet в прозрачной Activity; журнал — чипы фильтра уровней (Все/Предупреждения/Ошибки) + Snackbar; выбор приложений — поиск (был), скрытие системных приложений с переключателем; «Доверенные сети» — встроенная карточка с объяснением, зачем нужна геолокация (запрос разрешения только по кнопке); проверка маршрута — карточки с `ShxStatusPill`. Predictive back: `PredictiveBackScale` для страниц настроек и деталей инструкции; `ModalBottomSheet` (редактор наборов, импорт) использует встроенный predictive back.
- **Toast оставлен** только там, где нет Compose-поверхности, которая переживёт `finish()`: `UpdateInstallStatusReceiver` (ресивер), `VpnProfileControlActivity`, `VpnProfileImportActivity`, `TrustedCertificateImportActivity` (прозрачные Activity, закрываются сразу после показа результата).
-1
View File
@@ -1,5 +1,4 @@
android.enableJetifier=false
android.nonFinalResIds=false
android.nonTransitiveRClass=false
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip