feat: copy split config from existing

This commit is contained in:
Zane Schepke
2025-12-18 16:28:44 -05:00
parent 05b950fbb3
commit 90a693abb4
8 changed files with 161 additions and 34 deletions
@@ -2,18 +2,18 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException 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.Tunnel as WgTunnel
import com.wireguard.android.backend.WgQuickBackend
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.Kernel import com.zaneschepke.wireguardautotunnel.di.Kernel
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus 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.DnsFailure
import com.zaneschepke.wireguardautotunnel.domain.events.InvalidConfig import com.zaneschepke.wireguardautotunnel.domain.events.InvalidConfig
import com.zaneschepke.wireguardautotunnel.domain.events.KernelTunnelName 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.events.UnknownError
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.Sort 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.outlined.CopyAll
import androidx.compose.material.icons.rounded.* import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -279,12 +280,26 @@ fun currentRouteAsNavbarState(
}, },
topTitle = tunnelName ?: "", topTitle = tunnelName ?: "",
topTrailing = { topTrailing = {
IconButton( Row {
onClick = { IconButton(
sharedViewModel.postSideEffect(LocalSideEffect.SaveChanges) 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, showBottomItems = true,
@@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm 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.components.SplitTunnelContent
import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption
import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect
@@ -21,38 +22,57 @@ import org.orbitmvi.orbit.compose.collectSideEffect
@Composable @Composable
fun SplitTunnelScreen(viewModel: SplitTunnelViewModel) { fun SplitTunnelScreen(viewModel: SplitTunnelViewModel) {
val sharedViewModel = LocalSharedVm.current 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) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularWavyProgressIndicator(waveSpeed = 60.dp, modifier = Modifier.size(48.dp)) CircularWavyProgressIndicator(waveSpeed = 60.dp, modifier = Modifier.size(48.dp))
} }
return 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 { val conf by remember(effectiveTunnel) { derivedStateOf { effectiveTunnel.toAmConfig() } }
mutableStateOf(
when { var splitConfig by
conf.`interface`.excludedApplications.isNotEmpty() -> remember(conf) {
Pair(SplitOption.EXCLUDE, conf.`interface`.excludedApplications.toSet()) mutableStateOf(
conf.`interface`.includedApplications.isNotEmpty() -> when {
Pair(SplitOption.INCLUDE, conf.`interface`.includedApplications.toSet()) conf.`interface`.excludedApplications.isNotEmpty() ->
else -> Pair(SplitOption.ALL, emptySet<String>()) Pair(SplitOption.EXCLUDE, conf.`interface`.excludedApplications.toSet())
} conf.`interface`.includedApplications.isNotEmpty() ->
) Pair(SplitOption.INCLUDE, conf.`interface`.includedApplications.toSet())
} else -> Pair(SplitOption.ALL, emptySet<String>())
}
)
}
sharedViewModel.collectSideEffect { sideEffect -> sharedViewModel.collectSideEffect { sideEffect ->
if (sideEffect is LocalSideEffect.SaveChanges) if (sideEffect is LocalSideEffect.SaveChanges)
viewModel.saveSplitTunnelSelection(splitConfig) 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( SplitTunnelContent(
splitConfig = splitConfig, splitConfig = splitConfig,
installedPackages = splitTunnelState.installedPackages, installedPackages = uiState.installedPackages,
onSplitOptionChange = { splitConfig = Pair(it, splitConfig.second) }, onSplitOptionChange = { splitConfig = Pair(it, splitConfig.second) },
onAppSelectionToggle = { appPackage, enabled -> onAppSelectionToggle = { appPackage, enabled ->
val updated = val updated =
@@ -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<TunnelConfig>,
onAttest: (tunnelConf: TunnelConfig?) -> Unit,
onDismiss: () -> Unit,
) {
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(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),
)
}
}
@@ -3,11 +3,15 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.compo
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.model.InstalledPackage 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 import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.state.SplitOption
@Composable @Composable
@@ -22,16 +26,28 @@ fun SplitTunnelContent(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
SplitOptionSelector( Column {
selectedOption = splitConfig.first, GroupLabel(
onOptionChange = onSplitOptionChange, stringResource(R.string.mode),
) modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp),
if (splitConfig.first != SplitOption.ALL) {
AppListSection(
installedPackages = installedPackages,
onAppSelectionToggle = onAppSelectionToggle,
splitConfig = splitConfig,
) )
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,
)
}
} }
} }
} }
@@ -19,7 +19,7 @@ sealed class LocalSideEffect {
data object DeleteTunnels : Modal() data object DeleteTunnels : Modal()
data object AddCustomApp : Modal() data object SelectTunnel : Modal()
} }
sealed class SelectedTunnels : LocalSideEffect() { sealed class SelectedTunnels : LocalSideEffect() {
@@ -4,6 +4,7 @@ import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.wireguard.android.backend.WgQuickBackend import com.wireguard.android.backend.WgQuickBackend
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.data.model.AppMode import com.zaneschepke.wireguardautotunnel.data.model.AppMode
@@ -166,7 +167,9 @@ constructor(
postSideEffect(GlobalSideEffect.Snackbar(message)) postSideEffect(GlobalSideEffect.Snackbar(message))
if (!accepted) return@intent if (!accepted) return@intent
if (WgQuickBackend.hasKernelSupport()) 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 { else {
Timber.e("Device does not support kernel backend!") Timber.e("Device does not support kernel backend!")
intent { intent {
+3
View File
@@ -456,4 +456,7 @@
<string name="i3" translatable="false">I3</string> <string name="i3" translatable="false">I3</string>
<string name="i4" translatable="false">I4</string> <string name="i4" translatable="false">I4</string>
<string name="i5" translatable="false">I5</string> <string name="i5" translatable="false">I5</string>
<string name="copy_from">Copy from</string>
<string name="mode">Mode</string>
<string name="app_selection">App selection</string>
</resources> </resources>