From 6369d8975c92548bb9e3881260e0a60b44ef8563 Mon Sep 17 00:00:00 2001 From: Henry Essinghigh Date: Sat, 7 Mar 2026 17:22:30 +0000 Subject: [PATCH] feat: support for filtering by endpoint latency (#1155) --- .../ui/common/ExpandingRowListItem.kt | 3 +- .../currentBackStackEntryAsNavbarState.kt | 10 +++++ .../ui/screens/tunnels/sort/SortScreen.kt | 33 +++++++++++++- .../ui/sideeffect/LocalSideEffect.kt | 7 +++ .../viewmodel/SharedAppViewModel.kt | 45 +++++++++++++++++++ app/src/main/res/values/strings.xml | 2 + 6 files changed, 98 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt index ad95603d..767695b8 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ExpandingRowListItem.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -17,7 +18,7 @@ import androidx.compose.ui.unit.dp @Composable fun ExpandingRowListItem( leading: @Composable () -> Unit, - text: String, + text: AnnotatedString, trailing: @Composable () -> Unit, isSelected: Boolean, expanded: @Composable () -> Unit, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt index 642f3e2d..c2b16ca2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/navigation/components/currentBackStackEntryAsNavbarState.kt @@ -218,6 +218,16 @@ fun currentRouteAsNavbarState( topTitle = context.getString(R.string.sort), topTrailing = { Row { + IconButton( + onClick = { + sharedViewModel.postSideEffect(LocalSideEffect.SortByLatency) + } + ) { + Icon( + Icons.Rounded.NetworkCheck, + stringResource(R.string.sort_by_latency), + ) + } IconButton( onClick = { sharedViewModel.postSideEffect(LocalSideEffect.Sort) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt index 22983c34..b760b637 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/tunnels/sort/SortScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,12 +25,18 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem import com.zaneschepke.wireguardautotunnel.ui.sideeffect.LocalSideEffect +import com.zaneschepke.wireguardautotunnel.ui.theme.AlertRed +import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree +import com.zaneschepke.wireguardautotunnel.ui.theme.Straw import com.zaneschepke.wireguardautotunnel.util.extensions.isSortedBy import com.zaneschepke.wireguardautotunnel.viewmodel.SharedAppViewModel import org.koin.compose.viewmodel.koinActivityViewModel @@ -46,6 +53,7 @@ fun SortScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) { var sortAscending by rememberSaveable { mutableStateOf(null) } var editableTunnels by rememberSaveable { mutableStateOf(tunnelsUiState.tunnels) } + var latencies by rememberSaveable { mutableStateOf>(emptyMap()) } sharedViewModel.collectSideEffect { sideEffect -> when (sideEffect) { @@ -66,6 +74,13 @@ fun SortScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) { null -> tunnelsUiState.tunnels } } + LocalSideEffect.SortByLatency -> { + sharedViewModel.sortByLatency(editableTunnels) + } + is LocalSideEffect.LatencySortFinished -> { + editableTunnels = sideEffect.tunnels + latencies = sideEffect.latencies + } else -> Unit } } @@ -97,9 +112,25 @@ fun SortScreen(sharedViewModel: SharedAppViewModel = koinActivityViewModel()) { ) { itemsIndexed(editableTunnels, key = { _, tunnel -> tunnel.id }) { index, tunnel -> ReorderableItem(reorderableLazyListState, tunnel.id) { isDragging -> + val latency = latencies[tunnel.id] + val text = buildAnnotatedString { + append(tunnel.name) + if (latency != null && latency != Double.MAX_VALUE) { + append(" - ") + val color = + when (latency) { + in 0.0..50.0 -> SilverTree + in 50.0..150.0 -> Straw + else -> AlertRed + } + withStyle(style = SpanStyle(color = color)) { + append("${latency.toInt()}ms") + } + } + } ExpandingRowListItem( leading = {}, - text = tunnel.name, + text = text, trailing = { if (!isTv) Icon(Icons.Default.DragHandle, stringResource(R.string.drag_handle)) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt index 40ca0b00..c0fc0fa5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/sideeffect/LocalSideEffect.kt @@ -1,7 +1,14 @@ package com.zaneschepke.wireguardautotunnel.ui.sideeffect +import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConfig + sealed class LocalSideEffect { data object Sort : LocalSideEffect() + data object SortByLatency : LocalSideEffect() + data class LatencySortFinished( + val tunnels: List, + val latencies: Map, + ) : LocalSideEffect() data object SaveChanges : LocalSideEffect() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt index b5aab390..ce326db1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/SharedAppViewModel.kt @@ -29,12 +29,16 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue import com.zaneschepke.wireguardautotunnel.util.extensions.saveTunnelsUniquely +import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils import io.ktor.client.HttpClient import io.ktor.client.request.prepareGet import io.ktor.client.statement.bodyAsText import java.io.File import java.io.IOException import java.time.Instant +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -42,6 +46,7 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus +import kotlinx.coroutines.withContext import org.amnezia.awg.config.BadConfigException import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container @@ -60,6 +65,7 @@ class SharedAppViewModel( private val rootShellUtils: RootShellUtils, private val httpClient: HttpClient, private val fileUtils: FileUtils, + private val networkUtils: NetworkUtils, ) : ContainerHost, ViewModel() { val globalSideEffect = globalEffectRepository.flow @@ -235,6 +241,45 @@ class SharedAppViewModel( postSideEffect(GlobalSideEffect.PopBackStack) } + fun sortByLatency(tunnels: List) = intent { + postSideEffect( + GlobalSideEffect.Snackbar(StringValue.StringResource(R.string.pinging_servers)) + ) + val sortedResult = + withContext(Dispatchers.IO) { + tunnels + .map { tunnel -> + async { + val config = + try { + tunnel.toAmConfig() + } catch (e: Exception) { + null + } + val endpoint = + config?.peers?.firstOrNull()?.endpoint?.orElse(null)?.host + if (endpoint != null) { + val latency = + try { + val stats = networkUtils.pingWithStats(endpoint, 3) + if (stats.isReachable) stats.rttAvg else Double.MAX_VALUE + } catch (_: Exception) { + Double.MAX_VALUE + } + tunnel to latency + } else { + tunnel to Double.MAX_VALUE + } + } + } + .awaitAll() + .sortedBy { it.second } + } + val sortedTunnels = sortedResult.map { it.first } + val latencies = sortedResult.associate { it.first.id to it.second } + postSideEffect(LocalSideEffect.LatencySortFinished(sortedTunnels, latencies)) + } + fun importTunnelConfigs(configs: Map) = intent { try { val tunnelConfigs = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27f01c29..f2797ec2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -242,6 +242,8 @@ Release notes Shizuku not detected Sort + Sort by latency + Pinging servers… Drag Handle Move Up Move Down