diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 9db889638..490b701d3 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -94,6 +94,7 @@ import com.geeksville.mesh.ui.common.components.MainAppBar import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog import com.geeksville.mesh.ui.connections.DeviceType import com.geeksville.mesh.ui.connections.components.TopLevelNavIcon +import com.geeksville.mesh.ui.metrics.annotateTraceroute import com.geeksville.mesh.ui.node.components.NodeMenuAction import com.geeksville.mesh.ui.sharing.SharedContactDialog import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -206,7 +207,11 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode traceRouteResponse?.let { response -> SimpleAlertDialog( title = R.string.traceroute, - text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Text(text = response) } }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text(text = annotateTraceroute(response)) + } + }, dismissText = stringResource(id = R.string.okay), onDismiss = { uIViewModel.clearTracerouteResponse() }, ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/TracerouteLog.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/TracerouteLog.kt index bc3046c1a..41dafc679 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/TracerouteLog.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/TracerouteLog.kt @@ -52,6 +52,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -62,11 +67,17 @@ import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.strings.R +import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD +import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD import org.meshtastic.core.ui.component.SimpleAlertDialog import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.core.ui.theme.StatusColors.StatusGreen +import org.meshtastic.core.ui.theme.StatusColors.StatusOrange +import org.meshtastic.core.ui.theme.StatusColors.StatusYellow import java.text.DateFormat @OptIn(ExperimentalFoundationApi::class) +@Suppress("LongMethod") @Composable fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -74,10 +85,10 @@ fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewMod fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" } - var showDialog by remember { mutableStateOf(null) } + var showDialog by remember { mutableStateOf(null) } if (showDialog != null) { - val message = showDialog ?: return + val message = showDialog ?: AnnotatedString("") // Should not be null if dialog is shown SimpleAlertDialog( title = R.string.traceroute, text = { SelectionContainer { Text(text = message) } }, @@ -88,7 +99,7 @@ fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewMod LazyColumn(modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) { items(state.tracerouteRequests, key = { it.uuid }) { log -> val result = - remember(state.tracerouteRequests) { + remember(state.tracerouteRequests, log.fromRadio.packet.id) { state.tracerouteResults.find { it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id } } val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery } @@ -97,21 +108,35 @@ fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewMod val (text, icon) = route.getTextAndIcon() var expanded by remember { mutableStateOf(false) } + val tracerouteDetailsAnnotated: AnnotatedString? = + result?.let { res -> + if (route != null && route.routeList.isNotEmpty() && route.routeBackList.isNotEmpty()) { + val seconds = (res.received_date - log.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC + val annotatedBase = + annotateTraceroute(res.fromRadio.packet.getTracerouteResponse(::getUsername)) + buildAnnotatedString { + append(annotatedBase) + append("\n\nDuration: ${"%.1f".format(seconds)} s") + } + } else { + // For cases where there's a result but no full route, display plain text + res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) } + } + } + Box { TracerouteItem( icon = icon, text = "$time - $text", modifier = Modifier.combinedClickable(onLongClick = { expanded = true }) { - if (result != null) { - val full = route - if (full != null && full.routeList.isNotEmpty() && full.routeBackList.isNotEmpty()) { - val elapsedMs = (result.received_date - log.received_date).coerceAtLeast(0) - val seconds = elapsedMs.toDouble() / MS_PER_SEC - val base = result.fromRadio.packet.getTracerouteResponse(::getUsername) - showDialog = "$base\n\nDuration: ${"%.1f".format(seconds)} s" - } else { - showDialog = result.fromRadio.packet.getTracerouteResponse(::getUsername) + if (tracerouteDetailsAnnotated != null) { + showDialog = tracerouteDetailsAnnotated + } else if (result != null) { + // Fallback for results that couldn't be fully annotated but have basic info + val basicInfo = result.fromRadio.packet.getTracerouteResponse(::getUsername) + if (basicInfo != null) { + showDialog = AnnotatedString(basicInfo) } } }, @@ -159,13 +184,14 @@ private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = } } +/** Generates a display string and icon based on the route discovery information. */ @Composable private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair = when { this == null -> { stringResource(R.string.routing_error_no_response) to Icons.Default.PersonOff } - - routeCount <= 2 -> { + // A direct route means the sender and receiver are the only two nodes in the route. + routeCount <= 2 && routeBackCount <= 2 -> { // also check routeBackCount for direct to be more robust stringResource(R.string.traceroute_direct) to Icons.Default.Group } @@ -175,11 +201,51 @@ private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair { - val (towards, back) = maxOf(0, routeCount - 2) to maxOf(0, routeBackCount - 2) + // Asymmetric route + val towards = maxOf(0, routeCount - 2) + val back = maxOf(0, routeBackCount - 2) stringResource(R.string.traceroute_diff, towards, back) to Icons.Default.Groups } } +/** + * Converts a raw traceroute string into an [AnnotatedString] with SNR values highlighted according to their quality. + * + * @param inString The raw string output from a traceroute response. + * @return An [AnnotatedString] with SNR values styled, or an empty [AnnotatedString] if input is null. + */ +@Composable +fun annotateTraceroute(inString: String?): AnnotatedString { + if (inString == null) return buildAnnotatedString { append("") } + return buildAnnotatedString { + inString.lines().forEachIndexed { i, line -> + if (i > 0) append("\n") + // Example line: "⇊ -8.75 dB SNR" + if (line.trimStart().startsWith("⇊")) { + val snrRegex = Regex("""⇊ ([\d\.\?-]+) dB""") + val snrMatch = snrRegex.find(line) + val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull() + + if (snrValue != null) { + val snrColor = + when { + snrValue >= SNR_GOOD_THRESHOLD -> MaterialTheme.colorScheme.StatusGreen + snrValue >= SNR_FAIR_THRESHOLD -> MaterialTheme.colorScheme.StatusYellow + else -> MaterialTheme.colorScheme.StatusOrange + } + withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append(line) } + } else { + // Append line as is if SNR value cannot be parsed + append(line) + } + } else { + // Append non-SNR lines as is + append(line) + } + } + } +} + @PreviewLightDark @Composable private fun TracerouteItemPreview() { diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt index 5c3fc3448..6975d5bcb 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt @@ -53,11 +53,11 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow -private const val SNR_GOOD_THRESHOLD = -7f -private const val SNR_FAIR_THRESHOLD = -15f +const val SNR_GOOD_THRESHOLD = -7f +const val SNR_FAIR_THRESHOLD = -15f -private const val RSSI_GOOD_THRESHOLD = -115 -private const val RSSI_FAIR_THRESHOLD = -126 +const val RSSI_GOOD_THRESHOLD = -115 +const val RSSI_FAIR_THRESHOLD = -126 @Stable private enum class Quality(