diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt index 8ffb74dc6..ba9791a7a 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeScreen.kt @@ -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>() } + /* 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() } + 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) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt index d0cafd968..a05f47cd4 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/HomeViewModel.kt @@ -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(extraBufferCapacity = 4) val messages: SharedFlow = _messages + private val homeVisible = MutableStateFlow(false) private val _ui = MutableStateFlow(HomeUiState()) val ui: StateFlow = _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 diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt index 73ffc52b3..2e951d515 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/RoutingScreen.kt @@ -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()) + /* 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(), 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) } }