fix: only allow backup/restore when all services are down

closes #948
This commit is contained in:
Zane Schepke
2025-10-16 15:49:04 -04:00
parent de3f43100e
commit 90bdb0fae2
14 changed files with 257 additions and 321 deletions
@@ -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()
@@ -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 <T> removeFromDataStore(key: Preferences.Key<T>) {
withContext(ioDispatcher) {
try { dataStore.edit { it.remove(key) }
try {
dataStore.edit { it.remove(key) }
} catch (e: IOException) {
Timber.e(e)
} catch (e: Exception) {
@@ -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(
@@ -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
@@ -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,
),
)
) {
@@ -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)
}
@@ -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,
)
@@ -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<Boolean?>(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,
@@ -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<Int, String> = emptyMap(),
val selectedTunnelCount: Int = 0,
val tunnels: List<TunnelConfig> = emptyList(),
val selectedTunnels: List<TunnelConfig> = emptyList(),
val activeTunnels: Map<Int, TunnelState> = emptyMap(),
val isPingEnabled: Boolean = false,
val showPingStats: Boolean = false,
val isPinVerified: Boolean = false,
val isAutoTunnelActive: Boolean = false,
val isLocationDisclosureShown: Boolean = false,
@@ -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<TunnelConfig> = emptyList(),
val selectedTunnels: List<TunnelConfig> = emptyList(),
val activeTunnels: Map<Int, TunnelState> = emptyMap(),
val isPingEnabled: Boolean = false,
val showPingStats: Boolean = false,
val isLoading: Boolean = true,
)
@@ -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<SharedAppUiState, LocalSideEffect>, 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<TunnelConfig>) = 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<QuickConfig, TunnelName>) = 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<TunnelConfig>): List<File> =
tunnels.mapNotNull { config ->
if (config.wgQuick.isNotBlank()) {
fileUtils.createFile(config.name, config.wgQuick)
} else null
}
suspend fun createAmFiles(tunnels: Collection<TunnelConfig>): List<File> =
tunnels.mapNotNull { config ->
if (config.amQuick.isNotBlank()) {
fileUtils.createFile(config.name, config.amQuick)
} else null
}
}
@@ -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<TunnelUiState, Nothing>, 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
@@ -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<TunnelsUiState, Nothing>, ViewModel() {
override val container =
container<TunnelsUiState, Nothing>(
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<TunnelConfig>) = 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<QuickConfig, TunnelName>) = 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<TunnelConfig>): List<File> =
tunnels.mapNotNull { config ->
if (config.wgQuick.isNotBlank()) {
fileUtils.createFile(config.name, config.wgQuick)
} else null
}
suspend fun createAmFiles(tunnels: Collection<TunnelConfig>): List<File> =
tunnels.mapNotNull { config ->
if (config.amQuick.isNotBlank()) {
fileUtils.createFile(config.name, config.amQuick)
} else null
}
}
+1
View File
@@ -406,4 +406,5 @@
<string name="network_name">Network:&#160;</string>
<string name="wifi">Wi-Fi</string>
<string name="text_too_large_for_qr">Text is too large to display QR.</string>
<string name="all_services_disabled">Auto-tunnel and tunnels must be disabled to perform this action.</string>
</resources>