@@ -52,6 +52,11 @@ import androidx.compose.ui.Modifier
52
52
import androidx.compose.ui.graphics.vector.ImageVector
53
53
import androidx.compose.ui.res.pluralStringResource
54
54
import androidx.compose.ui.res.stringResource
55
+ import androidx.compose.ui.text.AnnotatedString
56
+ import androidx.compose.ui.text.SpanStyle
57
+ import androidx.compose.ui.text.buildAnnotatedString
58
+ import androidx.compose.ui.text.font.FontWeight
59
+ import androidx.compose.ui.text.withStyle
55
60
import androidx.compose.ui.tooling.preview.PreviewLightDark
56
61
import androidx.compose.ui.unit.dp
57
62
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -62,22 +67,28 @@ import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
62
67
import org.meshtastic.core.model.fullRouteDiscovery
63
68
import org.meshtastic.core.model.getTracerouteResponse
64
69
import org.meshtastic.core.strings.R
70
+ import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
71
+ import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
65
72
import org.meshtastic.core.ui.component.SimpleAlertDialog
66
73
import org.meshtastic.core.ui.theme.AppTheme
74
+ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
75
+ import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
76
+ import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
67
77
import java.text.DateFormat
68
78
69
79
@OptIn(ExperimentalFoundationApi ::class )
80
+ @Suppress(" LongMethod" )
70
81
@Composable
71
82
fun TracerouteLogScreen (modifier : Modifier = Modifier , viewModel : MetricsViewModel = hiltViewModel()) {
72
83
val state by viewModel.state.collectAsStateWithLifecycle()
73
84
val dateFormat = remember { DateFormat .getDateTimeInstance(DateFormat .SHORT , DateFormat .MEDIUM ) }
74
85
75
86
fun getUsername (nodeNum : Int ): String = with (viewModel.getUser(nodeNum)) { " $longName ($shortName )" }
76
87
77
- var showDialog by remember { mutableStateOf<String ?>(null ) }
88
+ var showDialog by remember { mutableStateOf<AnnotatedString ?>(null ) }
78
89
79
90
if (showDialog != null ) {
80
- val message = showDialog ? : return
91
+ val message = showDialog ? : AnnotatedString ( " " ) // Should not be null if dialog is shown
81
92
SimpleAlertDialog (
82
93
title = R .string.traceroute,
83
94
text = { SelectionContainer { Text (text = message) } },
@@ -88,7 +99,7 @@ fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewMod
88
99
LazyColumn (modifier = modifier.fillMaxSize(), contentPadding = PaddingValues (horizontal = 16 .dp)) {
89
100
items(state.tracerouteRequests, key = { it.uuid }) { log ->
90
101
val result =
91
- remember(state.tracerouteRequests) {
102
+ remember(state.tracerouteRequests, log.fromRadio.packet.id ) {
92
103
state.tracerouteResults.find { it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id }
93
104
}
94
105
val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery }
@@ -97,21 +108,35 @@ fun TracerouteLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewMod
97
108
val (text, icon) = route.getTextAndIcon()
98
109
var expanded by remember { mutableStateOf(false ) }
99
110
111
+ val tracerouteDetailsAnnotated: AnnotatedString ? =
112
+ result?.let { res ->
113
+ if (route != null && route.routeList.isNotEmpty() && route.routeBackList.isNotEmpty()) {
114
+ val seconds = (res.received_date - log.received_date).coerceAtLeast(0 ).toDouble() / MS_PER_SEC
115
+ val annotatedBase =
116
+ annotateTraceroute(res.fromRadio.packet.getTracerouteResponse(::getUsername))
117
+ buildAnnotatedString {
118
+ append(annotatedBase)
119
+ append(" \n\n Duration: ${" %.1f" .format(seconds)} s" )
120
+ }
121
+ } else {
122
+ // For cases where there's a result but no full route, display plain text
123
+ res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString (it) }
124
+ }
125
+ }
126
+
100
127
Box {
101
128
TracerouteItem (
102
129
icon = icon,
103
130
text = " $time - $text " ,
104
131
modifier =
105
132
Modifier .combinedClickable(onLongClick = { expanded = true }) {
106
- if (result != null ) {
107
- val full = route
108
- if (full != null && full.routeList.isNotEmpty() && full.routeBackList.isNotEmpty()) {
109
- val elapsedMs = (result.received_date - log.received_date).coerceAtLeast(0 )
110
- val seconds = elapsedMs.toDouble() / MS_PER_SEC
111
- val base = result.fromRadio.packet.getTracerouteResponse(::getUsername)
112
- showDialog = " $base \n\n Duration: ${" %.1f" .format(seconds)} s"
113
- } else {
114
- showDialog = result.fromRadio.packet.getTracerouteResponse(::getUsername)
133
+ if (tracerouteDetailsAnnotated != null ) {
134
+ showDialog = tracerouteDetailsAnnotated
135
+ } else if (result != null ) {
136
+ // Fallback for results that couldn't be fully annotated but have basic info
137
+ val basicInfo = result.fromRadio.packet.getTracerouteResponse(::getUsername)
138
+ if (basicInfo != null ) {
139
+ showDialog = AnnotatedString (basicInfo)
115
140
}
116
141
}
117
142
},
@@ -159,13 +184,14 @@ private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier =
159
184
}
160
185
}
161
186
187
+ /* * Generates a display string and icon based on the route discovery information. */
162
188
@Composable
163
189
private fun MeshProtos.RouteDiscovery?.getTextAndIcon (): Pair <String , ImageVector > = when {
164
190
this == null -> {
165
191
stringResource(R .string.routing_error_no_response) to Icons .Default .PersonOff
166
192
}
167
-
168
- routeCount <= 2 -> {
193
+ // A direct route means the sender and receiver are the only two nodes in the route.
194
+ routeCount <= 2 && routeBackCount <= 2 -> { // also check routeBackCount for direct to be more robust
169
195
stringResource(R .string.traceroute_direct) to Icons .Default .Group
170
196
}
171
197
@@ -175,11 +201,51 @@ private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVecto
175
201
}
176
202
177
203
else -> {
178
- val (towards, back) = maxOf(0 , routeCount - 2 ) to maxOf(0 , routeBackCount - 2 )
204
+ // Asymmetric route
205
+ val towards = maxOf(0 , routeCount - 2 )
206
+ val back = maxOf(0 , routeBackCount - 2 )
179
207
stringResource(R .string.traceroute_diff, towards, back) to Icons .Default .Groups
180
208
}
181
209
}
182
210
211
+ /* *
212
+ * Converts a raw traceroute string into an [AnnotatedString] with SNR values highlighted according to their quality.
213
+ *
214
+ * @param inString The raw string output from a traceroute response.
215
+ * @return An [AnnotatedString] with SNR values styled, or an empty [AnnotatedString] if input is null.
216
+ */
217
+ @Composable
218
+ fun annotateTraceroute (inString : String? ): AnnotatedString {
219
+ if (inString == null ) return buildAnnotatedString { append(" " ) }
220
+ return buildAnnotatedString {
221
+ inString.lines().forEachIndexed { i, line ->
222
+ if (i > 0 ) append(" \n " )
223
+ // Example line: "⇊ -8.75 dB SNR"
224
+ if (line.trimStart().startsWith(" ⇊" )) {
225
+ val snrRegex = Regex (""" ⇊ ([\d\.\?-]+) dB""" )
226
+ val snrMatch = snrRegex.find(line)
227
+ val snrValue = snrMatch?.groupValues?.getOrNull(1 )?.toFloatOrNull()
228
+
229
+ if (snrValue != null ) {
230
+ val snrColor =
231
+ when {
232
+ snrValue >= SNR_GOOD_THRESHOLD -> MaterialTheme .colorScheme.StatusGreen
233
+ snrValue >= SNR_FAIR_THRESHOLD -> MaterialTheme .colorScheme.StatusYellow
234
+ else -> MaterialTheme .colorScheme.StatusOrange
235
+ }
236
+ withStyle(style = SpanStyle (color = snrColor, fontWeight = FontWeight .Bold )) { append(line) }
237
+ } else {
238
+ // Append line as is if SNR value cannot be parsed
239
+ append(line)
240
+ }
241
+ } else {
242
+ // Append non-SNR lines as is
243
+ append(line)
244
+ }
245
+ }
246
+ }
247
+ }
248
+
183
249
@PreviewLightDark
184
250
@Composable
185
251
private fun TracerouteItemPreview () {
0 commit comments