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 <[email protected]>
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
+113 -51

No files matched your search

@@ -89,6 +89,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -169,15 +171,29 @@ fun HomeScreen(
val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle()
/* rolling RTT history per target, rendered as sparklines in the availability card */
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)
{
if (probes.isEmpty())
{
probeHistory.clear()
lastSeen.clear()
}
else
{
probes.forEach { (target, result) ->
if (lastSeen[target] === result)
{
return@forEach
}
lastSeen[target] = result
val list = probeHistory.getOrPut(target) { mutableStateListOf() }
list.add(result)
while (list.size > RTT_HISTORY)
@@ -533,30 +549,43 @@ private fun StatusGlowBackground(color: Color, active: Boolean)
) != 0f
}
val animated by animateColorAsState(color, label = "glow")
val transition = rememberInfiniteTransition(label = "pulse")
/* read inside the draw scope below so the pulse only invalidates drawing,
* instead of recomposing this composable on every frame */
val pulse by transition.animateFloat(
initialValue = 0.30f,
targetValue = 0.55f,
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Reverse),
label = "a",
)
Canvas(Modifier.fillMaxSize())
{
val alpha = if (active) (if (animationsOn) pulse else 0.42f) else 0f
val cx = size.width / 2f
val cy = size.height * 0.16f
drawCircle(
brush = Brush.radialGradient(
colors = listOf(animated.copy(alpha = alpha * 0.30f), Color.Transparent),
center = Offset(cx, cy),
radius = size.minDimension * 0.62f,
),
radius = size.minDimension * 0.62f,
center = Offset(cx, cy),
)
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")
/* read inside the draw scope below so the pulse only invalidates drawing,
* instead of recomposing this composable on every frame */
val pulse by transition.animateFloat(
initialValue = 0.30f,
targetValue = 0.55f,
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Reverse),
label = "a",
)
Canvas(Modifier.fillMaxSize()) { drawGlow(animated, pulse) }
}
else
{
Canvas(Modifier.fillMaxSize()) { drawGlow(animated, 0.42f) }
}
}
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(
brush = Brush.radialGradient(
colors = listOf(color.copy(alpha = alpha * 0.30f), Color.Transparent),
center = center,
radius = radius,
),
radius = radius,
center = center,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -36,11 +36,16 @@ import androidx.lifecycle.viewModelScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
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.isActive
import kotlinx.coroutines.launch
@@ -131,6 +136,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
private val _messages = MutableSharedFlow<Int>(extraBufferCapacity = 4)
val messages: SharedFlow<Int> = _messages
private val homeVisible = MutableStateFlow(false)
private val _ui = MutableStateFlow(HomeUiState())
val ui: StateFlow<HomeUiState> = _ui
@@ -204,46 +210,66 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
)
reloadProfiles()
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 {
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (isActive)
{
whileHomeConnected {
tickTraffic()
delay(TRAFFIC_POLL_MS)
}
}
}
/* probe availability while the app is visible and the VPN is connected */
/* probe availability under the same conditions */
viewModelScope.launch {
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
var lastProbeAt = 0L
while (isActive)
{
val connected = _ui.value.vpnState == State.CONNECTED
if (connected && SystemClock.elapsedRealtime() - lastProbeAt >= PROBE_INTERVAL_MS)
whileHomeConnected {
val wait = PROBE_INTERVAL_MS - (SystemClock.elapsedRealtime() - lastProbeAt)
if (wait > 0)
{
lastProbeAt = SystemClock.elapsedRealtime()
probeNow()
delay(wait)
}
delay(PROBE_TICK_MS)
lastProbeAt = SystemClock.elapsedRealtime()
probeNow()
}
}
}
}
/**
* 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). */
fun refreshProbes()
{
WhiteListManager.getInstance(getApplication()).checkNow()
viewModelScope.launch {
probeNow()
refreshExit(force = true)
refreshDeadTargets()
probeNow(forceExit = true)
}
}
private suspend fun probeNow()
private suspend fun probeNow(forceExit: Boolean = false)
{
/* direct (outside the tunnel) probes: at most once per TTL, event-driven */
WhiteListManager.getInstance(getApplication()).refreshIfStale()
@@ -263,7 +289,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
addAll(healthConfig.targets)
}
refreshDeadTargets()
viewModelScope.launch { refreshExit(force = false) }
viewModelScope.launch { refreshExit(force = forceExit) }
if (targets.isEmpty())
{
return
@@ -630,7 +656,6 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
companion object
{
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 EXIT_REFRESH_MS = 60_000L
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -161,21 +164,26 @@ private fun RoutingPageContent(
}
}
/* dead direct destinations the engine keeps refusing */
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>())
/* dead direct destinations the engine keeps refusing; polled only while
* the screen is visible (not while the activity is stopped) */
val lifecycle = LocalLifecycleOwner.current.lifecycle
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>(), lifecycle)
{
while (true)
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
{
val raw = try
while (true)
{
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
val raw = try
{
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
}
catch (_: Throwable)
{
null
}
value = HomeViewModel.parseDeadTargets(raw)
delay(5_000)
}
catch (_: Throwable)
{
null
}
value = HomeViewModel.parseDeadTargets(raw)
delay(5_000)
}
}