Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3e09ac5
Add traceroute map view improvements
DivineOmega Dec 8, 2025
f58fd8f
Merge remote-tracking branch 'upstream/main' into enhancement/tracero…
DivineOmega Dec 8, 2025
e5d8ed0
Merge branch 'main' into enhancement/traceroute-map-modal
DivineOmega Dec 8, 2025
14ce83a
Filter traceroute map to overlay nodes on Google map
DivineOmega Dec 9, 2025
95d6f4b
Use traceroute response overlay when map log data missing
DivineOmega Dec 9, 2025
ed45b17
Preserve traceroute overlay when opening map from popup
DivineOmega Dec 9, 2025
34259a2
Cache traceroute overlays for popup map
DivineOmega Dec 9, 2025
da3de9f
Soften traceroute colors with transparency
DivineOmega Dec 9, 2025
7e9429b
Adjust traceroute map offsets and lines
DivineOmega Dec 9, 2025
b5ae427
Interpolate traceroute nodes without locations and show missing endpo…
DivineOmega Dec 11, 2025
3f11b40
Revert traceroute interpolation and estimated node styling
DivineOmega Dec 11, 2025
885faed
Unify traceroute map checks and show node count
DivineOmega Dec 12, 2025
832554d
Handle dismissed traceroute requests and clear response on dialog dis…
DivineOmega Dec 12, 2025
b993b79
Fix traceroute popup map data and overlay layout
DivineOmega Dec 12, 2025
be3fd2c
Tweak traceroute map overlays and F-Droid zoom
DivineOmega Dec 13, 2025
f185555
Merge remote-tracking branch 'upstream/main' into enhancement/tracero…
DivineOmega Dec 14, 2025
ffc67a9
fix: simplify dialog handling in TracerouteLogScreen
DivineOmega Dec 14, 2025
ad2d3d1
fix(map): update traceroute bounds padding constant
DivineOmega Dec 14, 2025
533d210
Revert gradle.properties change
DivineOmega Dec 15, 2025
5ef8b11
Revert local Gradle ignore entries
DivineOmega Dec 15, 2025
9b2cdb8
Move traceroute colors to core UI theme
DivineOmega Dec 15, 2025
a5bf179
Refactor traceroute colors into TracerouteColors
DivineOmega Dec 15, 2025
09795ac
Merge branch 'main' into enhancement/traceroute-map-modal
DivineOmega Dec 16, 2025
101b049
Merge branch 'main' into enhancement/traceroute-map-modal
DivineOmega Dec 16, 2025
137b106
Fix detekt ReturnCount in traceroute map availability
DivineOmega Dec 16, 2025
fd71628
Fix detekt complexity and return count
DivineOmega Dec 16, 2025
807be24
Merge branch 'main' into enhancement/traceroute-map-modal
DivineOmega Dec 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
*.iml

.gradle
/.gradle-home
/.gradle-local
/local.properties
.DS_Store
**/build/**
Expand Down
13 changes: 12 additions & 1 deletion app/src/main/java/com/geeksville/mesh/model/UIState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,13 @@ import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.ui.component.ScrollToTopEvent
Expand Down Expand Up @@ -152,6 +155,14 @@ constructor(
private val _currentAlert: MutableStateFlow<AlertData?> = MutableStateFlow(null)
val currentAlert = _currentAlert.asStateFlow()

fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
evaluateTracerouteMapAvailability(
forwardRoute = forwardRoute,
returnRoute = returnRoute,
positionedNodeNums =
nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet(),
)

fun showAlert(
title: String,
message: String? = null,
Expand Down Expand Up @@ -248,7 +259,7 @@ constructor(
Timber.d("ViewModel cleared")
}

val tracerouteResponse: LiveData<String?>
val tracerouteResponse: LiveData<TracerouteResponse?>
get() = serviceRepository.tracerouteResponse.asLiveData()

fun clearTracerouteResponse() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
import kotlin.reflect.KClass

fun NavGraphBuilder.nodesGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
Expand Down Expand Up @@ -121,6 +122,54 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo
NodeMapScreen(vm, onNavigateUp = navController::navigateUp)
}

composable<NodeDetailRoutes.TracerouteLog>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.TracerouteLog>(
basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute",
),
navDeepLink<NodeDetailRoutes.TracerouteLog>(basePath = "$DEEP_LINK_BASE_URI/node/traceroute"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)

val args = backStackEntry.toRoute<NodeDetailRoutes.TracerouteLog>()
metricsViewModel.setNodeId(args.destNum)

TracerouteLogScreen(
viewModel = metricsViewModel,
onNavigateUp = navController::navigateUp,
onViewOnMap = { requestId ->
navController.navigate(NodeDetailRoutes.TracerouteMap(args.destNum, requestId))
},
)
}

composable<NodeDetailRoutes.TracerouteMap>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.TracerouteMap>(
basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute_map",
),
navDeepLink<NodeDetailRoutes.TracerouteMap>(basePath = "$DEEP_LINK_BASE_URI/node/traceroute_map"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)

val args = backStackEntry.toRoute<NodeDetailRoutes.TracerouteMap>()
metricsViewModel.setNodeId(args.destNum)

TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = args.requestId,
onNavigateUp = navController::navigateUp,
)
}

NodeDetailRoute.entries.forEach { entry ->
when (entry.routeClass) {
NodeDetailRoutes.DeviceMetrics::class ->
Expand Down Expand Up @@ -163,14 +212,6 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo
) {
it.destNum
}
NodeDetailRoutes.TracerouteLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.TracerouteLog>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.HostMetricsLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(
navController,
Expand Down
19 changes: 17 additions & 2 deletions app/src/main/java/com/geeksville/mesh/service/MeshService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.model.util.toOneLineString
Expand All @@ -92,6 +93,7 @@ import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected_count
import org.meshtastic.core.strings.connecting
Expand Down Expand Up @@ -924,11 +926,12 @@ class MeshService : Service() {

Portnums.PortNum.TRACEROUTE_APP_VALUE -> {
Timber.d("Received TRACEROUTE_APP from $fromId")
val routeDiscovery = packet.fullRouteDiscovery
val full = packet.getFullTracerouteResponse(::getUserName)
if (full != null) {
val requestId = packet.decoded.requestId
val start = tracerouteStartTimes.remove(requestId)
val response =
val responseText =
if (start != null) {
val elapsedMs = System.currentTimeMillis() - start
val seconds = elapsedMs / 1000.0
Expand All @@ -937,7 +940,19 @@ class MeshService : Service() {
} else {
full
}
serviceRepository.setTracerouteResponse(response)
val destination =
routeDiscovery?.routeList?.firstOrNull()
?: routeDiscovery?.routeBackList?.lastOrNull()
?: 0
serviceRepository.setTracerouteResponse(
TracerouteResponse(
message = responseText,
destinationNodeNum = destination,
requestId = requestId,
forwardRoute = routeDiscovery?.routeList.orEmpty(),
returnRoute = routeDiscovery?.routeBackList.orEmpty(),
),
)
}
}

Expand Down
53 changes: 45 additions & 8 deletions app/src/main/java/com/geeksville/mesh/ui/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,11 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.toMessageRes
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
Expand All @@ -117,6 +119,7 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.app_too_old
import org.meshtastic.core.strings.bottom_nav_settings
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.compromised_keys
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connecting
Expand All @@ -133,6 +136,7 @@ import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.should_update
import org.meshtastic.core.strings.should_update_firmware
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.SimpleAlertDialog
Expand Down Expand Up @@ -239,16 +243,49 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
}

val traceRouteResponse by uIViewModel.tracerouteResponse.observeAsState()
traceRouteResponse?.let { response ->
var tracerouteMapError by remember { mutableStateOf<StringResource?>(null) }
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
traceRouteResponse
?.takeIf { it.requestId != dismissedTracerouteRequestId }
?.let { response ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(text = annotateTraceroute(response.message))
}
},
confirmText = stringResource(Res.string.view_on_map),
onConfirm = {
val availability =
uIViewModel.tracerouteMapAvailability(
forwardRoute = response.forwardRoute,
returnRoute = response.returnRoute,
)
val errorRes = availability.toMessageRes()
if (errorRes == null) {
dismissedTracerouteRequestId = response.requestId
navController.navigate(
NodeDetailRoutes.TracerouteMap(response.destinationNodeNum, response.requestId),
)
} else {
tracerouteMapError = errorRes
uIViewModel.clearTracerouteResponse()
}
},
dismissText = stringResource(Res.string.okay),
onDismiss = {
uIViewModel.clearTracerouteResponse()
dismissedTracerouteRequestId = null
},
)
}
tracerouteMapError?.let { res ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(text = annotateTraceroute(response))
}
},
dismissText = stringResource(Res.string.okay),
onDismiss = { uIViewModel.clearTracerouteResponse() },
text = { Text(text = stringResource(res)) },
dismissText = stringResource(Res.string.close),
onDismiss = { tracerouteMapError = null },
)
}
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@

package org.meshtastic.core.model

import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.traceroute_endpoint_missing
import org.meshtastic.core.strings.traceroute_map_no_data
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.RouteDiscovery
import org.meshtastic.proto.Portnums
Expand All @@ -28,11 +32,13 @@ val MeshProtos.MeshPacket.fullRouteDiscovery: RouteDiscovery?
runCatching { RouteDiscovery.parseFrom(payload).toBuilder() }
.getOrNull()
?.apply {
val fullRoute = listOf(to) + routeList + from
val destinationId = dest.takeIf { it != 0 } ?: this@fullRouteDiscovery.to
val sourceId = source.takeIf { it != 0 } ?: this@fullRouteDiscovery.from
val fullRoute = listOf(destinationId) + routeList + sourceId
clearRoute()
addAllRoute(fullRoute)

val fullRouteBack = listOf(from) + routeBackList + to
val fullRouteBack = listOf(sourceId) + routeBackList + destinationId
clearRouteBack()
if (hopStart > 0 && snrBackCount > 0) { // otherwise back route is invalid
addAllRouteBack(fullRouteBack)
Expand Down Expand Up @@ -85,3 +91,36 @@ fun MeshProtos.MeshPacket.getTracerouteResponse(getUser: (nodeNum: Int) -> Strin
fun MeshProtos.MeshPacket.getFullTracerouteResponse(getUser: (nodeNum: Int) -> String): String? = fullRouteDiscovery
?.takeIf { it.routeList.isNotEmpty() && it.routeBackList.isNotEmpty() }
?.getTracerouteResponse(getUser)

enum class TracerouteMapAvailability {
Ok,
MissingEndpoints,
NoMappableNodes,
}

fun evaluateTracerouteMapAvailability(
forwardRoute: List<Int>,
returnRoute: List<Int>,
positionedNodeNums: Set<Int>,
): TracerouteMapAvailability {
if (forwardRoute.isEmpty() && returnRoute.isEmpty()) return TracerouteMapAvailability.NoMappableNodes
val endpoints =
listOfNotNull(
forwardRoute.firstOrNull(),
forwardRoute.lastOrNull(),
returnRoute.firstOrNull(),
returnRoute.lastOrNull(),
)
.distinct()
val missingEndpoint = endpoints.any { !positionedNodeNums.contains(it) }
if (missingEndpoint) return TracerouteMapAvailability.MissingEndpoints
val relatedNodeNums = (forwardRoute + returnRoute).toSet()
val hasAnyMappable = relatedNodeNums.any { positionedNodeNums.contains(it) }
return if (hasAnyMappable) TracerouteMapAvailability.Ok else TracerouteMapAvailability.NoMappableNodes
}

fun TracerouteMapAvailability.toMessageRes(): StringResource? = when (this) {
TracerouteMapAvailability.Ok -> null
TracerouteMapAvailability.MissingEndpoints -> Res.string.traceroute_endpoint_missing
TracerouteMapAvailability.NoMappableNodes -> Res.string.traceroute_map_no_data
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ object NodeDetailRoutes {

@Serializable data class TracerouteLog(val destNum: Int) : Route

@Serializable data class TracerouteMap(val destNum: Int, val requestId: Int) : Route

@Serializable data class HostMetricsLog(val destNum: Int) : Route

@Serializable data class PaxMetrics(val destNum: Int) : Route
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton

data class TracerouteResponse(
val message: String,
val destinationNodeNum: Int,
val requestId: Int,
val forwardRoute: List<Int> = emptyList(),
val returnRoute: List<Int> = emptyList(),
) {
val hasOverlay: Boolean
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
}

/** Repository class for managing the [IMeshService] instance and connection state */
@Suppress("TooManyFunctions")
@Singleton
Expand Down Expand Up @@ -94,11 +105,11 @@ class ServiceRepository @Inject constructor() {
_meshPacketFlow.emit(packet)
}

private val _tracerouteResponse = MutableStateFlow<String?>(null)
val tracerouteResponse: StateFlow<String?>
private val _tracerouteResponse = MutableStateFlow<TracerouteResponse?>(null)
val tracerouteResponse: StateFlow<TracerouteResponse?>
get() = _tracerouteResponse

fun setTracerouteResponse(value: String?) {
fun setTracerouteResponse(value: TracerouteResponse?) {
_tracerouteResponse.value = value
}

Expand Down
12 changes: 9 additions & 3 deletions core/strings/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,12 @@
<item quantity="other">%1$d hops</item>
</plurals>
<string name="traceroute_diff">Hops towards %1$d Hops back %2$d</string>
<string name="traceroute_outgoing_route">Outgoing route</string>
<string name="traceroute_return_route">Return route</string>
<string name="traceroute_endpoint_missing">Cannot show traceroute map because the start or destination node has no position information.</string>
<string name="view_on_map">View on map</string>
<string name="traceroute_map_no_data">This traceroute does not have any mappable nodes yet.</string>
<string name="traceroute_showing_nodes">Showing %1$d/%2$d nodes</string>
<string name="twenty_four_hours">24H</string>
<string name="forty_eight_hours">48H</string>
<string name="one_week">1W</string>
Expand Down Expand Up @@ -1030,8 +1036,8 @@
<item quantity="one">1 hour</item>
<item quantity="other">%1$d hours</item>
</plurals>
<!-- Compass -->

<!-- Compass -->
<string name="compass_title">Compass</string>
<string name="open_compass">Open Compass</string>
<string name="compass_distance">Distance: %1$s</string>
Expand All @@ -1043,4 +1049,4 @@
<string name="compass_no_location_fix">Waiting for a GPS fix to calculate distance and bearing.</string>
<string name="compass_uncertainty">Estimated area: \u00b1%1$s (\u00b1%2$s)</string>
<string name="compass_uncertainty_unknown">Estimated area: unknown accuracy</string>
</resources>
</resources>
Loading