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:
1 parent
5f8b813eb4
commit
514b15b1ca
3 files changed
+113
-51
No files matched your search
+52
-23
@@ -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)
|
||||
|
||||
+42
-17
@@ -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
|
||||
|
||||
+19
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user