From 0201523262c1db795083a629ddde47394ef0b721 Mon Sep 17 00:00:00 2001 From: Zane Schepke Date: Thu, 19 Dec 2024 19:52:31 -0500 Subject: [PATCH] fix: config screen performance (#495) * improve config screen performance * add ability to edit tunnel scripts for rooted phones * add new screen for split tunneling * fix split tunneling reset bug --- .../data/domain/TunnelConfig.kt | 4 + .../wireguardautotunnel/ui/AppViewModel.kt | 77 +- .../wireguardautotunnel/ui/MainActivity.kt | 20 +- .../wireguardautotunnel/ui/Route.kt | 12 +- .../ui/common/button/SelectionItemButton.kt | 16 +- .../ui/common/config/ConfigurationTextBox.kt | 8 +- .../ui/common/textbox/CustomTextField.kt | 4 +- .../ui/screens/config/ConfigScreen.kt | 601 ---------------- .../ui/screens/config/ConfigUiState.kt | 81 --- .../ui/screens/config/ConfigViewModel.kt | 591 ---------------- .../components/ApplicationSelectionDialog.kt | 199 ------ .../ui/screens/config/model/InterfaceProxy.kt | 121 ---- .../ui/screens/main/MainScreen.kt | 5 +- .../screens/main/components/TunnelRowItem.kt | 4 +- .../tunneloptions/TunnelOptionsScreen.kt | 146 ++++ .../TunnelOptionsViewModel.kt} | 4 +- .../tunneloptions/config/ConfigScreen.kt | 662 ++++++++++++++++++ .../config/model/InterfaceProxy.kt | 183 +++++ .../config/model/PeerProxy.kt | 24 +- .../tunneloptions/splittunnel/SplitOptions.kt | 32 + .../splittunnel/SplitTunnelApp.kt | 9 + .../splittunnel/SplitTunnelScreen.kt | 279 ++++++++ .../TunnelAutoTunnelScreen.kt} | 84 +-- .../TunnelAutoTunnelViewModel.kt | 88 +++ .../util/extensions/ContextExtensions.kt | 33 +- .../util/extensions/StringExtensions.kt | 17 +- app/src/main/res/values/strings.xml | 10 + 27 files changed, 1602 insertions(+), 1712 deletions(-) delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigUiState.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/components/ApplicationSelectionDialog.kt delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/model/InterfaceProxy.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/TunnelOptionsScreen.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{options/OptionsViewModel.kt => tunneloptions/TunnelOptionsViewModel.kt} (96%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/config/ConfigScreen.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/config/model/InterfaceProxy.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{ => tunneloptions}/config/model/PeerProxy.kt (70%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitOptions.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitTunnelApp.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitTunnelScreen.kt rename app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/{options/OptionsScreen.kt => tunneloptions/tunnelautotunnel/TunnelAutoTunnelScreen.kt} (74%) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/tunnelautotunnel/TunnelAutoTunnelViewModel.kt diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/domain/TunnelConfig.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/domain/TunnelConfig.kt index ca918ba..f6008ca 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/domain/TunnelConfig.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/domain/TunnelConfig.kt @@ -69,6 +69,10 @@ data class TunnelConfig( return configFromAmQuick(if (amQuick != "") amQuick else wgQuick) } + fun toWgConfig(): Config { + return configFromWgQuick(wgQuick) + } + companion object { fun configFromWgQuick(wgQuick: String): Config { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppViewModel.kt index 5980db6..a44d9c2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/AppViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wireguard.android.backend.WgQuickBackend import com.wireguard.android.util.RootShell +import com.wireguard.config.Config import com.zaneschepke.logcatter.LogReader import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel @@ -16,13 +17,17 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.BackendState import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController +import com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.config.model.InterfaceProxy +import com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.config.model.PeerProxy import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.LocaleUtil import com.zaneschepke.wireguardautotunnel.util.StringValue import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine @@ -50,6 +55,15 @@ constructor( private val logReader: LogReader, ) : ViewModel() { + private val _popBackStack = MutableSharedFlow() + val popBackStack = _popBackStack.asSharedFlow() + + private val _isAppReady = MutableStateFlow(false) + val isAppReady = _isAppReady.asStateFlow() + + private val _configurationChange = MutableStateFlow(false) + val configurationChange = _configurationChange.asStateFlow() + val uiState = combine( appDataRepository.settings.getSettingsFlow(), @@ -71,12 +85,6 @@ constructor( AppUiState(), ) - private val _isAppReady = MutableStateFlow(false) - val isAppReady = _isAppReady.asStateFlow() - - private val _configurationChange = MutableStateFlow(false) - val configurationChange = _configurationChange.asStateFlow() - init { viewModelScope.launch { initPin() @@ -259,7 +267,7 @@ constructor( } } - private suspend fun requestRoot(): Result { + suspend fun requestRoot(): Result { return withContext(ioDispatcher) { kotlin.runCatching { rootShell.get().start() @@ -269,4 +277,59 @@ constructor( } } } + + fun saveConfigChanges(config: TunnelConfig, peers: List? = null, `interface`: InterfaceProxy? = null) = viewModelScope.launch( + ioDispatcher, + ) { + runCatching { + val amConfig = config.toAmConfig() + val wgConfig = config.toWgConfig() + rebuildConfigsAndSave(config, amConfig, wgConfig, peers, `interface`) + _popBackStack.emit(true) + SnackbarController.showMessage(StringValue.StringResource(R.string.config_changes_saved)) + }.onFailure { + Timber.e(it) + SnackbarController.showMessage( + it.message?.let { message -> + (StringValue.DynamicString(message)) + } ?: StringValue.StringResource(R.string.unknown_error), + ) + } + } + + fun cleanUpUninstalledApps(tunnelConfig: TunnelConfig, packages: List) = viewModelScope.launch(ioDispatcher) { + runCatching { + val amConfig = tunnelConfig.toAmConfig() + val wgConfig = tunnelConfig.toWgConfig() + val proxy = InterfaceProxy.from(amConfig.`interface`) + if (proxy.includedApplications.isEmpty() && proxy.excludedApplications.isEmpty()) return@launch + if (proxy.includedApplications.retainAll(packages.toSet()) || proxy.excludedApplications.retainAll(packages.toSet())) { + Timber.i("Removing split tunnel package for app that no longer exists on the device") + rebuildConfigsAndSave(tunnelConfig, amConfig, wgConfig, `interface` = proxy) + } + }.onFailure { + Timber.e(it) + } + } + + private suspend fun rebuildConfigsAndSave( + config: TunnelConfig, + amConfig: org.amnezia.awg.config.Config, + wgConfig: Config, + peers: List? = null, + `interface`: InterfaceProxy? = null, + ) { + appDataRepository.tunnels.save( + config.copy( + wgQuick = Config.Builder().apply { + addPeers(peers?.map { it.toWgPeer() } ?: wgConfig.peers) + setInterface(`interface`?.toWgInterface() ?: wgConfig.`interface`) + }.build().toWgQuickString(true), + amQuick = org.amnezia.awg.config.Config.Builder().apply { + addPeers(peers?.map { it.toAmPeer() } ?: amConfig.peers) + setInterface(`interface`?.toAmInterface() ?: amConfig.`interface`) + }.build().toAwgQuickString(true), + ), + ) + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt index 4678be1..b0d6a99 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt @@ -41,9 +41,7 @@ import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.CustomSnackBar import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider -import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen -import com.zaneschepke.wireguardautotunnel.ui.screens.options.OptionsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.pinlock.PinLockScreen import com.zaneschepke.wireguardautotunnel.ui.screens.scanner.ScannerScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen @@ -55,6 +53,10 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.Locati import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.KillSwitchScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.OptionsScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.config.ConfigScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.splittunnel.SplitTunnelScreen +import com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.tunnelautotunnel.TunnelAutoTunnelScreen import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate @@ -190,11 +192,13 @@ class MainActivity : AppCompatActivity() { composable { val args = it.toRoute() ConfigScreen( + appUiState, tunnelId = args.id, + appViewModel = viewModel, ) } - composable { - val args = it.toRoute() + composable { + val args = it.toRoute() OptionsScreen( tunnelId = args.id, appUiState = appUiState, @@ -211,6 +215,14 @@ class MainActivity : AppCompatActivity() { composable { KillSwitchScreen(appUiState, viewModel) } + composable { + val args = it.toRoute() + SplitTunnelScreen(appUiState, args.id, viewModel) + } + composable { + val args = it.toRoute() + TunnelAutoTunnelScreen(appUiState, args.id) + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt index 7e085e1..3c1c79c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Route.kt @@ -31,7 +31,7 @@ sealed class Route { data object Main : Route() @Serializable - data class Option( + data class TunnelOptions( val id: Int, ) : Route() @@ -46,6 +46,16 @@ sealed class Route { val id: Int, ) : Route() + @Serializable + data class SplitTunnel( + val id: Int, + ) : Route() + + @Serializable + data class TunnelAutoTunnel( + val id: Int, + ) : Route() + @Serializable data object Logs : Route() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SelectionItemButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SelectionItemButton.kt index 959a21a..10ef1e3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SelectionItemButton.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/button/SelectionItemButton.kt @@ -5,7 +5,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -17,8 +19,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth @Composable fun SelectionItemButton( @@ -30,7 +34,8 @@ fun SelectionItemButton( ) { Card( modifier = - Modifier.clip(RoundedCornerShape(8.dp)) + Modifier + .clip(RoundedCornerShape(8.dp)) .clickable( indication = if (ripple) ripple() else null, interactionSource = remember { MutableInteractionSource() }, @@ -45,15 +50,20 @@ fun SelectionItemButton( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(end = 10.dp.scaledWidth()), ) { leading?.let { it() } Text( buttonText, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(3 / 4f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) trailing?.let { it() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt index 9330a7a..d730579 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt @@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.ui.common.config import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -23,19 +24,20 @@ fun ConfigurationTextBox( capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Done, ), - trailing: @Composable () -> Unit = {}, + trailing: (@Composable () -> Unit)? = null, interactionSource: MutableInteractionSource? = null, ) { OutlinedTextField( isError = isError, + textStyle = MaterialTheme.typography.labelLarge, modifier = modifier, value = value, singleLine = true, interactionSource = interactionSource, onValueChange = { onValueChange(it) }, - label = { Text(label) }, + label = { Text(label, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelMedium) }, maxLines = 1, - placeholder = { Text(hint) }, + placeholder = { Text(hint, color = MaterialTheme.colorScheme.outline, style = MaterialTheme.typography.labelLarge) }, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, trailingIcon = trailing, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/textbox/CustomTextField.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/textbox/CustomTextField.kt index 95d19b0..0c8ab4e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/textbox/CustomTextField.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/textbox/CustomTextField.kt @@ -28,8 +28,8 @@ fun CustomTextField( onValueChange: (value: String) -> Unit = {}, singleLine: Boolean = false, placeholder: @Composable (() -> Unit)? = null, - keyboardOptions: KeyboardOptions, - keyboardActions: KeyboardActions, + keyboardOptions: KeyboardOptions = KeyboardOptions(), + keyboardActions: KeyboardActions = KeyboardActions(), supportingText: @Composable (() -> Unit)? = null, leading: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt deleted file mode 100644 index 6fe28e6..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt +++ /dev/null @@ -1,601 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.config - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -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.rounded.ContentCopy -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Save -import androidx.compose.material3.FabPosition -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -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.platform.ClipboardManager -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.Route -import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox -import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle -import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController -import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar -import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt -import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController -import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle -import com.zaneschepke.wireguardautotunnel.ui.screens.config.components.ApplicationSelectionDialog -import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType -import com.zaneschepke.wireguardautotunnel.util.Constants -import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv -import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight -import kotlinx.coroutines.delay - -@Composable -fun ConfigScreen(tunnelId: Int) { - val viewModel = hiltViewModel { factory -> - factory.create(tunnelId) - } - - val context = LocalContext.current - val snackbar = SnackbarController.current - val clipboardManager: ClipboardManager = LocalClipboardManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val navController = LocalNavController.current - - var showApplicationsDialog by remember { mutableStateOf(false) } - var showAuthPrompt by remember { mutableStateOf(false) } - var isAuthenticated by remember { mutableStateOf(false) } - - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var configType by remember { mutableStateOf(null) } - val derivedConfigType = remember { - derivedStateOf { - configType ?: if (!uiState.isAmneziaEnabled) ConfigType.WIREGUARD else ConfigType.AMNEZIA - } - } - val saved by viewModel.saved.collectAsStateWithLifecycle(null) - - LaunchedEffect(saved) { - if (saved == true) { - navController.navigate(Route.Main) - } - } - - LaunchedEffect(Unit) { - delay(2_000L) - viewModel.cleanUpUninstalledApps() - } - - val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) - val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - - val fillMaxHeight = .85f - val fillMaxWidth = .85f - val screenPadding = 5.dp - - val applicationButtonText = - buildAnnotatedString { - append(stringResource(id = R.string.tunneling_apps)) - append(": ") - if (uiState.isAllApplicationsEnabled) { - append(stringResource(id = R.string.all)) - } else { - append("${uiState.checkedPackageNames.size} ") - ( - if (uiState.include) { - append(stringResource(id = R.string.included)) - } else { - append( - stringResource(id = R.string.excluded), - ) - } - ) - } - } - - if (showAuthPrompt) { - AuthorizationPrompt( - onSuccess = { - showAuthPrompt = false - isAuthenticated = true - }, - onError = { - showAuthPrompt = false - snackbar.showMessage( - context.getString(R.string.error_authentication_failed), - ) - }, - onFailure = { - showAuthPrompt = false - snackbar.showMessage( - context.getString(R.string.error_authorization_failed), - ) - }, - ) - } - - if (showApplicationsDialog) { - ApplicationSelectionDialog(viewModel, uiState) { - showApplicationsDialog = false - } - } - - Scaffold( - topBar = { - TopNavBar(stringResource(R.string.edit_tunnel)) - }, - floatingActionButtonPosition = FabPosition.End, - floatingActionButton = { - FloatingActionButton( - onClick = { - viewModel.onSaveAllChanges() - }, - containerColor = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(16.dp), - ) { - Icon( - imageVector = Icons.Rounded.Save, - contentDescription = stringResource(id = R.string.save_changes), - tint = MaterialTheme.colorScheme.background, - ) - } - }, - ) { padding -> - Column(Modifier.padding(padding)) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - modifier = - Modifier - .verticalScroll(rememberScrollState()) - .weight(1f, true) - .fillMaxSize(), - ) { - Surface( - tonalElevation = 2.dp, - shadowElevation = 2.dp, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - modifier = - ( - if (context.isRunningOnTv()) { - Modifier - .fillMaxHeight(fillMaxHeight) - .fillMaxWidth(fillMaxWidth) - } else { - Modifier.fillMaxWidth(fillMaxWidth) - } - ) - .padding(bottom = 10.dp.scaledHeight()).padding(top = 24.dp.scaledHeight()), - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = - Modifier - .padding(15.dp) - .focusGroup(), - ) { - SectionTitle( - stringResource(R.string.interface_), - padding = screenPadding, - ) - ConfigurationToggle( - stringResource(id = R.string.show_amnezia_properties), - checked = derivedConfigType.value == ConfigType.AMNEZIA, - onCheckChanged = { configType = if (it) ConfigType.AMNEZIA else ConfigType.WIREGUARD }, - ) - ConfigurationTextBox( - value = uiState.tunnelName, - onValueChange = viewModel::onTunnelNameChange, - keyboardActions = keyboardActions, - label = stringResource(R.string.name), - hint = stringResource(R.string.tunnel_name).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - OutlinedTextField( - modifier = - Modifier - .fillMaxWidth() - .clickable { showAuthPrompt = true }, - value = uiState.interfaceProxy.privateKey, - visualTransformation = - if ((tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - enabled = (tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated, - onValueChange = { value -> viewModel.onPrivateKeyChange(value) }, - trailingIcon = { - IconButton( - modifier = Modifier.focusRequester(FocusRequester.Default), - onClick = { viewModel.generateKeyPair() }, - ) { - Icon( - Icons.Rounded.Refresh, - stringResource(R.string.rotate_keys), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - }, - label = { Text(stringResource(R.string.private_key)) }, - singleLine = true, - placeholder = { Text(stringResource(R.string.base64_key)) }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - ) - OutlinedTextField( - modifier = - Modifier - .fillMaxWidth() - .focusRequester(FocusRequester.Default), - value = uiState.interfaceProxy.publicKey, - enabled = false, - onValueChange = {}, - trailingIcon = { - IconButton( - modifier = Modifier.focusRequester(FocusRequester.Default), - onClick = { - clipboardManager.setText( - AnnotatedString(uiState.interfaceProxy.publicKey), - ) - }, - ) { - Icon( - Icons.Rounded.ContentCopy, - stringResource(R.string.copy_public_key), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - }, - label = { Text(stringResource(R.string.public_key)) }, - singleLine = true, - placeholder = { Text(stringResource(R.string.base64_key)) }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - ) - ConfigurationTextBox( - value = uiState.interfaceProxy.addresses, - onValueChange = viewModel::onAddressesChanged, - keyboardActions = keyboardActions, - label = stringResource(R.string.addresses), - hint = stringResource(R.string.comma_separated_list), - modifier = - Modifier - .fillMaxWidth() - .padding(end = 5.dp), - ) - ConfigurationTextBox( - value = uiState.interfaceProxy.listenPort, - onValueChange = viewModel::onListenPortChanged, - keyboardActions = keyboardActions, - label = stringResource(R.string.listen_port), - hint = stringResource(R.string.random), - modifier = Modifier.fillMaxWidth(), - ) - Row(modifier = Modifier.fillMaxWidth()) { - ConfigurationTextBox( - value = uiState.interfaceProxy.dnsServers, - onValueChange = viewModel::onDnsServersChanged, - keyboardActions = keyboardActions, - label = stringResource(R.string.dns_servers), - hint = stringResource(R.string.comma_separated_list), - modifier = - Modifier - .fillMaxWidth(3 / 5f) - .padding(end = 5.dp), - ) - ConfigurationTextBox( - value = uiState.interfaceProxy.mtu, - onValueChange = viewModel::onMtuChanged, - keyboardActions = keyboardActions, - label = stringResource(R.string.mtu), - hint = stringResource(R.string.auto), - modifier = Modifier.width(IntrinsicSize.Min), - ) - } - if (derivedConfigType.value == ConfigType.AMNEZIA) { - ConfigurationTextBox( - value = uiState.interfaceProxy.junkPacketCount, - onValueChange = viewModel::onJunkPacketCountChanged, - keyboardActions = keyboardActions, - label = stringResource(R.string.junk_packet_count), - hint = stringResource(R.string.junk_packet_count).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = uiState.interfaceProxy.junkPacketMinSize, - onValueChange = viewModel::onJunkPacketMinSizeChanged, - keyboardActions = keyboardActions, - label = stringResource(R.string.junk_packet_minimum_size), - hint = - stringResource( - R.string.junk_packet_minimum_size, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = uiState.interfaceProxy.junkPacketMaxSize, - onValueChange = viewModel::onJunkPacketMaxSizeChanged, - keyboardActions = keyboardActions, - label = stringResource(R.string.junk_packet_maximum_size), - hint = - stringResource( - R.string.junk_packet_maximum_size, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = uiState.interfaceProxy.initPacketJunkSize, - onValueChange = viewModel::onInitPacketJunkSizeChanged, - keyboardActions = keyboardActions, - label = stringResource(R.string.init_packet_junk_size), - hint = stringResource(R.string.init_packet_junk_size).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = uiState.interfaceProxy.responsePacketJunkSize, - onValueChange = viewModel::onResponsePacketJunkSize, - keyboardActions = keyboardActions, - label = stringResource(R.string.response_packet_junk_size), - hint = - stringResource( - R.string.response_packet_junk_size, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = uiState.interfaceProxy.initPacketMagicHeader, - onValueChange = viewModel::onInitPacketMagicHeader, - keyboardActions = keyboardActions, - label = stringResource(R.string.init_packet_magic_header), - hint = - stringResource( - R.string.init_packet_magic_header, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = uiState.interfaceProxy.responsePacketMagicHeader, - onValueChange = viewModel::onResponsePacketMagicHeader, - keyboardActions = keyboardActions, - label = stringResource(R.string.response_packet_magic_header), - hint = - stringResource( - R.string.response_packet_magic_header, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = uiState.interfaceProxy.underloadPacketMagicHeader, - onValueChange = viewModel::onUnderloadPacketMagicHeader, - keyboardActions = keyboardActions, - label = stringResource(R.string.underload_packet_magic_header), - hint = - stringResource( - R.string.underload_packet_magic_header, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - ConfigurationTextBox( - value = uiState.interfaceProxy.transportPacketMagicHeader, - onValueChange = viewModel::onTransportPacketMagicHeader, - keyboardActions = keyboardActions, - label = stringResource(R.string.transport_packet_magic_header), - hint = - stringResource( - R.string.transport_packet_magic_header, - ).lowercase(), - modifier = - Modifier - .fillMaxWidth(), - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxSize() - .padding(top = 5.dp), - horizontalArrangement = Arrangement.Center, - ) { - TextButton(onClick = { showApplicationsDialog = true }) { - Text(applicationButtonText.text) - } - } - } - } - uiState.proxyPeers.forEachIndexed { index, peer -> - Surface( - tonalElevation = 2.dp, - shadowElevation = 2.dp, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - modifier = - ( - if (context.isRunningOnTv()) { - Modifier - .fillMaxHeight(fillMaxHeight) - .fillMaxWidth(fillMaxWidth) - } else { - Modifier.fillMaxWidth(fillMaxWidth) - } - ) - .padding(top = 10.dp, bottom = 10.dp), - ) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = - Modifier - .padding(horizontal = 15.dp) - .padding(bottom = 10.dp), - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 5.dp), - ) { - SectionTitle( - stringResource(R.string.peer), - padding = screenPadding, - ) - IconButton(onClick = { viewModel.onDeletePeer(index) }) { - val icon = Icons.Rounded.Delete - Icon(icon, icon.name) - } - } - - ConfigurationTextBox( - value = peer.publicKey, - onValueChange = { value -> - viewModel.onPeerPublicKeyChange(index, value) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.public_key), - hint = stringResource(R.string.base64_key), - modifier = Modifier.fillMaxWidth(), - ) - ConfigurationTextBox( - value = peer.preSharedKey, - onValueChange = { value -> - viewModel.onPreSharedKeyChange(index, value) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.preshared_key), - hint = stringResource(R.string.optional), - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = peer.persistentKeepalive, - enabled = true, - onValueChange = { value -> - viewModel.onPersistentKeepaliveChanged(index, value) - }, - trailingIcon = { - Text( - stringResource(R.string.seconds), - modifier = Modifier.padding(end = 10.dp), - ) - }, - label = { Text(stringResource(R.string.persistent_keepalive)) }, - singleLine = true, - placeholder = { - Text(stringResource(R.string.optional_no_recommend)) - }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - ) - ConfigurationTextBox( - value = peer.endpoint, - onValueChange = { value -> - viewModel.onEndpointChange(index, value) - }, - keyboardActions = keyboardActions, - label = stringResource(R.string.endpoint), - hint = stringResource(R.string.endpoint).lowercase(), - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = peer.allowedIps, - enabled = true, - onValueChange = { value -> - viewModel.onAllowedIpsChange(index, value) - }, - label = { Text(stringResource(R.string.allowed_ips)) }, - singleLine = true, - placeholder = { - Text(stringResource(R.string.comma_separated_list)) - }, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - ) - } - } - } - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxSize() - .padding(bottom = 140.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - TextButton(onClick = { viewModel.addEmptyPeer() }) { - Text(stringResource(R.string.add_peer)) - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigUiState.kt deleted file mode 100644 index 3c571a5..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigUiState.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.config - -import com.wireguard.config.Config -import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig -import com.zaneschepke.wireguardautotunnel.ui.screens.config.model.InterfaceProxy -import com.zaneschepke.wireguardautotunnel.ui.screens.config.model.PeerProxy -import com.zaneschepke.wireguardautotunnel.util.extensions.Packages - -data class ConfigUiState( - val proxyPeers: List = arrayListOf(PeerProxy()), - val interfaceProxy: InterfaceProxy = InterfaceProxy(), - val packages: Packages = emptyList(), - val checkedPackageNames: List = emptyList(), - val include: Boolean = true, - val isAllApplicationsEnabled: Boolean = false, - val loading: Boolean = true, - val tunnel: TunnelConfig? = null, - var tunnelName: String = "", - val isAmneziaEnabled: Boolean = false, -) { - companion object { - fun from(config: Config): ConfigUiState { - val proxyPeers = config.peers.map { PeerProxy.from(it) } - val proxyInterface = InterfaceProxy.from(config.`interface`) - var include = true - var isAllApplicationsEnabled = false - val checkedPackages = - if (config.`interface`.includedApplications.isNotEmpty()) { - config.`interface`.includedApplications - } else if (config.`interface`.excludedApplications.isNotEmpty()) { - include = false - config.`interface`.excludedApplications - } else { - isAllApplicationsEnabled = true - emptySet() - } - return ConfigUiState( - proxyPeers, - proxyInterface, - emptyList(), - checkedPackages.toList(), - include, - isAllApplicationsEnabled, - ) - } - - fun from(config: org.amnezia.awg.config.Config): ConfigUiState { - val proxyPeers = config.peers.map { PeerProxy.from(it) } - val proxyInterface = InterfaceProxy.from(config.`interface`) - var include = true - var isAllApplicationsEnabled = false - val checkedPackages = - if (config.`interface`.includedApplications.isNotEmpty()) { - config.`interface`.includedApplications - } else if (config.`interface`.excludedApplications.isNotEmpty()) { - include = false - config.`interface`.excludedApplications - } else { - isAllApplicationsEnabled = true - emptySet() - } - return ConfigUiState( - proxyPeers, - proxyInterface, - emptyList(), - checkedPackages.toList(), - include, - isAllApplicationsEnabled, - ) - } - - fun from(tunnel: TunnelConfig): ConfigUiState { - val config = tunnel.toAmConfig() - return from(config).copy( - tunnelName = tunnel.name, - tunnel = tunnel, - isAmneziaEnabled = config.`interface`.junkPacketCount.isPresent, - ) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt deleted file mode 100644 index 6f91a6b..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt +++ /dev/null @@ -1,591 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.config - -import android.Manifest -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.os.Build -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.wireguard.config.Config -import com.wireguard.config.Interface -import com.wireguard.config.Peer -import com.wireguard.crypto.Key -import com.wireguard.crypto.KeyPair -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel -import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig -import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository -import com.zaneschepke.wireguardautotunnel.module.IoDispatcher -import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController -import com.zaneschepke.wireguardautotunnel.ui.screens.config.model.PeerProxy -import com.zaneschepke.wireguardautotunnel.util.Constants -import com.zaneschepke.wireguardautotunnel.util.NumberUtils -import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.util.extensions.removeAt -import com.zaneschepke.wireguardautotunnel.util.extensions.update -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import timber.log.Timber - -@HiltViewModel(assistedFactory = ConfigViewModel.ConfigViewModelFactory::class) -class ConfigViewModel -@AssistedInject -constructor( - private val appDataRepository: AppDataRepository, - @Assisted val id: Int, - @IoDispatcher private val ioDispatcher: CoroutineDispatcher, -) : ViewModel() { - private val packageManager = WireGuardAutoTunnel.instance.packageManager - - private val _saved = MutableSharedFlow() - val saved = _saved.asSharedFlow() - - private val _uiState = MutableStateFlow(ConfigUiState()) - val uiState = _uiState.onStart { - appDataRepository.tunnels.getById(id)?.let { - val packages = getQueriedPackages() - _uiState.value = ConfigUiState.from(it).copy( - packages = packages, - ) - } - }.stateIn( - viewModelScope + ioDispatcher, - SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), - ConfigUiState(), - ) - - fun onTunnelNameChange(name: String) { - _uiState.update { - it.copy(tunnelName = name) - } - } - - fun onIncludeChange(include: Boolean) { - _uiState.update { - it.copy(include = include) - } - } - - fun cleanUpUninstalledApps() = viewModelScope.launch(ioDispatcher) { - uiState.value.tunnel?.let { - val config = it.toAmConfig() - val packages = getQueriedPackages() - val packageSet = packages.map { pack -> pack.packageName }.toSet() - val includedApps = config.`interface`.includedApplications.toMutableList() - val excludedApps = config.`interface`.excludedApplications.toMutableList() - if (includedApps.isEmpty() && excludedApps.isEmpty()) return@launch - if (includedApps.retainAll(packageSet) || excludedApps.retainAll(packageSet)) { - Timber.i("Removing split tunnel package name that no longer exists on the device") - _uiState.update { state -> - state.copy( - checkedPackageNames = if (_uiState.value.include) includedApps else excludedApps, - ) - } - val wgQuick = buildConfig().toWgQuickString(true) - val amQuick = buildAmConfig().toAwgQuickString(true) - saveConfig( - it.copy( - amQuick = amQuick, - wgQuick = wgQuick, - ), - ) - } - } - } - - fun onAddCheckedPackage(packageName: String) { - _uiState.update { - it.copy( - checkedPackageNames = it.checkedPackageNames + packageName, - ) - } - } - - fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) { - _uiState.update { - it.copy(isAllApplicationsEnabled = isAllApplicationsEnabled) - } - } - - fun onRemoveCheckedPackage(packageName: String) { - _uiState.update { - it.copy( - checkedPackageNames = it.checkedPackageNames - packageName, - ) - } - } - - private fun getQueriedPackages(query: String = ""): List { - return getAllInternetCapablePackages().filter { - getPackageLabel(it).lowercase().contains(query.lowercase()) - } - } - - fun getPackageLabel(packageInfo: PackageInfo): String { - return packageInfo.applicationInfo?.loadLabel(packageManager).toString() - } - - private fun getAllInternetCapablePackages(): List { - return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET)) - } - - private fun getPackagesHoldingPermissions(permissions: Array): List { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getPackagesHoldingPermissions( - permissions, - PackageManager.PackageInfoFlags.of(0L), - ) - } else { - packageManager.getPackagesHoldingPermissions(permissions, 0) - } - } - - private fun isAllApplicationsEnabled(): Boolean { - return _uiState.value.isAllApplicationsEnabled - } - - private fun saveConfig(tunnelConfig: TunnelConfig) = viewModelScope.launch { - appDataRepository.tunnels.save(tunnelConfig) - } - - private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) = viewModelScope.launch { - if (tunnelConfig != null) { - saveConfig(tunnelConfig).join() - } - } - - private fun buildPeerListFromProxyPeers(): List { - return _uiState.value.proxyPeers.map { - val builder = Peer.Builder() - if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim()) - if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim()) - if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim()) - if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim()) - if (it.persistentKeepalive.isNotEmpty()) { - builder.parsePersistentKeepalive(it.persistentKeepalive.trim()) - } - builder.build() - } - } - - private fun buildAmPeerListFromProxyPeers(): List { - return _uiState.value.proxyPeers.map { - val builder = org.amnezia.awg.config.Peer.Builder() - if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim()) - if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim()) - if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim()) - if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim()) - if (it.persistentKeepalive.isNotEmpty()) { - builder.parsePersistentKeepalive(it.persistentKeepalive.trim()) - } - builder.build() - } - } - - private fun emptyCheckedPackagesList() { - _uiState.update { - it.copy(checkedPackageNames = emptyList()) - } - } - - private fun buildInterfaceListFromProxyInterface(): Interface { - val builder = Interface.Builder() - with(_uiState.value.interfaceProxy) { - builder.parsePrivateKey(this.privateKey.trim()) - builder.parseAddresses(this.addresses.trim()) - if (this.dnsServers.isNotEmpty()) { - builder.parseDnsServers(this.dnsServers.trim()) - } - if (this.mtu.isNotEmpty()) { - builder.parseMtu(this.mtu.trim()) - } - if (this.listenPort.isNotEmpty()) { - builder.parseListenPort(this.listenPort.trim()) - } - if (isAllApplicationsEnabled()) emptyCheckedPackagesList() - if (_uiState.value.include) { - builder.includeApplications( - _uiState.value.checkedPackageNames, - ) - } - if (!_uiState.value.include) { - builder.excludeApplications( - _uiState.value.checkedPackageNames, - ) - } - } - - return builder.build() - } - - private fun buildAmInterfaceListFromProxyInterface(): org.amnezia.awg.config.Interface { - val builder = org.amnezia.awg.config.Interface.Builder() - with(_uiState.value.interfaceProxy) { - builder.parsePrivateKey(this.privateKey.trim()) - builder.parseAddresses(this.addresses.trim()) - if (this.dnsServers.isNotEmpty()) { - builder.parseDnsServers(this.dnsServers.trim()) - } - if (this.mtu.isNotEmpty()) { - builder.parseMtu(this.mtu.trim()) - } - if (this.listenPort.isNotEmpty()) { - builder.parseListenPort(this.listenPort.trim()) - } - if (isAllApplicationsEnabled()) emptyCheckedPackagesList() - if (_uiState.value.include) { - builder.includeApplications( - _uiState.value.checkedPackageNames, - ) - } - if (!_uiState.value.include) { - builder.excludeApplications( - _uiState.value.checkedPackageNames, - ) - } - if (this.junkPacketCount.isNotEmpty()) { - builder.setJunkPacketCount( - this.junkPacketCount.trim().toInt(), - ) - } - if (this.junkPacketMinSize.isNotEmpty()) { - builder.setJunkPacketMinSize( - this.junkPacketMinSize.trim().toInt(), - ) - } - if (this.junkPacketMaxSize.isNotEmpty()) { - builder.setJunkPacketMaxSize( - this.junkPacketMaxSize.trim().toInt(), - ) - } - if (this.initPacketJunkSize.isNotEmpty()) { - builder.setInitPacketJunkSize( - this.initPacketJunkSize.trim().toInt(), - ) - } - if (this.responsePacketJunkSize.isNotEmpty()) { - builder.setResponsePacketJunkSize( - this.responsePacketJunkSize.trim().toInt(), - ) - } - if (this.initPacketMagicHeader.isNotEmpty()) { - builder.setInitPacketMagicHeader( - this.initPacketMagicHeader.trim().toLong(), - ) - } - if (this.responsePacketMagicHeader.isNotEmpty()) { - builder.setResponsePacketMagicHeader( - this.responsePacketMagicHeader.trim().toLong(), - ) - } - if (this.transportPacketMagicHeader.isNotEmpty()) { - builder.setTransportPacketMagicHeader( - this.transportPacketMagicHeader.trim().toLong(), - ) - } - if (this.underloadPacketMagicHeader.isNotEmpty()) { - builder.setUnderloadPacketMagicHeader( - this.underloadPacketMagicHeader.trim().toLong(), - ) - } - } - - return builder.build() - } - - private fun buildConfig(): Config { - val peerList = buildPeerListFromProxyPeers() - val wgInterface = buildInterfaceListFromProxyInterface() - return Config.Builder().addPeers(peerList).setInterface(wgInterface).build() - } - - private fun buildAmConfig(): org.amnezia.awg.config.Config { - val peerList = buildAmPeerListFromProxyPeers() - val amInterface = buildAmInterfaceListFromProxyInterface() - return org.amnezia.awg.config.Config.Builder().addPeers( - peerList, - ).setInterface(amInterface) - .build() - } - - fun onSaveAllChanges() = viewModelScope.launch { - kotlin.runCatching { - val wgQuick = buildConfig().toWgQuickString(true) - val amQuick = buildAmConfig().toAwgQuickString(true) - val tunnelConfig = uiState.value.tunnel?.copy( - name = _uiState.value.tunnelName, - amQuick = amQuick, - wgQuick = wgQuick, - ) ?: TunnelConfig( - name = _uiState.value.tunnelName, - wgQuick = wgQuick, - amQuick = amQuick, - ) - updateTunnelConfig(tunnelConfig) - SnackbarController.showMessage( - StringValue.StringResource(R.string.config_changes_saved), - ) - _saved.emit(true) - }.onFailure { - Timber.e(it) - val message = it.message?.substringAfter(":", missingDelimiterValue = "") - val stringValue = if (message.isNullOrBlank()) { - StringValue.StringResource(R.string.unknown_error) - } else { - StringValue.DynamicString(message) - } - SnackbarController.showMessage(stringValue) - } - } - - fun onPeerPublicKeyChange(index: Int, value: String) { - _uiState.update { - it.copy( - proxyPeers = - _uiState.value.proxyPeers.update( - index, - _uiState.value.proxyPeers[index].copy(publicKey = value), - ), - ) - } - } - - fun onPreSharedKeyChange(index: Int, value: String) { - _uiState.update { - it.copy( - proxyPeers = - _uiState.value.proxyPeers.update( - index, - _uiState.value.proxyPeers[index].copy(preSharedKey = value), - ), - ) - } - } - - fun onEndpointChange(index: Int, value: String) { - _uiState.update { - it.copy( - proxyPeers = - _uiState.value.proxyPeers.update( - index, - _uiState.value.proxyPeers[index].copy(endpoint = value), - ), - ) - } - } - - fun onAllowedIpsChange(index: Int, value: String) { - _uiState.update { - it.copy( - proxyPeers = - _uiState.value.proxyPeers.update( - index, - _uiState.value.proxyPeers[index].copy(allowedIps = value), - ), - ) - } - } - - fun onPersistentKeepaliveChanged(index: Int, value: String) { - _uiState.update { - it.copy( - proxyPeers = - _uiState.value.proxyPeers.update( - index, - _uiState.value.proxyPeers[index].copy(persistentKeepalive = value), - ), - ) - } - } - - fun onDeletePeer(index: Int) { - _uiState.update { - it.copy( - proxyPeers = _uiState.value.proxyPeers.removeAt(index), - ) - } - } - - fun addEmptyPeer() { - _uiState.update { - it.copy(proxyPeers = _uiState.value.proxyPeers + PeerProxy()) - } - } - - fun generateKeyPair() { - val keyPair = KeyPair() - _uiState.update { - it.copy( - interfaceProxy = - it.interfaceProxy.copy( - privateKey = keyPair.privateKey.toBase64(), - publicKey = keyPair.publicKey.toBase64(), - ), - ) - } - } - - fun onAddressesChanged(value: String) { - _uiState.update { - it.copy( - interfaceProxy = it.interfaceProxy.copy(addresses = value), - ) - } - } - - fun onListenPortChanged(value: String) { - _uiState.update { - it.copy( - interfaceProxy = it.interfaceProxy.copy(listenPort = value), - ) - } - } - - fun onDnsServersChanged(value: String) { - _uiState.update { - it.copy( - interfaceProxy = it.interfaceProxy.copy(dnsServers = value), - ) - } - } - - fun onMtuChanged(value: String) { - _uiState.update { - it.copy(interfaceProxy = it.interfaceProxy.copy(mtu = value)) - } - } - - private fun onInterfacePublicKeyChange(value: String) { - _uiState.update { - it.copy( - interfaceProxy = it.interfaceProxy.copy(publicKey = value), - ) - } - } - - fun onPrivateKeyChange(value: String) { - _uiState.update { - it.copy( - interfaceProxy = it.interfaceProxy.copy(privateKey = value), - ) - } - if (NumberUtils.isValidKey(value)) { - val pair = KeyPair(Key.fromBase64(value)) - onInterfacePublicKeyChange(pair.publicKey.toBase64()) - } else { - onInterfacePublicKeyChange("") - } - } - - fun emitQueriedPackages(query: String) { - val packages = - getAllInternetCapablePackages().filter { - getPackageLabel(it).lowercase().contains(query.lowercase()) - } - _uiState.update { it.copy(packages = packages) } - } - - fun onJunkPacketCountChanged(value: String) { - _uiState.update { - it.copy( - interfaceProxy = it.interfaceProxy.copy(junkPacketCount = value), - ) - } - } - - fun onJunkPacketMinSizeChanged(value: String) { - _uiState.update { - it.copy( - interfaceProxy = it.interfaceProxy.copy(junkPacketMinSize = value), - ) - } - } - - fun onJunkPacketMaxSizeChanged(value: String) { - _uiState.update { - it.copy( - interfaceProxy = it.interfaceProxy.copy(junkPacketMaxSize = value), - ) - } - } - - fun onInitPacketJunkSizeChanged(value: String) { - _uiState.update { - it.copy( - interfaceProxy = it.interfaceProxy.copy(initPacketJunkSize = value), - ) - } - } - - fun onResponsePacketJunkSize(value: String) { - _uiState.update { - it.copy( - interfaceProxy = - it.interfaceProxy.copy( - responsePacketJunkSize = value, - ), - ) - } - } - - fun onInitPacketMagicHeader(value: String) { - _uiState.update { - it.copy( - interfaceProxy = - it.interfaceProxy.copy( - initPacketMagicHeader = value, - ), - ) - } - } - - fun onResponsePacketMagicHeader(value: String) { - _uiState.update { - it.copy( - interfaceProxy = - it.interfaceProxy.copy( - responsePacketMagicHeader = value, - ), - ) - } - } - - fun onTransportPacketMagicHeader(value: String) { - _uiState.update { - it.copy( - interfaceProxy = - it.interfaceProxy.copy( - transportPacketMagicHeader = value, - ), - ) - } - } - - fun onUnderloadPacketMagicHeader(value: String) { - _uiState.update { - it.copy( - interfaceProxy = - it.interfaceProxy.copy( - underloadPacketMagicHeader = value, - ), - ) - } - } - - @AssistedFactory - interface ConfigViewModelFactory { - fun create(id: Int): ConfigViewModel - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/components/ApplicationSelectionDialog.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/components/ApplicationSelectionDialog.kt deleted file mode 100644 index 020c716..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/components/ApplicationSelectionDialog.kt +++ /dev/null @@ -1,199 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.config.components - -import android.content.pm.PackageInfo -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -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.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Android -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Checkbox -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.google.accompanist.drawablepainter.DrawablePainter -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar -import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigUiState -import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigViewModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ApplicationSelectionDialog(viewModel: ConfigViewModel, uiState: ConfigUiState, onDismiss: () -> Unit) { - val context = LocalContext.current - val licenseComparator = compareBy { viewModel.getPackageLabel(it) } - - val sortedPackages = remember(uiState.packages, licenseComparator) { - uiState.packages.sortedWith(licenseComparator) - } - BasicAlertDialog( - onDismissRequest = { onDismiss() }, - ) { - Surface( - tonalElevation = 2.dp, - shadowElevation = 2.dp, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surface, - modifier = - Modifier - .fillMaxWidth() - .fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f), - ) { - Column( - modifier = - Modifier - .fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(stringResource(id = R.string.tunnel_all)) - Switch( - checked = uiState.isAllApplicationsEnabled, - onCheckedChange = viewModel::onAllApplicationsChange, - ) - } - if (!uiState.isAllApplicationsEnabled) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(stringResource(id = R.string.include)) - Checkbox( - checked = uiState.include, - onCheckedChange = { - viewModel.onIncludeChange(!uiState.include) - }, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(stringResource(id = R.string.exclude)) - Checkbox( - checked = !uiState.include, - onCheckedChange = { - viewModel.onIncludeChange(!uiState.include) - }, - ) - } - } - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - SearchBar(viewModel::emitQueriedPackages) - } - Spacer(Modifier.padding(5.dp)) - LazyColumn( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = Modifier.fillMaxHeight(19 / 22f), - ) { - items(sortedPackages, key = { it.packageName }) { pack -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = - Modifier - .fillMaxSize() - .padding(5.dp).padding(end = 25.dp), - ) { - Row(modifier = Modifier.fillMaxWidth().padding(start = 5.dp)) { - val drawable = - pack.applicationInfo?.loadIcon(context.packageManager) - val iconSize = 35.dp - if (drawable != null) { - Image( - painter = DrawablePainter(drawable), - stringResource(id = R.string.icon), - modifier = Modifier.size(iconSize), - ) - } else { - val icon = Icons.Rounded.Android - Icon( - icon, - icon.name, - modifier = Modifier.size(iconSize), - ) - } - Text( - viewModel.getPackageLabel(pack), - modifier = Modifier.padding(5.dp), - ) - } - Checkbox( - modifier = Modifier.fillMaxSize(), - checked = - ( - uiState.checkedPackageNames.contains( - pack.packageName, - ) - ), - onCheckedChange = { - if (it) { - viewModel.onAddCheckedPackage(pack.packageName) - } else { - viewModel.onRemoveCheckedPackage(pack.packageName) - } - }, - ) - } - } - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxSize() - .padding(top = 5.dp), - horizontalArrangement = Arrangement.Center, - ) { - TextButton(onClick = { onDismiss() }) { - Text(stringResource(R.string.done)) - } - } - } - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/model/InterfaceProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/model/InterfaceProxy.kt deleted file mode 100644 index 87aeb67..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/model/InterfaceProxy.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.config.model - -import com.wireguard.config.Interface - -data class InterfaceProxy( - val privateKey: String = "", - val publicKey: String = "", - val addresses: String = "", - val dnsServers: String = "", - val listenPort: String = "", - val mtu: String = "", - val junkPacketCount: String = "", - val junkPacketMinSize: String = "", - val junkPacketMaxSize: String = "", - val initPacketJunkSize: String = "", - val responsePacketJunkSize: String = "", - val initPacketMagicHeader: String = "", - val responsePacketMagicHeader: String = "", - val underloadPacketMagicHeader: String = "", - val transportPacketMagicHeader: String = "", -) { - companion object { - fun from(i: Interface): InterfaceProxy { - return InterfaceProxy( - publicKey = i.keyPair.publicKey.toBase64().trim(), - privateKey = i.keyPair.privateKey.toBase64().trim(), - addresses = i.addresses.joinToString(", ").trim(), - dnsServers = listOf( - i.dnsServers.joinToString(", ").replace("/", "").trim(), - i.dnsSearchDomains.joinToString(", ").trim(), - ).filter { it.length > 0 }.joinToString(", "), - listenPort = - if (i.listenPort.isPresent) { - i.listenPort.get().toString().trim() - } else { - "" - }, - mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "", - ) - } - - fun from(i: org.amnezia.awg.config.Interface): InterfaceProxy { - return InterfaceProxy( - publicKey = i.keyPair.publicKey.toBase64().trim(), - privateKey = i.keyPair.privateKey.toBase64().trim(), - addresses = i.addresses.joinToString(", ").trim(), - dnsServers = (i.dnsServers + i.dnsSearchDomains).joinToString(", ").replace("/", "").trim(), - listenPort = - if (i.listenPort.isPresent) { - i.listenPort.get().toString().trim() - } else { - "" - }, - mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "", - junkPacketCount = - if (i.junkPacketCount.isPresent) { - i.junkPacketCount.get() - .toString() - } else { - "" - }, - junkPacketMinSize = - if (i.junkPacketMinSize.isPresent) { - i.junkPacketMinSize.get() - .toString() - } else { - "" - }, - junkPacketMaxSize = - if (i.junkPacketMaxSize.isPresent) { - i.junkPacketMaxSize.get() - .toString() - } else { - "" - }, - initPacketJunkSize = - if (i.initPacketJunkSize.isPresent) { - i.initPacketJunkSize.get() - .toString() - } else { - "" - }, - responsePacketJunkSize = - if (i.responsePacketJunkSize.isPresent) { - i.responsePacketJunkSize.get() - .toString() - } else { - "" - }, - initPacketMagicHeader = - if (i.initPacketMagicHeader.isPresent) { - i.initPacketMagicHeader.get() - .toString() - } else { - "" - }, - responsePacketMagicHeader = - if (i.responsePacketMagicHeader.isPresent) { - i.responsePacketMagicHeader.get() - .toString() - } else { - "" - }, - transportPacketMagicHeader = - if (i.transportPacketMagicHeader.isPresent) { - i.transportPacketMagicHeader.get() - .toString() - } else { - "" - }, - underloadPacketMagicHeader = - if (i.underloadPacketMagicHeader.isPresent) { - i.underloadPacketMagicHeader.get() - .toString() - } else { - "" - }, - ) - } - } -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt index 678c2c3..40ee66e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.os.ConfigurationCompat import androidx.hilt.navigation.compose.hiltViewModel import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig @@ -59,6 +58,7 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight import java.text.Collator +import java.util.Locale @OptIn(ExperimentalFoundationApi::class) @Composable @@ -74,8 +74,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState) var selectedTunnel by remember { mutableStateOf(null) } val isRunningOnTv = remember { context.isRunningOnTv() } - val currentLocale = ConfigurationCompat.getLocales(context.resources.configuration)[0] - val collator = Collator.getInstance(currentLocale) + val collator = Collator.getInstance(Locale.getDefault()) val sortedTunnels = remember(uiState.tunnels) { uiState.tunnels.sortedWith(compareBy(collator) { it.name }) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelRowItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelRowItem.kt index 741cb08..aca7ef4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelRowItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/components/TunnelRowItem.kt @@ -95,7 +95,7 @@ fun TunnelRowItem( IconButton( onClick = { navController.navigate( - Route.Option(tunnel.id), + Route.TunnelOptions(tunnel.id), ) }, ) { @@ -128,7 +128,7 @@ fun TunnelRowItem( onClick = { onHold() navController.navigate( - Route.Option(tunnel.id), + Route.TunnelOptions(tunnel.id), ) }, ) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/TunnelOptionsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/TunnelOptionsScreen.kt new file mode 100644 index 0000000..60c77f9 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/TunnelOptionsScreen.kt @@ -0,0 +1,146 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions + +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.CallSplit +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.AppUiState +import com.zaneschepke.wireguardautotunnel.ui.Route +import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem +import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton +import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar +import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth + +@Composable +fun OptionsScreen(tunnelOptionsViewModel: TunnelOptionsViewModel = hiltViewModel(), appUiState: AppUiState, tunnelId: Int) { + val navController = LocalNavController.current + val config = remember { appUiState.tunnels.first { it.id == tunnelId } } + + var currentText by remember { mutableStateOf("") } + + LaunchedEffect(config.tunnelNetworks) { + currentText = "" + } + Scaffold( + topBar = { + TopNavBar(config.name) + }, + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top), + modifier = + Modifier + .fillMaxSize() + .padding(it) + .verticalScroll(rememberScrollState()) + .padding(top = 24.dp.scaledHeight()) + .padding(horizontal = 24.dp.scaledWidth()), + ) { + SurfaceSelectionGroupButton( + listOf( + SelectionItem( + Icons.Outlined.Star, + title = { + Text( + stringResource(R.string.primary_tunnel), + style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), + ) + }, + description = { + Text( + stringResource(R.string.set_primary_tunnel), + style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline), + ) + }, + trailing = { + ScaledSwitch( + config.isPrimaryTunnel, + onClick = { tunnelOptionsViewModel.onTogglePrimaryTunnel(config) }, + ) + }, + onClick = { tunnelOptionsViewModel.onTogglePrimaryTunnel(config) }, + ), + SelectionItem( + Icons.Outlined.Bolt, + title = { + Text( + stringResource(R.string.auto_tunneling), + style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), + ) + }, + description = { + Text( + stringResource(R.string.tunnel_specific_settings), + style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline), + ) + }, + onClick = { + navController.navigate(Route.TunnelAutoTunnel(id = tunnelId)) + }, + trailing = { + ForwardButton { navController.navigate(Route.TunnelAutoTunnel(id = tunnelId)) } + }, + ), + SelectionItem( + Icons.Outlined.Edit, + title = { + Text( + stringResource(R.string.edit_tunnel), + style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), + ) + }, + onClick = { + navController.navigate(Route.Config(id = tunnelId)) + }, + trailing = { + ForwardButton { navController.navigate(Route.Config(id = tunnelId)) } + }, + ), + SelectionItem( + Icons.AutoMirrored.Outlined.CallSplit, + title = { + Text( + stringResource(R.string.splt_tunneling), + style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), + ) + }, + onClick = { + navController.navigate(Route.SplitTunnel(id = tunnelId)) + }, + trailing = { + ForwardButton { navController.navigate(Route.SplitTunnel(id = tunnelId)) } + }, + ), + ), + ) +// GroupLabel(stringResource(R.string.quick_actions)) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/TunnelOptionsViewModel.kt similarity index 96% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsViewModel.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/TunnelOptionsViewModel.kt index e1bfaa5..6fecade 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/TunnelOptionsViewModel.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.options +package com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -12,7 +12,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class OptionsViewModel +class TunnelOptionsViewModel @Inject constructor( private val appDataRepository: AppDataRepository, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/config/ConfigScreen.kt new file mode 100644 index 0000000..0683a8f --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/config/ConfigScreen.kt @@ -0,0 +1,662 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.config + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +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.outlined.Save +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +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.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.AppUiState +import com.zaneschepke.wireguardautotunnel.ui.AppViewModel +import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox +import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle +import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel +import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar +import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController +import com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.config.model.InterfaceProxy +import com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.config.model.PeerProxy +import com.zaneschepke.wireguardautotunnel.util.Constants +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth +import kotlinx.coroutines.launch +import org.amnezia.awg.crypto.KeyPair + +@Composable +fun ConfigScreen(appUiState: AppUiState, appViewModel: AppViewModel, tunnelId: Int) { + val context = LocalContext.current + val snackbar = SnackbarController.current + val clipboardManager: ClipboardManager = LocalClipboardManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + + val popBackStack by appViewModel.popBackStack.collectAsStateWithLifecycle(false) + + val tunnelConfig by remember { + derivedStateOf { + appUiState.tunnels.first { it.id == tunnelId } + } + } + + val configPair by remember { + derivedStateOf { + Pair(tunnelConfig.name, tunnelConfig.toAmConfig()) + } + } + + var tunnelName by remember { + mutableStateOf(configPair.first) + } + + var interfaceState by remember { + mutableStateOf(InterfaceProxy.from(configPair.second.`interface`)) + } + + var showAmneziaValues by remember { + mutableStateOf(configPair.second.`interface`.junkPacketCount.isPresent) + } + + var showScripts by remember { + mutableStateOf(false) + } + + val peersState = remember { + configPair.second.peers.map { PeerProxy.from(it) }.toMutableStateList() + } + + var showAuthPrompt by remember { mutableStateOf(false) } + var isAuthenticated by remember { mutableStateOf(false) } + + val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) + val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + + if (showAuthPrompt) { + AuthorizationPrompt( + onSuccess = { + showAuthPrompt = false + isAuthenticated = true + }, + onError = { + showAuthPrompt = false + snackbar.showMessage( + context.getString(R.string.error_authentication_failed), + ) + }, + onFailure = { + showAuthPrompt = false + snackbar.showMessage( + context.getString(R.string.error_authorization_failed), + ) + }, + ) + } + + LaunchedEffect(popBackStack) { + if (popBackStack) navController.popBackStack() + } + + Scaffold( + topBar = { + TopNavBar(stringResource(R.string.edit_tunnel), trailing = { + IconButton(onClick = { + appViewModel.saveConfigChanges( + tunnelConfig.copy( + name = tunnelName, + ), + peers = peersState, + `interface` = interfaceState, + ) + }) { + val icon = Icons.Outlined.Save + Icon( + imageVector = icon, + contentDescription = icon.name, + ) + } + }) + }, + ) { padding -> + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top), + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(top = 24.dp.scaledHeight()) + .padding(horizontal = 24.dp.scaledWidth()), + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top), + modifier = Modifier + .padding(16.dp.scaledWidth()) + .focusGroup(), + ) { + GroupLabel( + stringResource(R.string.interface_), + ) + ConfigurationToggle( + stringResource(id = R.string.show_amnezia_properties), + checked = showAmneziaValues, + onCheckChanged = { + if (appUiState.settings.isKernelEnabled) { + snackbar.showMessage(context.getString(R.string.amnezia_kernel_message)) + } else { + showAmneziaValues = it + } + }, + ) + ConfigurationToggle( + stringResource(id = R.string.show_scripts), + checked = showScripts, + onCheckChanged = { checked -> + if (appUiState.settings.isKernelEnabled) { + showScripts = checked + } else { + scope.launch { + appViewModel.requestRoot().onSuccess { + showScripts = checked + } + } + } + }, + ) + ConfigurationTextBox( + value = tunnelName, + onValueChange = { tunnelName = it }, + keyboardActions = keyboardActions, + label = stringResource(R.string.name), + hint = stringResource(R.string.tunnel_name).lowercase(), + modifier = + Modifier + .fillMaxWidth(), + ) + OutlinedTextField( + textStyle = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .fillMaxWidth() + .clickable { showAuthPrompt = true }, + value = interfaceState.privateKey, + visualTransformation = + if ((tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + enabled = (tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated, + onValueChange = { interfaceState = interfaceState.copy(privateKey = it) }, + trailingIcon = { + IconButton( + enabled = isAuthenticated, + modifier = Modifier.focusRequester(FocusRequester.Default), + onClick = { + val keypair = KeyPair() + interfaceState = interfaceState.copy( + privateKey = keypair.privateKey.toBase64(), + publicKey = keypair.publicKey.toBase64(), + ) + }, + ) { + Icon( + Icons.Rounded.Refresh, + stringResource(R.string.rotate_keys), + tint = if (isAuthenticated) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline, + ) + } + }, + label = { Text(stringResource(R.string.private_key)) }, + singleLine = true, + placeholder = { + Text( + stringResource(R.string.base64_key), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.outline, + ) + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + OutlinedTextField( + textStyle = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(FocusRequester.Default), + value = interfaceState.publicKey, + enabled = false, + onValueChange = { + interfaceState = interfaceState.copy(publicKey = it) + }, + trailingIcon = { + IconButton( + modifier = Modifier.focusRequester(FocusRequester.Default), + onClick = { + clipboardManager.setText( + AnnotatedString(interfaceState.publicKey), + ) + }, + ) { + Icon( + Icons.Rounded.ContentCopy, + stringResource(R.string.copy_public_key), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + label = { Text(stringResource(R.string.public_key)) }, + singleLine = true, + placeholder = { + Text( + stringResource(R.string.base64_key), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.outline, + ) + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + ConfigurationTextBox( + value = interfaceState.addresses, + onValueChange = { + interfaceState = interfaceState.copy(addresses = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.addresses), + hint = stringResource(R.string.comma_separated_list), + modifier = + Modifier + .fillMaxWidth() + .padding(end = 5.dp), + ) + ConfigurationTextBox( + value = interfaceState.listenPort, + onValueChange = { + interfaceState = interfaceState.copy(listenPort = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.listen_port), + hint = stringResource(R.string.random), + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + ConfigurationTextBox( + value = interfaceState.dnsServers, + onValueChange = { + interfaceState = interfaceState.copy(dnsServers = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.dns_servers), + hint = stringResource(R.string.comma_separated_list), + modifier = + Modifier + .fillMaxWidth(3 / 5f) + .padding(end = 5.dp), + ) + ConfigurationTextBox( + value = interfaceState.mtu, + onValueChange = { + interfaceState = interfaceState.copy(mtu = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.mtu), + hint = stringResource(R.string.auto), + modifier = Modifier.width(IntrinsicSize.Min), + ) + } + if (showScripts) { + ConfigurationTextBox( + value = interfaceState.preUp, + onValueChange = { + interfaceState = interfaceState.copy(preUp = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.pre_up), + hint = stringResource(R.string.comma_separated_list).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.postUp, + onValueChange = { + interfaceState = interfaceState.copy(postUp = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.post_up), + hint = stringResource(R.string.comma_separated_list).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.preDown, + onValueChange = { + interfaceState = interfaceState.copy(preDown = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.pre_down), + hint = stringResource(R.string.comma_separated_list).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.postDown, + onValueChange = { + interfaceState = interfaceState.copy(postDown = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.post_down), + hint = stringResource(R.string.comma_separated_list).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + } + if (showAmneziaValues) { + ConfigurationTextBox( + value = interfaceState.junkPacketCount, + onValueChange = { + interfaceState = interfaceState.copy(junkPacketCount = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.junk_packet_count), + hint = stringResource(R.string.junk_packet_count).lowercase(), + modifier = + Modifier + .fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.junkPacketMinSize, + onValueChange = { + interfaceState = interfaceState.copy(junkPacketMinSize = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.junk_packet_minimum_size), + hint = + stringResource( + R.string.junk_packet_minimum_size, + ).lowercase(), + modifier = + Modifier + .fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.junkPacketMaxSize, + onValueChange = { + interfaceState = interfaceState.copy(junkPacketMaxSize = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.junk_packet_maximum_size), + hint = + stringResource( + R.string.junk_packet_maximum_size, + ).lowercase(), + modifier = + Modifier + .fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.initPacketJunkSize, + onValueChange = { + interfaceState = interfaceState.copy(initPacketJunkSize = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.init_packet_junk_size), + hint = stringResource(R.string.init_packet_junk_size).lowercase(), + modifier = + Modifier + .fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.responsePacketJunkSize, + onValueChange = { + interfaceState = interfaceState.copy(responsePacketJunkSize = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.response_packet_junk_size), + hint = + stringResource( + R.string.response_packet_junk_size, + ).lowercase(), + modifier = + Modifier + .fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.initPacketMagicHeader, + onValueChange = { + interfaceState = interfaceState.copy(initPacketMagicHeader = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.init_packet_magic_header), + hint = + stringResource( + R.string.init_packet_magic_header, + ).lowercase(), + modifier = + Modifier + .fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.responsePacketMagicHeader, + onValueChange = { + interfaceState = interfaceState.copy(responsePacketMagicHeader = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.response_packet_magic_header), + hint = + stringResource( + R.string.response_packet_magic_header, + ).lowercase(), + modifier = + Modifier + .fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.underloadPacketMagicHeader, + onValueChange = { + interfaceState = interfaceState.copy(underloadPacketMagicHeader = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.underload_packet_magic_header), + hint = + stringResource( + R.string.underload_packet_magic_header, + ).lowercase(), + modifier = + Modifier + .fillMaxWidth(), + ) + ConfigurationTextBox( + value = interfaceState.transportPacketMagicHeader, + onValueChange = { + interfaceState = interfaceState.copy(transportPacketMagicHeader = it) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.transport_packet_magic_header), + hint = + stringResource( + R.string.transport_packet_magic_header, + ).lowercase(), + modifier = + Modifier + .fillMaxWidth(), + ) + } + } + } + peersState.forEachIndexed { index, peer -> + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top), + modifier = Modifier + .padding(16.dp.scaledWidth()) + .focusGroup(), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth(), + ) { + GroupLabel( + stringResource(R.string.peer), + ) + IconButton(onClick = { + peersState.removeAt(index) + }) { + val icon = Icons.Rounded.Delete + Icon(icon, icon.name) + } + } + + ConfigurationTextBox( + value = peer.publicKey, + onValueChange = { value -> + peersState[index] = peersState[index].copy(publicKey = value) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.public_key), + hint = stringResource(R.string.base64_key), + modifier = Modifier.fillMaxWidth(), + ) + ConfigurationTextBox( + value = peer.preSharedKey, + onValueChange = { value -> + peersState[index] = peersState[index].copy(preSharedKey = value) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.preshared_key), + hint = stringResource(R.string.optional), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + textStyle = MaterialTheme.typography.labelLarge, + modifier = Modifier.fillMaxWidth(), + value = peer.persistentKeepalive, + enabled = true, + onValueChange = { value -> + peersState[index] = peersState[index].copy(persistentKeepalive = value) + }, + trailingIcon = { + Text( + stringResource(R.string.seconds), + modifier = Modifier.padding(end = 10.dp), + style = MaterialTheme.typography.labelMedium, + ) + }, + label = { Text(stringResource(R.string.persistent_keepalive), style = MaterialTheme.typography.labelMedium) }, + singleLine = true, + placeholder = { + Text(stringResource(R.string.optional_no_recommend), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline) + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + ConfigurationTextBox( + value = peer.endpoint, + onValueChange = { value -> + peersState[index] = peersState[index].copy(endpoint = value) + }, + keyboardActions = keyboardActions, + label = stringResource(R.string.endpoint), + hint = stringResource(R.string.endpoint).lowercase(), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + textStyle = MaterialTheme.typography.labelLarge, + modifier = Modifier.fillMaxWidth(), + value = peer.allowedIps, + enabled = true, + onValueChange = { value -> + peersState[index] = peersState[index].copy(allowedIps = value) + }, + label = { Text(stringResource(R.string.allowed_ips)) }, + singleLine = true, + placeholder = { + Text(stringResource(R.string.comma_separated_list), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.outline) + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + ) + } + } + } + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxSize() + .padding(bottom = 140.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + TextButton(onClick = { + peersState.add(PeerProxy()) + }) { + Text(stringResource(R.string.add_peer)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/config/model/InterfaceProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/config/model/InterfaceProxy.kt new file mode 100644 index 0000000..a1b07fe --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/config/model/InterfaceProxy.kt @@ -0,0 +1,183 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.config.model + +import com.wireguard.config.Interface +import com.zaneschepke.wireguardautotunnel.util.extensions.joinAndTrim +import com.zaneschepke.wireguardautotunnel.util.extensions.toTrimmedList + +data class InterfaceProxy( + val privateKey: String = "", + val publicKey: String = "", + val addresses: String = "", + val dnsServers: String = "", + val listenPort: String = "", + val mtu: String = "", + val includedApplications: MutableSet = mutableSetOf(), + val excludedApplications: MutableSet = mutableSetOf(), + val junkPacketCount: String = "", + val junkPacketMinSize: String = "", + val junkPacketMaxSize: String = "", + val initPacketJunkSize: String = "", + val responsePacketJunkSize: String = "", + val initPacketMagicHeader: String = "", + val responsePacketMagicHeader: String = "", + val underloadPacketMagicHeader: String = "", + val transportPacketMagicHeader: String = "", + val preUp: String = "", + val postUp: String = "", + val preDown: String = "", + val postDown: String = "", +) { + + fun toWgInterface(): Interface { + return Interface.Builder().apply { + parseAddresses(addresses) + parsePrivateKey(privateKey) + if (dnsServers.isNotBlank()) parseDnsServers(dnsServers) + if (mtu.isNotBlank()) parseMtu(mtu) + if (listenPort.isNotBlank()) parseListenPort(listenPort) + includeApplications(includedApplications) + excludeApplications(excludedApplications) + preUp.toTrimmedList().forEach { parsePreUp(it) } + postUp.toTrimmedList().forEach { parsePostUp(it) } + preDown.toTrimmedList().forEach { parsePreDown(it) } + postDown.toTrimmedList().forEach { parsePostDown(it) } + }.build() + } + + fun toAmInterface(): org.amnezia.awg.config.Interface { + return org.amnezia.awg.config.Interface.Builder().apply { + parseAddresses(addresses) + parsePrivateKey(privateKey) + if (dnsServers.isNotBlank()) parseDnsServers(dnsServers) + if (mtu.isNotBlank()) parseMtu(mtu) + if (listenPort.isNotBlank()) parseListenPort(listenPort) + includeApplications(includedApplications) + excludeApplications(excludedApplications) + preUp.toTrimmedList().forEach { parsePreUp(it) } + postUp.toTrimmedList().forEach { parsePostUp(it) } + preDown.toTrimmedList().forEach { parsePreDown(it) } + postDown.toTrimmedList().forEach { parsePostDown(it) } + if (junkPacketCount.isNotBlank()) parseJunkPacketCount(junkPacketCount) + if (junkPacketMinSize.isNotBlank()) parseJunkPacketMinSize(junkPacketMinSize) + if (junkPacketMaxSize.isNotBlank()) parseJunkPacketMaxSize(junkPacketMaxSize) + if (initPacketJunkSize.isNotBlank()) parseInitPacketJunkSize(initPacketJunkSize) + if (responsePacketJunkSize.isNotBlank()) parseResponsePacketJunkSize(responsePacketJunkSize) + if (initPacketMagicHeader.isNotBlank()) parseInitPacketMagicHeader(initPacketMagicHeader) + if (responsePacketMagicHeader.isNotBlank()) parseResponsePacketMagicHeader(responsePacketMagicHeader) + if (underloadPacketMagicHeader.isNotBlank()) parseUnderloadPacketMagicHeader(underloadPacketMagicHeader) + if (transportPacketMagicHeader.isNotBlank()) parseTransportPacketMagicHeader(transportPacketMagicHeader) + }.build() + } + + companion object { + fun from(i: Interface): InterfaceProxy { + return InterfaceProxy( + publicKey = i.keyPair.publicKey.toBase64().trim(), + privateKey = i.keyPair.privateKey.toBase64().trim(), + addresses = i.addresses.joinToString(", ").trim(), + dnsServers = listOf( + i.dnsServers.joinToString(", ").replace("/", "").trim(), + i.dnsSearchDomains.joinAndTrim(), + ).filter { it.isNotEmpty() }.joinToString(", "), + listenPort = + if (i.listenPort.isPresent) { + i.listenPort.get().toString().trim() + } else { + "" + }, + mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "", + includedApplications = i.includedApplications.toMutableSet(), + excludedApplications = i.excludedApplications.toMutableSet(), + preUp = i.preUp.joinAndTrim(), + postUp = i.postUp.joinAndTrim(), + preDown = i.preDown.joinAndTrim(), + postDown = i.postDown.joinAndTrim(), + ) + } + + fun from(i: org.amnezia.awg.config.Interface): InterfaceProxy { + return InterfaceProxy( + publicKey = i.keyPair.publicKey.toBase64().trim(), + privateKey = i.keyPair.privateKey.toBase64().trim(), + addresses = i.addresses.joinToString(", ").trim(), + dnsServers = (i.dnsServers + i.dnsSearchDomains).joinToString(", ").replace("/", "").trim(), + listenPort = + if (i.listenPort.isPresent) { + i.listenPort.get().toString().trim() + } else { + "" + }, + mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "", + includedApplications = i.includedApplications.toMutableSet(), + excludedApplications = i.excludedApplications.toMutableSet(), + preUp = i.preUp.joinAndTrim(), + postUp = i.postUp.joinAndTrim(), + preDown = i.preDown.joinAndTrim(), + postDown = i.postDown.joinAndTrim(), + junkPacketCount = + if (i.junkPacketCount.isPresent) { + i.junkPacketCount.get() + .toString() + } else { + "" + }, + junkPacketMinSize = + if (i.junkPacketMinSize.isPresent) { + i.junkPacketMinSize.get() + .toString() + } else { + "" + }, + junkPacketMaxSize = + if (i.junkPacketMaxSize.isPresent) { + i.junkPacketMaxSize.get() + .toString() + } else { + "" + }, + initPacketJunkSize = + if (i.initPacketJunkSize.isPresent) { + i.initPacketJunkSize.get() + .toString() + } else { + "" + }, + responsePacketJunkSize = + if (i.responsePacketJunkSize.isPresent) { + i.responsePacketJunkSize.get() + .toString() + } else { + "" + }, + initPacketMagicHeader = + if (i.initPacketMagicHeader.isPresent) { + i.initPacketMagicHeader.get() + .toString() + } else { + "" + }, + responsePacketMagicHeader = + if (i.responsePacketMagicHeader.isPresent) { + i.responsePacketMagicHeader.get() + .toString() + } else { + "" + }, + transportPacketMagicHeader = + if (i.transportPacketMagicHeader.isPresent) { + i.transportPacketMagicHeader.get() + .toString() + } else { + "" + }, + underloadPacketMagicHeader = + if (i.underloadPacketMagicHeader.isPresent) { + i.underloadPacketMagicHeader.get() + .toString() + } else { + "" + }, + ) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/model/PeerProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/config/model/PeerProxy.kt similarity index 70% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/model/PeerProxy.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/config/model/PeerProxy.kt index 9e63266..cf992ae 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/model/PeerProxy.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/config/model/PeerProxy.kt @@ -1,14 +1,34 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.config.model +package com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.config.model import com.wireguard.config.Peer +import com.zaneschepke.wireguardautotunnel.util.extensions.joinAndTrim data class PeerProxy( val publicKey: String = "", val preSharedKey: String = "", val persistentKeepalive: String = "", val endpoint: String = "", - val allowedIps: String = IPV4_WILDCARD.joinToString(", ").trim(), + val allowedIps: String = IPV4_WILDCARD.joinAndTrim(), ) { + fun toWgPeer(): Peer { + return Peer.Builder().apply { + parsePublicKey(publicKey) + if (preSharedKey.isNotBlank()) parsePreSharedKey(preSharedKey) + if (persistentKeepalive.isNotBlank()) parsePersistentKeepalive(persistentKeepalive) + parseEndpoint(endpoint) + parseAllowedIPs(allowedIps) + }.build() + } + fun toAmPeer(): org.amnezia.awg.config.Peer { + return org.amnezia.awg.config.Peer.Builder().apply { + parsePublicKey(publicKey) + if (preSharedKey.isNotBlank()) parsePreSharedKey(preSharedKey) + if (persistentKeepalive.isNotBlank()) parsePersistentKeepalive(persistentKeepalive) + parseEndpoint(endpoint) + parseAllowedIPs(allowedIps) + }.build() + } + companion object { fun from(peer: Peer): PeerProxy { return PeerProxy( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitOptions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitOptions.kt new file mode 100644 index 0000000..b622017 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitOptions.kt @@ -0,0 +1,32 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.splittunnel + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AllInclusive +import androidx.compose.material.icons.filled.Remove +import androidx.compose.ui.graphics.vector.ImageVector +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.util.StringValue + +enum class SplitOptions { + INCLUDE, + ALL, + EXCLUDE, + ; + + fun icon(): ImageVector { + return when (this) { + ALL -> Icons.Filled.AllInclusive + INCLUDE -> Icons.Filled.Add + EXCLUDE -> Icons.Filled.Remove + } + } + + fun text(): StringValue { + return when (this) { + ALL -> StringValue.StringResource(R.string.all) + INCLUDE -> StringValue.StringResource(R.string.include) + EXCLUDE -> StringValue.StringResource(R.string.exclude) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitTunnelApp.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitTunnelApp.kt new file mode 100644 index 0000000..59be6f2 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitTunnelApp.kt @@ -0,0 +1,9 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.splittunnel + +import android.graphics.drawable.Drawable + +data class SplitTunnelApp( + val icon: Drawable, + val name: String, + val `package`: String, +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitTunnelScreen.kt new file mode 100644 index 0000000..4cba395 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/splittunnel/SplitTunnelScreen.kt @@ -0,0 +1,279 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.splittunnel + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material.icons.outlined.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.MultiChoiceSegmentedButtonRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.ui.AppUiState +import com.zaneschepke.wireguardautotunnel.ui.AppViewModel +import com.zaneschepke.wireguardautotunnel.ui.common.button.SelectionItemButton +import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController +import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar +import com.zaneschepke.wireguardautotunnel.ui.common.textbox.CustomTextField +import com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.config.model.InterfaceProxy +import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize +import com.zaneschepke.wireguardautotunnel.util.extensions.getAllInternetCapablePackages +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight +import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth +import java.text.Collator +import java.util.Locale + +@Composable +fun SplitTunnelScreen(appUiState: AppUiState, tunnelId: Int, viewModel: AppViewModel) { + val context = LocalContext.current + val navController = LocalNavController.current + + val inputHeight = 45.dp + + val collator = Collator.getInstance(Locale.getDefault()) + + val popBackStack by viewModel.popBackStack.collectAsStateWithLifecycle(false) + + LaunchedEffect(popBackStack) { + if (popBackStack) navController.popBackStack() + } + + val config by remember { derivedStateOf { appUiState.tunnels.first { it.id == tunnelId } } } + + var proxyInterface by remember { mutableStateOf(InterfaceProxy()) } + + var selectedSplitOption by remember { mutableStateOf(SplitOptions.ALL) } + + val selectedPackages = remember { mutableStateListOf() } + + LaunchedEffect(Unit) { + proxyInterface = InterfaceProxy.from(config.toWgConfig().`interface`) + val pair = when { + proxyInterface.excludedApplications.isNotEmpty() -> Pair(SplitOptions.EXCLUDE, proxyInterface.excludedApplications) + proxyInterface.includedApplications.isNotEmpty() -> Pair(SplitOptions.INCLUDE, proxyInterface.includedApplications) + else -> Pair(SplitOptions.ALL, mutableSetOf()) + } + selectedSplitOption = pair.first + selectedPackages.addAll(pair.second) + } + + val packages = remember { + context.getAllInternetCapablePackages().filter { it.applicationInfo != null }.map { pack -> + SplitTunnelApp( + context.packageManager.getApplicationIcon(pack.applicationInfo!!), + context.packageManager.getApplicationLabel(pack.applicationInfo!!).toString(), + pack.packageName, + ) + } + } + + var query: String by remember { mutableStateOf("") } + + val sortedPackages by remember { + derivedStateOf { + packages.sortedWith(compareBy(collator) { it.name }).filter { it.name.contains(query) }.toMutableStateList() + } + } + + LaunchedEffect(Unit) { + // clean up any split tunnel packages for apps that were uninstalled + viewModel.cleanUpUninstalledApps(config, packages.map { it.`package` }) + } + + Scaffold( + topBar = { + TopNavBar(stringResource(R.string.tunneling_apps), trailing = { + IconButton(onClick = { + proxyInterface.apply { + includedApplications.clear() + excludedApplications.clear() + } + when (selectedSplitOption) { + SplitOptions.INCLUDE -> proxyInterface.includedApplications.apply { + addAll(selectedPackages) + } + SplitOptions.EXCLUDE -> proxyInterface.excludedApplications.apply { + addAll(selectedPackages) + } + SplitOptions.ALL -> Unit + } + viewModel.saveConfigChanges(config, `interface` = proxyInterface) + }) { + val icon = Icons.Outlined.Save + Icon( + imageVector = icon, + contentDescription = icon.name, + ) + } + }) + }, + ) { padding -> + + Column( + verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(padding) + .padding(top = 24.dp.scaledHeight()), + ) { + MultiChoiceSegmentedButtonRow( + modifier = Modifier.background(color = MaterialTheme.colorScheme.background).fillMaxWidth() + .padding(horizontal = 24.dp.scaledWidth()).height(inputHeight), + ) { + SplitOptions.entries.forEachIndexed { index, entry -> + val active = selectedSplitOption == entry + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index = index, count = SplitOptions.entries.size, baseShape = RoundedCornerShape(8.dp)), + icon = { + SegmentedButtonDefaults.Icon(active = active, activeContent = { + val icon = Icons.Outlined.Check + Icon(imageVector = icon, icon.name, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(SegmentedButtonDefaults.IconSize)) + }) { + Icon( + imageVector = entry.icon(), + contentDescription = entry.icon().name, + modifier = Modifier.size(SegmentedButtonDefaults.IconSize), + ) + } + }, + colors = SegmentedButtonDefaults.colors().copy( + activeContainerColor = MaterialTheme.colorScheme.surface, + inactiveContainerColor = MaterialTheme.colorScheme.background, + ), + onCheckedChange = { + selectedSplitOption = entry + }, + checked = active, + ) { + Text( + entry.text().asString(context) + .replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + if (selectedSplitOption != SplitOptions.ALL) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth(), + ) { + CustomTextField( + textStyle = MaterialTheme.typography.labelMedium.copy( + color = MaterialTheme.colorScheme.onBackground, + ), + value = query, + onValueChange = { input -> + query = input + }, + interactionSource = remember { MutableInteractionSource() }, + label = {}, + leading = { + val icon = Icons.Outlined.Search + Icon(icon, icon.name) + }, + containerColor = MaterialTheme.colorScheme.background, + modifier = + Modifier + .fillMaxWidth().height(inputHeight).padding(horizontal = 24.dp.scaledWidth()), + singleLine = true, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(), + ) + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + contentPadding = PaddingValues(top = 10.dp), + ) { + items(sortedPackages, key = { it.`package` }) { app -> + val checked = selectedPackages.contains(app.`package`) + val onClick = { + if (checked) selectedPackages.remove(app.`package`) else selectedPackages.add(app.`package`) + } + SelectionItemButton( + { + Image( + rememberDrawablePainter(app.icon), + app.name, + modifier = + Modifier + .padding(horizontal = 24.dp.scaledWidth()) + .size( + iconSize, + ), + ) + }, + buttonText = app.name, + onClick = { + onClick() + }, + trailing = { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + onCheckedChange = { + onClick() + }, + ) + } + }, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/tunnelautotunnel/TunnelAutoTunnelScreen.kt similarity index 74% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsScreen.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/tunnelautotunnel/TunnelAutoTunnelScreen.kt index 3bd321e..d25c4d9 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/options/OptionsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/tunnelautotunnel/TunnelAutoTunnelScreen.kt @@ -1,4 +1,4 @@ -package com.zaneschepke.wireguardautotunnel.ui.screens.options +package com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.tunnelautotunnel import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -11,14 +11,11 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.NetworkPing import androidx.compose.material.icons.outlined.PhoneAndroid import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.SettingsEthernet -import androidx.compose.material.icons.outlined.Star import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -37,13 +34,10 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.AppUiState -import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox -import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel -import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.TrustedNetworkTextBox import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.WildcardsLabel @@ -54,9 +48,8 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth @Composable -fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), appUiState: AppUiState, tunnelId: Int) { - val navController = LocalNavController.current - val config = appUiState.tunnels.first { it.id == tunnelId } +fun TunnelAutoTunnelScreen(appUiState: AppUiState, tunnelId: Int, tunnelAutoTunnelViewModel: TunnelAutoTunnelViewModel = hiltViewModel()) { + val config = remember { appUiState.tunnels.first { it.id == tunnelId } } var currentText by remember { mutableStateOf("") } @@ -65,59 +58,24 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), appUiSta } Scaffold( topBar = { - TopNavBar(config.name, trailing = { - IconButton(onClick = { - navController.navigate( - Route.Config(config.id), - ) - }) { - val icon = Icons.Outlined.Edit - Icon( - imageVector = icon, - contentDescription = icon.name, - ) - } - }) + TopNavBar(config.name) }, - ) { + ) { padding -> Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top), modifier = Modifier .fillMaxSize() - .padding(it) + .padding(padding) .verticalScroll(rememberScrollState()) .padding(top = 24.dp.scaledHeight()) .padding(horizontal = 24.dp.scaledWidth()), ) { - GroupLabel(stringResource(R.string.auto_tunneling)) SurfaceSelectionGroupButton( buildList { addAll( listOf( - SelectionItem( - Icons.Outlined.Star, - title = { - Text( - stringResource(R.string.primary_tunnel), - style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), - ) - }, - description = { - Text( - stringResource(R.string.set_primary_tunnel), - style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.outline), - ) - }, - trailing = { - ScaledSwitch( - config.isPrimaryTunnel, - onClick = { optionsViewModel.onTogglePrimaryTunnel(config) }, - ) - }, - onClick = { optionsViewModel.onTogglePrimaryTunnel(config) }, - ), SelectionItem( Icons.Outlined.PhoneAndroid, title = { @@ -129,16 +87,16 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), appUiSta description = { Text( stringResource(R.string.mobile_data_tunnel), - style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.outline), + style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline), ) }, trailing = { ScaledSwitch( config.isMobileDataTunnel, - onClick = { optionsViewModel.onToggleIsMobileDataTunnel(config) }, + onClick = { tunnelAutoTunnelViewModel.onToggleIsMobileDataTunnel(config) }, ) }, - onClick = { optionsViewModel.onToggleIsMobileDataTunnel(config) }, + onClick = { tunnelAutoTunnelViewModel.onToggleIsMobileDataTunnel(config) }, ), SelectionItem( Icons.Outlined.SettingsEthernet, @@ -151,16 +109,16 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), appUiSta description = { Text( stringResource(R.string.set_ethernet_tunnel), - style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.outline), + style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline), ) }, trailing = { ScaledSwitch( config.isEthernetTunnel, - onClick = { optionsViewModel.onToggleIsEthernetTunnel(config) }, + onClick = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(config) }, ) }, - onClick = { optionsViewModel.onToggleIsEthernetTunnel(config) }, + onClick = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(config) }, ), SelectionItem( Icons.Outlined.NetworkPing, @@ -173,10 +131,10 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), appUiSta trailing = { ScaledSwitch( checked = config.isPingEnabled, - onClick = { optionsViewModel.onToggleRestartOnPing(config) }, + onClick = { tunnelAutoTunnelViewModel.onToggleRestartOnPing(config) }, ) }, - onClick = { optionsViewModel.onToggleRestartOnPing(config) }, + onClick = { tunnelAutoTunnelViewModel.onToggleRestartOnPing(config) }, ), ), ) @@ -191,7 +149,7 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), appUiSta stringResource(R.string.default_ping_ip), isErrorValue = { !it.isNullOrBlank() && !it.isValidIpv4orIpv6Address() }, onSubmit = { - optionsViewModel.saveTunnelChanges( + tunnelAutoTunnelViewModel.saveTunnelChanges( config.copy(pingIp = it.ifBlank { null }), ) }, @@ -209,7 +167,7 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), appUiSta ), isErrorValue = ::isSecondsError, onSubmit = { - optionsViewModel.saveTunnelChanges( + tunnelAutoTunnelViewModel.saveTunnelChanges( config.copy(pingInterval = if (it.isBlank()) null else it.toLong() * 1000), ) }, @@ -223,7 +181,7 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), appUiSta ), isErrorValue = ::isSecondsError, onSubmit = { - optionsViewModel.saveTunnelChanges( + tunnelAutoTunnelViewModel.saveTunnelChanges( config.copy(pingCooldown = if (it.isBlank()) null else it.toLong() * 1000), ) }, @@ -237,7 +195,9 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), appUiSta title = { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp.scaledHeight()), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp.scaledHeight()), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -270,9 +230,9 @@ fun OptionsScreen(optionsViewModel: OptionsViewModel = hiltViewModel(), appUiSta description = { TrustedNetworkTextBox( config.tunnelNetworks, - onDelete = { optionsViewModel.onDeleteRunSSID(it, config) }, + onDelete = { tunnelAutoTunnelViewModel.onDeleteRunSSID(it, config) }, currentText = currentText, - onSave = { optionsViewModel.onSaveRunSSID(it, config) }, + onSave = { tunnelAutoTunnelViewModel.onSaveRunSSID(it, config) }, onValueChange = { currentText = it }, supporting = { if (appUiState.settings.isWildcardsEnabled) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/tunnelautotunnel/TunnelAutoTunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/tunnelautotunnel/TunnelAutoTunnelViewModel.kt new file mode 100644 index 0000000..3cbe737 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunneloptions/tunnelautotunnel/TunnelAutoTunnelViewModel.kt @@ -0,0 +1,88 @@ +package com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions.tunnelautotunnel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig +import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository +import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController +import com.zaneschepke.wireguardautotunnel.util.StringValue +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class TunnelAutoTunnelViewModel +@Inject +constructor( + private val appDataRepository: AppDataRepository, +) : ViewModel() { + + fun onDeleteRunSSID(ssid: String, tunnelConfig: TunnelConfig) = viewModelScope.launch { + appDataRepository.tunnels.save( + tunnelConfig = + tunnelConfig.copy( + tunnelNetworks = (tunnelConfig.tunnelNetworks - ssid).toMutableList(), + ), + ) + } + + fun saveTunnelChanges(tunnelConfig: TunnelConfig) = viewModelScope.launch { + appDataRepository.tunnels.save(tunnelConfig) + } + + fun onSaveRunSSID(ssid: String, tunnelConfig: TunnelConfig) = viewModelScope.launch { + if (ssid.isBlank()) return@launch + val trimmed = ssid.trim() + val tunnelsWithName = appDataRepository.tunnels.findByTunnelNetworksName(trimmed) + + if (!tunnelConfig.tunnelNetworks.contains(trimmed) && + tunnelsWithName.isEmpty() + ) { + saveTunnelChanges( + tunnelConfig.copy( + tunnelNetworks = (tunnelConfig.tunnelNetworks + ssid).toMutableList(), + ), + ) + } else { + SnackbarController.showMessage( + StringValue.StringResource( + R.string.error_ssid_exists, + ), + ) + } + } + + fun onToggleIsMobileDataTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch { + if (tunnelConfig.isMobileDataTunnel) { + appDataRepository.tunnels.updateMobileDataTunnel(null) + } else { + appDataRepository.tunnels.updateMobileDataTunnel(tunnelConfig) + } + } + + fun onTogglePrimaryTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch { + appDataRepository.tunnels.updatePrimaryTunnel( + when (tunnelConfig.isPrimaryTunnel) { + true -> null + false -> tunnelConfig + }, + ) + } + + fun onToggleRestartOnPing(tunnelConfig: TunnelConfig) = viewModelScope.launch { + appDataRepository.tunnels.save( + tunnelConfig.copy( + isPingEnabled = !tunnelConfig.isPingEnabled, + ), + ) + } + + fun onToggleIsEthernetTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch { + if (tunnelConfig.isEthernetTunnel) { + appDataRepository.tunnels.updateEthernetTunnel(null) + } else { + appDataRepository.tunnels.updateEthernetTunnel(tunnelConfig) + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt index af3324a..4ed8bd7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/ContextExtensions.kt @@ -1,12 +1,15 @@ package com.zaneschepke.wireguardautotunnel.util.extensions +import android.Manifest import android.content.ComponentName import android.content.Context import android.content.Context.POWER_SERVICE import android.content.Intent +import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.location.LocationManager import android.net.Uri +import android.os.Build import android.os.PowerManager import android.provider.Settings import android.service.quicksettings.TileService @@ -168,24 +171,6 @@ fun Context.launchAppSettings() { } } -// fun Context.startTunnelBackground(tunnelId: Int) { -// sendBroadcast( -// Intent(this, BackgroundActionReceiver::class.java).apply { -// action = BackgroundActionReceiver.ACTION_CONNECT -// putExtra(BackgroundActionReceiver.TUNNEL_ID_EXTRA_KEY, tunnelId) -// }, -// ) -// } -// -// fun Context.stopTunnelBackground(tunnelId: Int) { -// sendBroadcast( -// Intent(this, BackgroundActionReceiver::class.java).apply { -// action = BackgroundActionReceiver.ACTION_DISCONNECT -// putExtra(BackgroundActionReceiver.TUNNEL_ID_EXTRA_KEY, tunnelId) -// }, -// ) -// } - fun Context.requestTunnelTileServiceStateUpdate() { TileService.requestListeningState( this, @@ -199,3 +184,15 @@ fun Context.requestAutoTunnelTileServiceUpdate() { ComponentName(this, AutoTunnelControlTile::class.java), ) } + +fun Context.getAllInternetCapablePackages(): List { + val permissions = arrayOf(Manifest.permission.INTERNET) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackagesHoldingPermissions( + permissions, + PackageManager.PackageInfoFlags.of(0L), + ) + } else { + packageManager.getPackagesHoldingPermissions(permissions, 0) + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt index 48ae90a..2464528 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/extensions/StringExtensions.kt @@ -51,20 +51,17 @@ fun String.replaceUnescapedChar(charToReplace: String, replacement: String): Str this[matchResult.range.first - 1] != '\\' || (matchResult.range.first > 1 && this[matchResult.range.first - 2] == '\\') ) { - replacement.toString() + replacement } else { matchResult.value } } } -fun String.isCharacterEscaped(index: Int): Boolean { - if (index <= 0) return false - var backslashCount = 0 - var currentIndex = index - 1 - while (currentIndex >= 0 && this[currentIndex] == '\\') { - backslashCount++ - currentIndex-- - } - return backslashCount % 2 != 0 +fun Iterable.joinAndTrim(): String { + return this.joinToString(", ").trim() +} + +fun String.toTrimmedList(): List { + return this.split(",").map { it.trim() }.filter { it.isNotEmpty() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7117deb..f23a365 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -190,4 +190,14 @@ Auto-tunnel Notification Channel A channel for auto-tunnel state notifications stop + Config + Split tunneling + Tunnel specific settings + Quick actions + Show scripts + Pre up + Post up + Pre down + Post down + Amnezia unavailable in kernel mode