refactor: restart modal on config changes for proxy and tuns

This commit is contained in:
Zane Schepke
2025-11-01 01:24:19 -04:00
parent 6d483459a6
commit 0874db8bbf
9 changed files with 167 additions and 67 deletions
@@ -397,6 +397,46 @@ constructor(
}
}
suspend fun restartActiveTunnel(id: Int) =
withContext(ioDispatcher) {
val activeIds = activeTunnels.value.keys.toList()
if (activeIds.isEmpty()) return@withContext
if (!activeIds.contains(id)) return@withContext
val tunnel = tunnelsRepository.getById(id) ?: return@withContext
restartTunnel(tunnel)
}
suspend fun restartActiveTunnels() =
withContext(ioDispatcher) {
val activeIds = activeTunnels.value.keys.toList()
if (activeIds.isEmpty()) return@withContext
val tunnels = tunnelsRepository.getAll()
if (tunnels.isEmpty()) return@withContext
supervisorScope {
activeIds.forEach { id ->
val tunnel =
tunnels.find { it.id == id }
?: run {
Timber.w("Tunnel config $id not found; skipping restart")
return@forEach
}
restartTunnel(tunnel)
}
}
}
private suspend fun restartTunnel(tunnel: TunnelConfig) {
runCatching { stopTunnel(tunnel.id) }
.onFailure { e -> Timber.e(e, "Failed to stop tunnel ${tunnel.id} during restart") }
delay(RESTART_TUNNEL_DELAY)
runCatching { startTunnel(tunnel) }
.onFailure { e -> Timber.e(e, "Failed to restart tunnel ${tunnel.id}") }
}
private suspend fun handleDynamicDnsMonitoring(
activeTuns: Map<Int, TunnelState>,
configs: List<TunnelConfig>,
@@ -513,5 +553,6 @@ constructor(
companion object {
const val BASE_BACKOFF = 30_000L
const val MAX_BACKOFF_TIME = 300_000L
const val RESTART_TUNNEL_DELAY = 300L
}
}
@@ -43,6 +43,8 @@ fun LockdownSettingsScreen(viewModel: LockdownViewModel = hiltViewModel()) {
val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
if (uiState.isLoading) return
var metered by remember { mutableStateOf(uiState.lockdownSettings.metered) }
var dualStack by remember { mutableStateOf(uiState.lockdownSettings.dualStack) }
var bypassLan by remember { mutableStateOf(uiState.lockdownSettings.bypassLan) }
@@ -51,8 +53,6 @@ fun LockdownSettingsScreen(viewModel: LockdownViewModel = hiltViewModel()) {
if (it is LocalSideEffect.SaveChanges) viewModel.setShowSaveModal(true)
}
if (uiState.isLoading) return
if (uiState.showSaveModal) {
InfoDialog(
onDismiss = { viewModel.setShowSaveModal(false) },
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.outlined.Http
import androidx.compose.material.icons.outlined.RemoveRedEye
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -26,71 +27,82 @@ import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings
import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm
import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow
import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel
import com.zaneschepke.wireguardautotunnel.ui.common.security.SecureScreenFromRecording
import com.zaneschepke.wireguardautotunnel.ui.common.textbox.ConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect
import com.zaneschepke.wireguardautotunnel.viewmodel.ProxySettingsViewModel
import java.util.Locale
import org.orbitmvi.orbit.compose.collectSideEffect
@Composable
fun ProxySettingsScreen(viewModel: ProxySettingsViewModel = hiltViewModel()) {
val sharedViewModel = LocalSharedVm.current
val proxySettingsState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
if (proxySettingsState.isLoading) return
val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
val proxySettings by
remember(proxySettingsState) { mutableStateOf(proxySettingsState.proxySettings) }
if (uiState.isLoading) return
val locale = remember { Locale.getDefault() }
val proxySettings by remember(uiState) { mutableStateOf(uiState.proxySettings) }
var socks5Enabled by
remember(proxySettings) {
mutableStateOf(proxySettingsState.proxySettings.socks5ProxyEnabled)
}
remember(proxySettings) { mutableStateOf(uiState.proxySettings.socks5ProxyEnabled) }
var httpEnabled by
remember(proxySettings) {
mutableStateOf(proxySettingsState.proxySettings.httpProxyEnabled)
}
remember(proxySettings) { mutableStateOf(uiState.proxySettings.httpProxyEnabled) }
var socksBindAddress by
remember(proxySettings) {
mutableStateOf(proxySettingsState.proxySettings.socks5ProxyBindAddress ?: "")
mutableStateOf(uiState.proxySettings.socks5ProxyBindAddress ?: "")
}
var httpBindAddress by
remember(proxySettings) {
mutableStateOf(proxySettingsState.proxySettings.httpProxyBindAddress ?: "")
}
remember(proxySettings) { mutableStateOf(uiState.proxySettings.httpProxyBindAddress ?: "") }
var proxyUsername by
remember(proxySettings) {
mutableStateOf(proxySettingsState.proxySettings.proxyUsername ?: "")
}
remember(proxySettings) { mutableStateOf(uiState.proxySettings.proxyUsername ?: "") }
var proxyPassword by
remember(proxySettings) {
mutableStateOf(proxySettingsState.proxySettings.proxyPassword ?: "")
}
var passwordVisible by
remember(proxySettings) { mutableStateOf(proxySettingsState.passwordVisible) }
remember(proxySettings) { mutableStateOf(uiState.proxySettings.proxyPassword ?: "") }
var passwordVisible by remember(proxySettings) { mutableStateOf(uiState.passwordVisible) }
val keyboardController = LocalSoftwareKeyboardController.current
val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
fun saveChanges() {
viewModel.save(
ProxySettings(
socks5ProxyEnabled = socks5Enabled,
socks5ProxyBindAddress = socksBindAddress,
httpProxyEnabled = httpEnabled,
httpProxyBindAddress = httpBindAddress,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword,
)
)
}
sharedViewModel.collectSideEffect { sideEffect ->
when (sideEffect) {
LocalSideEffect.SaveChanges -> {
viewModel.save(
ProxySettings(
socks5ProxyEnabled = socks5Enabled,
socks5ProxyBindAddress = socksBindAddress,
httpProxyEnabled = httpEnabled,
httpProxyBindAddress = httpBindAddress,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword,
if (sideEffect is LocalSideEffect.SaveChanges) {
if (uiState.activeTuns.isNotEmpty()) viewModel.setShowSaveModal(true) else saveChanges()
}
}
if (uiState.showSaveModal) {
InfoDialog(
onDismiss = { viewModel.setShowSaveModal(false) },
onAttest = { saveChanges() },
title = stringResource(R.string.save_changes),
body = {
Text(
stringResource(
R.string.restart_message_template,
stringResource(R.string.tunnels).lowercase(locale),
)
)
}
else -> Unit
}
},
confirmText = stringResource(R.string._continue),
)
}
SecureScreenFromRecording()
@@ -119,10 +131,9 @@ fun ProxySettingsScreen(viewModel: ProxySettingsViewModel = hiltViewModel()) {
),
label = stringResource(R.string.socks_5_bind_address),
value = socksBindAddress,
isError = proxySettingsState.isSocks5BindAddressError,
isError = uiState.isSocks5BindAddressError,
onValueChange = {
if (proxySettingsState.isSocks5BindAddressError)
viewModel.clearSocks5BindError()
if (uiState.isSocks5BindAddressError) viewModel.clearSocks5BindError()
socksBindAddress = it
},
)
@@ -144,10 +155,9 @@ fun ProxySettingsScreen(viewModel: ProxySettingsViewModel = hiltViewModel()) {
),
label = stringResource(R.string.http_bind_address),
value = httpBindAddress,
isError = proxySettingsState.isHttpBindAddressError,
isError = uiState.isHttpBindAddressError,
onValueChange = {
if (proxySettingsState.isSocks5BindAddressError)
viewModel.clearHttpBindError()
if (uiState.isSocks5BindAddressError) viewModel.clearHttpBindError()
httpBindAddress = it
},
modifier = Modifier.padding(horizontal = 12.dp),
@@ -169,11 +179,11 @@ fun ProxySettingsScreen(viewModel: ProxySettingsViewModel = hiltViewModel()) {
ConfigurationTextBox(
value = proxyUsername,
onValueChange = {
if (proxySettingsState.isUserNameError) viewModel.clearUsernameError()
if (uiState.isUserNameError) viewModel.clearUsernameError()
proxyUsername = it
},
label = stringResource(R.string.username),
isError = proxySettingsState.isUserNameError,
isError = uiState.isUserNameError,
hint = "",
keyboardActions = keyboardActions,
keyboardOptions = keyboardOptions,
@@ -181,11 +191,11 @@ fun ProxySettingsScreen(viewModel: ProxySettingsViewModel = hiltViewModel()) {
ConfigurationTextBox(
value = proxyPassword,
onValueChange = {
if (proxySettingsState.isUserNameError) viewModel.clearPasswordError()
if (uiState.isUserNameError) viewModel.clearPasswordError()
proxyPassword = it
},
label = stringResource(R.string.password),
isError = proxySettingsState.isPasswordError,
isError = uiState.isPasswordError,
hint = "",
keyboardActions = keyboardActions,
keyboardOptions = keyboardOptions,
@@ -5,14 +5,18 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.LocalSharedVm
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.security.SecureScreenFromRecording
import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components.AddPeerButton
import com.zaneschepke.wireguardautotunnel.ui.screens.tunnels.config.components.InterfaceSection
@@ -21,33 +25,50 @@ 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 java.util.Locale
import org.orbitmvi.orbit.compose.collectSideEffect
@Composable
fun ConfigScreen(viewModel: ConfigViewModel) {
val sharedViewModel = LocalSharedVm.current
val configUiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
if (configUiState.isLoading) return
if (uiState.isLoading) return
val locale = remember { Locale.getDefault() }
var configProxy by remember {
mutableStateOf(
configUiState.tunnel?.let { ConfigProxy.from(it.toAmConfig()) } ?: ConfigProxy()
)
mutableStateOf(uiState.tunnel?.let { ConfigProxy.from(it.toAmConfig()) } ?: ConfigProxy())
}
var tunnelName by remember { mutableStateOf(configUiState.tunnel?.name ?: "") }
var tunnelName by remember { mutableStateOf(uiState.tunnel?.name ?: "") }
val isGlobalConfig = rememberSaveable { tunnelName == TunnelConfig.GLOBAL_CONFIG_NAME }
val isTunnelNameTaken by
remember(tunnelName) {
derivedStateOf { configUiState.unavailableNames.contains(tunnelName) }
}
remember(tunnelName) { derivedStateOf { uiState.unavailableNames.contains(tunnelName) } }
sharedViewModel.collectSideEffect { sideEffect ->
if (sideEffect is LocalSideEffect.SaveChanges)
viewModel.saveConfigProxy(configProxy, tunnelName)
if (uiState.isRunning) viewModel.setShowSaveModal(true)
else viewModel.saveConfigProxy(configProxy, tunnelName)
}
if (uiState.showSaveModal) {
InfoDialog(
onDismiss = { viewModel.setShowSaveModal(false) },
onAttest = { viewModel.saveConfigProxy(configProxy, tunnelName) },
title = stringResource(R.string.save_changes),
body = {
Text(
stringResource(
R.string.restart_message_template,
stringResource(R.string.tunnels).lowercase(locale),
)
)
},
confirmText = stringResource(R.string._continue),
)
}
SecureScreenFromRecording()
@@ -60,7 +81,7 @@ fun ConfigScreen(viewModel: ConfigViewModel) {
InterfaceSection(
isGlobalConfig,
configProxy = configProxy,
configUiState.isRunning,
uiState.isRunning,
tunnelName,
isTunnelNameTaken,
onInterfaceChange = { configProxy = configProxy.copy(`interface` = it) },
@@ -7,4 +7,5 @@ data class ConfigUiState(
val isLoading: Boolean = true,
val tunnel: TunnelConfig? = null,
val isRunning: Boolean = false,
val showSaveModal: Boolean = false,
)
@@ -1,13 +1,16 @@
package com.zaneschepke.wireguardautotunnel.ui.state
import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
data class ProxySettingsUiState(
val proxySettings: ProxySettings = ProxySettings(),
val activeTuns: Map<Int, TunnelState> = emptyMap(),
val isSocks5BindAddressError: Boolean = false,
val isHttpBindAddressError: Boolean = false,
val isUserNameError: Boolean = false,
val isPasswordError: Boolean = false,
val passwordVisible: Boolean = false,
val isLoading: Boolean = true,
val showSaveModal: Boolean = false,
)
@@ -51,6 +51,7 @@ constructor(
}
fun saveConfigProxy(configProxy: ConfigProxy, tunnelName: String) = intent {
reduce { state.copy(showSaveModal = false) }
if (state.unavailableNames.contains(tunnelName))
return@intent postSideEffect(
GlobalSideEffect.Toast(StringValue.StringResource(R.string.tunnel_name_taken))
@@ -72,12 +73,15 @@ constructor(
}
if (tunnelConfig != null) {
tunnelRepository.save(tunnelConfig)
postSideEffect(
GlobalSideEffect.Toast(
StringValue.StringResource(R.string.config_changes_saved)
)
)
postSideEffect(GlobalSideEffect.PopBackStack)
if (state.isRunning) tunnelManager.restartActiveTunnel(tunnelConfig.id)
}
}
.onFailure {
@@ -96,6 +100,10 @@ constructor(
globalEffectRepository.post(globalSideEffect)
}
fun setShowSaveModal(showSaveModal: Boolean) = intent {
reduce { state.copy(showSaveModal = showSaveModal) }
}
@AssistedFactory
interface Factory {
fun create(tunnelId: Int?): ConfigViewModel
@@ -38,6 +38,12 @@ constructor(
fun setLockdownSettings(lockdownSettings: LockdownSettings) = intent {
reduce { state.copy(showSaveModal = false) }
lockdownSettingsRepository.upsert(lockdownSettings)
postSideEffect(GlobalSideEffect.PopBackStack)
postSideEffect(
GlobalSideEffect.Toast(StringValue.StringResource(R.string.config_changes_saved))
)
tunnelManager.setBackendMode(BackendMode.Inactive)
val allowedIps =
if (lockdownSettings.bypassLan) TunnelConfig.LAN_BYPASS_ALLOWED_IPS else emptySet()
@@ -48,10 +54,6 @@ constructor(
dualStack = lockdownSettings.dualStack,
)
)
postSideEffect(GlobalSideEffect.PopBackStack)
postSideEffect(
GlobalSideEffect.Toast(StringValue.StringResource(R.string.config_changes_saved))
)
}
suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) {
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.viewmodel
import androidx.lifecycle.ViewModel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.domain.model.ProxySettings
import com.zaneschepke.wireguardautotunnel.domain.repository.GlobalEffectRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.ProxySettingsRepository
@@ -11,6 +12,7 @@ import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidAndroidProxyBindAddress
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.combine
import org.orbitmvi.orbit.ContainerHost
import org.orbitmvi.orbit.viewmodel.container
@@ -20,6 +22,7 @@ class ProxySettingsViewModel
constructor(
private val proxySettingsRepository: ProxySettingsRepository,
private val globalEffectRepository: GlobalEffectRepository,
private val tunnelManager: TunnelManager,
) : ContainerHost<ProxySettingsUiState, Nothing>, ViewModel() {
override val container =
@@ -27,12 +30,16 @@ constructor(
ProxySettingsUiState(),
buildSettings = { repeatOnSubscribedStopTimeout = 5000L },
) {
proxySettingsRepository.flow.collect {
reduce { state.copy(proxySettings = it, isLoading = false) }
}
combine(tunnelManager.activeTunnels, proxySettingsRepository.flow) {
activeTuns,
settings ->
state.copy(proxySettings = settings, isLoading = false, activeTuns = activeTuns)
}
.collect { reduce { it } }
}
fun save(proxySettings: ProxySettings) = intent {
reduce { state.copy(showSaveModal = false) }
val updated =
state.proxySettings.copy(
socks5ProxyEnabled = proxySettings.socks5ProxyEnabled,
@@ -94,19 +101,22 @@ constructor(
)
}
}
// Validate password for whitespace
if (updated.proxyPassword?.any { it.isWhitespace() } == true) {
postSideEffect(
GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.password_no_spaces))
)
return@intent reduce { state.copy(isPasswordError = true) }
}
// Save if all validations pass
proxySettingsRepository.upsert(updated)
postSideEffect(
GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.config_changes_saved))
)
postSideEffect(GlobalSideEffect.PopBackStack)
if (state.activeTuns.isNotEmpty()) tunnelManager.restartActiveTunnels()
}
fun clearHttpBindError() = intent { reduce { state.copy(isHttpBindAddressError = false) } }
@@ -117,6 +127,10 @@ constructor(
fun clearPasswordError() = intent { reduce { state.copy(isPasswordError = false) } }
fun setShowSaveModal(showSaveModal: Boolean) = intent {
reduce { state.copy(showSaveModal = showSaveModal) }
}
suspend fun postSideEffect(globalSideEffect: GlobalSideEffect) {
globalEffectRepository.post(globalSideEffect)
}