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
+89
-27
No files matched your search
+39
-10
@@ -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
|
||||||
|
|||||||
+40
-15
@@ -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
|
||||||
|
|||||||
+10
-2
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user