android: remove per-profile app picker, rule-set UI and SelectedApplicationsActivity

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-10 01:48:00 +07:00
1 parent 488c0aa210
commit e2ce8420a7
6 files changed
+11 -880

No files matched your search

@@ -108,10 +108,6 @@
android:name=".ui.TrustedCertificatesActivity"
android:label="@string/trusted_certs_title" >
</activity>
<activity
android:name=".ui.SelectedApplicationsActivity"
android:label="@string/profile_select_apps" >
</activity>
<activity
android:name=".ui.LogActivity"
android:label="@string/log_title" >
@@ -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<String>()
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<String>,
readOnly: Boolean,
onBack: () -> Unit,
onChange: (Set<String>) -> Unit,
)
{
val context = LocalContext.current
var query by remember { mutableStateOf("") }
var apps by remember { mutableStateOf<List<SelectedApplicationEntry>>(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<SelectedApplicationEntry>()
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) },
)
}
}
}
}
}
}
@@ -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 <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.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<SelectedApplicationEntry>
{
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());
}
}
@@ -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 = " · ")
}
}
@@ -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<String> = 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,
@@ -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<String>,
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)
}