fix: improve tunnel display states and transitions

This commit is contained in:
zaneschepke
2026-06-04 15:11:05 -04:00
parent 7789e73951
commit 4c320f89a6
12 changed files with 103 additions and 45 deletions
@@ -98,6 +98,7 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
provider.events,
provider.backendStatus,
coordinator.errors,
tunnelCoordinator.tunnelDisplayStates,
)
applicationScope.launch(ioDispatcher) { boostrapCoordinator.bootstrap() }
@@ -29,6 +29,7 @@ class TunnelEventDispatcher(
providerEvents: Flow<TunnelEvent>,
providerStatus: StateFlow<BackendStatus>,
coordinatorErrors: Flow<TunnelErrorEvent>,
tunnelDisplayStates: StateFlow<Map<Int, DisplayTunnelState>>,
) {
// 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,
@@ -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<Map<Int, DisplayTunnelState>> =
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,
@@ -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()
@@ -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
}
@@ -7,5 +7,6 @@ data class TunnelsUiState(
val tunnels: List<TunnelConfig> = emptyList(),
val backendStatus: BackendStatus = BackendStatus(),
val selectedTunnels: List<TunnelConfig> = emptyList(),
val displayStates: Map<Int, DisplayTunnelState> = emptyMap(),
val isLoading: Boolean = true,
)
@@ -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,
)
+1
View File
@@ -580,4 +580,5 @@
<string name="app">App</string>
<string name="app_channel_description">A channel for general application notifications, like version updates</string>
<string name="stop_all">Stop all</string>
<string name="tunnel_state_establishing_connection">Establishing connection</string>
</resources>