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:name=".ui.TrustedCertificatesActivity"
|
||||||
android:label="@string/trusted_certs_title" >
|
android:label="@string/trusted_certs_title" >
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
|
||||||
android:name=".ui.SelectedApplicationsActivity"
|
|
||||||
android:label="@string/profile_select_apps" >
|
|
||||||
</activity>
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.LogActivity"
|
android:name=".ui.LogActivity"
|
||||||
android:label="@string/log_title" >
|
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
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxSectionCard
|
import org.strongswan.android.ui.compose.components.ShxSectionCard
|
||||||
|
import org.strongswan.android.ui.compose.components.ShxSettingRow
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.security.KeyChain
|
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.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material.icons.rounded.AltRoute
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
@@ -85,17 +87,15 @@ import androidx.compose.ui.text.input.VisualTransformation
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
import org.strongswan.android.data.VpnProfile
|
import org.strongswan.android.data.VpnProfile
|
||||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
|
||||||
import org.strongswan.android.data.VpnProfileDataSource
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
import org.strongswan.android.data.VpnType
|
import org.strongswan.android.data.VpnType
|
||||||
import org.strongswan.android.data.VpnType.VpnTypeFeature
|
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.TrustedCertificatesActivity
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||||
import org.strongswan.android.utils.Constants
|
import org.strongswan.android.utils.Constants
|
||||||
import org.strongswan.android.utils.IPRangeSet
|
import org.strongswan.android.utils.IPRangeSet
|
||||||
import org.strongswan.android.utils.Utils
|
import org.strongswan.android.utils.Utils
|
||||||
import java.util.TreeSet
|
|
||||||
|
|
||||||
data class ProfileFormState(
|
data class ProfileFormState(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@@ -123,8 +123,6 @@ data class ProfileFormState(
|
|||||||
val excludedSubnets: String = "",
|
val excludedSubnets: String = "",
|
||||||
val blockIpv4: Boolean = false,
|
val blockIpv4: Boolean = false,
|
||||||
val blockIpv6: Boolean = false,
|
val blockIpv6: Boolean = false,
|
||||||
val appsHandling: SelectedAppsHandling = SelectedAppsHandling.SELECTED_APPS_DISABLE,
|
|
||||||
val selectedApps: Set<String> = emptySet(),
|
|
||||||
val ikeProposal: String = "",
|
val ikeProposal: String = "",
|
||||||
val espProposal: String = "",
|
val espProposal: String = "",
|
||||||
val proxyHost: 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 (blockIpv4) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4
|
||||||
if (blockIpv6) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6
|
if (blockIpv6) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6
|
||||||
profile.splitTunneling = if (st == 0) null else st
|
profile.splitTunneling = if (st == 0) null else st
|
||||||
profile.selectedAppsHandling = appsHandling
|
|
||||||
profile.setSelectedApps(TreeSet(selectedApps))
|
|
||||||
profile.ikeProposal = ikeProposal.trim().ifEmpty { null }
|
profile.ikeProposal = ikeProposal.trim().ifEmpty { null }
|
||||||
profile.espProposal = espProposal.trim().ifEmpty { null }
|
profile.espProposal = espProposal.trim().ifEmpty { null }
|
||||||
profile.proxyHost = proxyHost.trim().ifEmpty { null }
|
profile.proxyHost = proxyHost.trim().ifEmpty { null }
|
||||||
@@ -217,8 +213,6 @@ fun VpnProfile.toFormState(): ProfileFormState
|
|||||||
excludedSubnets = excludedSubnets ?: "",
|
excludedSubnets = excludedSubnets ?: "",
|
||||||
blockIpv4 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4 != 0,
|
blockIpv4 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4 != 0,
|
||||||
blockIpv6 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6 != 0,
|
blockIpv6 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6 != 0,
|
||||||
appsHandling = selectedAppsHandling,
|
|
||||||
selectedApps = selectedAppsSet ?: emptySet(),
|
|
||||||
ikeProposal = ikeProposal ?: "",
|
ikeProposal = ikeProposal ?: "",
|
||||||
espProposal = espProposal ?: "",
|
espProposal = espProposal ?: "",
|
||||||
proxyHost = proxyHost ?: "",
|
proxyHost = proxyHost ?: "",
|
||||||
@@ -228,7 +222,6 @@ fun VpnProfile.toFormState(): ProfileFormState
|
|||||||
advanced = remoteId != null || localId != null || dnsServers != null || getMTU() != null
|
advanced = remoteId != null || localId != null || dnsServers != null || getMTU() != null
|
||||||
|| port != null || getNATKeepAlive() != null || flags != 0 || st != 0
|
|| port != null || getNATKeepAlive() != null || flags != 0 || st != 0
|
||||||
|| includedSubnets != null || excludedSubnets != null
|
|| includedSubnets != null || excludedSubnets != null
|
||||||
|| selectedAppsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE
|
|
||||||
|| ikeProposal != null || espProposal != null || proxyHost != null,
|
|| ikeProposal != null || espProposal != null || proxyHost != null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -308,9 +301,7 @@ fun ProfileFormScreen(
|
|||||||
messages?.collect { snackbarHostState.showSnackbar(context.getString(it)) }
|
messages?.collect { snackbarHostState.showSnackbar(context.getString(it)) }
|
||||||
}
|
}
|
||||||
val types = stringArrayResource(R.array.vpn_types)
|
val types = stringArrayResource(R.array.vpn_types)
|
||||||
val appsHandling = stringArrayResource(R.array.apps_handling)
|
|
||||||
var typeExpanded by remember { mutableStateOf(false) }
|
var typeExpanded by remember { mutableStateOf(false) }
|
||||||
var appsExpanded by remember { mutableStateOf(false) }
|
|
||||||
var showPassword by remember { mutableStateOf(false) }
|
var showPassword by remember { mutableStateOf(false) }
|
||||||
val enabled = !state.readOnly
|
val enabled = !state.readOnly
|
||||||
|
|
||||||
@@ -322,14 +313,6 @@ fun ProfileFormScreen(
|
|||||||
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
|
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(
|
Scaffold(
|
||||||
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
|
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
|
||||||
modifier = Modifier.windowInsetsPadding(
|
modifier = Modifier.windowInsetsPadding(
|
||||||
@@ -577,38 +560,14 @@ fun ProfileFormScreen(
|
|||||||
checked = state.blockIpv6,
|
checked = state.blockIpv6,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
) { onChange(state.copy(blockIpv6 = it)) }
|
) { onChange(state.copy(blockIpv6 = it)) }
|
||||||
ExposedDropdownMenuBox(expanded = appsExpanded, onExpandedChange = { if (enabled) appsExpanded = it }) {
|
ShxSettingRow(
|
||||||
OutlinedTextField(
|
icon = Icons.Rounded.AltRoute,
|
||||||
value = appsHandling.getOrElse(state.appsHandling.ordinal) { "" },
|
title = stringResource(R.string.profile_routing_link_title),
|
||||||
onValueChange = {},
|
subtitle = stringResource(R.string.profile_routing_link_summary),
|
||||||
readOnly = true,
|
onClick = {
|
||||||
enabled = enabled,
|
context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
|
||||||
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})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
|
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
|
||||||
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
|
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
|
||||||
isError = state.ikeProposalError != null,
|
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