diff --git a/src/frontends/android/app/src/main/AndroidManifest.xml b/src/frontends/android/app/src/main/AndroidManifest.xml
index 2265944ec..db6b85a0a 100644
--- a/src/frontends/android/app/src/main/AndroidManifest.xml
+++ b/src/frontends/android/app/src/main/AndroidManifest.xml
@@ -108,10 +108,6 @@
android:name=".ui.TrustedCertificatesActivity"
android:label="@string/trusted_certs_title" >
-
-
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/SelectedApplicationsActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/SelectedApplicationsActivity.kt
deleted file mode 100644
index 8fdade228..000000000
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/SelectedApplicationsActivity.kt
+++ /dev/null
@@ -1,213 +0,0 @@
-/*
- * Copyright (C) 2026 SHX VPN
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the
- * Free Software Foundation; either version 2 of the License, or (at your
- * option) any later version.
- */
-
-@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
-
-package org.strongswan.android.ui
-
-import android.Manifest
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.os.Bundle
-import androidx.activity.OnBackPressedCallback
-import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.foundation.clickable
-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.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.Checkbox
-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
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.asImageBitmap
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.Column
-import androidx.core.graphics.drawable.toBitmap
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-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
-
-class SelectedApplicationsActivity : AppCompatActivity()
-{
- private val selection = TreeSet()
- private var readOnly = false
-
- override fun onCreate(savedInstanceState: Bundle?)
- {
- super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- readOnly = intent.getBooleanExtra(VpnProfileDataSource.KEY_READ_ONLY, false)
- val initial = savedInstanceState?.getStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
- ?: intent.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
- ?: arrayListOf()
- selection.addAll(initial)
- onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true)
- {
- override fun handleOnBackPressed()
- {
- prepareResult()
- finish()
- }
- })
- setContent {
- ShxTheme {
- SelectedAppsScreen(
- initial = selection,
- readOnly = readOnly,
- onBack = {
- prepareResult()
- finish()
- },
- onChange = {
- selection.clear()
- selection.addAll(it)
- },
- )
- }
- }
- }
-
- override fun onSaveInstanceState(outState: Bundle)
- {
- super.onSaveInstanceState(outState)
- outState.putStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
- }
-
- private fun prepareResult()
- {
- val data = Intent()
- data.putExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
- setResult(RESULT_OK, data)
- }
-}
-
-@Composable
-private fun SelectedAppsScreen(
- initial: Set,
- readOnly: Boolean,
- onBack: () -> Unit,
- onChange: (Set) -> Unit,
-)
-{
- val context = LocalContext.current
- var query by remember { mutableStateOf("") }
- var apps by remember { mutableStateOf>(emptyList()) }
- var selected by remember { mutableStateOf(initial.toSet()) }
- var loading by remember { mutableStateOf(true) }
-
- LaunchedEffect(Unit) {
- val loaded = withContext(Dispatchers.IO) {
- val pm = context.packageManager
- val list = ArrayList()
- for (info in pm.getInstalledApplications(PackageManager.GET_META_DATA))
- {
- if (pm.checkPermission(Manifest.permission.INTERNET, info.packageName) == PackageManager.PERMISSION_GRANTED)
- {
- val entry = SelectedApplicationEntry(pm, info)
- entry.isSelected = selected.contains(info.packageName)
- list.add(entry)
- }
- }
- list.sorted()
- }
- apps = loaded
- loading = false
- }
-
- fun toggle(pkg: String)
- {
- if (readOnly) return
- selected = if (selected.contains(pkg)) selected - pkg else selected + pkg
- onChange(selected)
- }
-
- /* 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 ->
- Column(Modifier.fillMaxSize().padding(padding)) {
- OutlinedTextField(
- value = query,
- onValueChange = { query = it },
- label = { Text(stringResource(R.string.search)) },
- singleLine = true,
- 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)
- {
- LoadingIndicator(Modifier.padding(ShxSpacing.xl).size(32.dp))
- }
- else
- {
- LazyColumn(Modifier.fillMaxSize()) {
- items(filtered, key = { it.info.packageName }) { entry ->
- val pkg = entry.info.packageName
- val checked = selected.contains(pkg)
- ListItem(
- headlineContent = { Text(entry.toString()) },
- supportingContent = { Text(pkg) },
- leadingContent = {
- val bmp = remember(pkg) { entry.icon.toBitmap() }
- Image(bitmap = bmp.asImageBitmap(), contentDescription = null, modifier = Modifier.size(40.dp))
- },
- trailingContent = {
- Checkbox(checked = checked, onCheckedChange = { toggle(pkg) }, enabled = !readOnly)
- },
- modifier = Modifier.clickable(enabled = !readOnly) { toggle(pkg) },
- )
- }
- }
- }
- }
- }
-}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/adapter/SelectedApplicationEntry.java b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/adapter/SelectedApplicationEntry.java
deleted file mode 100644
index e61089378..000000000
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/adapter/SelectedApplicationEntry.java
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * Copyright (C) 2017 Tobias Brunner
- *
- * Copyright (C) secunet Security Networks AG
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the
- * Free Software Foundation; either version 2 of the License, or (at your
- * option) any later version. See .
- *
- * This program is distributed in the hope that it will be useful, but
- * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
- * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
- * for more details.
- */
-
-package org.strongswan.android.ui.adapter;
-
-import android.content.pm.ApplicationInfo;
-import android.content.pm.PackageManager;
-import android.graphics.drawable.Drawable;
-
-import java.text.Collator;
-
-import androidx.annotation.NonNull;
-
-public class SelectedApplicationEntry implements Comparable
-{
- private final ApplicationInfo mInfo;
- private final Drawable mIcon;
- private final String mName;
- private boolean mSelected;
-
- public SelectedApplicationEntry(PackageManager packageManager, ApplicationInfo info)
- {
- mInfo = info;
- CharSequence name = info.loadLabel(packageManager);
- mName = name == null ? info.packageName : name.toString();
- mIcon = info.loadIcon(packageManager);
- }
-
- public void setSelected(boolean selected)
- {
- mSelected = selected;
- }
-
- public boolean isSelected()
- {
- return mSelected;
- }
-
- public ApplicationInfo getInfo()
- {
- return mInfo;
- }
-
- public Drawable getIcon()
- {
- return mIcon;
- }
-
- @Override
- public String toString()
- {
- return mName;
- }
-
- @Override
- public int compareTo(@NonNull SelectedApplicationEntry another)
- {
- return Collator.getInstance().compare(toString(), another.toString());
- }
-}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PresetPickerSheet.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PresetPickerSheet.kt
deleted file mode 100644
index b0ac755c7..000000000
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/PresetPickerSheet.kt
+++ /dev/null
@@ -1,179 +0,0 @@
-/*
- * Copyright (C) 2026 SHX VPN
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the
- * Free Software Foundation; either version 2 of the License, or (at your
- * option) any later version.
- */
-
-package org.strongswan.android.ui.compose
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.ArrowBack
-import androidx.compose.material.icons.rounded.Search
-import androidx.compose.material3.Checkbox
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-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
- * sheet. Selection toggles immediately; "Done"/back returns to the editor.
- */
-@Composable
-fun PresetPickerContent(
- ruleSet: RuleSet,
- onToggle: (RoutingPresets.Preset) -> Unit,
- onBack: () -> Unit,
-)
-{
- var query by remember { mutableStateOf("") }
- val filtered = remember(query)
- {
- val q = query.trim()
- if (q.isEmpty())
- {
- RoutingPresets.RF_BUNDLE
- }
- else
- {
- RoutingPresets.RF_BUNDLE.filter { it.name.contains(q, ignoreCase = true) }
- }
- }
-
- Column(Modifier.fillMaxWidth())
- {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(start = ShxSpacing.md, end = ShxSpacing.md, bottom = ShxSpacing.xs),
- )
- {
- IconButton(onClick = onBack)
- {
- Icon(
- Icons.AutoMirrored.Rounded.ArrowBack,
- contentDescription = stringResource(R.string.action_back),
- )
- }
- Text(
- text = stringResource(R.string.routing_presets_label),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- modifier = Modifier.weight(1f),
- )
- TextButton(onClick = onBack)
- {
- Text(stringResource(R.string.action_done))
- }
- }
-
- OutlinedTextField(
- value = query,
- onValueChange = { query = it },
- label = { Text(stringResource(R.string.routing_presets_search)) },
- singleLine = true,
- leadingIcon = {
- Icon(Icons.Rounded.Search, contentDescription = null)
- },
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = ShxSpacing.xl),
- shape = MaterialTheme.shapes.medium,
- )
-
- LazyColumn(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(max = 460.dp)
- .padding(top = ShxSpacing.xs),
- )
- {
- items(filtered, key = { it.name })
- { preset ->
- val selected = ruleSet.prefixes.containsAll(preset.prefixes) &&
- ruleSet.keywords.containsAll(preset.keywords)
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth()
- .clickable { onToggle(preset) }
- .padding(horizontal = ShxSpacing.xl, vertical = 6.dp),
- )
- {
- Column(
- Modifier
- .weight(1f)
- .padding(vertical = 6.dp),
- )
- {
- Text(preset.name, style = MaterialTheme.typography.bodyLarge)
- Text(
- preset.patternSummary(),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- Checkbox(checked = selected, onCheckedChange = { onToggle(preset) })
- }
- }
- if (filtered.isEmpty())
- {
- item {
- Text(
- text = stringResource(R.string.routing_presets_none),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.lg),
- )
- }
- }
- }
- }
-}
-
-/** "3 prefixes · 2 keywords" summary, skipping empty groups. */
-@Composable
-private fun RoutingPresets.Preset.patternSummary(): String
-{
- val parts = listOf(
- if (prefixes.isEmpty()) null else stringResource(R.string.routing_preset_prefixes, prefixes.size),
- if (keywords.isEmpty()) null else stringResource(R.string.routing_preset_keywords, keywords.size),
- ).filterNotNull()
- return if (parts.isEmpty())
- {
- stringResource(R.string.routing_preset_no_patterns)
- }
- else
- {
- parts.joinToString(separator = " · ")
- }
-}
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt
index a3c1bca3c..4fc4e5dbd 100644
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt
+++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt
@@ -10,6 +10,7 @@
package org.strongswan.android.ui.compose
import org.strongswan.android.ui.compose.components.ShxSectionCard
+import org.strongswan.android.ui.compose.components.ShxSettingRow
import android.app.Activity
import android.content.Intent
import android.security.KeyChain
@@ -43,6 +44,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material.icons.rounded.AltRoute
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenuItem
@@ -85,17 +87,15 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import org.strongswan.android.R
import org.strongswan.android.data.VpnProfile
-import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
import org.strongswan.android.data.VpnProfileDataSource
import org.strongswan.android.data.VpnType
import org.strongswan.android.data.VpnType.VpnTypeFeature
-import org.strongswan.android.ui.SelectedApplicationsActivity
+import org.strongswan.android.ui.MainActivity
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
-import java.util.TreeSet
data class ProfileFormState(
val name: String = "",
@@ -123,8 +123,6 @@ data class ProfileFormState(
val excludedSubnets: String = "",
val blockIpv4: Boolean = false,
val blockIpv6: Boolean = false,
- val appsHandling: SelectedAppsHandling = SelectedAppsHandling.SELECTED_APPS_DISABLE,
- val selectedApps: Set = emptySet(),
val ikeProposal: String = "",
val espProposal: String = "",
val proxyHost: String = "",
@@ -177,8 +175,6 @@ fun ProfileFormState.toProfile(existing: VpnProfile?): VpnProfile
if (blockIpv4) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4
if (blockIpv6) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6
profile.splitTunneling = if (st == 0) null else st
- profile.selectedAppsHandling = appsHandling
- profile.setSelectedApps(TreeSet(selectedApps))
profile.ikeProposal = ikeProposal.trim().ifEmpty { null }
profile.espProposal = espProposal.trim().ifEmpty { null }
profile.proxyHost = proxyHost.trim().ifEmpty { null }
@@ -217,8 +213,6 @@ fun VpnProfile.toFormState(): ProfileFormState
excludedSubnets = excludedSubnets ?: "",
blockIpv4 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4 != 0,
blockIpv6 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6 != 0,
- appsHandling = selectedAppsHandling,
- selectedApps = selectedAppsSet ?: emptySet(),
ikeProposal = ikeProposal ?: "",
espProposal = espProposal ?: "",
proxyHost = proxyHost ?: "",
@@ -228,7 +222,6 @@ fun VpnProfile.toFormState(): ProfileFormState
advanced = remoteId != null || localId != null || dnsServers != null || getMTU() != null
|| port != null || getNATKeepAlive() != null || flags != 0 || st != 0
|| includedSubnets != null || excludedSubnets != null
- || selectedAppsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE
|| ikeProposal != null || espProposal != null || proxyHost != null,
)
}
@@ -308,9 +301,7 @@ fun ProfileFormScreen(
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) }
- var appsExpanded by remember { mutableStateOf(false) }
var showPassword by remember { mutableStateOf(false) }
val enabled = !state.readOnly
@@ -322,14 +313,6 @@ fun ProfileFormScreen(
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
}
}
- val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == Activity.RESULT_OK)
- {
- val list = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST) ?: arrayListOf()
- onChange(latest.value.copy(selectedApps = list.toSet()))
- }
- }
-
Scaffold(
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
modifier = Modifier.windowInsetsPadding(
@@ -577,38 +560,14 @@ fun ProfileFormScreen(
checked = state.blockIpv6,
enabled = enabled,
) { onChange(state.copy(blockIpv6 = it)) }
- ExposedDropdownMenuBox(expanded = appsExpanded, onExpandedChange = { if (enabled) appsExpanded = it }) {
- OutlinedTextField(
- value = appsHandling.getOrElse(state.appsHandling.ordinal) { "" },
- onValueChange = {},
- readOnly = true,
- enabled = enabled,
- label = { Text(stringResource(R.string.profile_select_apps)) },
- trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(appsExpanded) },
- modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
- )
- ExposedDropdownMenu(expanded = appsExpanded, onDismissRequest = { appsExpanded = false }) {
- SelectedAppsHandling.values().forEachIndexed { index, handling ->
- DropdownMenuItem(
- text = { Text(appsHandling.getOrElse(index) { handling.name }) },
- onClick = {
- onChange(state.copy(appsHandling = handling))
- appsExpanded = false
- },
- )
- }
- }
- }
- if (state.appsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE)
- {
- TextButton(onClick = {
- val intent = Intent(context, SelectedApplicationsActivity::class.java)
- intent.putStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(state.selectedApps))
- pickApps.launch(intent)
- }, enabled = enabled) {
- Text(stringResource(R.string.profile_select_apps) + " (${state.selectedApps.size})")
- }
- }
+ ShxSettingRow(
+ icon = Icons.Rounded.AltRoute,
+ title = stringResource(R.string.profile_routing_link_title),
+ subtitle = stringResource(R.string.profile_routing_link_summary),
+ onClick = {
+ context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
+ },
+ )
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
isError = state.ikeProposalError != null,
diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RuleSetEditorSheet.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RuleSetEditorSheet.kt
deleted file mode 100644
index ee199767f..000000000
--- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RuleSetEditorSheet.kt
+++ /dev/null
@@ -1,359 +0,0 @@
-/*
- * Copyright (C) 2026 SHX VPN
- *
- * This program is free software; you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the
- * Free Software Foundation; either version 2 of the License, or (at your
- * option) any later version.
- */
-
-package org.strongswan.android.ui.compose
-
-import androidx.activity.compose.BackHandler
-import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.togetherWith
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-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.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
-import androidx.compose.material.icons.rounded.Add
-import androidx.compose.material.icons.rounded.Apps
-import androidx.compose.material.icons.rounded.Category
-import androidx.compose.material.icons.rounded.Close
-import androidx.compose.material.icons.rounded.Delete
-import androidx.compose.material3.AssistChip
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.rememberModalBottomSheetState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.input.ImeAction
-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.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
- * second view inside the same sheet (back returns to the editor) so that two
- * sheets never stack on top of each other.
- */
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun RuleSetEditorSheet(
- ruleSet: RuleSet,
- requestNameFocus: Boolean,
- onDismiss: () -> Unit,
- onRename: (String) -> Unit,
- onPickApps: () -> Unit,
- onTogglePreset: (RoutingPresets.Preset) -> Unit,
- onAddPrefix: (String) -> Unit,
- onRemovePrefix: (String) -> Unit,
- onAddKeyword: (String) -> Unit,
- onRemoveKeyword: (String) -> Unit,
- onDelete: () -> Unit,
-)
-{
- var showPresets by remember { mutableStateOf(false) }
- BackHandler(enabled = showPresets) { showPresets = false }
-
- ModalBottomSheet(
- onDismissRequest = onDismiss,
- sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
- containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
- )
- {
- AnimatedContent(
- targetState = showPresets,
- transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(120)) },
- label = "rule-set-editor",
- )
- { presetsVisible ->
- if (presetsVisible)
- {
- PresetPickerContent(
- ruleSet = ruleSet,
- onToggle = onTogglePreset,
- onBack = { showPresets = false },
- )
- }
- else
- {
- EditorContent(
- ruleSet = ruleSet,
- requestNameFocus = requestNameFocus,
- onDismiss = onDismiss,
- onRename = onRename,
- onPickApps = onPickApps,
- onOpenPresets = { showPresets = true },
- onAddPrefix = onAddPrefix,
- onRemovePrefix = onRemovePrefix,
- onAddKeyword = onAddKeyword,
- onRemoveKeyword = onRemoveKeyword,
- onDelete = onDelete,
- )
- }
- }
- }
-}
-
-@Composable
-private fun EditorContent(
- ruleSet: RuleSet,
- requestNameFocus: Boolean,
- onDismiss: () -> Unit,
- onRename: (String) -> Unit,
- onPickApps: () -> Unit,
- onOpenPresets: () -> Unit,
- onAddPrefix: (String) -> Unit,
- onRemovePrefix: (String) -> Unit,
- onAddKeyword: (String) -> Unit,
- onRemoveKeyword: (String) -> Unit,
- onDelete: () -> Unit,
-)
-{
- val nameFocusRequester = remember { FocusRequester() }
- LaunchedEffect(ruleSet.id, requestNameFocus)
- {
- if (requestNameFocus)
- {
- nameFocusRequester.requestFocus()
- }
- }
-
- Column(
- Modifier
- .fillMaxWidth()
- .verticalScroll(rememberScrollState())
- .padding(bottom = ShxSpacing.xl),
- )
- {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(start = ShxSpacing.xl, end = ShxSpacing.md, bottom = ShxSpacing.sm),
- )
- {
- Text(
- text = stringResource(R.string.routing_editor_title),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- modifier = Modifier.weight(1f),
- )
- TextButton(onClick = onDismiss)
- {
- Text(stringResource(R.string.action_done))
- }
- }
-
- OutlinedTextField(
- value = ruleSet.name,
- onValueChange = onRename,
- label = { Text(stringResource(R.string.routing_rule_name)) },
- singleLine = true,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = ShxSpacing.xl)
- .focusRequester(nameFocusRequester),
- shape = MaterialTheme.shapes.medium,
- )
-
- EditorRow(
- icon = Icons.Rounded.Apps,
- title = stringResource(R.string.routing_pick_apps),
- subtitle = stringResource(R.string.routing_apps_count, ruleSet.packages.size),
- onClick = onPickApps,
- )
- EditorRow(
- icon = Icons.Rounded.Category,
- title = stringResource(R.string.routing_presets_selected, countSelectedPresets(ruleSet)),
- subtitle = stringResource(R.string.routing_presets_hint),
- onClick = onOpenPresets,
- )
-
- ChipGroup(
- label = stringResource(R.string.routing_prefixes_label),
- values = ruleSet.prefixes.sorted(),
- text = { it },
- onRemove = onRemovePrefix,
- addLabel = stringResource(R.string.routing_add_prefix),
- onAdd = onAddPrefix,
- )
- ChipGroup(
- label = stringResource(R.string.routing_keywords_label),
- values = ruleSet.keywords.sorted(),
- text = { "*$it*" },
- onRemove = onRemoveKeyword,
- addLabel = stringResource(R.string.routing_add_keyword),
- onAdd = onAddKeyword,
- )
-
- Button(
- onClick = onDelete,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.errorContainer,
- contentColor = MaterialTheme.colorScheme.onErrorContainer,
- ),
- shape = MaterialTheme.shapes.medium,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.md),
- )
- {
- Icon(
- Icons.Rounded.Delete,
- contentDescription = null,
- modifier = Modifier.size(18.dp),
- )
- Spacer(Modifier.width(ShxSpacing.sm))
- Text(stringResource(R.string.routing_delete))
- }
- }
-}
-
-/** Labeled chip group with trailing add field, shared by prefixes and keywords. */
-@OptIn(ExperimentalLayoutApi::class)
-@Composable
-private fun ChipGroup(
- label: String,
- values: List,
- text: (String) -> String,
- onRemove: (String) -> Unit,
- addLabel: String,
- onAdd: (String) -> Unit,
-)
-{
- Text(
- text = label,
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(start = ShxSpacing.xl, top = ShxSpacing.lg),
- )
- FlowRow(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.xs),
- horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
- verticalArrangement = Arrangement.spacedBy(ShxSpacing.xs),
- )
- {
- values.forEach { value ->
- AssistChip(
- onClick = { onRemove(value) },
- label = { Text(text(value), maxLines = 1) },
- trailingIcon = {
- Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.size(16.dp))
- },
- )
- }
- }
- AddChipField(label = addLabel, onAdd = onAdd)
-}
-
-/** Editor row: badge, title/subtitle, chevron. */
-@Composable
-private fun EditorRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit)
-{
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth()
- .clickable(onClick = onClick)
- .padding(horizontal = ShxSpacing.xl, vertical = 14.dp),
- )
- {
- ShxIconBadge(icon)
- 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,
- )
- }
- Icon(
- Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
-}
-
-/** Compact input with an add action; commits on the IME "done" or the trailing button. */
-@Composable
-private fun AddChipField(
- label: String,
- onAdd: (String) -> Unit,
-)
-{
- var value by remember { mutableStateOf("") }
- val commit = {
- if (value.isNotBlank())
- {
- onAdd(value.trim())
- value = ""
- }
- }
- OutlinedTextField(
- value = value,
- onValueChange = { value = it },
- label = { Text(label) },
- singleLine = true,
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
- keyboardActions = KeyboardActions(onDone = { commit() }),
- trailingIcon = {
- IconButton(onClick = { commit() }, enabled = value.isNotBlank())
- {
- Icon(Icons.Rounded.Add, contentDescription = null)
- }
- },
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.xs),
- shape = MaterialTheme.shapes.medium,
- )
-}
-
-/** A preset counts as selected when all of its patterns are in the set. */
-private fun countSelectedPresets(ruleSet: RuleSet): Int =
- RoutingPresets.RF_BUNDLE.count { preset ->
- ruleSet.prefixes.containsAll(preset.prefixes) && ruleSet.keywords.containsAll(preset.keywords)
- }