Skip to content

Commit af67ffa

Browse files
naonakclaude
andcommitted
feat(ui): auto-restart settings screen with real-time progress display
AutoRestartScreen: configures auto-restart (enable/disable, ping failures before restart, cooldown, max attempts, exponential backoff, on-max-attempts action). Accessible from Settings → Tunnel monitoring. TunnelList: inline restart progress label below tunnel name shows the current phase — "restarting 1/3…", "verifying 1/3…", "restart 1/3 · next in 28s", "awaiting ping recovery" — and total restart counter alongside uptime ("uptime: 4m · ↺ 3"). Dot color forced to UNHEALTHY during active restart. MonitoringViewModel bridges MonitoringSettings persistence and exposes restartProgress state from TunnelManager to the UI layer. Snackbar notifications emitted on ConnectionRestored and ConnectionPermanentlyLost (always active, no per-setting toggle). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent da243a9 commit af67ffa

14 files changed

Lines changed: 381 additions & 16 deletions

File tree

app/src/main/java/com/zaneschepke/wireguardautotunnel/MainActivity.kt

100644100755
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.dns.DnsSettingsSc
9595
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.integrations.AndroidIntegrationsScreen
9696
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.lockdown.LockdownSettingsScreen
9797
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.TunnelMonitoringScreen
98+
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.autorestart.AutoRestartScreen
9899
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.logs.LogsScreen
99100
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.ping.PingTargetScreen
100101
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
@@ -503,6 +504,7 @@ class MainActivity : AppCompatActivity() {
503504
PreferredTunnelScreen(key.tunnelNetwork)
504505
}
505506
entry<Route.PingTarget> { PingTargetScreen() }
507+
entry<Route.AutoRestart> { AutoRestartScreen() }
506508
},
507509
)
508510
}

app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/dropdown/LabelledNumberDropdown.kt

100644100755
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ fun <T> LabelledDropdown(
88
title: String,
99
description: (@Composable () -> Unit)? = null,
1010
leading: @Composable () -> Unit,
11+
enabled: Boolean = true,
1112
onSelected: (T?) -> Unit,
1213
options: List<T?>,
1314
currentValue: T?,
@@ -19,6 +20,7 @@ fun <T> LabelledDropdown(
1920
leading = leading,
2021
title = title,
2122
description = description,
23+
enabled = enabled,
2224
onClick = { isDropDownExpanded = true },
2325
trailing = {
2426
DropdownSelector(

app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/Route.kt

100644100755
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ sealed class Route : NavKey {
7575
@Keep @Serializable data class PreferredTunnel(val tunnelNetwork: TunnelNetwork) : Route()
7676

7777
@Keep @Serializable data object PingTarget : Route()
78+
79+
@Keep @Serializable data object AutoRestart : Route()
7880
}
7981

8082
@Serializable
@@ -128,6 +130,7 @@ enum class Tab(
128130
Route.Language,
129131
Route.Display,
130132
Route.PingTarget,
133+
Route.AutoRestart,
131134
is Route.ConfigGlobal,
132135
Route.Logs -> SETTINGS
133136
is Route.Support,

app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt

100644100755
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,19 @@ fun currentRouteAsNavbarState(
554554
topTitle = context.getString(R.string.ping_target),
555555
showBottomItems = true,
556556
)
557+
AutoRestart ->
558+
NavbarState(
559+
topLeading = {
560+
IconButton(onClick = { navController.pop() }) {
561+
Icon(
562+
Icons.AutoMirrored.Rounded.ArrowBack,
563+
stringResource(R.string.back),
564+
)
565+
}
566+
},
567+
topTitle = context.getString(R.string.auto_restart),
568+
showBottomItems = true,
569+
)
557570
null -> NavbarState()
558571
}
559572
}

app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt

100644100755
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import androidx.compose.material.icons.outlined.Dns
1414
import androidx.compose.material.icons.outlined.ExpandMore
1515
import androidx.compose.material.icons.outlined.NetworkPing
1616
import androidx.compose.material.icons.outlined.Pin
17+
import androidx.compose.material.icons.outlined.RestartAlt
1718
import androidx.compose.material.icons.outlined.SettingsBackupRestore
1819
import androidx.compose.material.icons.outlined.ViewHeadline
1920
import androidx.compose.material3.Icon
2021
import androidx.compose.material3.IconButton
2122
import androidx.compose.material3.MaterialTheme
23+
import androidx.compose.material3.Text
2224
import androidx.compose.runtime.Composable
2325
import androidx.compose.runtime.derivedStateOf
2426
import androidx.compose.runtime.getValue
@@ -237,6 +239,32 @@ fun SettingsScreen(
237239
},
238240
onClick = { navController.push(Route.TunnelMonitoring) },
239241
)
242+
SurfaceRow(
243+
enabled = uiState.monitoring.isPingEnabled,
244+
leading = { Icon(Icons.Outlined.RestartAlt, contentDescription = null) },
245+
title = stringResource(R.string.auto_restart),
246+
description =
247+
if (!uiState.monitoring.isPingEnabled) {
248+
{
249+
Text(
250+
text = stringResource(R.string.use_ping_for_detection_description),
251+
style =
252+
MaterialTheme.typography.bodySmall.copy(
253+
color = MaterialTheme.colorScheme.outline
254+
),
255+
)
256+
}
257+
} else null,
258+
trailing = { modifier ->
259+
SwitchWithDivider(
260+
checked = uiState.monitoring.isRestartOnHandshakeTimeoutEnabled,
261+
onClick = { viewModel.setRestartOnHandshakeTimeout(it) },
262+
enabled = uiState.monitoring.isPingEnabled,
263+
modifier = modifier,
264+
)
265+
},
266+
onClick = { navController.push(Route.AutoRestart) },
267+
)
240268
SurfaceRow(
241269
leading = { Icon(Icons.Outlined.ViewHeadline, contentDescription = null) },
242270
title = stringResource(R.string.local_logging),

app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/monitoring/TunnelMonitoringScreen.kt

100644100755
File mode changed.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.autorestart
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.fillMaxSize
6+
import androidx.compose.foundation.layout.padding
7+
import androidx.compose.foundation.rememberScrollState
8+
import androidx.compose.foundation.verticalScroll
9+
import androidx.compose.material.icons.Icons
10+
import androidx.compose.material.icons.outlined.FilterAlt
11+
import androidx.compose.material.icons.outlined.PowerSettingsNew
12+
import androidx.compose.material.icons.outlined.Replay
13+
import androidx.compose.material.icons.outlined.Timer
14+
import androidx.compose.material.icons.automirrored.outlined.TrendingUp
15+
import androidx.compose.material3.Icon
16+
import androidx.compose.material3.MaterialTheme
17+
import androidx.compose.material3.Text
18+
import androidx.compose.runtime.Composable
19+
import androidx.compose.runtime.getValue
20+
import androidx.compose.ui.Alignment
21+
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.res.stringResource
23+
import androidx.compose.ui.unit.dp
24+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
25+
import com.zaneschepke.wireguardautotunnel.R
26+
import com.zaneschepke.wireguardautotunnel.data.model.MaxAttemptsAction
27+
import com.zaneschepke.wireguardautotunnel.ui.common.button.SurfaceRow
28+
import com.zaneschepke.wireguardautotunnel.ui.common.button.ThemedSwitch
29+
import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledDropdown
30+
import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel
31+
import com.zaneschepke.wireguardautotunnel.viewmodel.MonitoringViewModel
32+
import org.koin.androidx.compose.koinViewModel
33+
34+
@Composable
35+
fun AutoRestartScreen(viewModel: MonitoringViewModel = koinViewModel()) {
36+
val uiState by viewModel.container.stateFlow.collectAsStateWithLifecycle()
37+
38+
if (uiState.isLoading) return
39+
40+
val pingEnabled = uiState.monitoringSettings.isPingEnabled
41+
42+
Column(
43+
horizontalAlignment = Alignment.Start,
44+
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Top),
45+
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
46+
) {
47+
Column {
48+
GroupLabel(
49+
stringResource(R.string.auto_restart),
50+
modifier = Modifier.padding(horizontal = 16.dp),
51+
)
52+
if (!pingEnabled) {
53+
Text(
54+
text = stringResource(R.string.use_ping_for_detection_description),
55+
style =
56+
MaterialTheme.typography.bodySmall.copy(
57+
color = MaterialTheme.colorScheme.outline
58+
),
59+
modifier = Modifier.padding(horizontal = 16.dp),
60+
)
61+
}
62+
LabelledDropdown(
63+
enabled = pingEnabled,
64+
title = stringResource(R.string.ping_failures_before_restart),
65+
leading = { Icon(Icons.Outlined.FilterAlt, contentDescription = null) },
66+
currentValue = uiState.monitoringSettings.pingFailuresBeforeRestart,
67+
onSelected = { selected ->
68+
selected?.let { viewModel.setPingFailuresBeforeRestart(it) }
69+
},
70+
options = listOf(1, 2, 3, 4, 5),
71+
optionToString = { it?.toString() ?: stringResource(R.string._default) },
72+
)
73+
LabelledDropdown(
74+
enabled = pingEnabled,
75+
title = stringResource(R.string.restart_cooldown),
76+
leading = { Icon(Icons.Outlined.Timer, contentDescription = null) },
77+
currentValue = uiState.monitoringSettings.restartCooldownSeconds,
78+
onSelected = { selected ->
79+
selected?.let { viewModel.setRestartCooldownSeconds(it) }
80+
},
81+
options = listOf(5, 10, 15, 30, 60, 120, 300),
82+
optionToString = { it?.let { "${it}s" } ?: stringResource(R.string._default) },
83+
)
84+
SurfaceRow(
85+
enabled = pingEnabled,
86+
leading = { Icon(Icons.AutoMirrored.Outlined.TrendingUp, contentDescription = null) },
87+
title = stringResource(R.string.exponential_backoff),
88+
description = {
89+
Text(
90+
text = stringResource(R.string.exponential_backoff_description),
91+
style =
92+
MaterialTheme.typography.bodySmall.copy(
93+
color = MaterialTheme.colorScheme.outline
94+
),
95+
)
96+
},
97+
trailing = {
98+
ThemedSwitch(
99+
checked = uiState.monitoringSettings.isBackoffEnabled,
100+
onClick = { viewModel.setBackoffEnabled(it) },
101+
enabled = pingEnabled,
102+
)
103+
},
104+
onClick = {
105+
viewModel.setBackoffEnabled(!uiState.monitoringSettings.isBackoffEnabled)
106+
},
107+
)
108+
LabelledDropdown(
109+
enabled = pingEnabled,
110+
title = stringResource(R.string.max_restart_attempts),
111+
leading = { Icon(Icons.Outlined.Replay, contentDescription = null) },
112+
currentValue = uiState.monitoringSettings.maxRestartAttempts,
113+
onSelected = { selected -> selected?.let { viewModel.setMaxRestartAttempts(it) } },
114+
options =
115+
if (uiState.monitoringSettings.isBackoffEnabled) listOf(3, 4, 5, 6, 7, 8)
116+
else listOf(3, 5, 10, 20),
117+
optionToString = { n ->
118+
if (n == null) return@LabelledDropdown stringResource(R.string._default)
119+
val baseSec = uiState.monitoringSettings.restartCooldownSeconds.toLong()
120+
val totalSec =
121+
if (uiState.monitoringSettings.isBackoffEnabled) baseSec * ((1L shl n) - 1)
122+
else baseSec * n
123+
val display =
124+
when {
125+
totalSec < 60 -> "${totalSec}s"
126+
totalSec < 3600 -> {
127+
val m = totalSec / 60
128+
val s = totalSec % 60
129+
if (s == 0L) "${m}m" else "${m}m${s}s"
130+
}
131+
else -> {
132+
val h = totalSec / 3600
133+
val m = (totalSec % 3600) / 60
134+
if (m == 0L) "${h}h" else "${h}h${m}m"
135+
}
136+
}
137+
"$n attempts (~$display)"
138+
},
139+
)
140+
LabelledDropdown(
141+
enabled = pingEnabled,
142+
title = stringResource(R.string.max_attempts_action),
143+
leading = { Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null) },
144+
currentValue = uiState.monitoringSettings.maxAttemptsAction,
145+
onSelected = { selected -> selected?.let { viewModel.setMaxAttemptsAction(it) } },
146+
options = MaxAttemptsAction.entries.toList(),
147+
optionToString = { action ->
148+
when (action) {
149+
MaxAttemptsAction.DO_NOTHING ->
150+
stringResource(R.string.max_attempts_action_do_nothing)
151+
MaxAttemptsAction.STOP_TUNNEL ->
152+
stringResource(R.string.max_attempts_action_stop_tunnel)
153+
null -> stringResource(R.string._default)
154+
}
155+
},
156+
)
157+
}
158+
}
159+
}

0 commit comments

Comments
 (0)