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:
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" >
|
||||
|
||||
-213
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-73
@@ -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());
|
||||
}
|
||||
}
|
||||
-179
@@ -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 = " · ")
|
||||
}
|
||||
}
|
||||
+11
-52
@@ -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,
|
||||
|
||||
-359
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user