feat: support for filtering by endpoint latency (#1155)

This commit is contained in:
Henry Essinghigh
2026-03-07 17:22:30 +00:00
committed by GitHub
parent 0c57bea2ff
commit 6369d8975c
6 changed files with 98 additions and 2 deletions
@@ -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,
@@ -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)
@@ -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<Boolean?>(null) }
var editableTunnels by rememberSaveable { mutableStateOf(tunnelsUiState.tunnels) }
var latencies by rememberSaveable { mutableStateOf<Map<Int, Double>>(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))
@@ -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<TunnelConfig>,
val latencies: Map<Int, Double>,
) : LocalSideEffect()
data object SaveChanges : LocalSideEffect()
@@ -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<GlobalAppUiState, LocalSideEffect>, ViewModel() {
val globalSideEffect = globalEffectRepository.flow
@@ -235,6 +241,45 @@ class SharedAppViewModel(
postSideEffect(GlobalSideEffect.PopBackStack)
}
fun sortByLatency(tunnels: List<TunnelConfig>) = 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<QuickConfig, TunnelName>) = intent {
try {
val tunnelConfigs =
+2
View File
@@ -242,6 +242,8 @@
<string name="release_notes">Release notes</string>
<string name="shizuku_not_detected">Shizuku not detected</string>
<string name="sort">Sort</string>
<string name="sort_by_latency">Sort by latency</string>
<string name="pinging_servers">Pinging servers…</string>
<string name="drag_handle">Drag Handle</string>
<string name="move_up">Move Up</string>
<string name="move_down">Move Down</string>