From 90a693abb44778a22a762846a1aceffe5ee978f6 Mon Sep 17 00:00:00 2001 From: Zane Schepke Date: Thu, 18 Dec 2025 16:28:44 -0500 Subject: [PATCH] feat: copy split config from existing --- .../core/tunnel/KernelTunnel.kt | 4 +- .../currentBackStackEntryAsNavbarState.kt | 25 +++++-- .../tunnels/splittunnel/SplitTunnelScreen.kt | 52 +++++++++----- .../components/SelectTunnelModal.kt | 70 +++++++++++++++++++ .../components/SplitTunnelContent.kt | 34 ++++++--- .../ui/sideeffect/LocalSideEffect.kt | 2 +- .../viewmodel/SharedAppViewModel.kt | 5 +- app/src/main/res/values/strings.xml | 3 + 8 files changed, 161 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SelectTunnelModal.kt diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt index b5fdf78c..d2430578 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt @@ -2,18 +2,18 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel import com.wireguard.android.backend.Backend import com.wireguard.android.backend.BackendException -import com.wireguard.android.backend.WgQuickBackend import com.wireguard.android.backend.Tunnel as WgTunnel +import com.wireguard.android.backend.WgQuickBackend import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.Kernel import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus -import com.zaneschepke.wireguardautotunnel.domain.events.KernelWireguardNotSupported import com.zaneschepke.wireguardautotunnel.domain.events.DnsFailure import com.zaneschepke.wireguardautotunnel.domain.events.InvalidConfig import com.zaneschepke.wireguardautotunnel.domain.events.KernelTunnelName +import com.zaneschepke.wireguardautotunnel.domain.events.KernelWireguardNotSupported import com.zaneschepke.wireguardautotunnel.domain.events.UnknownError import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt index 63efe81c..773a6918 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Sort +import androidx.compose.material.icons.outlined.ContentPasteGo import androidx.compose.material.icons.outlined.CopyAll import androidx.compose.material.icons.rounded.* import androidx.compose.material3.Icon @@ -279,12 +280,26 @@ fun currentRouteAsNavbarState( }, topTitle = tunnelName ?: "", topTrailing = { - IconButton( - onClick = { - sharedViewModel.postSideEffect(LocalSideEffect.SaveChanges) + Row { + IconButton( + onClick = { + sharedViewModel.postSideEffect( + LocalSideEffect.Modal.SelectTunnel + ) + } + ) { + Icon( + Icons.Outlined.ContentPasteGo, + stringResource(R.string.copy_from), + ) + } + IconButton( + onClick = { + sharedViewModel.postSideEffect(LocalSideEffect.SaveChanges) + } + ) { + Icon(Icons.Rounded.Save, stringResource(R.string.save)) } - ) { - Icon(Icons.Rounded.Save, stringResource(R.string.save)) } }, showBottomItems = true, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt index 49c39791..8bca14b8 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm +import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components.SelectTunnelModal import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components.SplitTunnelContent import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect @@ -21,38 +22,57 @@ import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun SplitTunnelScreen(viewModel: SplitTunnelViewModel) { val sharedViewModel = LocalSharedVm.current - val splitTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() - if (splitTunnelState.isLoading) { + val sharedUiState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle() + val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + + var showDialog by remember { mutableStateOf(false) } + + if (uiState.isLoading) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularWavyProgressIndicator(waveSpeed = 60.dp, modifier = Modifier.size(48.dp)) } return } - val tunnel = splitTunnelState.tunnel ?: return + val tunnel = uiState.tunnel ?: return - val conf by remember { derivedStateOf { tunnel.toAmConfig() } } + var effectiveTunnel by remember { mutableStateOf(tunnel) } - var splitConfig by remember { - mutableStateOf( - when { - conf.`interface`.excludedApplications.isNotEmpty() -> - Pair(SplitOption.EXCLUDE, conf.`interface`.excludedApplications.toSet()) - conf.`interface`.includedApplications.isNotEmpty() -> - Pair(SplitOption.INCLUDE, conf.`interface`.includedApplications.toSet()) - else -> Pair(SplitOption.ALL, emptySet()) - } - ) - } + val conf by remember(effectiveTunnel) { derivedStateOf { effectiveTunnel.toAmConfig() } } + + var splitConfig by + remember(conf) { + mutableStateOf( + when { + conf.`interface`.excludedApplications.isNotEmpty() -> + Pair(SplitOption.EXCLUDE, conf.`interface`.excludedApplications.toSet()) + conf.`interface`.includedApplications.isNotEmpty() -> + Pair(SplitOption.INCLUDE, conf.`interface`.includedApplications.toSet()) + else -> Pair(SplitOption.ALL, emptySet()) + } + ) + } sharedViewModel.collectSideEffect { sideEffect -> if (sideEffect is LocalSideEffect.SaveChanges) viewModel.saveSplitTunnelSelection(splitConfig) + if (sideEffect is LocalSideEffect.Modal.SelectTunnel) showDialog = true } + SelectTunnelModal( + showDialog, + sharedUiState.tunnels, + onAttest = { conf -> + if (conf == null) return@SelectTunnelModal + effectiveTunnel = conf + showDialog = false + }, + onDismiss = { showDialog = false }, + ) + SplitTunnelContent( splitConfig = splitConfig, - installedPackages = splitTunnelState.installedPackages, + installedPackages = uiState.installedPackages, onSplitOptionChange = { splitConfig = Pair(it, splitConfig.second) }, onAppSelectionToggle = { appPackage, enabled -> val updated = diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SelectTunnelModal.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SelectTunnelModal.kt new file mode 100644 index 00000000..52beef1e --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SelectTunnelModal.kt @@ -0,0 +1,70 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components + +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.overscroll +import androidx.compose.foundation.rememberOverscrollEffect +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow +import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog + +@Composable +fun SelectTunnelModal( + show: Boolean, + tunnels: List, + onAttest: (tunnelConf: TunnelConfig?) -> Unit, + onDismiss: () -> Unit, +) { + var selectedTunnel by remember { mutableStateOf(null) } + if (show) { + InfoDialog( + title = stringResource(R.string.copy_from), + body = { + LazyColumn( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = + Modifier.pointerInput(Unit) { if (tunnels.isEmpty()) return@pointerInput } + .overscroll(rememberOverscrollEffect()), + state = rememberLazyListState(), + userScrollEnabled = true, + reverseLayout = false, + flingBehavior = ScrollableDefaults.flingBehavior(), + ) { + items(tunnels, key = { it.id }) { tunnel -> + SurfaceRow( + title = tunnel.name, + trailing = + if (selectedTunnel?.id == tunnel.id) { + { + Icon( + Icons.Outlined.Check, + stringResource(id = R.string.selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + } else null, + onClick = { selectedTunnel = tunnel }, + ) + } + } + }, + onAttest = { onAttest(selectedTunnel) }, + onDismiss = { onDismiss() }, + confirmText = stringResource(R.string.copy), + ) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitTunnelContent.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitTunnelContent.kt index b267e853..bfdcb4b0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitTunnelContent.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/components/SplitTunnelContent.kt @@ -3,11 +3,15 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.compo import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.domain.model.InstalledPackage +import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption @Composable @@ -22,16 +26,28 @@ fun SplitTunnelContent( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize(), ) { - SplitOptionSelector( - selectedOption = splitConfig.first, - onOptionChange = onSplitOptionChange, - ) - if (splitConfig.first != SplitOption.ALL) { - AppListSection( - installedPackages = installedPackages, - onAppSelectionToggle = onAppSelectionToggle, - splitConfig = splitConfig, + Column { + GroupLabel( + stringResource(R.string.mode), + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp), ) + SplitOptionSelector( + selectedOption = splitConfig.first, + onOptionChange = onSplitOptionChange, + ) + } + if (splitConfig.first != SplitOption.ALL) { + Column { + GroupLabel( + stringResource(R.string.app_selection), + modifier = Modifier.padding(16.dp).padding(bottom = 8.dp), + ) + AppListSection( + installedPackages = installedPackages, + onAppSelectionToggle = onAppSelectionToggle, + splitConfig = splitConfig, + ) + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt index 1801f8cf..40ca0b00 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt @@ -19,7 +19,7 @@ sealed class LocalSideEffect { data object DeleteTunnels : Modal() - data object AddCustomApp : Modal() + data object SelectTunnel : Modal() } sealed class SelectedTunnels : LocalSideEffect() { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt index 2e293807..c1581db0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt @@ -4,6 +4,7 @@ import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.ViewModel import com.wireguard.android.backend.WgQuickBackend +import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.data.model.AppMode @@ -166,7 +167,9 @@ constructor( postSideEffect(GlobalSideEffect.Snackbar(message)) if (!accepted) return@intent if (WgQuickBackend.hasKernelSupport()) - Timber.i("Device supports kernel backend. WireGuard module is built in, switching to kernel backend.") + Timber.i( + "Device supports kernel backend. WireGuard module is built in, switching to kernel backend." + ) else { Timber.e("Device does not support kernel backend!") intent { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20f31585..70b54de2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -456,4 +456,7 @@ I3 I4 I5 + Copy from + Mode + App selection