fix: nav crash bugfix

This commit is contained in:
Zane Schepke
2025-10-08 18:37:26 -04:00
parent 3612cbda08
commit a6a7b3f770
29 changed files with 686 additions and 506 deletions
@@ -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<Route.Tunnels> { TunnelsScreen() }
entry<Route.Sort> { SortScreen() }
entry<Route.TunnelOptions> { key ->
TunnelOptionsScreen(key.id)
val viewModel =
hiltViewModel<
TunnelViewModel,
TunnelViewModel.Factory,
>(
creationCallback = { factory ->
factory.create(key.id)
}
)
TunnelOptionsScreen(viewModel)
}
entry<Route.SplitTunnel> { key ->
SplitTunnelScreen(key.id)
val viewModel =
hiltViewModel<
SplitTunnelViewModel,
SplitTunnelViewModel.Factory,
>(
creationCallback = { factory ->
factory.create(key.id)
}
)
SplitTunnelScreen(viewModel)
}
entry<Route.TunnelAutoTunnel> { key ->
TunnelAutoTunnelScreen(key.id)
val viewModel =
hiltViewModel<
TunnelViewModel,
TunnelViewModel.Factory,
>(
creationCallback = { factory ->
factory.create(key.id)
}
)
TunnelAutoTunnelScreen(viewModel)
}
entry<Route.Config> { key ->
val viewModel =
hiltViewModel<
ConfigViewModel,
ConfigViewModel.Factory,
>(
creationCallback = { factory ->
factory.create(key.id)
}
)
ConfigScreen(viewModel)
}
entry<Route.Config> { key -> ConfigScreen(key.id) }
entry<Route.LocationDisclosure> {
LocationDisclosureScreen()
}
@@ -393,10 +422,28 @@ class MainActivity : AppCompatActivity() {
TunnelGlobalsScreen(key.id)
}
entry<Route.ConfigGlobal> { key ->
ConfigScreen(key.id)
val viewModel =
hiltViewModel<
ConfigViewModel,
ConfigViewModel.Factory,
>(
creationCallback = { factory ->
factory.create(key.id)
}
)
ConfigScreen(viewModel)
}
entry<Route.SplitTunnelGlobal> { key ->
SplitTunnelScreen(key.id)
val viewModel =
hiltViewModel<
SplitTunnelViewModel,
SplitTunnelViewModel.Factory,
>(
creationCallback = { factory ->
factory.create(key.id)
}
)
SplitTunnelScreen(viewModel)
}
entry<Route.ProxySettings> { ProxySettingsScreen() }
entry<Route.Appearance> { AppearanceScreen() }
@@ -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<RootShell>,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
): RootShellUtils {
return RootShellUtils(rootShell, ioDispatcher)
}
}
@@ -24,13 +24,12 @@ fun currentRouteAsNavbarState(
sharedState: SharedAppUiState,
sharedViewModel: SharedAppViewModel,
route: Route?,
selectedCount: Int,
navController: NavController,
): State<NavbarState> {
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(
@@ -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()
}
}
@@ -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) },
)
}
@@ -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
)
},
),
@@ -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()
},
)
},
@@ -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,
)
}
@@ -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)
}
@@ -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) },
)
)
}
@@ -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,
)
@@ -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()
@@ -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(
@@ -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) })
)
}
}
@@ -21,4 +21,10 @@ sealed class LocalSideEffect {
data object DeleteTunnels : Modal()
}
sealed class SelectedTunnels : LocalSideEffect() {
data object SelectAll : SelectedTunnels()
data object Copy : SelectedTunnels()
}
}
@@ -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,
@@ -0,0 +1,9 @@
package com.zaneschepke.wireguardautotunnel.ui.state
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
data class ConfigUiState(
val unavailableNames: Set<String> = emptySet(),
val isLoading: Boolean = true,
val tunnel: TunnelConf? = null,
)
@@ -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<Int, String> = emptyMap(),
val selectedTunnelCount: Int = 0,
val isAuthorized: Boolean = false,
val isAutoTunnelActive: Boolean = false,
val isLocationDisclosureShown: Boolean = false,
val tunnels: List<TunnelConf> = emptyList(),
val selectedTunnels: Set<TunnelConf> = setOf(),
val settings: GeneralSettings = GeneralSettings(),
)
@@ -5,6 +5,6 @@ import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
data class SplitTunnelUiState(
val installedPackages: List<InstalledPackage> = emptyList(),
val stateInitialized: Boolean = false,
val tunnels: List<TunnelConf> = emptyList(),
val isLoading: Boolean = true,
val tunnel: TunnelConf? = null,
)
@@ -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,
)
@@ -5,7 +5,8 @@ import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
data class TunnelsUiState(
val tunnels: List<TunnelConf> = listOf(),
val tunnels: List<TunnelConf> = emptyList(),
val selectedTunnels: List<TunnelConf> = emptyList(),
val activeTunnels: Map<Int, TunnelState> = emptyMap(),
val isPingEnabled: Boolean = false,
val appMode: AppMode = AppMode.VPN,
@@ -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.
@@ -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<RootShell>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun requestRoot(): Boolean =
withContext(ioDispatcher) {
val accepted =
try {
rootShell.get().start()
true
} catch (_: Exception) {
false
}
accepted
}
}
@@ -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<AutoTunnelUiState, Nothing>, 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<String>) = 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))
)
}
}
}
@@ -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<ConfigUiState, Nothing>, ViewModel() {
override val container =
container<ConfigUiState, Nothing>(
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
}
}
@@ -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<RootShell>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val rootShellUtils: RootShellUtils,
) : ContainerHost<SharedAppUiState, LocalSideEffect>, 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<TunnelConf>): List<File> =
withContext(ioDispatcher) {
tunnels.mapNotNull { config ->
if (config.wgQuick.isNotBlank()) {
fileUtils.createFile(config.tunName, config.wgQuick)
} else null
}
}
suspend fun createAmFiles(tunnels: Collection<TunnelConf>): List<File> =
withContext(ioDispatcher) {
tunnels.mapNotNull { config ->
if (
config.amQuick != TunnelConfig.AM_QUICK_DEFAULT && config.amQuick.isNotBlank()
) {
fileUtils.createFile(config.tunName, config.amQuick)
} else null
}
}
}
@@ -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<SplitTunnelUiState, Nothing>, 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<SplitOption, Set<String>>) =
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<String>())
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<SplitOption, Set<String>>) = 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<String>())
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
}
}
@@ -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<TunnelUiState, Nothing>, ViewModel() {
override val container =
container<TunnelUiState, Nothing>(
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
}
}
@@ -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<TunnelConf>): List<File> =
tunnels.mapNotNull { config ->
if (config.wgQuick.isNotBlank()) {
fileUtils.createFile(config.tunName, config.wgQuick)
} else null
}
suspend fun createAmFiles(tunnels: Collection<TunnelConf>): List<File> =
tunnels.mapNotNull { config ->
if (config.amQuick != TunnelConfig.AM_QUICK_DEFAULT && config.amQuick.isNotBlank()) {
fileUtils.createFile(config.tunName, config.amQuick)
} else null
}
}