From 4c320f89a66f096ba8c6ae6ac9aec2202bae73be Mon Sep 17 00:00:00 2001 From: zaneschepke Date: Thu, 4 Jun 2026 15:11:05 -0400 Subject: [PATCH] fix: improve tunnel display states and transitions --- .../WireGuardAutoTunnel.kt | 1 + .../core/event/TunnelEventDispatcher.kt | 33 +++++---- .../core/orchestration/TunnelCoordinator.kt | 17 +++++ .../screens/tunnels/components/TunnelList.kt | 7 +- .../ui/state/DisplayTunnelState.kt | 73 ++++++++++++++----- .../ui/state/TunnelsUiState.kt | 1 + .../viewmodel/SharedAppViewModel.kt | 4 +- app/src/main/res/values/strings.xml | 1 + .../zaneschepke/tunnel/backend/TunnelActor.kt | 4 +- .../tunnel/backend/TunnelBackend.kt | 3 +- .../tunnel/backend/WireGuardTunnelEngine.kt | 2 +- .../tunnel/state/BootstrapState.kt | 2 + 12 files changed, 103 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt index 6e4ab547..5567a667 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt @@ -98,6 +98,7 @@ class WireGuardAutoTunnel : Application(), KoinComponent { provider.events, provider.backendStatus, coordinator.errors, + tunnelCoordinator.tunnelDisplayStates, ) applicationScope.launch(ioDispatcher) { boostrapCoordinator.bootstrap() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/event/TunnelEventDispatcher.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/event/TunnelEventDispatcher.kt index f08d9616..6c1daea1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/event/TunnelEventDispatcher.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/event/TunnelEventDispatcher.kt @@ -29,6 +29,7 @@ class TunnelEventDispatcher( providerEvents: Flow, providerStatus: StateFlow, coordinatorErrors: Flow, + tunnelDisplayStates: StateFlow>, ) { // informational events @@ -85,22 +86,23 @@ class TunnelEventDispatcher( .launchIn(scope) // vpn - combine(providerStatus.map { it.activeTunnels }, tunnelRepository.userTunnelsFlow) { - activeTunnels, - allTunnels -> + combine( + providerStatus.map { it.activeTunnels }, + tunnelRepository.userTunnelsFlow, + tunnelDisplayStates, + ) { activeTunnels, allTunnels, displayStates -> activeTunnels .mapNotNull { (id, activeTunnel) -> val mode = activeTunnel.mode ?: return@mapNotNull null - - // Only include VPN / KillSwitchPrimary modes if ( mode !is BackendMode.Vpn && mode !is BackendMode.Proxy.KillSwitchPrimary ) { return@mapNotNull null } - val tunnel = allTunnels.find { it.id == id } ?: return@mapNotNull null - val displayState = DisplayTunnelState.from(activeTunnel) + + val displayState = + displayStates[id] ?: DisplayTunnelState.from(activeTunnel) TunnelNotificationLine( id = id, @@ -115,20 +117,19 @@ class TunnelEventDispatcher( .launchIn(scope) // proxy - combine(providerStatus.map { it.activeTunnels }, tunnelRepository.userTunnelsFlow) { - activeTunnels, - allTunnels -> + combine( + providerStatus.map { it.activeTunnels }, + tunnelRepository.userTunnelsFlow, + tunnelDisplayStates, + ) { activeTunnels, allTunnels, displayStates -> activeTunnels .mapNotNull { (id, activeTunnel) -> val mode = activeTunnel.mode ?: return@mapNotNull null - - // Only include Standard Proxy mode - if (mode !is BackendMode.Proxy.Standard) { - return@mapNotNull null - } + if (mode !is BackendMode.Proxy.Standard) return@mapNotNull null val tunnel = allTunnels.find { it.id == id } ?: return@mapNotNull null - val displayState = DisplayTunnelState.from(activeTunnel) + val displayState = + displayStates[id] ?: DisplayTunnelState.from(activeTunnel) TunnelNotificationLine( id = id, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/TunnelCoordinator.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/TunnelCoordinator.kt index 359adbdb..800d5759 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/TunnelCoordinator.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/orchestration/TunnelCoordinator.kt @@ -17,14 +17,20 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepos import com.zaneschepke.wireguardautotunnel.domain.repository.MonitoringSettingsRepository import com.zaneschepke.wireguardautotunnel.domain.repository.ProxySettingsRepository import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository +import com.zaneschepke.wireguardautotunnel.ui.state.DisplayTunnelState +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -41,6 +47,17 @@ class TunnelCoordinator( scope: CoroutineScope, ) { + @OptIn(FlowPreview::class) + val tunnelDisplayStates: StateFlow> = + tunnelProvider.backendStatus + .map { status -> + status.activeTunnels.mapValues { (_, activeTunnel) -> + DisplayTunnelState.from(activeTunnel) + } + } + .debounce(400L.milliseconds) + .stateIn(scope = scope, started = SharingStarted.Eagerly, initialValue = emptyMap()) + data class RuntimeSettingsSnapshot( val general: GeneralSettings, val dns: DnsSettings, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt index 8640ef64..8bcf0b24 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/components/TunnelList.kt @@ -87,15 +87,16 @@ fun TunnelList( uiState.backendStatus.activeTunnels[tunnel.id] ?: ActiveTunnel() } - val displayState = remember(activeTunnel) { DisplayTunnelState.from(activeTunnel) } + val displayState = + uiState.displayStates[tunnel.id] ?: DisplayTunnelState.from(activeTunnel) + + val isRunning = uiState.backendStatus.activeTunnels.containsKey(tunnel.id) val selected = remember(uiState.selectedTunnels) { uiState.selectedTunnels.any { it.id == tunnel.id } } - val isRunning = displayState != DisplayTunnelState.Disconnected - SurfaceRow( modifier = Modifier.animateItem() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/DisplayTunnelState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/DisplayTunnelState.kt index fd838b4c..18ba1b3f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/DisplayTunnelState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/DisplayTunnelState.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.annotation.StringRes import androidx.compose.ui.graphics.Color import com.zaneschepke.tunnel.Tunnel +import com.zaneschepke.tunnel.model.BackendMode import com.zaneschepke.tunnel.state.ActiveTunnel import com.zaneschepke.tunnel.state.BootstrapState import com.zaneschepke.wireguardautotunnel.R @@ -13,42 +14,49 @@ import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree import com.zaneschepke.wireguardautotunnel.ui.theme.Straw sealed class DisplayTunnelState { + data object Disconnected : DisplayTunnelState() + data object Connecting : DisplayTunnelState() data object ResolvingDns : DisplayTunnelState() - data object Connected : DisplayTunnelState() + data object EstablishingConnection : DisplayTunnelState() data object Ready : DisplayTunnelState() - data object Degraded : DisplayTunnelState() + data object Connected : DisplayTunnelState() - data object Disconnected : DisplayTunnelState() + data object Degraded : DisplayTunnelState() @StringRes fun labelRes(): Int { return when (this) { - ResolvingDns -> R.string.tunnel_state_resolving_dns + Disconnected -> R.string.tunnel_state_disconnected Connecting -> R.string.tunnel_state_starting + ResolvingDns -> R.string.tunnel_state_resolving_dns + EstablishingConnection -> R.string.tunnel_state_establishing_connection + Ready -> R.string.ready Connected -> R.string.tunnel_state_connected Degraded -> R.string.tunnel_state_handshake_failure - Disconnected -> R.string.tunnel_state_disconnected - Ready -> R.string.ready } } fun asLocalizedString(context: Context): String { - return context.getString(this.labelRes()) + return context.getString(labelRes()) } fun asColor(): Color { return when (this) { Disconnected -> CoolGray + Connecting, - Ready, - ResolvingDns -> Straw - Degraded -> AlertRed + ResolvingDns, + EstablishingConnection, + Ready -> Straw + Connected -> SilverTree + + Degraded -> AlertRed } } @@ -56,25 +64,50 @@ sealed class DisplayTunnelState { fun from(activeTunnel: ActiveTunnel): DisplayTunnelState { val transport = activeTunnel.transportState val bootstrap = activeTunnel.bootstrapState + val mode = activeTunnel.mode + val isVpnStyle = mode is BackendMode.Vpn || mode is BackendMode.Proxy.KillSwitchPrimary + + // Static peers bootstrap never goes to complete, treat none the same + val bootstrapPhaseDone = + bootstrap is BootstrapState.Complete || bootstrap is BootstrapState.None return when { transport is Tunnel.State.Down -> Disconnected - (bootstrap is BootstrapState.Complete && - !activeTunnel.isPeerUpdating && - transport is Tunnel.State.Starting) || - bootstrap is BootstrapState.None && transport is Tunnel.State.Starting -> Ready + bootstrap is BootstrapState.Failed -> Degraded + // DNS resolution still in progress bootstrap is BootstrapState.ResolvingDns || - (bootstrap is BootstrapState.Complete && activeTunnel.isPeerUpdating) -> - ResolvingDns - - transport is Tunnel.State.Starting -> Connecting + bootstrap is BootstrapState.UpdatingPeers -> ResolvingDns transport is Tunnel.State.Up.Healthy -> Connected - transport is Tunnel.State.Up.HandshakeFailure && !activeTunnel.isPeerUpdating -> - Degraded + transport is Tunnel.State.Up.HandshakeFailure -> { + val age = System.currentTimeMillis() - activeTunnel.lastStateChangeMs + + if (age > 15_000L && bootstrapPhaseDone) { + Degraded + } else if (isVpnStyle && bootstrapPhaseDone) { + EstablishingConnection + } else if (bootstrapPhaseDone) { + // For regular proxy mode, we go to ready once past bootstrap phase + Ready + } else { + Connecting + } + } + + transport is Tunnel.State.Starting -> { + when { + bootstrapPhaseDone -> { + if (isVpnStyle) EstablishingConnection else Ready + } + else -> Connecting + } + } + + // Final fallback after bootstrap phase is done + bootstrapPhaseDone -> if (isVpnStyle) EstablishingConnection else Ready else -> Connecting } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt index dd18d33d..26fc79fd 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/state/TunnelsUiState.kt @@ -7,5 +7,6 @@ data class TunnelsUiState( val tunnels: List = emptyList(), val backendStatus: BackendStatus = BackendStatus(), val selectedTunnels: List = emptyList(), + val displayStates: Map = emptyMap(), val isLoading: Boolean = true, ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt index 6b7aa125..2df6fb12 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt @@ -71,10 +71,12 @@ class SharedAppViewModel( tunnelRepository.userTunnelsFlow, tunnelCoordinator.backendStatus, selectedTunnelsRepository.flow, - ) { tunnels, backendStatus, selectedTuns -> + tunnelCoordinator.tunnelDisplayStates, + ) { tunnels, backendStatus, selectedTuns, displayStates -> TunnelsUiState( tunnels = tunnels, backendStatus = backendStatus, + displayStates = displayStates, selectedTunnels = selectedTuns, isLoading = false, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3fcb37e0..e77b0566 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -580,4 +580,5 @@ App A channel for general application notifications, like version updates Stop all + Establishing connection diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelActor.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelActor.kt index b4bfd017..a90827c7 100644 --- a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelActor.kt +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelActor.kt @@ -101,8 +101,6 @@ internal class TunnelActor( try { when (cmd) { is TunnelCommand.Start -> { - val mode = cmd.mode - val result = engine.start(cmd.tunnel, cmd.mode) apply(TunnelStarted(result, cmd)) @@ -306,7 +304,7 @@ internal class TunnelActor( updatedRunning.buildResolvedPeers( preferIpv6 = running.currentPreferIpv6 && networkHasIpv6 ) - + send(TunnelCommand.SetBootstrapState(tunnelId, BootstrapState.UpdatingPeers)) send(TunnelCommand.ApplyResolvedPeers(tunnelId = tunnelId, cache = cache, peers = peers)) send(TunnelCommand.SetBootstrapState(tunnelId, BootstrapState.Complete)) } diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelBackend.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelBackend.kt index 9ab7c2b9..f158f24a 100644 --- a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelBackend.kt +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/TunnelBackend.kt @@ -20,6 +20,7 @@ import com.zaneschepke.tunnel.state.RuntimeDnsConfig import com.zaneschepke.tunnel.util.BackendException import java.lang.ref.WeakReference import kotlin.reflect.KClass +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive @@ -230,7 +231,7 @@ class TunnelBackend( private suspend fun emitInitialSystemDnsConfig() { val state = - withTimeoutOrNull(2_500L) { + withTimeoutOrNull(2_500L.milliseconds) { networkMonitor.connectivityStateFlow.first { connectivityState -> val dns = connectivityState.underlyingDnsInfo dns.servers.isNotEmpty() || diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/WireGuardTunnelEngine.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/WireGuardTunnelEngine.kt index c103ca37..b338fdf3 100644 --- a/tunnel/src/main/java/com/zaneschepke/tunnel/backend/WireGuardTunnelEngine.kt +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/backend/WireGuardTunnelEngine.kt @@ -85,7 +85,7 @@ internal class WireGuardTunnelEngine( if (port !in 1..65_535) return false return try { ServerSocket(port).use { true } - } catch (e: IOException) { + } catch (_: IOException) { false } } diff --git a/tunnel/src/main/java/com/zaneschepke/tunnel/state/BootstrapState.kt b/tunnel/src/main/java/com/zaneschepke/tunnel/state/BootstrapState.kt index c1cc2479..249e51df 100644 --- a/tunnel/src/main/java/com/zaneschepke/tunnel/state/BootstrapState.kt +++ b/tunnel/src/main/java/com/zaneschepke/tunnel/state/BootstrapState.kt @@ -5,6 +5,8 @@ sealed class BootstrapState { data object ResolvingDns : BootstrapState() + object UpdatingPeers : BootstrapState() + data object Complete : BootstrapState() data class Failed(val error: Throwable? = null) : BootstrapState()