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