android: home/routing UI - poll only while visible, no idle animation

- traffic (1 s) and probe (10 s) loops run only while the Home screen is
  shown and the VPN is connected; no 1 s idle tick otherwise
- the refresh button no longer runs a second exit-IP trace
- the glow pulse is not composed at all while disconnected or with
  animations off (it redrew a transparent circle every frame)
- RTT sparkline appends each probe result once (it duplicated stale ones)
- Routing dead-host poll is lifecycle-aware

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-10 09:15:10 +07:00
1 parent 5f8b813eb4
commit 514b15b1ca
3 files changed
+89 -27

No files matched your search

@@ -89,6 +89,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
@@ -104,6 +105,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -169,15 +171,29 @@ fun HomeScreen(
val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle() val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle()
/* rolling RTT history per target, rendered as sparklines in the availability card */ /* rolling RTT history per target, rendered as sparklines in the availability card */
val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() } val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() }
/* the map is updated one target at a time as the parallel probes finish:
* only append results that are new, not every entry on every emission */
val lastSeen = remember { HashMap<ProbeTarget, ProbeResult>() }
DisposableEffect(viewModel)
{ /* the view model only polls traffic and probes while Home is shown */
viewModel.setHomeVisible(true)
onDispose { viewModel.setHomeVisible(false) }
}
LaunchedEffect(probes) LaunchedEffect(probes)
{ {
if (probes.isEmpty()) if (probes.isEmpty())
{ {
probeHistory.clear() probeHistory.clear()
lastSeen.clear()
} }
else else
{ {
probes.forEach { (target, result) -> probes.forEach { (target, result) ->
if (lastSeen[target] === result)
{
return@forEach
}
lastSeen[target] = result
val list = probeHistory.getOrPut(target) { mutableStateListOf() } val list = probeHistory.getOrPut(target) { mutableStateListOf() }
list.add(result) list.add(result)
while (list.size > RTT_HISTORY) while (list.size > RTT_HISTORY)
@@ -533,6 +549,13 @@ private fun StatusGlowBackground(color: Color, active: Boolean)
) != 0f ) != 0f
} }
val animated by animateColorAsState(color, label = "glow") val animated by animateColorAsState(color, label = "glow")
if (!active)
{ /* nothing to draw: no canvas and, above all, no infinite transition
* redrawing a transparent circle every frame while disconnected */
return
}
if (animationsOn)
{
val transition = rememberInfiniteTransition(label = "pulse") val transition = rememberInfiniteTransition(label = "pulse")
/* read inside the draw scope below so the pulse only invalidates drawing, /* read inside the draw scope below so the pulse only invalidates drawing,
* instead of recomposing this composable on every frame */ * instead of recomposing this composable on every frame */
@@ -542,22 +565,28 @@ private fun StatusGlowBackground(color: Color, active: Boolean)
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Reverse), animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Reverse),
label = "a", label = "a",
) )
Canvas(Modifier.fillMaxSize()) Canvas(Modifier.fillMaxSize()) { drawGlow(animated, pulse) }
}
else
{ {
val alpha = if (active) (if (animationsOn) pulse else 0.42f) else 0f Canvas(Modifier.fillMaxSize()) { drawGlow(animated, 0.42f) }
val cx = size.width / 2f }
val cy = size.height * 0.16f }
private fun DrawScope.drawGlow(color: Color, alpha: Float)
{
val center = Offset(size.width / 2f, size.height * 0.16f)
val radius = size.minDimension * 0.62f
drawCircle( drawCircle(
brush = Brush.radialGradient( brush = Brush.radialGradient(
colors = listOf(animated.copy(alpha = alpha * 0.30f), Color.Transparent), colors = listOf(color.copy(alpha = alpha * 0.30f), Color.Transparent),
center = Offset(cx, cy), center = center,
radius = size.minDimension * 0.62f, radius = radius,
), ),
radius = size.minDimension * 0.62f, radius = radius,
center = Offset(cx, cy), center = center,
) )
} }
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -36,11 +36,16 @@ import androidx.lifecycle.viewModelScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -131,6 +136,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
private val _messages = MutableSharedFlow<Int>(extraBufferCapacity = 4) private val _messages = MutableSharedFlow<Int>(extraBufferCapacity = 4)
val messages: SharedFlow<Int> = _messages val messages: SharedFlow<Int> = _messages
private val homeVisible = MutableStateFlow(false)
private val _ui = MutableStateFlow(HomeUiState()) private val _ui = MutableStateFlow(HomeUiState())
val ui: StateFlow<HomeUiState> = _ui val ui: StateFlow<HomeUiState> = _ui
@@ -204,32 +210,54 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
) )
reloadProfiles() reloadProfiles()
WhiteListManager.getInstance(ctx).refreshIfStale() WhiteListManager.getInstance(ctx).refreshIfStale()
/* poll traffic only while the app is visible, and off the main thread */ /* poll traffic only while the Home screen is shown (not on the other
* tabs or behind another activity), the VPN is up, and off the main thread */
viewModelScope.launch { viewModelScope.launch {
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (isActive) whileHomeConnected {
{
tickTraffic() tickTraffic()
delay(TRAFFIC_POLL_MS) delay(TRAFFIC_POLL_MS)
} }
} }
} }
/* probe availability while the app is visible and the VPN is connected */ /* probe availability under the same conditions */
viewModelScope.launch { viewModelScope.launch {
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
var lastProbeAt = 0L var lastProbeAt = 0L
while (isActive) whileHomeConnected {
{ val wait = PROBE_INTERVAL_MS - (SystemClock.elapsedRealtime() - lastProbeAt)
val connected = _ui.value.vpnState == State.CONNECTED if (wait > 0)
if (connected && SystemClock.elapsedRealtime() - lastProbeAt >= PROBE_INTERVAL_MS)
{ {
delay(wait)
}
lastProbeAt = SystemClock.elapsedRealtime() lastProbeAt = SystemClock.elapsedRealtime()
probeNow() probeNow()
} }
delay(PROBE_TICK_MS)
} }
} }
} }
/**
* Runs [block] repeatedly while the Home screen is visible and the VPN is
* connected; suspends without any polling otherwise. [block] is
* cancelled as soon as either condition goes away.
*/
private suspend fun whileHomeConnected(block: suspend () -> Unit)
{
combine(homeVisible, _ui.map { it.vpnState == State.CONNECTED }) { visible, connected ->
visible && connected
}.distinctUntilChanged().collectLatest { active ->
while (active && currentCoroutineContext().isActive)
{
block()
}
}
}
/** Called by the Home screen while it is (not) shown. */
fun setHomeVisible(visible: Boolean)
{
homeVisible.value = visible
} }
/** Immediately reruns the availability probe round (refresh button). */ /** Immediately reruns the availability probe round (refresh button). */
@@ -237,13 +265,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
{ {
WhiteListManager.getInstance(getApplication()).checkNow() WhiteListManager.getInstance(getApplication()).checkNow()
viewModelScope.launch { viewModelScope.launch {
probeNow() probeNow(forceExit = true)
refreshExit(force = true)
refreshDeadTargets()
} }
} }
private suspend fun probeNow() private suspend fun probeNow(forceExit: Boolean = false)
{ {
/* direct (outside the tunnel) probes: at most once per TTL, event-driven */ /* direct (outside the tunnel) probes: at most once per TTL, event-driven */
WhiteListManager.getInstance(getApplication()).refreshIfStale() WhiteListManager.getInstance(getApplication()).refreshIfStale()
@@ -263,7 +289,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
addAll(healthConfig.targets) addAll(healthConfig.targets)
} }
refreshDeadTargets() refreshDeadTargets()
viewModelScope.launch { refreshExit(force = false) } viewModelScope.launch { refreshExit(force = forceExit) }
if (targets.isEmpty()) if (targets.isEmpty())
{ {
return return
@@ -630,7 +656,6 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
companion object companion object
{ {
private const val TRAFFIC_POLL_MS = 1000L private const val TRAFFIC_POLL_MS = 1000L
private const val PROBE_TICK_MS = 1000L
private const val PROBE_INTERVAL_MS = 10_000L private const val PROBE_INTERVAL_MS = 10_000L
private const val EXIT_REFRESH_MS = 60_000L private const val EXIT_REFRESH_MS = 60_000L
private const val EXIT_TIMEOUT_MS = 5000 private const val EXIT_TIMEOUT_MS = 5000
@@ -60,7 +60,10 @@ import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -161,8 +164,12 @@ private fun RoutingPageContent(
} }
} }
/* dead direct destinations the engine keeps refusing */ /* dead direct destinations the engine keeps refusing; polled only while
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>()) * the screen is visible (not while the activity is stopped) */
val lifecycle = LocalLifecycleOwner.current.lifecycle
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>(), lifecycle)
{
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
{ {
while (true) while (true)
{ {
@@ -178,6 +185,7 @@ private fun RoutingPageContent(
delay(5_000) delay(5_000)
} }
} }
}
val refreshGeoip = { val refreshGeoip = {
scope.launch { scope.launch {