From a6a7b3f7702edc2ae2fa2492860beb9066d0aa08 Mon Sep 17 00:00:00 2001 From: Zane Schepke Date: Wed, 8 Oct 2025 18:37:26 -0400 Subject: [PATCH] fix: nav crash bugfix --- .../wireguardautotunnel/MainActivity.kt | 85 +++++-- .../wireguardautotunnel/di/ViewModelModule.kt | 13 + .../currentBackStackEntryAsNavbarState.kt | 31 +-- .../ui/screens/autotunnel/AutoTunnelScreen.kt | 18 +- .../advanced/AutoTunnelAdvancedScreen.kt | 2 +- .../components/NetworkTunnelingItems.kt | 16 +- .../components/wifiTunnelingItems.kt | 23 +- .../detection/WifiDetectionMethodScreen.kt | 6 +- .../ui/screens/tunnels/TunnelsScreen.kt | 16 +- .../autotunnel/TunnelAutoTunnelScreen.kt | 33 +-- .../screens/tunnels/components/TunnelList.kt | 29 +-- .../ui/screens/tunnels/config/ConfigScreen.kt | 29 ++- .../tunnels/splittunnel/SplitTunnelScreen.kt | 14 +- .../tunneloptions/TunnelOptionsScreen.kt | 43 ++-- .../ui/sideeffect/LocalSideEffect.kt | 6 + .../ui/state/AutoTunnelUiState.kt | 2 +- .../ui/state/ConfigUiState.kt | 9 + .../ui/state/SharedAppUiState.kt | 5 +- .../ui/state/SplitTunnelUiState.kt | 4 +- .../ui/state/TunnelUiState.kt | 13 + .../ui/state/TunnelsUiState.kt | 3 +- .../wireguardautotunnel/util/FileUtils.kt | 6 +- .../util/RootShellUtils.kt | 26 ++ .../viewmodel/AutoTunnelViewModel.kt | 71 +++++- .../viewmodel/ConfigViewModel.kt | 95 +++++++ .../viewmodel/SharedAppViewModel.kt | 234 ++---------------- .../viewmodel/SplitTunnelViewModel.kt | 84 ++++--- .../viewmodel/TunnelViewModel.kt | 109 ++++++++ .../viewmodel/TunnelsViewModel.kt | 167 ++++++++----- 29 files changed, 686 insertions(+), 506 deletions(-) create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelUiState.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/util/RootShellUtils.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ConfigViewModel.kt create mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt index 0ef172e3..5bf1f29a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt @@ -18,19 +18,17 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.* import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.net.toUri import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -88,7 +86,10 @@ import com.zaneschepke.wireguardautotunnel.ui.theme.OffWhite import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.util.LocaleUtil import com.zaneschepke.wireguardautotunnel.util.extensions.* +import com.zaneschepke.wireguardautotunnel.viewmodel.ConfigViewModel import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.SplitTunnelViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelViewModel import dagger.hilt.android.AndroidEntryPoint import de.raphaelebner.roomdatabasebackup.core.RoomBackup import java.util.* @@ -253,17 +254,11 @@ class MainActivity : AppCompatActivity() { val currentTab by remember { derivedStateOf { Tab.fromRoute(currentRoute ?: Route.Tunnels) } } - val selectedCount by - rememberSaveable(appState.selectedTunnels) { - mutableIntStateOf(appState.selectedTunnels.size) - } - val navState by currentRouteAsNavbarState( appState, viewModel, currentRoute, - selectedCount, navController, ) @@ -302,10 +297,6 @@ class MainActivity : AppCompatActivity() { ) } }, - modifier = - Modifier.pointerInput(Unit) { - detectTapGestures { viewModel.clearSelectedTunnels() } - }, ) { padding -> Column( modifier = @@ -362,15 +353,53 @@ class MainActivity : AppCompatActivity() { entry { TunnelsScreen() } entry { SortScreen() } entry { key -> - TunnelOptionsScreen(key.id) + val viewModel = + hiltViewModel< + TunnelViewModel, + TunnelViewModel.Factory, + >( + creationCallback = { factory -> + factory.create(key.id) + } + ) + TunnelOptionsScreen(viewModel) } entry { key -> - SplitTunnelScreen(key.id) + val viewModel = + hiltViewModel< + SplitTunnelViewModel, + SplitTunnelViewModel.Factory, + >( + creationCallback = { factory -> + factory.create(key.id) + } + ) + SplitTunnelScreen(viewModel) } entry { key -> - TunnelAutoTunnelScreen(key.id) + val viewModel = + hiltViewModel< + TunnelViewModel, + TunnelViewModel.Factory, + >( + creationCallback = { factory -> + factory.create(key.id) + } + ) + TunnelAutoTunnelScreen(viewModel) + } + entry { key -> + val viewModel = + hiltViewModel< + ConfigViewModel, + ConfigViewModel.Factory, + >( + creationCallback = { factory -> + factory.create(key.id) + } + ) + ConfigScreen(viewModel) } - entry { key -> ConfigScreen(key.id) } entry { LocationDisclosureScreen() } @@ -393,10 +422,28 @@ class MainActivity : AppCompatActivity() { TunnelGlobalsScreen(key.id) } entry { key -> - ConfigScreen(key.id) + val viewModel = + hiltViewModel< + ConfigViewModel, + ConfigViewModel.Factory, + >( + creationCallback = { factory -> + factory.create(key.id) + } + ) + ConfigScreen(viewModel) } entry { key -> - SplitTunnelScreen(key.id) + val viewModel = + hiltViewModel< + SplitTunnelViewModel, + SplitTunnelViewModel.Factory, + >( + creationCallback = { factory -> + factory.create(key.id) + } + ) + SplitTunnelScreen(viewModel) } entry { ProxySettingsScreen() } entry { AppearanceScreen() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/ViewModelModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/ViewModelModule.kt index 7086b894..1a23c8b7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/ViewModelModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/di/ViewModelModule.kt @@ -1,13 +1,17 @@ package com.zaneschepke.wireguardautotunnel.di import android.content.Context +import com.wireguard.android.util.RootShell import com.zaneschepke.wireguardautotunnel.util.FileUtils +import com.zaneschepke.wireguardautotunnel.util.RootShellUtils import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.scopes.ViewModelScoped +import io.ktor.utils.io.ioDispatcher +import javax.inject.Provider import kotlinx.coroutines.CoroutineDispatcher @Module @@ -21,4 +25,13 @@ class ViewModelModule { ): FileUtils { return FileUtils(context, ioDispatcher) } + + @ViewModelScoped + @Provides + fun provideRootShellUtils( + @AppShell rootShell: Provider, + @IoDispatcher ioDispatcher: CoroutineDispatcher, + ): RootShellUtils { + return RootShellUtils(rootShell, ioDispatcher) + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt index 541feb29..2d3dc737 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt @@ -24,13 +24,12 @@ fun currentRouteAsNavbarState( sharedState: SharedAppUiState, sharedViewModel: SharedAppViewModel, route: Route?, - selectedCount: Int, navController: NavController, ): State { val keyboardController = LocalSoftwareKeyboardController.current val context = LocalContext.current - return remember(route, selectedCount) { + return remember(route, sharedState) { derivedStateOf { when (route) { Route.AdvancedAutoTunnel -> @@ -169,7 +168,7 @@ fun currentRouteAsNavbarState( }, ) is Route.Config -> { - val tunnel = sharedState.tunnels.find { it.id == route.id } + val tunnelName = sharedState.tunnelNames[route.id] NavbarState( topLeading = { ActionIconButton(Icons.AutoMirrored.Rounded.ArrowBack, R.string.back) { @@ -177,7 +176,7 @@ fun currentRouteAsNavbarState( } }, showBottomItems = true, - topTitle = tunnel?.tunName ?: context.getString(R.string.new_tunnel), + topTitle = tunnelName ?: context.getString(R.string.new_tunnel), topTrailing = { ActionIconButton(Icons.Rounded.Save, R.string.save) { keyboardController?.hide() @@ -187,14 +186,14 @@ fun currentRouteAsNavbarState( ) } is Route.SplitTunnel -> { - val tunnel = sharedState.tunnels.find { it.id == route.id } + val tunnelName = sharedState.tunnelNames[route.id] NavbarState( topLeading = { ActionIconButton(Icons.AutoMirrored.Rounded.ArrowBack, R.string.back) { navController.pop() } }, - topTitle = tunnel?.tunName ?: "", + topTitle = tunnelName ?: "", topTrailing = { ActionIconButton(Icons.Rounded.Save, R.string.save) { sharedViewModel.postSideEffect(LocalSideEffect.SaveChanges) @@ -252,7 +251,7 @@ fun currentRouteAsNavbarState( showBottomItems = true, ) is Route.TunnelAutoTunnel -> { - val tunnel = sharedState.tunnels.find { it.id == route.id } + val tunnelName = sharedState.tunnelNames[route.id] NavbarState( topLeading = { ActionIconButton(Icons.AutoMirrored.Rounded.ArrowBack, R.string.back) { @@ -260,7 +259,7 @@ fun currentRouteAsNavbarState( } }, showBottomItems = true, - topTitle = tunnel?.tunName ?: "", + topTitle = tunnelName ?: "", ) } Route.TunnelMonitoring -> @@ -274,7 +273,7 @@ fun currentRouteAsNavbarState( showBottomItems = true, ) is Route.TunnelOptions -> { - val tunnel = sharedState.tunnels.find { it.id == route.id } + val tunnelName = sharedState.tunnelNames[route.id] NavbarState( topLeading = { ActionIconButton(Icons.AutoMirrored.Rounded.ArrowBack, R.string.back) { @@ -282,7 +281,7 @@ fun currentRouteAsNavbarState( } }, showBottomItems = true, - topTitle = tunnel?.tunName ?: "", + topTitle = tunnelName ?: "", topTrailing = { Row { ActionIconButton(Icons.Rounded.QrCode2, R.string.show_qr) { @@ -299,7 +298,7 @@ fun currentRouteAsNavbarState( NavbarState( topTitle = context.getString(R.string.tunnels), topTrailing = { - when (selectedCount) { + when (sharedState.selectedTunnelCount) { 0 -> Row { ActionIconButton( @@ -320,7 +319,9 @@ fun currentRouteAsNavbarState( Icons.Rounded.SelectAll, R.string.select_all, ) { - sharedViewModel.toggleSelectAllTunnels() + sharedViewModel.postSideEffect( + LocalSideEffect.SelectedTunnels.SelectAll + ) } // due to permissions, and SAF issues on TV, not support // less than Android @@ -336,9 +337,11 @@ fun currentRouteAsNavbarState( } } - if (selectedCount == 1) { + if (sharedState.selectedTunnelCount == 1) { ActionIconButton(Icons.Rounded.CopyAll, R.string.copy) { - sharedViewModel.copySelectedTunnel() + sharedViewModel.postSideEffect( + LocalSideEffect.SelectedTunnels.Copy + ) } } ActionIconButton( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt index d2982a7b..5ba4262d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/AutoTunnelScreen.kt @@ -54,28 +54,26 @@ fun AutoTunnelScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) { val showLocationServicesWarning by remember( autoTunnelState.connectivityState?.wifiState, - autoTunnelState.generalSettings.trustedNetworkSSIDs, - autoTunnelState.generalSettings.wifiDetectionMethod, + autoTunnelState.settings.trustedNetworkSSIDs, + autoTunnelState.settings.wifiDetectionMethod, ) { derivedStateOf { autoTunnelState.connectivityState?.wifiState?.locationServicesEnabled == false && - autoTunnelState.generalSettings.wifiDetectionMethod - .needsLocationPermissions() && - autoTunnelState.generalSettings.trustedNetworkSSIDs.isNotEmpty() + autoTunnelState.settings.wifiDetectionMethod.needsLocationPermissions() && + autoTunnelState.settings.trustedNetworkSSIDs.isNotEmpty() } } val showLocationPermissionsWarning by remember( autoTunnelState.connectivityState?.wifiState, - autoTunnelState.generalSettings.trustedNetworkSSIDs, - autoTunnelState.generalSettings.wifiDetectionMethod, + autoTunnelState.settings.trustedNetworkSSIDs, + autoTunnelState.settings.wifiDetectionMethod, ) { derivedStateOf { autoTunnelState.connectivityState?.wifiState?.locationPermissionsGranted == false && - autoTunnelState.generalSettings.wifiDetectionMethod - .needsLocationPermissions() && - autoTunnelState.generalSettings.trustedNetworkSSIDs.isNotEmpty() + autoTunnelState.settings.wifiDetectionMethod.needsLocationPermissions() && + autoTunnelState.settings.trustedNetworkSSIDs.isNotEmpty() } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/AutoTunnelAdvancedScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/AutoTunnelAdvancedScreen.kt index ec09caa0..dba8e168 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/AutoTunnelAdvancedScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/advanced/AutoTunnelAdvancedScreen.kt @@ -45,7 +45,7 @@ fun AutoTunnelAdvancedScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) { leading = { Icon(Icons.Outlined.PauseCircle, null) }, onSelected = { selected -> viewModel.setDebounceDelay(selected!!) }, options = (0..10).toList(), - currentValue = autoTunnelState.generalSettings.debounceDelaySeconds, + currentValue = autoTunnelState.settings.debounceDelaySeconds, optionToString = { it?.toString() ?: stringResource(R.string._default) }, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/NetworkTunnelingItems.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/NetworkTunnelingItems.kt index 53d54221..5863ef6e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/NetworkTunnelingItems.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/NetworkTunnelingItems.kt @@ -36,7 +36,7 @@ fun networkTunnelingItems( }, trailing = { ScaledSwitch( - checked = autoTunnelState.generalSettings.isTunnelOnMobileDataEnabled, + checked = autoTunnelState.settings.isTunnelOnMobileDataEnabled, onClick = { viewModel.setTunnelOnCellular(it) }, ) }, @@ -58,9 +58,7 @@ fun networkTunnelingItems( ) }, onClick = { - viewModel.setTunnelOnCellular( - !autoTunnelState.generalSettings.isTunnelOnMobileDataEnabled - ) + viewModel.setTunnelOnCellular(!autoTunnelState.settings.isTunnelOnMobileDataEnabled) }, ), SelectionItem( @@ -76,7 +74,7 @@ fun networkTunnelingItems( }, trailing = { ScaledSwitch( - checked = autoTunnelState.generalSettings.isTunnelOnEthernetEnabled, + checked = autoTunnelState.settings.isTunnelOnEthernetEnabled, onClick = { viewModel.setTunnelOnEthernet(it) }, ) }, @@ -98,9 +96,7 @@ fun networkTunnelingItems( ) }, onClick = { - viewModel.setTunnelOnEthernet( - !autoTunnelState.generalSettings.isTunnelOnEthernetEnabled - ) + viewModel.setTunnelOnEthernet(!autoTunnelState.settings.isTunnelOnEthernetEnabled) }, ), SelectionItem( @@ -123,13 +119,13 @@ fun networkTunnelingItems( }, trailing = { ScaledSwitch( - checked = autoTunnelState.generalSettings.isStopOnNoInternetEnabled, + checked = autoTunnelState.settings.isStopOnNoInternetEnabled, onClick = { viewModel.setStopOnNoInternetEnabled(it) }, ) }, onClick = { viewModel.setStopOnNoInternetEnabled( - !autoTunnelState.generalSettings.isStopOnNoInternetEnabled + !autoTunnelState.settings.isStopOnNoInternetEnabled ) }, ), diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/wifiTunnelingItems.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/wifiTunnelingItems.kt index 938c014b..50e5cbd0 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/wifiTunnelingItems.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/components/wifiTunnelingItems.kt @@ -43,7 +43,7 @@ fun wifiTunnelingItems( var currentText by rememberSaveable { mutableStateOf("") } - LaunchedEffect(autoTunnelState.generalSettings.trustedNetworkSSIDs) { currentText = "" } + LaunchedEffect(autoTunnelState.settings.trustedNetworkSSIDs) { currentText = "" } val baseItems = listOf( @@ -60,7 +60,7 @@ fun wifiTunnelingItems( }, trailing = { ScaledSwitch( - checked = autoTunnelState.generalSettings.isTunnelOnWifiEnabled, + checked = autoTunnelState.settings.isTunnelOnWifiEnabled, onClick = { viewModel.setAutoTunnelOnWifiEnabled(it) }, ) }, @@ -104,13 +104,13 @@ fun wifiTunnelingItems( }, onClick = { viewModel.setAutoTunnelOnWifiEnabled( - !autoTunnelState.generalSettings.isTunnelOnWifiEnabled + !autoTunnelState.settings.isTunnelOnWifiEnabled ) }, ) ) - return if (autoTunnelState.generalSettings.isTunnelOnWifiEnabled) { + return if (autoTunnelState.settings.isTunnelOnWifiEnabled) { baseItems + listOf( SelectionItem( @@ -128,9 +128,7 @@ fun wifiTunnelingItems( Text( stringResource( R.string.current_template, - autoTunnelState.generalSettings.wifiDetectionMethod.asTitleString( - context - ), + autoTunnelState.settings.wifiDetectionMethod.asTitleString(context), ), style = MaterialTheme.typography.bodySmall.copy( @@ -160,14 +158,12 @@ fun wifiTunnelingItems( }, trailing = { ScaledSwitch( - checked = autoTunnelState.generalSettings.isWildcardsEnabled, + checked = autoTunnelState.settings.isWildcardsEnabled, onClick = { viewModel.setWildcardsEnabled(it) }, ) }, onClick = { - viewModel.setWildcardsEnabled( - !autoTunnelState.generalSettings.isWildcardsEnabled - ) + viewModel.setWildcardsEnabled(!autoTunnelState.settings.isWildcardsEnabled) }, ), SelectionItem( @@ -204,14 +200,13 @@ fun wifiTunnelingItems( }, description = { TrustedNetworkTextBox( - autoTunnelState.generalSettings.trustedNetworkSSIDs, + autoTunnelState.settings.trustedNetworkSSIDs, onDelete = { viewModel.removeTrustedNetworkName(it) }, currentText = currentText, onSave = { ssid -> viewModel.saveTrustedNetworkName(ssid) }, onValueChange = { currentText = it }, supporting = { - if (autoTunnelState.generalSettings.isWildcardsEnabled) - WildcardsLabel() + if (autoTunnelState.settings.isWildcardsEnabled) WildcardsLabel() }, ) }, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt index 3b8724e5..0317dcac 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/autotunnel/detection/WifiDetectionMethodScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod -import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.button.IconSurfaceButton import com.zaneschepke.wireguardautotunnel.util.extensions.asDescriptionString import com.zaneschepke.wireguardautotunnel.util.extensions.asTitleString @@ -22,7 +21,6 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.AutoTunnelViewModel @Composable fun WifiDetectionMethodScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) { val context = LocalContext.current - val sharedViewModel = LocalSharedVm.current val autoTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() @@ -36,8 +34,8 @@ fun WifiDetectionMethodScreen(viewModel: AutoTunnelViewModel = hiltViewModel()) val description = it.asDescriptionString(context) IconSurfaceButton( title = title, - onClick = { sharedViewModel.setWifiDetectionMethod(it) }, - selected = autoTunnelState.generalSettings.wifiDetectionMethod == it, + onClick = { viewModel.setWifiDetectionMethod(it) }, + selected = autoTunnelState.settings.wifiDetectionMethod == it, description = description, ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt index 8bdebb72..c26e1e68 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/TunnelsScreen.kt @@ -5,6 +5,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.fillMaxSize 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.saveable.rememberSaveable @@ -38,7 +39,6 @@ fun TunnelsScreen(viewModel: TunnelsViewModel = hiltViewModel()) { val navController = LocalNavController.current val clipboard = rememberClipboardHelper() - val sharedState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle() val tunnelsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() var showExportSheet by rememberSaveable { mutableStateOf(false) } @@ -46,11 +46,17 @@ fun TunnelsScreen(viewModel: TunnelsViewModel = hiltViewModel()) { var showDeleteModal by rememberSaveable { mutableStateOf(false) } var showUrlDialog by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(tunnelsState.selectedTunnels) { + sharedViewModel.setSelectedTunnelCount(tunnelsState.selectedTunnels.size) + } + sharedViewModel.collectSideEffect { sideEffect -> when (sideEffect) { LocalSideEffect.Sheet.ImportTunnels -> showImportSheet = true LocalSideEffect.Modal.DeleteTunnels -> showDeleteModal = true LocalSideEffect.Sheet.ExportTunnels -> showExportSheet = true + LocalSideEffect.SelectedTunnels.Copy -> viewModel.copySelectedTunnel() + LocalSideEffect.SelectedTunnels.SelectAll -> viewModel.toggleSelectAllTunnels() else -> Unit } } @@ -94,7 +100,7 @@ fun TunnelsScreen(viewModel: TunnelsViewModel = hiltViewModel()) { InfoDialog( onDismiss = { showDeleteModal = false }, onAttest = { - sharedViewModel.deleteSelectedTunnels() + viewModel.deleteSelectedTunnels() showDeleteModal = false }, title = { Text(text = stringResource(R.string.delete_tunnel)) }, @@ -105,11 +111,11 @@ fun TunnelsScreen(viewModel: TunnelsViewModel = hiltViewModel()) { if (showExportSheet) { ExportTunnelsBottomSheet({ type, uri -> - sharedViewModel.exportSelectedTunnels(type, uri) + viewModel.exportSelectedTunnels(type, uri) showExportSheet = false }) { showExportSheet = false - sharedViewModel.clearSelectedTunnels() + viewModel.clearSelectedTunnels() } } @@ -140,5 +146,5 @@ fun TunnelsScreen(viewModel: TunnelsViewModel = hiltViewModel()) { ) } - TunnelList(tunnelsState, sharedState, modifier = Modifier.fillMaxSize(), sharedViewModel) + TunnelList(tunnelsState, Modifier.fillMaxSize(), viewModel, sharedViewModel) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/TunnelAutoTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/TunnelAutoTunnelScreen.kt index 8909117e..6f535461 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/TunnelAutoTunnelScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/autotunnel/TunnelAutoTunnelScreen.kt @@ -7,30 +7,23 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.components.MobileDataTunnelItem import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.components.WifiTunnelItem import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.autotunnel.components.ethernetTunnelItem -import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelsViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelViewModel @Composable -fun TunnelAutoTunnelScreen(tunnelId: Int, viewModel: TunnelsViewModel = hiltViewModel()) { - val tunnelsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() +fun TunnelAutoTunnelScreen(viewModel: TunnelViewModel) { + val tunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() - if (!tunnelsState.stateInitialized) return - - val tunnelConf by - remember(tunnelsState.tunnels) { - derivedStateOf { tunnelsState.tunnels.find { it.id == tunnelId }!! } - } + if (tunnelState.isLoading) return + val tunnel = tunnelState.tunnel ?: return Column( horizontalAlignment = Alignment.Start, @@ -42,21 +35,21 @@ fun TunnelAutoTunnelScreen(tunnelId: Int, viewModel: TunnelsViewModel = hiltView items = buildList { add( - MobileDataTunnelItem(tunnelConf.isMobileDataTunnel) { - viewModel.setMobileDataTunnel(tunnelId, it) + MobileDataTunnelItem(tunnel.isMobileDataTunnel) { + viewModel.setMobileDataTunnel(it) } ) add( - ethernetTunnelItem(tunnelConf.isEthernetTunnel) { - viewModel.setEthernetTunnel(tunnelId, it) + ethernetTunnelItem(tunnel.isEthernetTunnel) { + viewModel.setEthernetTunnel(it) } ) add( WifiTunnelItem( - tunnelConf.tunnelNetworks, - tunnelsState.isWildcardsEnabled, - onSaveTunnelNetwork = { viewModel.addTunnelNetwork(tunnelId, it) }, - onDeleteTunnelNetwork = { viewModel.removeTunnelNetwork(tunnelId, it) }, + tunnel.tunnelNetworks, + isWildcardsEnabled = tunnelState.isWildcardsEnabled, + onSaveTunnelNetwork = { viewModel.addTunnelNetwork(it) }, + onDeleteTunnelNetwork = { viewModel.removeTunnelNetwork(it) }, ) ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt index 9a82a03b..388e40ad 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt @@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -19,17 +20,17 @@ import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.navigation.Route -import com.zaneschepke.wireguardautotunnel.ui.state.SharedAppUiState import com.zaneschepke.wireguardautotunnel.ui.state.TunnelsUiState import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelsViewModel @OptIn(ExperimentalFoundationApi::class) @Composable fun TunnelList( tunnelsState: TunnelsUiState, - sharedState: SharedAppUiState, modifier: Modifier = Modifier, + viewModel: TunnelsViewModel, sharedViewModel: SharedAppViewModel, ) { val navController = LocalNavController.current @@ -43,8 +44,10 @@ fun TunnelList( modifier = modifier .pointerInput(Unit) { - if (tunnelsState.tunnels.isEmpty()) return@pointerInput - sharedViewModel.clearSelectedTunnels() + detectTapGestures { + if (tunnelsState.tunnels.isEmpty()) return@detectTapGestures + viewModel.clearSelectedTunnels() + } } .overscroll(rememberOverscrollEffect()), state = lazyListState, @@ -55,23 +58,21 @@ fun TunnelList( if (tunnelsState.tunnels.isEmpty()) { item { GettingStartedLabel(onClick = { context.openWebUrl(it) }) } } - items(tunnelsState.tunnels, key = { it.id }) { tunnel -> + items(tunnelsState.tunnels.toList(), key = { it.id }) { tunnel -> val tunnelState = remember(tunnelsState.activeTunnels) { tunnelsState.activeTunnels[tunnel.id] ?: TunnelState() } val selected = - remember(sharedState.selectedTunnels) { - sharedState.selectedTunnels.any { it.id == tunnel.id } + remember(tunnelsState.selectedTunnels) { + tunnelsState.selectedTunnels.any { it.id == tunnel.id } } TunnelRowItem( state = tunnelState, isSelected = selected, tunnel = tunnel, onTvClick = { navController.push(Route.TunnelOptions(tunnel.id)) }, - onToggleSelectedTunnel = { tunnel -> - sharedViewModel.toggleSelectedTunnel(tunnel.id) - }, + onToggleSelectedTunnel = { tunnel -> viewModel.toggleSelectedTunnel(tunnel.id) }, onSwitchClick = { checked -> if (checked) sharedViewModel.startTunnel(tunnel) else sharedViewModel.stopTunnel(tunnel) @@ -83,14 +84,14 @@ fun TunnelList( (if (!isTv) Modifier.combinedClickable( onClick = { - if (sharedState.selectedTunnels.isNotEmpty()) { - sharedViewModel.toggleSelectedTunnel(tunnel.id) + if (tunnelsState.selectedTunnels.isNotEmpty()) { + viewModel.toggleSelectedTunnel(tunnel.id) } else { navController.push(Route.TunnelOptions(tunnel.id)) - sharedViewModel.clearSelectedTunnels() + viewModel.clearSelectedTunnels() } }, - onLongClick = { sharedViewModel.toggleSelectedTunnel(tunnel.id) }, + onLongClick = { viewModel.toggleSelectedTunnel(tunnel.id) }, interactionSource = remember { MutableInteractionSource() }, indication = null, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/ConfigScreen.kt index 79dccd8a..6c2e4b97 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/ConfigScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/config/ConfigScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.SecureScreenFromRecording @@ -21,36 +22,34 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components. import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy import com.zaneschepke.wireguardautotunnel.ui.state.PeerProxy +import com.zaneschepke.wireguardautotunnel.viewmodel.ConfigViewModel import org.orbitmvi.orbit.compose.collectSideEffect @Composable -fun ConfigScreen(tunnelId: Int? = null) { - val viewModel = LocalSharedVm.current +fun ConfigScreen(viewModel: ConfigViewModel) { + val sharedViewModel = LocalSharedVm.current - val tunnelsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val configUiState by viewModel.container.stateFlow.collectAsStateWithLifecycle() - val tunnelConf by - remember(tunnelsState.tunnels) { - derivedStateOf { tunnelsState.tunnels.find { it.id == tunnelId } } - } + if (configUiState.isLoading) return var configProxy by remember { - mutableStateOf(tunnelConf?.let { ConfigProxy.from(it.toAmConfig()) } ?: ConfigProxy()) + mutableStateOf( + configUiState.tunnel?.let { ConfigProxy.from(it.toAmConfig()) } ?: ConfigProxy() + ) } - var tunnelName by remember { mutableStateOf(tunnelConf?.tunName ?: "") } + var tunnelName by remember { mutableStateOf(configUiState.tunnel?.tunName ?: "") } val isGlobalConfig = rememberSaveable { tunnelName == TunnelConfig.GLOBAL_CONFIG_NAME } val isTunnelNameTaken by - remember(tunnelName, tunnelsState.tunnels) { - derivedStateOf { - tunnelsState.tunnels.any { it.tunName == tunnelName && it.id != tunnelConf?.id } - } + remember(tunnelName) { + derivedStateOf { configUiState.unavailableNames.contains(tunnelName) } } - viewModel.collectSideEffect { sideEffect -> + sharedViewModel.collectSideEffect { sideEffect -> if (sideEffect is LocalSideEffect.SaveChanges) - viewModel.saveConfigProxy(tunnelId, configProxy, tunnelName) + viewModel.saveConfigProxy(configProxy, tunnelName) } SecureScreenFromRecording() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt index c2b0b35c..49c39791 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/splittunnel/SplitTunnelScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.splittunnel.components.SplitTunnelContent @@ -20,22 +19,19 @@ import org.orbitmvi.orbit.compose.collectSideEffect @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun SplitTunnelScreen(tunnelId: Int, viewModel: SplitTunnelViewModel = hiltViewModel()) { +fun SplitTunnelScreen(viewModel: SplitTunnelViewModel) { val sharedViewModel = LocalSharedVm.current val splitTunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() - if (!splitTunnelState.stateInitialized) { + if (splitTunnelState.isLoading) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularWavyProgressIndicator(waveSpeed = 60.dp, modifier = Modifier.size(48.dp)) } return } - val tunnelConf by - remember(splitTunnelState.tunnels) { - derivedStateOf { splitTunnelState.tunnels.find { it.id == tunnelId }!! } - } + val tunnel = splitTunnelState.tunnel ?: return - val conf by remember { derivedStateOf { tunnelConf.toAmConfig() } } + val conf by remember { derivedStateOf { tunnel.toAmConfig() } } var splitConfig by remember { mutableStateOf( @@ -51,7 +47,7 @@ fun SplitTunnelScreen(tunnelId: Int, viewModel: SplitTunnelViewModel = hiltViewM sharedViewModel.collectSideEffect { sideEffect -> if (sideEffect is LocalSideEffect.SaveChanges) - viewModel.saveSplitTunnelSelection(tunnelId, splitConfig) + viewModel.saveSplitTunnelSelection(splitConfig) } SplitTunnelContent( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/TunnelOptionsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/TunnelOptionsScreen.kt index 035323e3..c8e8c9a7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/TunnelOptionsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/tunneloptions/TunnelOptionsScreen.kt @@ -12,7 +12,6 @@ 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.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.model.AppMode @@ -23,25 +22,18 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelec import com.zaneschepke.wireguardautotunnel.ui.navigation.Route import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.tunneloptions.components.* import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect -import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelsViewModel +import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelViewModel import org.orbitmvi.orbit.compose.collectSideEffect @Composable -fun TunnelOptionsScreen(tunnelId: Int, viewModel: TunnelsViewModel = hiltViewModel()) { +fun TunnelOptionsScreen(viewModel: TunnelViewModel) { val navController = LocalNavController.current val sharedViewModel = LocalSharedVm.current - val tunnelsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val tunnelState by viewModel.container.stateFlow.collectAsStateWithLifecycle() - if (!tunnelsState.stateInitialized) return - - val tunnelConf by - remember(tunnelsState.tunnels) { - derivedStateOf { tunnelsState.tunnels.find { it.id == tunnelId }!! } - } - - val ipv6Preferred by - remember(tunnelConf.isIpv4Preferred) { mutableStateOf(!tunnelConf.isIpv4Preferred) } + if (tunnelState.isLoading) return + val tunnel = tunnelState.tunnel ?: return var showQrModal by rememberSaveable { mutableStateOf(false) } @@ -50,7 +42,7 @@ fun TunnelOptionsScreen(tunnelId: Int, viewModel: TunnelsViewModel = hiltViewMod } if (showQrModal) { - QrCodeDialog(tunnelConf = tunnelConf, onDismiss = { showQrModal = false }) + QrCodeDialog(tunnelConf = tunnel, onDismiss = { showQrModal = false }) } Column( @@ -62,11 +54,11 @@ fun TunnelOptionsScreen(tunnelId: Int, viewModel: TunnelsViewModel = hiltViewMod SurfaceSelectionGroupButton( items = buildList { - add(primaryTunnelItem(tunnelConf) { viewModel.togglePrimaryTunnel(tunnelId) }) - add(autoTunnelingItem(tunnelConf)) + add(primaryTunnelItem(tunnel) { viewModel.togglePrimaryTunnel() }) + add(autoTunnelingItem(tunnel)) add( splitTunnelingItem(stringResource(R.string.splt_tunneling)) { - navController.push(Route.SplitTunnel(id = tunnelConf.id)) + navController.push(Route.SplitTunnel(id = tunnel.id)) } ) } @@ -76,25 +68,22 @@ fun TunnelOptionsScreen(tunnelId: Int, viewModel: TunnelsViewModel = hiltViewMod items = buildList { add( - dynamicDnsItem(tunnelConf.restartOnPingFailure) { - viewModel.setRestartOnPing(tunnelId, it) + dynamicDnsItem(tunnel.restartOnPingFailure) { + viewModel.setRestartOnPing(it) } ) - if (tunnelsState.appMode != AppMode.KERNEL) + if (tunnelState.appMode != AppMode.KERNEL) add( - preferIpv6Item(ipv6Preferred) { - viewModel.toggleIpv4Preferred(tunnelId) + preferIpv6Item(!tunnel.isIpv4Preferred) { + viewModel.toggleIpv4Preferred() } ) } ) - if (tunnelsState.isPingEnabled) { + if (tunnelState.isPingEnabled) { SectionDivider() SurfaceSelectionGroupButton( - items = - listOf( - pingConfigItem(tunnelConf) { ip -> viewModel.setTunnelPingIp(ip, tunnelId) } - ) + items = listOf(pingConfigItem(tunnel) { ip -> viewModel.setTunnelPingIp(ip) }) ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt index d7080884..9a2898dc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt @@ -21,4 +21,10 @@ sealed class LocalSideEffect { data object DeleteTunnels : Modal() } + + sealed class SelectedTunnels : LocalSideEffect() { + data object SelectAll : SelectedTunnels() + + data object Copy : SelectedTunnels() + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt index 3c62d676..9d4b2e3b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/AutoTunnelUiState.kt @@ -6,7 +6,7 @@ import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings data class AutoTunnelUiState( val autoTunnelActive: Boolean = false, val connectivityState: ConnectivityState? = null, - val generalSettings: GeneralSettings = GeneralSettings(), + val settings: GeneralSettings = GeneralSettings(), val isBatteryOptimizationShown: Boolean = false, val isLocationDisclosureShown: Boolean = false, val stateInitialized: Boolean = false, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigUiState.kt new file mode 100644 index 00000000..70442ee5 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/ConfigUiState.kt @@ -0,0 +1,9 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf + +data class ConfigUiState( + val unavailableNames: Set = emptySet(), + val isLoading: Boolean = true, + val tunnel: TunnelConf? = null, +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SharedAppUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SharedAppUiState.kt index 2ab81855..a48e3fa7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SharedAppUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SharedAppUiState.kt @@ -1,7 +1,6 @@ package com.zaneschepke.wireguardautotunnel.ui.state import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.ui.theme.Theme import com.zaneschepke.wireguardautotunnel.util.LocaleUtil @@ -10,10 +9,10 @@ data class SharedAppUiState( val theme: Theme = Theme.AUTOMATIC, val locale: String = LocaleUtil.OPTION_PHONE_LANGUAGE, val pinLockEnabled: Boolean = false, + val tunnelNames: Map = emptyMap(), + val selectedTunnelCount: Int = 0, val isAuthorized: Boolean = false, val isAutoTunnelActive: Boolean = false, val isLocationDisclosureShown: Boolean = false, - val tunnels: List = emptyList(), - val selectedTunnels: Set = setOf(), val settings: GeneralSettings = GeneralSettings(), ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt index 66b47c94..ee28464d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/SplitTunnelUiState.kt @@ -5,6 +5,6 @@ import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf data class SplitTunnelUiState( val installedPackages: List = emptyList(), - val stateInitialized: Boolean = false, - val tunnels: List = emptyList(), + val isLoading: Boolean = true, + val tunnel: TunnelConf? = null, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelUiState.kt new file mode 100644 index 00000000..7b20157e --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelUiState.kt @@ -0,0 +1,13 @@ +package com.zaneschepke.wireguardautotunnel.ui.state + +import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf + +data class TunnelUiState( + val tunnel: TunnelConf? = null, + val isActive: Boolean = false, + val isLoading: Boolean = true, + val isPingEnabled: Boolean = false, + val isWildcardsEnabled: Boolean = false, + val appMode: AppMode = AppMode.VPN, +) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt index 0c5d27b6..0bce76e3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt @@ -5,7 +5,8 @@ import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState data class TunnelsUiState( - val tunnels: List = listOf(), + val tunnels: List = emptyList(), + val selectedTunnels: List = emptyList(), val activeTunnels: Map = emptyMap(), val isPingEnabled: Boolean = false, val appMode: AppMode = AppMode.VPN, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt index 92d46519..748fa93c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/FileUtils.kt @@ -8,6 +8,7 @@ import android.os.Build import android.provider.MediaStore import android.provider.OpenableColumns import androidx.annotation.RequiresApi +import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName import com.zaneschepke.wireguardautotunnel.util.extensions.getInputStreamFromUri @@ -19,7 +20,10 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import timber.log.Timber -class FileUtils(private val context: Context, private val ioDispatcher: CoroutineDispatcher) { +class FileUtils( + private val context: Context, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { /** * Creates a configuration file for a given tunnel if the config string is valid. diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/RootShellUtils.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/RootShellUtils.kt new file mode 100644 index 00000000..12ef66d5 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/RootShellUtils.kt @@ -0,0 +1,26 @@ +package com.zaneschepke.wireguardautotunnel.util + +import com.wireguard.android.util.RootShell +import com.zaneschepke.wireguardautotunnel.di.AppShell +import com.zaneschepke.wireguardautotunnel.di.IoDispatcher +import javax.inject.Provider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +class RootShellUtils( + @AppShell private val rootShell: Provider, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) { + + suspend fun requestRoot(): Boolean = + withContext(ioDispatcher) { + val accepted = + try { + rootShell.get().start() + true + } catch (_: Exception) { + false + } + accepted + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt index 771575cb..b4373ab2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AutoTunnelViewModel.kt @@ -1,15 +1,18 @@ package com.zaneschepke.wireguardautotunnel.viewmodel +import androidx.core.content.PermissionChecker.PERMISSION_GRANTED import androidx.lifecycle.ViewModel import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect import com.zaneschepke.wireguardautotunnel.ui.state.AutoTunnelUiState +import com.zaneschepke.wireguardautotunnel.util.RootShellUtils import com.zaneschepke.wireguardautotunnel.util.StringValue import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -17,6 +20,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container +import rikka.shizuku.Shizuku @HiltViewModel class AutoTunnelViewModel @@ -27,6 +31,7 @@ constructor( private val networkMonitor: NetworkMonitor, private val globalEffectRepository: GlobalEffectRepository, private val appStateRepository: AppStateRepository, + private val rootShellUtils: RootShellUtils, ) : ContainerHost, ViewModel() { override val container = @@ -44,7 +49,7 @@ constructor( AutoTunnelUiState( autoTunnelActive = active, connectivityState = connectivity, - generalSettings = settings, + settings = settings, isBatteryOptimizationShown = appState.isBatteryOptimizationDisableShown, isLocationDisclosureShown = appState.isLocationDisclosureShown, stateInitialized = true, @@ -64,7 +69,7 @@ constructor( fun toggleAutoTunnel() = intent { if (!state.autoTunnelActive) { - when (state.generalSettings.appMode) { + when (state.settings.appMode) { AppMode.VPN -> if (!serviceManager.hasVpnPermission()) return@intent postSideEffect( @@ -82,45 +87,87 @@ constructor( } fun setAutoTunnelOnWifiEnabled(to: Boolean) = intent { - settingsRepository.save(state.generalSettings.copy(isTunnelOnWifiEnabled = to)) + settingsRepository.save(state.settings.copy(isTunnelOnWifiEnabled = to)) } fun setWildcardsEnabled(to: Boolean) = intent { - settingsRepository.save(state.generalSettings.copy(isWildcardsEnabled = to)) + settingsRepository.save(state.settings.copy(isWildcardsEnabled = to)) } fun setStopOnNoInternetEnabled(to: Boolean) = intent { - settingsRepository.save(state.generalSettings.copy(isStopOnNoInternetEnabled = to)) + settingsRepository.save(state.settings.copy(isStopOnNoInternetEnabled = to)) } fun saveTrustedNetworkName(name: String) = intent { if (name.isEmpty()) return@intent val trimmed = name.trim() - if (state.generalSettings.trustedNetworkSSIDs.contains(name)) { + if (state.settings.trustedNetworkSSIDs.contains(name)) { return@intent postSideEffect( GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.error_ssid_exists)) ) } - setTrustedNetworkNames((state.generalSettings.trustedNetworkSSIDs + trimmed).toMutableSet()) + setTrustedNetworkNames((state.settings.trustedNetworkSSIDs + trimmed).toMutableSet()) } fun setTrustedNetworkNames(to: Set) = intent { - settingsRepository.save(state.generalSettings.copy(trustedNetworkSSIDs = to)) + settingsRepository.save(state.settings.copy(trustedNetworkSSIDs = to)) } fun removeTrustedNetworkName(name: String) = intent { - setTrustedNetworkNames((state.generalSettings.trustedNetworkSSIDs - name).toMutableSet()) + setTrustedNetworkNames((state.settings.trustedNetworkSSIDs - name).toMutableSet()) } fun setTunnelOnCellular(to: Boolean) = intent { - settingsRepository.save(state.generalSettings.copy(isTunnelOnMobileDataEnabled = to)) + settingsRepository.save(state.settings.copy(isTunnelOnMobileDataEnabled = to)) } fun setTunnelOnEthernet(to: Boolean) = intent { - settingsRepository.save(state.generalSettings.copy(isTunnelOnEthernetEnabled = to)) + settingsRepository.save(state.settings.copy(isTunnelOnEthernetEnabled = to)) } fun setDebounceDelay(to: Int) = intent { - settingsRepository.save(state.generalSettings.copy(debounceDelaySeconds = to)) + settingsRepository.save(state.settings.copy(debounceDelaySeconds = to)) + } + + fun setWifiDetectionMethod(method: WifiDetectionMethod) = intent { + when (method) { + WifiDetectionMethod.ROOT -> { + val accepted = rootShellUtils.requestRoot() + val message = + if (!accepted) StringValue.StringResource(R.string.error_root_denied) + else StringValue.StringResource(R.string.root_accepted) + postSideEffect(GlobalSideEffect.Snackbar(message)) + if (!accepted) return@intent + } + WifiDetectionMethod.SHIZUKU -> { + requestShizuku() + return@intent + } + else -> Unit + } + settingsRepository.save(state.settings.copy(wifiDetectionMethod = method)) + } + + private fun requestShizuku() = intent { + Shizuku.addRequestPermissionResultListener( + Shizuku.OnRequestPermissionResultListener { requestCode: Int, grantResult: Int -> + if (grantResult != PERMISSION_GRANTED) return@OnRequestPermissionResultListener + intent { + settingsRepository.save( + state.settings.copy(wifiDetectionMethod = WifiDetectionMethod.SHIZUKU) + ) + } + } + ) + try { + if (Shizuku.checkSelfPermission() != PERMISSION_GRANTED) Shizuku.requestPermission(123) + settingsRepository.save( + state.settings.copy(wifiDetectionMethod = WifiDetectionMethod.SHIZUKU) + ) + } catch (_: Exception) { + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.shizuku_not_detected)) + ) + } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ConfigViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ConfigViewModel.kt new file mode 100644 index 00000000..2f90af91 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/ConfigViewModel.kt @@ -0,0 +1,95 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel + +import androidx.lifecycle.ViewModel +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy +import com.zaneschepke.wireguardautotunnel.ui.state.ConfigUiState +import com.zaneschepke.wireguardautotunnel.util.StringValue +import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import org.amnezia.awg.config.BadConfigException +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import timber.log.Timber + +@HiltViewModel(assistedFactory = ConfigViewModel.Factory::class) +class ConfigViewModel +@AssistedInject +constructor( + private val tunnelRepository: TunnelRepository, + private val globalEffectRepository: GlobalEffectRepository, + @Assisted val tunnelId: Int?, +) : ContainerHost, ViewModel() { + + override val container = + container( + ConfigUiState(), + buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, + ) { + tunnelRepository.flow.collect { tuns -> + reduce { + val tunnel = tuns.firstOrNull { it.id == tunnelId } + ConfigUiState( + tuns.filter { it.id != tunnelId }.map { it.tunName }.toSet(), + false, + tunnel, + ) + } + } + } + + fun saveConfigProxy(configProxy: ConfigProxy, tunnelName: String) = intent { + if (state.unavailableNames.contains(tunnelName)) + return@intent postSideEffect( + GlobalSideEffect.Toast(StringValue.StringResource(R.string.tunnel_name_taken)) + ) + runCatching { + val (wg, am) = configProxy.buildConfigs() + val tunnelConf = + if (tunnelId == null) { + TunnelConf.tunnelConfFromQuick(am.toAwgQuickString(true, false), tunnelName) + } else { + state.tunnel?.copy( + tunName = tunnelName, + amQuick = am.toAwgQuickString(true, false), + wgQuick = wg.toWgQuickString(true), + ) + } + if (tunnelConf != null) { + tunnelRepository.save(tunnelConf) + postSideEffect( + GlobalSideEffect.Toast( + StringValue.StringResource(R.string.config_changes_saved) + ) + ) + postSideEffect(GlobalSideEffect.PopBackStack) + } + } + .onFailure { + Timber.e(it) + val message = + when (it) { + is BadConfigException -> it.asStringValue() + is com.wireguard.config.BadConfigException -> it.asStringValue() + else -> StringValue.StringResource(R.string.unknown_error) + } + postSideEffect(GlobalSideEffect.Snackbar(message)) + } + } + + suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) + } + + @AssistedFactory + interface Factory { + fun create(tunnelId: Int?): ConfigViewModel + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt index 5a5a0852..b92c093d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt @@ -1,18 +1,10 @@ package com.zaneschepke.wireguardautotunnel.viewmodel -import android.net.Uri -import androidx.core.content.PermissionChecker.PERMISSION_GRANTED import androidx.lifecycle.ViewModel -import com.wireguard.android.util.RootShell import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.model.AppMode -import com.zaneschepke.wireguardautotunnel.data.model.WifiDetectionMethod -import com.zaneschepke.wireguardautotunnel.di.AppShell -import com.zaneschepke.wireguardautotunnel.di.IoDispatcher -import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository @@ -20,29 +12,17 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectReposit import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect -import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy import com.zaneschepke.wireguardautotunnel.ui.state.SharedAppUiState import com.zaneschepke.wireguardautotunnel.ui.theme.Theme -import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.LocaleUtil +import com.zaneschepke.wireguardautotunnel.util.RootShellUtils import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue -import com.zaneschepke.wireguardautotunnel.util.extensions.saveTunnelsUniquely import dagger.hilt.android.lifecycle.HiltViewModel -import java.io.File -import java.time.Instant import javax.inject.Inject -import javax.inject.Provider -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext -import org.amnezia.awg.config.BadConfigException import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container -import rikka.shizuku.Shizuku -import timber.log.Timber import xyz.teamgravity.pin_lock_compose.PinManager @HiltViewModel @@ -55,9 +35,7 @@ constructor( private val globalEffectRepository: GlobalEffectRepository, private val tunnelRepository: TunnelRepository, private val settingsRepository: GeneralSettingRepository, - private val fileUtils: FileUtils, - @AppShell private val rootShell: Provider, - @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val rootShellUtils: RootShellUtils, ) : ContainerHost, ViewModel() { val globalSideEffect = globalEffectRepository.flow @@ -70,16 +48,18 @@ constructor( intent { combine( appStateRepository.flow, + tunnelRepository.userTunnelsFlow.map { tuns -> + tuns.associate { it.id to it.tunName } + }, serviceManager.autoTunnelService.map { it != null }, settingsRepository.flow, - tunnelRepository.flow, - ) { appState, autoTunnelActive, settings, tunnels -> + ) { appState, tunnelNames, autoTunnelActive, settings -> state.copy( theme = appState.theme, locale = appState.locale ?: LocaleUtil.OPTION_PHONE_LANGUAGE, pinLockEnabled = appState.isPinLockEnabled, isAutoTunnelActive = autoTunnelActive, - tunnels = tunnels, + tunnelNames = tunnelNames, settings = settings, isLocationDisclosureShown = appState.isLocationDisclosureShown, isAppLoaded = true, @@ -127,44 +107,8 @@ constructor( appStateRepository.setPinLockEnabled(enabled) } - fun saveConfigProxy(tunnelId: Int?, configProxy: ConfigProxy, tunnelName: String) = intent { - if (state.tunnels.any { it.tunName == tunnelName && it.id != tunnelId }) - return@intent postSideEffect( - GlobalSideEffect.Toast(StringValue.StringResource(R.string.tunnel_name_taken)) - ) - runCatching { - val (wg, am) = configProxy.buildConfigs() - val tunnelConf = - if (tunnelId == null) { - TunnelConf.tunnelConfFromQuick(am.toAwgQuickString(true, false), tunnelName) - } else { - val latestTunnel = state.tunnels.find { it.id == tunnelId } - latestTunnel?.copy( - tunName = tunnelName, - amQuick = am.toAwgQuickString(true, false), - wgQuick = wg.toWgQuickString(true), - ) - } - if (tunnelConf != null) { - tunnelRepository.save(tunnelConf) - postSideEffect( - GlobalSideEffect.Toast( - StringValue.StringResource(R.string.config_changes_saved) - ) - ) - postSideEffect(GlobalSideEffect.PopBackStack) - } - } - .onFailure { - Timber.e(it) - val message = - when (it) { - is BadConfigException -> it.asStringValue() - is com.wireguard.config.BadConfigException -> it.asStringValue() - else -> StringValue.StringResource(R.string.unknown_error) - } - postSideEffect(GlobalSideEffect.Snackbar(message)) - } + fun setSelectedTunnelCount(count: Int) = intent { + reduce { state.copy(selectedTunnelCount = count) } } fun stopTunnel(tunnelConf: TunnelConf) = intent { tunnelManager.stopTunnel(tunnelConf.id) } @@ -180,7 +124,14 @@ constructor( ) } } - AppMode.KERNEL -> if (!requestRoot()) return@intent + AppMode.KERNEL -> { + val accepted = rootShellUtils.requestRoot() + val message = + if (!accepted) StringValue.StringResource(R.string.error_root_denied) + else StringValue.StringResource(R.string.root_accepted) + postSideEffect(GlobalSideEffect.Snackbar(message)) + if (!accepted) return@intent + } } settingsRepository.save(state.settings.copy(appMode = appMode)) } @@ -204,155 +155,4 @@ constructor( fun disableBatteryOptimizationsShown() = intent { appStateRepository.setBatteryOptimizationDisableShown(true) } - - fun setWifiDetectionMethod(method: WifiDetectionMethod) = intent { - when (method) { - WifiDetectionMethod.ROOT -> if (!requestRoot()) return@intent - WifiDetectionMethod.SHIZUKU -> { - requestShizuku() - return@intent - } - else -> Unit - } - settingsRepository.save(state.settings.copy(wifiDetectionMethod = method)) - } - - private fun requestShizuku() = intent { - Shizuku.addRequestPermissionResultListener( - Shizuku.OnRequestPermissionResultListener { requestCode: Int, grantResult: Int -> - if (grantResult != PERMISSION_GRANTED) return@OnRequestPermissionResultListener - intent { - settingsRepository.save( - state.settings.copy(wifiDetectionMethod = WifiDetectionMethod.SHIZUKU) - ) - } - } - ) - try { - if (Shizuku.checkSelfPermission() != PERMISSION_GRANTED) Shizuku.requestPermission(123) - settingsRepository.save( - state.settings.copy(wifiDetectionMethod = WifiDetectionMethod.SHIZUKU) - ) - } catch (_: Exception) { - postSideEffect( - GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.shizuku_not_detected)) - ) - } - } - - private suspend fun requestRoot(): Boolean = - withContext(ioDispatcher) { - val accepted = - try { - rootShell.get().start() - true - } catch (_: Exception) { - false - } - val message = - if (!accepted) StringValue.StringResource(R.string.error_root_denied) - else StringValue.StringResource(R.string.root_accepted) - intent { postSideEffect(GlobalSideEffect.Snackbar(message)) } - accepted - } - - fun toggleSelectAllTunnels() = intent { - if (state.selectedTunnels.size != state.tunnels.size) { - return@intent reduce { state.copy(selectedTunnels = state.tunnels.toSet()) } - } - reduce { state.copy(selectedTunnels = emptySet()) } - } - - fun clearSelectedTunnels() = intent { reduce { state.copy(selectedTunnels = emptySet()) } } - - fun toggleSelectedTunnel(tunnelId: Int) = intent { - reduce { - state.copy( - selectedTunnels = - state.selectedTunnels.toMutableSet().apply { - val removed = removeIf { it.id == tunnelId } - if (!removed) addAll(state.tunnels.filter { it.id == tunnelId }) - } - ) - } - } - - fun deleteSelectedTunnels() = intent { - val activeTunIds = tunnelManager.activeTunnels.firstOrNull()?.map { it.key } - if (state.selectedTunnels.any { activeTunIds?.contains(it.id) == true }) - return@intent postSideEffect( - GlobalSideEffect.Snackbar( - StringValue.StringResource(R.string.delete_active_message) - ) - ) - tunnelRepository.delete(state.selectedTunnels.toList()) - clearSelectedTunnels() - } - - fun copySelectedTunnel() = intent { - val selected = state.selectedTunnels.firstOrNull() ?: return@intent - val copy = TunnelConf.tunnelConfFromQuick(selected.amQuick, selected.tunName) - tunnelRepository.saveTunnelsUniquely(listOf(copy), state.tunnels) - clearSelectedTunnels() - } - - fun exportSelectedTunnels(configType: ConfigType, uri: Uri?) = intent { - val (files, shareFileName) = - when (configType) { - ConfigType.AM -> - Pair( - createAmFiles(state.selectedTunnels), - "am-export_${Instant.now().epochSecond}.zip", - ) - ConfigType.WG -> - Pair( - createWgFiles(state.selectedTunnels), - "wg-export_${Instant.now().epochSecond}.zip", - ) - } - val onFailure = { action: Throwable -> - intent { - postSideEffect( - GlobalSideEffect.Toast( - StringValue.StringResource( - R.string.export_failed, - ": ${action.localizedMessage}", - ) - ) - ) - } - Unit - } - fileUtils - .createNewShareFile(shareFileName) - .onSuccess { - fileUtils.zipAll(it, files).onFailure(onFailure) - fileUtils.exportFile(it, uri, FileUtils.ZIP_FILE_MIME_TYPE).onFailure(onFailure) - postSideEffect( - GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.export_success)) - ) - clearSelectedTunnels() - } - .onFailure(onFailure) - } - - suspend fun createWgFiles(tunnels: Collection): List = - withContext(ioDispatcher) { - tunnels.mapNotNull { config -> - if (config.wgQuick.isNotBlank()) { - fileUtils.createFile(config.tunName, config.wgQuick) - } else null - } - } - - suspend fun createAmFiles(tunnels: Collection): List = - withContext(ioDispatcher) { - tunnels.mapNotNull { config -> - if ( - config.amQuick != TunnelConfig.AM_QUICK_DEFAULT && config.amQuick.isNotBlank() - ) { - fileUtils.createFile(config.tunName, config.amQuick) - } else null - } - } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt index 95788ae8..454ac3fb 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SplitTunnelViewModel.kt @@ -11,20 +11,24 @@ import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy import com.zaneschepke.wireguardautotunnel.ui.state.InterfaceProxy import com.zaneschepke.wireguardautotunnel.ui.state.SplitTunnelUiState import com.zaneschepke.wireguardautotunnel.util.StringValue +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container -@HiltViewModel +@HiltViewModel(assistedFactory = SplitTunnelViewModel.Factory::class) class SplitTunnelViewModel -@Inject +@AssistedInject constructor( private val tunnelRepository: TunnelRepository, private val packageRepository: InstalledPackageRepository, private val globalEffectRepository: GlobalEffectRepository, + @Assisted val tunnelId: Int, ) : ContainerHost, ViewModel() { override val container = @@ -37,8 +41,13 @@ constructor( emit(packages) } - combine(packagesFlow, tunnelRepository.flow) { packages, tunnels -> - SplitTunnelUiState(packages, true, tunnels) + combine( + packagesFlow, + tunnelRepository.userTunnelsFlow.map { + it.firstOrNull { tun -> tun.id == tunnelId } + }, + ) { packages, tunnel -> + SplitTunnelUiState(packages, tunnel == null, tunnel) } .collect { reduce { it } } } @@ -47,39 +56,36 @@ constructor( globalEffectRepository.post(globalSideEffect) } - fun saveSplitTunnelSelection(tunnelId: Int, splitConfig: Pair>) = - intent { - val latestTunnel = state.tunnels.find { it.id == tunnelId } - if (latestTunnel != null) { - val config = latestTunnel.toAmConfig() - val (option, pkgs) = splitConfig - val configProxy = ConfigProxy.from(config) - val interfaceProxy = InterfaceProxy.from(config.`interface`) - val (included, excluded) = - when (option) { - SplitOption.INCLUDE -> Pair(pkgs, emptySet()) - SplitOption.ALL -> Pair(emptySet(), emptySet()) - SplitOption.EXCLUDE -> Pair(emptySet(), pkgs) - } - val updatedInterface = - interfaceProxy.copy( - includedApplications = included, - excludedApplications = excluded, - ) - val updatedConfig = configProxy.copy(`interface` = updatedInterface) - val (wg, am) = updatedConfig.buildConfigs() - tunnelRepository.save( - latestTunnel.copy( - amQuick = am.toAwgQuickString(true, false), - wgQuick = wg.toWgQuickString(true), - ) - ) - postSideEffect( - GlobalSideEffect.Snackbar( - StringValue.StringResource(R.string.config_changes_saved) - ) - ) - postSideEffect(GlobalSideEffect.PopBackStack) + fun saveSplitTunnelSelection(splitConfig: Pair>) = intent { + val tunnel = state.tunnel ?: return@intent + val config = tunnel.toAmConfig() + val (option, pkgs) = splitConfig + val configProxy = ConfigProxy.from(config) + val interfaceProxy = InterfaceProxy.from(config.`interface`) + val (included, excluded) = + when (option) { + SplitOption.INCLUDE -> Pair(pkgs, emptySet()) + SplitOption.ALL -> Pair(emptySet(), emptySet()) + SplitOption.EXCLUDE -> Pair(emptySet(), pkgs) } - } + val updatedInterface = + interfaceProxy.copy(includedApplications = included, excludedApplications = excluded) + val updatedConfig = configProxy.copy(`interface` = updatedInterface) + val (wg, am) = updatedConfig.buildConfigs() + tunnelRepository.save( + tunnel.copy( + amQuick = am.toAwgQuickString(true, false), + wgQuick = wg.toWgQuickString(true), + ) + ) + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.config_changes_saved)) + ) + postSideEffect(GlobalSideEffect.PopBackStack) + } + + @AssistedFactory + interface Factory { + fun create(tunnelId: Int): SplitTunnelViewModel + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt new file mode 100644 index 00000000..db248415 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt @@ -0,0 +1,109 @@ +package com.zaneschepke.wireguardautotunnel.viewmodel + +import androidx.lifecycle.ViewModel +import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository +import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.state.TunnelUiState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container + +@HiltViewModel(assistedFactory = TunnelViewModel.Factory::class) +class TunnelViewModel +@AssistedInject +constructor( + private val tunnelRepository: TunnelRepository, + private val settingsRepository: GeneralSettingRepository, + private val globalEffectRepository: GlobalEffectRepository, + private val tunnelManager: TunnelManager, + @Assisted val tunnelId: Int, +) : ContainerHost, ViewModel() { + + override val container = + container( + TunnelUiState(), + buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, + ) { + combine( + tunnelRepository.userTunnelsFlow.map { + it.firstOrNull { tun -> tun.id == tunnelId } + }, + tunnelManager.activeTunnels.map { it.containsKey(tunnelId) }, + settingsRepository.flow, + ) { tunnel, active, settings -> + state.copy( + tunnel = tunnel, + isActive = active, + isLoading = tunnel == null, + isWildcardsEnabled = settings.isWildcardsEnabled, + isPingEnabled = settings.isPingEnabled, + appMode = settings.appMode, + ) + } + .collect { reduce { it } } + } + + fun setTunnelPingIp(ip: String) = intent { + val tunnel = state.tunnel ?: return@intent + tunnelRepository.save(tunnel.copy(pingTarget = ip.ifBlank { null })) + } + + fun addTunnelNetwork(ssid: String) = intent { + val tunnel = state.tunnel ?: return@intent + tunnelRepository.save( + tunnel.copy(tunnelNetworks = tunnel.tunnelNetworks.toMutableSet().apply { add(ssid) }) + ) + } + + fun removeTunnelNetwork(ssid: String) = intent { + val tunnel = state.tunnel ?: return@intent + tunnelRepository.save( + tunnel.copy( + tunnelNetworks = tunnel.tunnelNetworks.toMutableSet().apply { remove(ssid) } + ) + ) + } + + fun setRestartOnPing(to: Boolean) = intent { + val tunnel = state.tunnel ?: return@intent + tunnelRepository.save(tunnel.copy(restartOnPingFailure = to)) + } + + fun togglePrimaryTunnel() = intent { + val tunnel = state.tunnel ?: return@intent + val update = if (tunnel.isPrimaryTunnel) null else tunnel + tunnelRepository.updatePrimaryTunnel(update) + } + + fun setMobileDataTunnel(to: Boolean) = intent { + val tunnel = state.tunnel ?: return@intent + tunnelRepository.save(tunnel.copy(isMobileDataTunnel = to)) + } + + fun setEthernetTunnel(to: Boolean) = intent { + val tunnel = state.tunnel ?: return@intent + tunnelRepository.save(tunnel.copy(isEthernetTunnel = to)) + } + + fun toggleIpv4Preferred() = intent { + val latestTunnel = state.tunnel ?: return@intent + tunnelRepository.save(latestTunnel.copy(isIpv4Preferred = !latestTunnel.isIpv4Preferred)) + } + + private suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { + globalEffectRepository.post(globalSideEffect) + } + + @AssistedFactory + interface Factory { + fun create(tunnelId: Int): TunnelViewModel + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelsViewModel.kt index 15ffd919..f795b04f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelsViewModel.kt @@ -5,6 +5,8 @@ import androidx.core.net.toUri import androidx.lifecycle.ViewModel import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager +import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig +import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepository @@ -19,10 +21,13 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue import com.zaneschepke.wireguardautotunnel.util.extensions.saveTunnelsUniquely import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File import java.io.IOException import java.net.URL +import java.time.Instant import javax.inject.Inject import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull import org.amnezia.awg.config.BadConfigException import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container @@ -89,74 +94,6 @@ constructor( } } - fun setTunnelPingIp(ip: String, tunnelId: Int) = intent { - val latestTunnel = state.tunnels.find { it.id == tunnelId } - if (latestTunnel != null) { - tunnelRepository.save(latestTunnel.copy(pingTarget = ip.ifBlank { null })) - } - } - - fun addTunnelNetwork(tunnelId: Int, ssid: String) = intent { - val latestTunnel = state.tunnels.find { it.id == tunnelId } - if (latestTunnel != null) { - tunnelRepository.save( - latestTunnel.copy( - tunnelNetworks = latestTunnel.tunnelNetworks.toMutableSet().apply { add(ssid) } - ) - ) - } - } - - fun removeTunnelNetwork(tunnelId: Int, ssid: String) = intent { - val latestTunnel = state.tunnels.find { it.id == tunnelId } - if (latestTunnel != null) { - tunnelRepository.save( - latestTunnel.copy( - tunnelNetworks = - latestTunnel.tunnelNetworks.toMutableSet().apply { remove(ssid) } - ) - ) - } - } - - fun setRestartOnPing(tunnelId: Int, to: Boolean) = intent { - val latestTunnel = state.tunnels.find { it.id == tunnelId } - if (latestTunnel != null) { - tunnelRepository.save(latestTunnel.copy(restartOnPingFailure = to)) - } - } - - fun togglePrimaryTunnel(tunnelId: Int) = intent { - val latestTunnel = state.tunnels.find { it.id == tunnelId } - if (latestTunnel != null) { - val update = if (latestTunnel.isPrimaryTunnel) null else latestTunnel - tunnelRepository.updatePrimaryTunnel(update) - } - } - - fun setMobileDataTunnel(tunnelId: Int, to: Boolean) = intent { - val latestTunnel = state.tunnels.find { it.id == tunnelId } - if (latestTunnel != null) { - tunnelRepository.save(latestTunnel.copy(isMobileDataTunnel = to)) - } - } - - fun setEthernetTunnel(tunnelId: Int, to: Boolean) = intent { - val latestTunnel = state.tunnels.find { it.id == tunnelId } - if (latestTunnel != null) { - tunnelRepository.save(latestTunnel.copy(isEthernetTunnel = to)) - } - } - - fun toggleIpv4Preferred(tunnelId: Int) = intent { - val latestTunnel = state.tunnels.find { it.id == tunnelId } - if (latestTunnel != null) { - tunnelRepository.save( - latestTunnel.copy(isIpv4Preferred = !latestTunnel.isIpv4Preferred) - ) - } - } - fun importFromClipboard(conf: String) { importTunnelConfigs(mapOf(conf to null)) } @@ -191,4 +128,98 @@ constructor( postSideEffect(GlobalSideEffect.Toast(message)) } } + + fun toggleSelectAllTunnels() = intent { + if (state.selectedTunnels.size != state.tunnels.size) { + return@intent reduce { state.copy(selectedTunnels = state.tunnels) } + } + reduce { state.copy(selectedTunnels = emptyList()) } + } + + fun clearSelectedTunnels() = intent { reduce { state.copy(selectedTunnels = emptyList()) } } + + fun toggleSelectedTunnel(tunnelId: Int) = intent { + reduce { + state.copy( + selectedTunnels = + state.selectedTunnels.toMutableList().apply { + val removed = removeIf { it.id == tunnelId } + if (!removed) addAll(state.tunnels.filter { it.id == tunnelId }) + } + ) + } + } + + fun deleteSelectedTunnels() = intent { + val activeTunIds = tunnelManager.activeTunnels.firstOrNull()?.map { it.key } + if (state.selectedTunnels.any { activeTunIds?.contains(it.id) == true }) + return@intent postSideEffect( + GlobalSideEffect.Snackbar( + StringValue.StringResource(R.string.delete_active_message) + ) + ) + tunnelRepository.delete(state.selectedTunnels) + clearSelectedTunnels() + } + + fun copySelectedTunnel() = intent { + val selected = state.selectedTunnels.firstOrNull() ?: return@intent + val copy = TunnelConf.tunnelConfFromQuick(selected.amQuick, selected.tunName) + tunnelRepository.saveTunnelsUniquely(listOf(copy), state.tunnels) + clearSelectedTunnels() + } + + fun exportSelectedTunnels(configType: ConfigType, uri: Uri?) = intent { + val (files, shareFileName) = + when (configType) { + ConfigType.AM -> + Pair( + createAmFiles(state.selectedTunnels), + "am-export_${Instant.now().epochSecond}.zip", + ) + ConfigType.WG -> + Pair( + createWgFiles(state.selectedTunnels), + "wg-export_${Instant.now().epochSecond}.zip", + ) + } + val onFailure = { action: Throwable -> + intent { + postSideEffect( + GlobalSideEffect.Toast( + StringValue.StringResource( + R.string.export_failed, + ": ${action.localizedMessage}", + ) + ) + ) + } + Unit + } + fileUtils + .createNewShareFile(shareFileName) + .onSuccess { + fileUtils.zipAll(it, files).onFailure(onFailure) + fileUtils.exportFile(it, uri, FileUtils.ZIP_FILE_MIME_TYPE).onFailure(onFailure) + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.export_success)) + ) + clearSelectedTunnels() + } + .onFailure(onFailure) + } + + suspend fun createWgFiles(tunnels: Collection): List = + tunnels.mapNotNull { config -> + if (config.wgQuick.isNotBlank()) { + fileUtils.createFile(config.tunName, config.wgQuick) + } else null + } + + suspend fun createAmFiles(tunnels: Collection): List = + tunnels.mapNotNull { config -> + if (config.amQuick != TunnelConfig.AM_QUICK_DEFAULT && config.amQuick.isNotBlank()) { + fileUtils.createFile(config.tunName, config.amQuick) + } else null + } }