From 90bdb0fae28a0533311eb9697be10cb38d26f5ec Mon Sep 17 00:00:00 2001 From: Zane Schepke Date: Thu, 16 Oct 2025 15:49:04 -0400 Subject: [PATCH] fix: only allow backup/restore when all services are down closes #948 --- .../wireguardautotunnel/MainActivity.kt | 3 +- .../data/DataStoreManager.kt | 4 +- .../currentBackStackEntryAsNavbarState.kt | 10 +- .../ui/screens/settings/SettingsScreen.kt | 18 +- .../settings/components/BackupBottomSheet.kt | 13 +- .../ui/screens/tunnels/TunnelsScreen.kt | 22 +- .../screens/tunnels/components/TunnelList.kt | 32 ++- .../ui/screens/tunnels/sort/SortScreen.kt | 18 +- .../ui/state/SharedAppUiState.kt | 9 +- .../ui/state/TunnelsUiState.kt | 5 - .../viewmodel/SharedAppViewModel.kt | 202 +++++++++++++++- .../viewmodel/TunnelViewModel.kt | 22 -- .../viewmodel/TunnelsViewModel.kt | 219 ------------------ app/src/main/res/values/strings.xml | 1 + 14 files changed, 257 insertions(+), 321 deletions(-) delete mode 100644 app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelsViewModel.kt diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt index f33b6b3c..09b93e51 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt @@ -91,6 +91,7 @@ import dagger.hilt.android.AndroidEntryPoint import de.raphaelebner.roomdatabasebackup.core.RoomBackup import java.util.* import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import xyz.teamgravity.pin_lock_compose.PinManager @@ -179,7 +180,7 @@ class MainActivity : AppCompatActivity() { ) LaunchedEffect(Unit) { - viewModel.globalSideEffect.collect { sideEffect -> + viewModel.globalSideEffect.collectLatest { sideEffect -> when (sideEffect) { GlobalSideEffect.ConfigChanged -> restartApp() GlobalSideEffect.PopBackStack -> navController.pop() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DataStoreManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DataStoreManager.kt index f49c18ce..d1a9d6c5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DataStoreManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/data/DataStoreManager.kt @@ -22,6 +22,7 @@ class DataStoreManager( private val preferencesKey = "preferences" val Context.dataStore by preferencesDataStore(name = preferencesKey) val dataStore = context.dataStore + companion object { val locationDisclosureShown = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN") val batteryDisableShown = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN") @@ -51,7 +52,8 @@ class DataStoreManager( suspend fun removeFromDataStore(key: Preferences.Key) { withContext(ioDispatcher) { - try { dataStore.edit { it.remove(key) } + try { + dataStore.edit { it.remove(key) } } catch (e: IOException) { Timber.e(e) } catch (e: Exception) { 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 1baab994..2ed4e7a3 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 @@ -211,7 +211,7 @@ fun currentRouteAsNavbarState( }, ) is Config -> { - val tunnelName = sharedState.tunnelNames[route.id] + val tunnelName = sharedState.tunnels.find { it.id == route.id }?.name NavbarState( topLeading = { IconButton(onClick = { navController.pop() }) { @@ -236,7 +236,7 @@ fun currentRouteAsNavbarState( ) } is SplitTunnel -> { - val tunnelName = sharedState.tunnelNames[route.id] + val tunnelName = sharedState.tunnels.find { it.id == route.id }?.name NavbarState( topLeading = { IconButton(onClick = { navController.pop() }) { @@ -337,7 +337,7 @@ fun currentRouteAsNavbarState( showBottomItems = true, ) is TunnelOptions -> { - val tunnelName = sharedState.tunnelNames[route.id] + val tunnelName = sharedState.tunnels.find { it.id == route.id }?.name NavbarState( topLeading = { IconButton(onClick = { navController.pop() }) { @@ -369,7 +369,7 @@ fun currentRouteAsNavbarState( NavbarState( topTitle = context.getString(R.string.tunnels), topTrailing = { - when (sharedState.selectedTunnelCount) { + when (sharedState.selectedTunnels.size) { 0 -> Row { IconButton(onClick = { navController.push(Sort) }) { @@ -423,7 +423,7 @@ fun currentRouteAsNavbarState( } } - if (sharedState.selectedTunnelCount == 1) { + if (sharedState.selectedTunnels.size == 1) { IconButton( onClick = { sharedViewModel.postSideEffect( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt index 4fa22354..f0605c82 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.zaneschepke.wireguardautotunnel.MainActivity import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.model.AppMode import com.zaneschepke.wireguardautotunnel.ui.LocalNavController @@ -39,6 +40,7 @@ import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.extensions.asString import com.zaneschepke.wireguardautotunnel.util.extensions.asTitleString import com.zaneschepke.wireguardautotunnel.util.extensions.capitalize +import com.zaneschepke.wireguardautotunnel.util.extensions.showToast import com.zaneschepke.wireguardautotunnel.viewmodel.SettingsViewModel import java.util.* @@ -50,6 +52,7 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) { val locale = remember { Locale.getDefault() } + val sharedState by sharedViewModel.container.stateFlow.collectAsStateWithLifecycle() val settingsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() if (settingsState.isLoading) return @@ -77,7 +80,20 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) { } } - if (showBackupSheet) BackupBottomSheet { showBackupSheet = false } + fun performBackupRestore(action: () -> Unit) { + if (sharedState.activeTunnels.isNotEmpty() || sharedState.isAutoTunnelActive) + return context.showToast(R.string.all_services_disabled) + showBackupSheet = false + action() + } + + if (showBackupSheet) + BackupBottomSheet( + { performBackupRestore { (context as? MainActivity)?.performBackup() } }, + { performBackupRestore { (context as? MainActivity)?.performRestore() } }, + ) { + showBackupSheet = false + } if (showAppModeSheet) AppModeBottomSheet(sharedViewModel::setAppMode, settingsState.settings.appMode) { showAppModeSheet = false diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/BackupBottomSheet.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/BackupBottomSheet.kt index a92e738b..08c2805d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/BackupBottomSheet.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/components/BackupBottomSheet.kt @@ -8,14 +8,13 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource -import com.zaneschepke.wireguardautotunnel.MainActivity import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.common.sheet.CustomBottomSheet import com.zaneschepke.wireguardautotunnel.ui.common.sheet.SheetOption @OptIn(ExperimentalMaterial3Api::class) @Composable -fun BackupBottomSheet(onDismiss: () -> Unit) { +fun BackupBottomSheet(onBackup: () -> Unit, onRestore: () -> Unit, onDismiss: () -> Unit) { val context = LocalContext.current CustomBottomSheet( @@ -23,18 +22,12 @@ fun BackupBottomSheet(onDismiss: () -> Unit) { SheetOption( ImageVector.vectorResource(R.drawable.database), stringResource(R.string.backup_application), - onClick = { - onDismiss() - (context as? MainActivity)?.performBackup() - }, + onClick = onBackup, ), SheetOption( Icons.Outlined.Restore, stringResource(R.string.restore_application), - onClick = { - onDismiss() - (context as? MainActivity)?.performRestore() - }, + onClick = onRestore, ), ) ) { 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 f1cdb00e..8fa1bc7e 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 @@ -8,7 +8,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions @@ -26,29 +25,22 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.components.UrlImpo import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.util.FileUtils import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelsViewModel import org.orbitmvi.orbit.compose.collectSideEffect @Composable -fun TunnelsScreen(viewModel: TunnelsViewModel = hiltViewModel()) { - val sharedViewModel = LocalSharedVm.current +fun TunnelsScreen() { + val viewModel = LocalSharedVm.current val navController = LocalNavController.current val clipboard = rememberClipboardHelper() - val tunnelsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() + val sharedState by viewModel.container.stateFlow.collectAsStateWithLifecycle() var showExportSheet by rememberSaveable { mutableStateOf(false) } var showImportSheet by rememberSaveable { mutableStateOf(false) } var showDeleteModal by rememberSaveable { mutableStateOf(false) } var showUrlDialog by rememberSaveable { mutableStateOf(false) } - if (tunnelsState.isLoading) return - - LaunchedEffect(tunnelsState.selectedTunnels) { - sharedViewModel.setSelectedTunnelCount(tunnelsState.selectedTunnels.size) - } - - sharedViewModel.collectSideEffect { sideEffect -> + viewModel.collectSideEffect { sideEffect -> when (sideEffect) { LocalSideEffect.Sheet.ImportTunnels -> showImportSheet = true LocalSideEffect.Modal.DeleteTunnels -> showDeleteModal = true @@ -62,7 +54,7 @@ fun TunnelsScreen(viewModel: TunnelsViewModel = hiltViewModel()) { val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult( onNoFileExplorer = { - sharedViewModel.showSnackMessage( + viewModel.showSnackMessage( StringValue.StringResource(R.string.error_no_file_explorer) ) }, @@ -82,7 +74,7 @@ fun TunnelsScreen(viewModel: TunnelsViewModel = hiltViewModel()) { rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (!isGranted) { - sharedViewModel.showSnackMessage( + viewModel.showSnackMessage( StringValue.StringResource(R.string.camera_permission_required) ) return@rememberLauncherForActivityResult @@ -142,5 +134,5 @@ fun TunnelsScreen(viewModel: TunnelsViewModel = hiltViewModel()) { ) } - TunnelList(tunnelsState, Modifier.fillMaxSize(), viewModel, sharedViewModel) + TunnelList(sharedState, Modifier.fillMaxSize(), viewModel) } 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 9aa260eb..76b434f5 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 @@ -25,19 +25,17 @@ import com.zaneschepke.wireguardautotunnel.ui.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow import com.zaneschepke.wireguardautotunnel.ui.common.button.SwitchWithDivider import com.zaneschepke.wireguardautotunnel.ui.navigation.Route -import com.zaneschepke.wireguardautotunnel.ui.state.TunnelsUiState +import com.zaneschepke.wireguardautotunnel.ui.state.SharedAppUiState import com.zaneschepke.wireguardautotunnel.util.extensions.asColor 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, + viewModel: SharedAppViewModel, ) { val navController = LocalNavController.current val context = LocalContext.current @@ -50,7 +48,7 @@ fun TunnelList( modifier .pointerInput(Unit) { detectTapGestures { - if (tunnelsState.tunnels.isEmpty()) return@detectTapGestures + if (sharedState.tunnels.isEmpty()) return@detectTapGestures viewModel.clearSelectedTunnels() } } @@ -60,17 +58,17 @@ fun TunnelList( reverseLayout = false, flingBehavior = ScrollableDefaults.flingBehavior(), ) { - if (tunnelsState.tunnels.isEmpty()) { + if (sharedState.tunnels.isEmpty()) { item { GettingStartedLabel(onClick = { context.openWebUrl(it) }) } } - items(tunnelsState.tunnels, key = { it.id }) { tunnel -> + items(sharedState.tunnels, key = { it.id }) { tunnel -> val tunnelState = - remember(tunnelsState.activeTunnels) { - tunnelsState.activeTunnels[tunnel.id] ?: TunnelState() + remember(sharedState.activeTunnels) { + sharedState.activeTunnels[tunnel.id] ?: TunnelState() } val selected = - remember(tunnelsState.selectedTunnels) { - tunnelsState.selectedTunnels.any { it.id == tunnel.id } + remember(sharedState.selectedTunnels) { + sharedState.selectedTunnels.any { it.id == tunnel.id } } var leadingIconColor by remember( @@ -93,7 +91,7 @@ fun TunnelList( }, title = tunnel.name, onClick = { - if (tunnelsState.selectedTunnels.isNotEmpty()) { + if (sharedState.selectedTunnels.isNotEmpty()) { viewModel.toggleSelectedTunnel(tunnel.id) } else { navController.push(Route.TunnelOptions(tunnel.id)) @@ -106,8 +104,8 @@ fun TunnelList( { TunnelStatisticsRow( tunnelState, - tunnelsState.isPingEnabled, - tunnelsState.showPingStats, + sharedState.isPingEnabled, + sharedState.showPingStats, ) } } else null, @@ -116,8 +114,8 @@ fun TunnelList( SwitchWithDivider( checked = tunnelState.status.isUpOrStarting(), onClick = { checked -> - if (checked) sharedViewModel.startTunnel(tunnel) - else sharedViewModel.stopTunnel(tunnel) + if (checked) viewModel.startTunnel(tunnel) + else viewModel.stopTunnel(tunnel) }, modifier = modifier, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt index 57e0b22e..27b8c8f7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback 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.ui.LocalIsAndroidTV @@ -33,25 +32,22 @@ import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect import com.zaneschepke.wireguardautotunnel.util.extensions.isSortedBy -import com.zaneschepke.wireguardautotunnel.viewmodel.TunnelsViewModel import org.orbitmvi.orbit.compose.collectSideEffect import sh.calvin.reorderable.DragGestureDetector import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @Composable -fun SortScreen(viewModel: TunnelsViewModel = hiltViewModel()) { - val sharedViewModel = LocalSharedVm.current - val tunnelsState by viewModel.container.stateFlow.collectAsStateWithLifecycle() +fun SortScreen() { + val viewModel = LocalSharedVm.current + val sharedState by viewModel.container.stateFlow.collectAsStateWithLifecycle() val hapticFeedback = LocalHapticFeedback.current val isTv = LocalIsAndroidTV.current - if (tunnelsState.isLoading) return - var sortAscending by rememberSaveable { mutableStateOf(null) } - var editableTunnels by rememberSaveable { mutableStateOf(tunnelsState.tunnels) } + var editableTunnels by rememberSaveable { mutableStateOf(sharedState.tunnels) } - sharedViewModel.collectSideEffect { sideEffect -> + viewModel.collectSideEffect { sideEffect -> when (sideEffect) { LocalSideEffect.SaveChanges -> { viewModel.saveSortChanges(editableTunnels) @@ -67,7 +63,7 @@ fun SortScreen(viewModel: TunnelsViewModel = hiltViewModel()) { when (sortAscending) { true -> editableTunnels.sortedBy { it.name } false -> editableTunnels.sortedByDescending { it.name } - null -> tunnelsState.tunnels + null -> sharedState.tunnels } } else -> Unit @@ -90,7 +86,7 @@ fun SortScreen(viewModel: TunnelsViewModel = hiltViewModel()) { horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top), modifier = - Modifier.pointerInput(Unit) { if (tunnelsState.tunnels.isEmpty()) return@pointerInput } + Modifier.pointerInput(Unit) { if (sharedState.tunnels.isEmpty()) return@pointerInput } .overscroll(rememberOverscrollEffect()), state = lazyListState, userScrollEnabled = true, 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 27dc161c..08282029 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,6 +1,8 @@ package com.zaneschepke.wireguardautotunnel.ui.state import com.zaneschepke.wireguardautotunnel.domain.model.GeneralSettings +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState import com.zaneschepke.wireguardautotunnel.ui.theme.Theme import com.zaneschepke.wireguardautotunnel.util.LocaleUtil @@ -9,8 +11,11 @@ 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 tunnels: List = emptyList(), + val selectedTunnels: List = emptyList(), + val activeTunnels: Map = emptyMap(), + val isPingEnabled: Boolean = false, + val showPingStats: Boolean = false, val isPinVerified: Boolean = false, val isAutoTunnelActive: Boolean = false, val isLocationDisclosureShown: Boolean = false, 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 5e2210a7..b1a42513 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 @@ -1,13 +1,8 @@ package com.zaneschepke.wireguardautotunnel.ui.state import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState data class TunnelsUiState( val tunnels: List = emptyList(), - val selectedTunnels: List = emptyList(), - val activeTunnels: Map = emptyMap(), - val isPingEnabled: Boolean = false, - val showPingStats: Boolean = false, val isLoading: Boolean = true, ) 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 17b3f777..5c5d349a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt @@ -1,26 +1,44 @@ package com.zaneschepke.wireguardautotunnel.viewmodel +import android.net.Uri +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager import com.zaneschepke.wireguardautotunnel.data.model.AppMode +import com.zaneschepke.wireguardautotunnel.domain.enums.ConfigType import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig 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.repository.MonitoringSettingsRepository 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.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.QuickConfig +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 kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.map import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map +import org.amnezia.awg.config.BadConfigException import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container import xyz.teamgravity.pin_lock_compose.PinManager @@ -35,7 +53,9 @@ constructor( private val globalEffectRepository: GlobalEffectRepository, private val tunnelRepository: TunnelRepository, private val settingsRepository: GeneralSettingRepository, + private val monitoringSettingsRepository: MonitoringSettingsRepository, private val rootShellUtils: RootShellUtils, + private val fileUtils: FileUtils, ) : ContainerHost, ViewModel() { val globalSideEffect = globalEffectRepository.flow @@ -47,22 +67,22 @@ constructor( ) { intent { combine( - appStateRepository.flow, - tunnelRepository.userTunnelsFlow.map { tuns -> - tuns.associate { it.id to it.name } - }, + tunnelRepository.userTunnelsFlow, serviceManager.autoTunnelService.map { it != null }, settingsRepository.flow, - ) { appState, tunnelNames, autoTunnelActive, settings -> + tunnelManager.activeTunnels, + monitoringSettingsRepository.flow, + ) { tunnels, autoTunnelActive, settings, activeTunnels, monitoring -> state.copy( theme = settings.theme, locale = settings.locale ?: LocaleUtil.OPTION_PHONE_LANGUAGE, pinLockEnabled = settings.isPinLockEnabled, isAutoTunnelActive = autoTunnelActive, - tunnelNames = tunnelNames, settings = settings, - isLocationDisclosureShown = appState.isLocationDisclosureShown, - isBatteryOptimizationShown = appState.isBatteryOptimizationDisableShown, + tunnels = tunnels, + activeTunnels = activeTunnels, + isPingEnabled = monitoring.isPingEnabled, + showPingStats = monitoring.showDetailedPingStats, isAppLoaded = true, ) } @@ -75,6 +95,17 @@ constructor( } } + intent { + appStateRepository.flow.collect { + reduce { + state.copy( + isLocationDisclosureShown = it.isLocationDisclosureShown, + isBatteryOptimizationShown = it.isBatteryOptimizationDisableShown, + ) + } + } + } + intent { tunnelManager.messageEvents.collect { (_, message) -> postSideEffect(GlobalSideEffect.Snackbar(message.toStringValue())) @@ -114,10 +145,6 @@ constructor( settingsRepository.upsert(state.settings.copy(isPinLockEnabled = enabled)) } - fun setSelectedTunnelCount(count: Int) = intent { - reduce { state.copy(selectedTunnelCount = count) } - } - fun stopTunnel(tunnelConfig: TunnelConfig) = intent { tunnelManager.stopTunnel(tunnelConfig.id) } @@ -164,4 +191,155 @@ constructor( fun disableBatteryOptimizationsShown() = intent { appStateRepository.setBatteryOptimizationDisableShown(true) } + + fun saveSortChanges(tunnels: List) = intent { + tunnelRepository.saveAll(tunnels.mapIndexed { index, conf -> conf.copy(position = index) }) + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.config_changes_saved)) + ) + postSideEffect(GlobalSideEffect.PopBackStack) + } + + fun importTunnelConfigs(configs: Map) = intent { + try { + val tunnelConfigs = + configs.map { (config, name) -> TunnelConfig.tunnelConfFromQuick(config, name) } + tunnelRepository.saveTunnelsUniquely(tunnelConfigs, state.tunnels) + } catch (_: IOException) { + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.read_failed)) + ) + } catch (e: BadConfigException) { + postSideEffect(GlobalSideEffect.Snackbar(e.asStringValue())) + } + } + + fun importFromClipboard(conf: String) { + importTunnelConfigs(mapOf(conf to null)) + } + + fun importFromQr(conf: String) = intent { importFromClipboard(conf) } + + fun importFromUrl(url: String) = intent { + runCatching { + val url = URL(url) + val uri = url.toURI().toString().toUri() + importFromUri(uri) + } + .onFailure { + postSideEffect( + GlobalSideEffect.Toast( + StringValue.StringResource(R.string.error_download_failed) + ) + ) + } + } + + fun importFromUri(uri: Uri) = intent { + fileUtils + .readConfigsFromUri(uri) + .onSuccess { configs -> importTunnelConfigs(configs) } + .onFailure { + val message = + when (it) { + is IOException -> StringValue.StringResource(R.string.error_download_failed) + else -> StringValue.StringResource(R.string.error_file_extension) + } + 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 = TunnelConfig.tunnelConfFromQuick(selected.amQuick, selected.name) + 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.name, config.wgQuick) + } else null + } + + suspend fun createAmFiles(tunnels: Collection): List = + tunnels.mapNotNull { config -> + if (config.amQuick.isNotBlank()) { + fileUtils.createFile(config.name, config.amQuick) + } else null + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt index 93b2e01a..f315a08e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelViewModel.kt @@ -2,9 +2,7 @@ package com.zaneschepke.wireguardautotunnel.viewmodel import androidx.lifecycle.ViewModel import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager -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 @@ -20,7 +18,6 @@ class TunnelViewModel @AssistedInject constructor( private val tunnelRepository: TunnelRepository, - private val globalEffectRepository: GlobalEffectRepository, private val tunnelManager: TunnelManager, @Assisted val tunnelId: Int, ) : ContainerHost, ViewModel() { @@ -41,11 +38,6 @@ constructor( .collect { reduce { it } } } - fun setTunnelPingIp(ip: String) = intent { - val tunnel = state.tunnel ?: return@intent - tunnelRepository.save(tunnel.copy(pingTarget = ip.ifBlank { null })) - } - fun setRestartOnPing(to: Boolean) = intent { val tunnel = state.tunnel ?: return@intent tunnelRepository.save(tunnel.copy(restartOnPingFailure = to)) @@ -57,25 +49,11 @@ constructor( 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 deleted file mode 100644 index 467690d3..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/TunnelsViewModel.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.viewmodel - -import android.net.Uri -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.domain.enums.ConfigType -import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository -import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository -import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect -import com.zaneschepke.wireguardautotunnel.ui.state.TunnelsUiState -import com.zaneschepke.wireguardautotunnel.util.FileUtils -import com.zaneschepke.wireguardautotunnel.util.StringValue -import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig -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 - -@HiltViewModel -class TunnelsViewModel -@Inject -constructor( - private val tunnelRepository: TunnelRepository, - private val monitoringRepository: MonitoringSettingsRepository, - private val fileUtils: FileUtils, - private val globalEffectRepository: GlobalEffectRepository, - private val tunnelManager: TunnelManager, -) : ContainerHost, ViewModel() { - - override val container = - container( - TunnelsUiState(), - buildSettings = { repeatOnSubscribedStopTimeout = 5000L }, - ) { - combine( - tunnelRepository.userTunnelsFlow, - tunnelManager.activeTunnels, - monitoringRepository.flow, - ) { tunnels, activeTunnels, monitoring -> - state.copy( - tunnels = tunnels, - activeTunnels = activeTunnels, - isPingEnabled = monitoring.isPingEnabled, - showPingStats = monitoring.showDetailedPingStats, - isLoading = false, - ) - } - .collect { reduce { it } } - } - - suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) { - globalEffectRepository.post(globalSideEffect) - } - - fun saveSortChanges(tunnels: List) = intent { - tunnelRepository.saveAll(tunnels.mapIndexed { index, conf -> conf.copy(position = index) }) - postSideEffect( - GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.config_changes_saved)) - ) - postSideEffect(GlobalSideEffect.PopBackStack) - } - - fun importTunnelConfigs(configs: Map) = intent { - try { - val tunnelConfigs = - configs.map { (config, name) -> TunnelConfig.tunnelConfFromQuick(config, name) } - tunnelRepository.saveTunnelsUniquely(tunnelConfigs, state.tunnels) - } catch (_: IOException) { - postSideEffect( - GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.read_failed)) - ) - } catch (e: BadConfigException) { - postSideEffect(GlobalSideEffect.Snackbar(e.asStringValue())) - } - } - - fun importFromClipboard(conf: String) { - importTunnelConfigs(mapOf(conf to null)) - } - - fun importFromQr(conf: String) = intent { importFromClipboard(conf) } - - fun importFromUrl(url: String) = intent { - runCatching { - val url = URL(url) - val uri = url.toURI().toString().toUri() - importFromUri(uri) - } - .onFailure { - postSideEffect( - GlobalSideEffect.Toast( - StringValue.StringResource(R.string.error_download_failed) - ) - ) - } - } - - fun importFromUri(uri: Uri) = intent { - fileUtils - .readConfigsFromUri(uri) - .onSuccess { configs -> importTunnelConfigs(configs) } - .onFailure { - val message = - when (it) { - is IOException -> StringValue.StringResource(R.string.error_download_failed) - else -> StringValue.StringResource(R.string.error_file_extension) - } - 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 = TunnelConfig.tunnelConfFromQuick(selected.amQuick, selected.name) - 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.name, config.wgQuick) - } else null - } - - suspend fun createAmFiles(tunnels: Collection): List = - tunnels.mapNotNull { config -> - if (config.amQuick.isNotBlank()) { - fileUtils.createFile(config.name, config.amQuick) - } else null - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 728b98ee..54ebfea6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -406,4 +406,5 @@ Network:  Wi-Fi Text is too large to display QR. + Auto-tunnel and tunnels must be disabled to perform this action.