From 675d57fff8ad74a09706306d3847773a89be9ada Mon Sep 17 00:00:00 2001 From: kari-ts Date: Mon, 9 Dec 2024 13:02:40 -0800 Subject: [PATCH] android: synchronize ipn state and UI Pass in intended state to toggleVpn and keep track of progress to avoid redundant updates and ensure that the action taken reflects the user's intent. This fixes a possible recomposition loop caused by the ipn state and the vpn toggle state getting out of sync. Updates tailscale/tailscale#14125 Signed-off-by: kari-ts --- .../com/tailscale/ipn/ui/view/MainView.kt | 15 ++++--- .../ipn/ui/viewModel/MainViewModel.kt | 39 ++++++++++++++----- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 65a684477f..fb2eb8c20a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -145,13 +145,12 @@ fun MainView( leadingContent = { if (!hideHeader) { TintedSwitch( - onCheckedChange = { - if (!disableToggle.value) { - viewModel.toggleVpn() - } - }, - enabled = !disableToggle.value, - checked = isOn) + checked = isOn, + enabled = + !disableToggle.value && + !viewModel.isToggleInProgress + .value, // Disable switch if toggle is in progress + onCheckedChange = { desiredState -> viewModel.toggleVpn(desiredState) }) } }, headlineContent = { @@ -228,7 +227,7 @@ fun MainView( // action (eg, if the user connected to another VPN). state != Ipn.State.Stopping, user, - { viewModel.toggleVpn() }, + { viewModel.toggleVpn(desiredState = !isOn) }, { viewModel.login() }, loginAtUrl, netmap?.SelfNode, diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index fa92360b78..b139b46098 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -18,7 +18,6 @@ import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.notifier.Notifier -import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.TimeUtil @@ -53,6 +52,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { private val _vpnToggleState = MutableStateFlow(false) val vpnToggleState: StateFlow = _vpnToggleState + // Keeps track of whether a toggle operation is in progress. This ensures that toggleVpn cannot be + // invoked until the current operation is complete. + var isToggleInProgress = MutableStateFlow(false) + // Permission to prepare VPN private var vpnPermissionLauncher: ActivityResultLauncher? = null @@ -184,15 +187,33 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } - fun toggleVpn() { - val state = Notifier.state.value - val isPrepared = vpnViewModel.vpnPrepared.value + fun toggleVpn(desiredState: Boolean) { + if (isToggleInProgress.value) { + // Prevent toggling while a previous toggle is in progress + return + } - when { - !isPrepared -> showVPNPermissionLauncherIfUnauthorized() - state == Ipn.State.Running -> stopVPN() - state == Ipn.State.NeedsLogin && isAndroidTV() -> login() - else -> startVPN() + viewModelScope.launch { + isToggleInProgress.value = true + try { + val currentState = Notifier.state.value + val isPrepared = vpnViewModel.vpnPrepared.value + + if (desiredState) { + // User wants to turn ON the VPN + when { + !isPrepared -> showVPNPermissionLauncherIfUnauthorized() + currentState != Ipn.State.Running -> startVPN() + } + } else { + // User wants to turn OFF the VPN + if (currentState == Ipn.State.Running) { + stopVPN() + } + } + } finally { + isToggleInProgress.value = false + } } }