Compare commits
28
Commits
e9a239e419
...
6a8528fc98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a8528fc98 | ||
|
|
8ef7c0a53b | ||
|
|
dc6e1e32fb | ||
|
|
2681233449 | ||
|
|
7a7673119e | ||
|
|
8deafeae8e | ||
|
|
59a31d6a1d | ||
|
|
8bedf432e2 | ||
|
|
42cd361c58 | ||
|
|
87c3d73fbc | ||
|
|
564b8aaa8d | ||
|
|
7cc6343ba1 | ||
|
|
c9048143cc | ||
|
|
203a67ee86 | ||
|
|
3c7c121c7d | ||
|
|
0e5e28b4da | ||
|
|
22e4082825 | ||
|
|
0c3ea75cac | ||
|
|
a451386e0a | ||
|
|
8bd65d535e | ||
|
|
c5585df135 | ||
|
|
385819caf0 | ||
|
|
3f7bd94a69 | ||
|
|
6fd03f37f7 | ||
|
|
65989b3a5a | ||
|
|
5f6726f173 | ||
|
|
fda3ec158c | ||
|
|
c872246b8c |
No files matched your search
@@ -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'
|
||||
|
||||
+81
@@ -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"
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
+71
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-12
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-6
@@ -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
|
||||
{
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
+8
-1
@@ -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();
|
||||
}
|
||||
|
||||
+7
-7
@@ -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))
|
||||
|
||||
+238
-714
File diff suppressed because it is too large.
Load diff
+8
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
-27
@@ -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,
|
||||
|
||||
+6
-5
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+80
@@ -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,
|
||||
)
|
||||
}
|
||||
+18
-27
@@ -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,
|
||||
|
||||
+6
-16
@@ -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),
|
||||
) {}
|
||||
}
|
||||
+87
-7
@@ -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())
|
||||
|
||||
+60
-37
@@ -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) },
|
||||
|
||||
+126
-545
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+13
-12
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -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) },
|
||||
|
||||
+487
-302
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
+34
-9
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+40
-26
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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 },
|
||||
|
||||
+65
@@ -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()
|
||||
}
|
||||
}
|
||||
+21
-11
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+80
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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)
|
||||
}
|
||||
+62
@@ -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,
|
||||
)
|
||||
}
|
||||
+575
@@ -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,
|
||||
)
|
||||
}
|
||||
+244
@@ -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())
|
||||
}
|
||||
}
|
||||
+234
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+451
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+219
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+198
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+135
-59
@@ -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,
|
||||
)
|
||||
|
||||
+210
@@ -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" />
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
Reference in new issue
Block a user