mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2025-12-24 00:07:48 -05:00
feat: Traceroute map visualisation (#4002)
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
@@ -248,7 +259,7 @@ constructor(
|
||||
Timber.d("ViewModel cleared")
|
||||
}
|
||||
|
||||
val tracerouteResponse: LiveData<String?>
|
||||
val tracerouteResponse: LiveData<TracerouteResponse?>
|
||||
get() = serviceRepository.tracerouteResponse.asLiveData()
|
||||
|
||||
fun clearTracerouteResponse() {
|
||||
|
||||
@@ -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>) {
|
||||
@@ -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 ->
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -927,11 +929,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
|
||||
@@ -940,7 +943,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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -85,3 +91,35 @@ 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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -27,6 +27,13 @@ val MeshtasticAlt = Color(0xFF2C2D3C)
|
||||
val HyperlinkBlue = Color(0xFF43C3B0)
|
||||
val AnnotationColor = Color(0xFF039BE5)
|
||||
|
||||
object TracerouteColors {
|
||||
// High-contrast pair that stays legible on light/dark tiles and for most color-blind users.
|
||||
// Use partial alpha so polylines don’t overpower markers/tiles.
|
||||
val OutgoingRoute = Color(0xCCE86A00) // orange @ ~80% opacity
|
||||
val ReturnRoute = Color(0xCC0081C7) // cyan @ ~80% opacity
|
||||
}
|
||||
|
||||
object IAQColors {
|
||||
val IAQExcellent = Color(0xFF00E400)
|
||||
val IAQGood = Color(0xFF92D050)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.meshtastic.feature.map
|
||||
|
||||
import android.Manifest // Added for Accompanist
|
||||
import android.graphics.Paint
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -66,6 +67,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -120,6 +122,7 @@ import org.meshtastic.core.strings.waypoint_delete
|
||||
import org.meshtastic.core.strings.you
|
||||
import org.meshtastic.core.ui.component.BasicListItem
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.theme.TracerouteColors
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.map.cluster.RadiusMarkerClusterer
|
||||
import org.meshtastic.feature.map.component.CacheLayout
|
||||
@@ -128,6 +131,7 @@ import org.meshtastic.feature.map.component.EditWaypointDialog
|
||||
import org.meshtastic.feature.map.component.MapButton
|
||||
import org.meshtastic.feature.map.model.CustomTileSource
|
||||
import org.meshtastic.feature.map.model.MarkerWithLabel
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.proto.MeshProtos.Waypoint
|
||||
import org.meshtastic.proto.copy
|
||||
import org.meshtastic.proto.waypoint
|
||||
@@ -148,14 +152,19 @@ import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.MapEventsOverlay
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polygon
|
||||
import org.osmdroid.views.overlay.Polyline
|
||||
import org.osmdroid.views.overlay.infowindow.InfoWindow
|
||||
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
@Composable
|
||||
private fun MapView.UpdateMarkers(
|
||||
private fun MapView.updateMarkers(
|
||||
nodeMarkers: List<MarkerWithLabel>,
|
||||
waypointMarkers: List<MarkerWithLabel>,
|
||||
nodeClusterer: RadiusMarkerClusterer,
|
||||
@@ -218,7 +227,12 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int)
|
||||
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@Composable
|
||||
fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) {
|
||||
fun MapView(
|
||||
mapViewModel: MapViewModel = hiltViewModel(),
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
tracerouteOverlay: TracerouteOverlay? = null,
|
||||
onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
var mapFilterExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
|
||||
@@ -320,6 +334,59 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
|
||||
|
||||
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
|
||||
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
|
||||
val nodeLookup = remember(nodes) { nodes.filter { it.validPosition != null }.associateBy { it.num } }
|
||||
val overlayNodeNums = remember(tracerouteOverlay) { tracerouteOverlay?.relatedNodeNums ?: emptySet() }
|
||||
val nodesForMarkers =
|
||||
if (tracerouteOverlay != null) {
|
||||
nodes.filter { overlayNodeNums.contains(it.num) }
|
||||
} else {
|
||||
nodes
|
||||
}
|
||||
val tracerouteForwardPoints =
|
||||
remember(tracerouteOverlay, nodeLookup) {
|
||||
tracerouteOverlay?.forwardRoute?.mapNotNull {
|
||||
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
|
||||
} ?: emptyList()
|
||||
}
|
||||
val tracerouteReturnPoints =
|
||||
remember(tracerouteOverlay, nodeLookup) {
|
||||
tracerouteOverlay?.returnRoute?.mapNotNull {
|
||||
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
|
||||
} ?: emptyList()
|
||||
}
|
||||
LaunchedEffect(tracerouteOverlay, nodesForMarkers) {
|
||||
if (tracerouteOverlay != null) {
|
||||
onTracerouteMappableCountChanged(nodesForMarkers.size, tracerouteOverlay.relatedNodeNums.size)
|
||||
}
|
||||
}
|
||||
val tracerouteHeadingReferencePoints =
|
||||
remember(tracerouteForwardPoints, tracerouteReturnPoints) {
|
||||
when {
|
||||
tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
|
||||
tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
val tracerouteForwardOffsetPoints =
|
||||
remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
|
||||
offsetPolyline(
|
||||
points = tracerouteForwardPoints,
|
||||
offsetMeters = TRACEROUTE_OFFSET_METERS,
|
||||
headingReferencePoints = tracerouteHeadingReferencePoints,
|
||||
sideMultiplier = 1.0,
|
||||
)
|
||||
}
|
||||
val tracerouteReturnOffsetPoints =
|
||||
remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
|
||||
offsetPolyline(
|
||||
points = tracerouteReturnPoints,
|
||||
offsetMeters = TRACEROUTE_OFFSET_METERS,
|
||||
headingReferencePoints = tracerouteHeadingReferencePoints,
|
||||
sideMultiplier = -1.0,
|
||||
)
|
||||
}
|
||||
val traceroutePolylines = remember { mutableStateListOf<Polyline>() }
|
||||
var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
|
||||
|
||||
val markerIcon = remember {
|
||||
AppCompatResources.getDrawable(context, org.meshtastic.core.ui.R.drawable.ic_baseline_location_on_24)
|
||||
@@ -331,7 +398,12 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
|
||||
val displayUnits = mapViewModel.config.display.units
|
||||
val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly
|
||||
return nodesWithPosition.mapNotNull { node ->
|
||||
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
|
||||
if (
|
||||
mapFilterStateValue.onlyFavorites &&
|
||||
!node.isFavorite &&
|
||||
!overlayNodeNums.contains(node.num) &&
|
||||
!node.equals(ourNode)
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
@@ -424,7 +496,6 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
|
||||
mapViewModel.getUser(id).longName
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("MagicNumber")
|
||||
fun MapView.onWaypointChanged(waypoints: Collection<Packet>): List<MarkerWithLabel> {
|
||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||
@@ -459,7 +530,10 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
|
||||
MarkerWithLabel(this, label, emoji).apply {
|
||||
id = "${pt.id}"
|
||||
title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)"
|
||||
snippet = "[$time] ${pt.description} " + stringResource(Res.string.expires) + ": $expireTimeStr"
|
||||
snippet =
|
||||
"[$time] ${pt.description} " +
|
||||
com.meshtastic.core.strings.getString(Res.string.expires) +
|
||||
": $expireTimeStr"
|
||||
position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7)
|
||||
setVisible(false) // This seems to be always false, was this intended?
|
||||
setOnLongClickListener {
|
||||
@@ -509,7 +583,52 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
|
||||
invalidate()
|
||||
}
|
||||
|
||||
with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) }
|
||||
fun MapView.updateTracerouteOverlay(forwardPoints: List<GeoPoint>, returnPoints: List<GeoPoint>) {
|
||||
overlays.removeAll(traceroutePolylines)
|
||||
traceroutePolylines.clear()
|
||||
|
||||
fun buildPolyline(points: List<GeoPoint>, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
|
||||
setPoints(points)
|
||||
outlinePaint.apply {
|
||||
this.color = color
|
||||
this.strokeWidth = strokeWidth
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
style = Paint.Style.STROKE
|
||||
}
|
||||
}
|
||||
|
||||
forwardPoints
|
||||
.takeIf { it.size >= 2 }
|
||||
?.let { points ->
|
||||
traceroutePolylines.add(
|
||||
buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }),
|
||||
)
|
||||
}
|
||||
returnPoints
|
||||
.takeIf { it.size >= 2 }
|
||||
?.let { points ->
|
||||
traceroutePolylines.add(
|
||||
buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }),
|
||||
)
|
||||
}
|
||||
overlays.addAll(traceroutePolylines)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) {
|
||||
if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect
|
||||
val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
|
||||
if (allPoints.isNotEmpty()) {
|
||||
if (allPoints.size == 1) {
|
||||
map.controller.setCenter(allPoints.first())
|
||||
map.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
|
||||
} else {
|
||||
map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), true)
|
||||
}
|
||||
hasCenteredTraceroute = true
|
||||
}
|
||||
}
|
||||
|
||||
fun MapView.generateBoxOverlay() {
|
||||
overlays.removeAll { it is Polygon }
|
||||
@@ -587,7 +706,17 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
update = { mapView -> mapView.drawOverlays() }, // Renamed map to mapView to avoid conflict
|
||||
update = { mapView ->
|
||||
mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints)
|
||||
with(mapView) {
|
||||
updateMarkers(
|
||||
onNodesChanged(nodesForMarkers),
|
||||
onWaypointChanged(waypoints.values),
|
||||
nodeClusterer,
|
||||
)
|
||||
}
|
||||
mapView.drawOverlays()
|
||||
}, // Renamed map to mapView to avoid conflict
|
||||
)
|
||||
if (downloadRegionBoundingBox != null) {
|
||||
CacheLayout(
|
||||
@@ -943,3 +1072,54 @@ private fun MapsDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val EARTH_RADIUS_METERS = 6_371_000.0
|
||||
private const val TRACEROUTE_OFFSET_METERS = 100.0
|
||||
private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
|
||||
private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
|
||||
|
||||
private fun Double.toRad(): Double = Math.toRadians(this)
|
||||
|
||||
private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
|
||||
val lat1 = from.latitude.toRad()
|
||||
val lat2 = to.latitude.toRad()
|
||||
val dLon = (to.longitude - from.longitude).toRad()
|
||||
return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
|
||||
}
|
||||
|
||||
private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
|
||||
val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
|
||||
val lat1 = latitude.toRad()
|
||||
val lon1 = longitude.toRad()
|
||||
val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
|
||||
val lon2 =
|
||||
lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
|
||||
return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2))
|
||||
}
|
||||
|
||||
private fun offsetPolyline(
|
||||
points: List<GeoPoint>,
|
||||
offsetMeters: Double,
|
||||
headingReferencePoints: List<GeoPoint> = points,
|
||||
sideMultiplier: Double = 1.0,
|
||||
): List<GeoPoint> {
|
||||
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
|
||||
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
|
||||
|
||||
val headings =
|
||||
headingPoints.mapIndexed { index, _ ->
|
||||
when (index) {
|
||||
0 -> bearingRad(headingPoints[0], headingPoints[1])
|
||||
headingPoints.lastIndex ->
|
||||
bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
|
||||
|
||||
else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
|
||||
}
|
||||
}
|
||||
|
||||
return points.mapIndexed { index, point ->
|
||||
val heading = headings[index.coerceIn(0, headings.lastIndex)]
|
||||
val perpendicularHeading = heading + (Math.PI / 2 * sideMultiplier)
|
||||
point.offsetPoint(perpendicularHeading, abs(offsetMeters))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ import com.google.android.gms.maps.model.CameraPosition
|
||||
import com.google.android.gms.maps.model.JointType
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.android.gms.maps.model.LatLngBounds
|
||||
import com.google.maps.android.SphericalUtil
|
||||
import com.google.maps.android.compose.ComposeMapColorScheme
|
||||
import com.google.maps.android.compose.GoogleMap
|
||||
import com.google.maps.android.compose.MapEffect
|
||||
@@ -107,6 +108,7 @@ import org.meshtastic.core.strings.speed
|
||||
import org.meshtastic.core.strings.timestamp
|
||||
import org.meshtastic.core.strings.track_point
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.core.ui.theme.TracerouteColors
|
||||
import org.meshtastic.core.ui.util.formatPositionTime
|
||||
import org.meshtastic.feature.map.component.ClusterItemsListDialog
|
||||
import org.meshtastic.feature.map.component.CustomMapLayersSheet
|
||||
@@ -116,6 +118,7 @@ import org.meshtastic.feature.map.component.MapControlsOverlay
|
||||
import org.meshtastic.feature.map.component.NodeClusterMarkers
|
||||
import org.meshtastic.feature.map.component.WaypointMarkers
|
||||
import org.meshtastic.feature.map.model.NodeClusterItem
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
||||
import org.meshtastic.proto.MeshProtos.Position
|
||||
import org.meshtastic.proto.MeshProtos.Waypoint
|
||||
@@ -123,10 +126,14 @@ import org.meshtastic.proto.copy
|
||||
import org.meshtastic.proto.waypoint
|
||||
import timber.log.Timber
|
||||
import java.text.DateFormat
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f
|
||||
private const val DEG_D = 1e-7
|
||||
private const val HEADING_DEG = 1e-5
|
||||
private const val TRACEROUTE_OFFSET_METERS = 100.0
|
||||
private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@@ -136,6 +143,8 @@ fun MapView(
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
focusedNodeNum: Int? = null,
|
||||
nodeTracks: List<Position>? = null,
|
||||
tracerouteOverlay: TracerouteOverlay? = null,
|
||||
onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -253,6 +262,7 @@ fun MapView(
|
||||
.collectAsStateWithLifecycle(listOf())
|
||||
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
|
||||
val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint }
|
||||
val overlayNodeNums = remember(tracerouteOverlay) { tracerouteOverlay?.relatedNodeNums ?: emptySet() }
|
||||
|
||||
val filteredNodes =
|
||||
allNodes
|
||||
@@ -263,8 +273,20 @@ fun MapView(
|
||||
node.num == ourNodeInfo?.num
|
||||
}
|
||||
|
||||
val displayNodes =
|
||||
if (tracerouteOverlay != null) {
|
||||
allNodes.filter { overlayNodeNums.contains(it.num) }
|
||||
} else {
|
||||
filteredNodes
|
||||
}
|
||||
LaunchedEffect(tracerouteOverlay, displayNodes) {
|
||||
if (tracerouteOverlay != null) {
|
||||
onTracerouteMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size)
|
||||
}
|
||||
}
|
||||
|
||||
val nodeClusterItems =
|
||||
filteredNodes.map { node ->
|
||||
displayNodes.map { node ->
|
||||
val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D)
|
||||
NodeClusterItem(
|
||||
node = node,
|
||||
@@ -287,6 +309,43 @@ fun MapView(
|
||||
true -> ComposeMapColorScheme.DARK
|
||||
else -> ComposeMapColorScheme.LIGHT
|
||||
}
|
||||
val tracerouteForwardPoints =
|
||||
remember(tracerouteOverlay, displayNodes) {
|
||||
val nodeLookup = displayNodes.associateBy { it.num }
|
||||
tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList()
|
||||
}
|
||||
val tracerouteReturnPoints =
|
||||
remember(tracerouteOverlay, displayNodes) {
|
||||
val nodeLookup = displayNodes.associateBy { it.num }
|
||||
tracerouteOverlay?.returnRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList()
|
||||
}
|
||||
val tracerouteHeadingReferencePoints =
|
||||
remember(tracerouteForwardPoints, tracerouteReturnPoints) {
|
||||
when {
|
||||
tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
|
||||
tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
val tracerouteForwardOffsetPoints =
|
||||
remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
|
||||
offsetPolyline(
|
||||
points = tracerouteForwardPoints,
|
||||
offsetMeters = TRACEROUTE_OFFSET_METERS,
|
||||
headingReferencePoints = tracerouteHeadingReferencePoints,
|
||||
sideMultiplier = 1.0,
|
||||
)
|
||||
}
|
||||
val tracerouteReturnOffsetPoints =
|
||||
remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
|
||||
offsetPolyline(
|
||||
points = tracerouteReturnPoints,
|
||||
offsetMeters = TRACEROUTE_OFFSET_METERS,
|
||||
headingReferencePoints = tracerouteHeadingReferencePoints,
|
||||
sideMultiplier = -1.0,
|
||||
)
|
||||
}
|
||||
var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
|
||||
|
||||
var showLayersBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -329,6 +388,26 @@ fun MapView(
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) {
|
||||
if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect
|
||||
val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
|
||||
if (allPoints.isNotEmpty()) {
|
||||
val cameraUpdate =
|
||||
if (allPoints.size == 1) {
|
||||
CameraUpdateFactory.newLatLngZoom(allPoints.first(), max(cameraPositionState.position.zoom, 12f))
|
||||
} else {
|
||||
val bounds = LatLngBounds.builder()
|
||||
allPoints.forEach { bounds.include(it) }
|
||||
CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX)
|
||||
}
|
||||
try {
|
||||
cameraPositionState.animate(cameraUpdate)
|
||||
hasCenteredTraceroute = true
|
||||
} catch (e: IllegalStateException) {
|
||||
Timber.d("Error centering traceroute overlay: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold { paddingValues ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
@@ -367,6 +446,25 @@ fun MapView(
|
||||
}
|
||||
}
|
||||
|
||||
if (tracerouteForwardPoints.size >= 2) {
|
||||
Polyline(
|
||||
points = tracerouteForwardOffsetPoints,
|
||||
jointType = JointType.ROUND,
|
||||
color = TracerouteColors.OutgoingRoute,
|
||||
width = 9f,
|
||||
zIndex = 1.5f,
|
||||
)
|
||||
}
|
||||
if (tracerouteReturnPoints.size >= 2) {
|
||||
Polyline(
|
||||
points = tracerouteReturnOffsetPoints,
|
||||
jointType = JointType.ROUND,
|
||||
color = TracerouteColors.ReturnRoute,
|
||||
width = 7f,
|
||||
zIndex = 1.4f,
|
||||
)
|
||||
}
|
||||
|
||||
if (nodeTracks != null && focusedNodeNum != null) {
|
||||
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
|
||||
val timeFilteredPositions =
|
||||
@@ -449,6 +547,25 @@ fun MapView(
|
||||
)
|
||||
}
|
||||
|
||||
if (tracerouteForwardPoints.size >= 2) {
|
||||
Polyline(
|
||||
points = tracerouteForwardOffsetPoints,
|
||||
jointType = JointType.ROUND,
|
||||
color = TracerouteColors.OutgoingRoute,
|
||||
width = 9f,
|
||||
zIndex = 2f,
|
||||
)
|
||||
}
|
||||
if (tracerouteReturnPoints.size >= 2) {
|
||||
Polyline(
|
||||
points = tracerouteReturnOffsetPoints,
|
||||
jointType = JointType.ROUND,
|
||||
color = TracerouteColors.ReturnRoute,
|
||||
width = 7f,
|
||||
zIndex = 1.5f,
|
||||
)
|
||||
}
|
||||
|
||||
WaypointMarkers(
|
||||
displayableWaypoints = displayableWaypoints,
|
||||
mapFilterState = mapFilterState,
|
||||
@@ -696,3 +813,33 @@ internal fun Position.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.l
|
||||
private fun Node.toLatLng(): LatLng? = this.position.toLatLng()
|
||||
|
||||
private fun Waypoint.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D)
|
||||
|
||||
private fun offsetPolyline(
|
||||
points: List<LatLng>,
|
||||
offsetMeters: Double,
|
||||
headingReferencePoints: List<LatLng> = points,
|
||||
sideMultiplier: Double = 1.0,
|
||||
): List<LatLng> {
|
||||
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
|
||||
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
|
||||
|
||||
val headings =
|
||||
headingPoints.mapIndexed { index, _ ->
|
||||
when (index) {
|
||||
0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1])
|
||||
headingPoints.lastIndex ->
|
||||
SphericalUtil.computeHeading(
|
||||
headingPoints[headingPoints.lastIndex - 1],
|
||||
headingPoints[headingPoints.lastIndex],
|
||||
)
|
||||
|
||||
else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1])
|
||||
}
|
||||
}
|
||||
|
||||
return points.mapIndexed { index, point ->
|
||||
val heading = headings[index.coerceIn(0, headings.lastIndex)]
|
||||
val perpendicularHeading = heading + (90.0 * sideMultiplier)
|
||||
SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.map.model
|
||||
|
||||
data class TracerouteOverlay(
|
||||
val requestId: Int,
|
||||
val forwardRoute: List<Int> = emptyList(),
|
||||
val returnRoute: List<Int> = emptyList(),
|
||||
) {
|
||||
val relatedNodeNums: Set<Int> = (forwardRoute + returnRoute).toSet()
|
||||
|
||||
val hasRoutes: Boolean
|
||||
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
|
||||
}
|
||||
@@ -35,6 +35,7 @@ dependencies {
|
||||
implementation(projects.core.strings)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.feature.map)
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material.iconsExtended)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal object TracerouteMapOverlayInsets {
|
||||
val overlayAlignment: Alignment = Alignment.BottomEnd
|
||||
val overlayPadding: PaddingValues = PaddingValues(end = 16.dp, bottom = 16.dp)
|
||||
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.End
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal object TracerouteMapOverlayInsets {
|
||||
val overlayAlignment: Alignment = Alignment.BottomCenter
|
||||
val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp)
|
||||
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally
|
||||
}
|
||||
@@ -47,12 +47,15 @@ import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.TracerouteMapAvailability
|
||||
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.fallback_node_name
|
||||
import org.meshtastic.core.ui.util.toPosition
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
@@ -92,6 +95,8 @@ constructor(
|
||||
|
||||
private var jobs: Job? = null
|
||||
|
||||
private val tracerouteOverlayCache = MutableStateFlow<Map<Int, TracerouteOverlay>>(emptyMap())
|
||||
|
||||
private fun MeshLog.hasValidTraceroute(): Boolean =
|
||||
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
|
||||
|
||||
@@ -118,6 +123,60 @@ constructor(
|
||||
|
||||
fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) }
|
||||
|
||||
fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? {
|
||||
val cached = tracerouteOverlayCache.value[requestId]
|
||||
if (cached != null) return cached
|
||||
|
||||
val overlay =
|
||||
serviceRepository.tracerouteResponse.value
|
||||
?.takeIf { it.requestId == requestId }
|
||||
?.let { response ->
|
||||
TracerouteOverlay(
|
||||
requestId = response.requestId,
|
||||
forwardRoute = response.forwardRoute,
|
||||
returnRoute = response.returnRoute,
|
||||
)
|
||||
}
|
||||
?.takeIf { it.hasRoutes }
|
||||
|
||||
if (overlay != null) {
|
||||
tracerouteOverlayCache.update { it + (requestId to overlay) }
|
||||
}
|
||||
|
||||
return overlay
|
||||
}
|
||||
|
||||
fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse()
|
||||
|
||||
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
|
||||
evaluateTracerouteMapAvailability(
|
||||
forwardRoute = forwardRoute,
|
||||
returnRoute = returnRoute,
|
||||
positionedNodeNums = positionedNodeNums(),
|
||||
)
|
||||
|
||||
fun tracerouteMapAvailability(overlay: TracerouteOverlay): TracerouteMapAvailability =
|
||||
tracerouteMapAvailability(overlay.forwardRoute, overlay.returnRoute)
|
||||
|
||||
fun positionedNodeNums(): Set<Int> =
|
||||
nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
serviceRepository.tracerouteResponse.filterNotNull().collect { response ->
|
||||
val overlay =
|
||||
TracerouteOverlay(
|
||||
requestId = response.requestId,
|
||||
forwardRoute = response.forwardRoute,
|
||||
returnRoute = response.returnRoute,
|
||||
)
|
||||
if (overlay.hasRoutes) {
|
||||
tracerouteOverlayCache.update { it + (response.requestId to overlay) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
|
||||
destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ package org.meshtastic.feature.node.metrics
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -51,7 +52,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
@@ -61,17 +61,21 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.model.getTracerouteResponse
|
||||
import org.meshtastic.core.model.toMessageRes
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.close
|
||||
import org.meshtastic.core.strings.delete
|
||||
import org.meshtastic.core.strings.routing_error_no_response
|
||||
import org.meshtastic.core.strings.traceroute
|
||||
import org.meshtastic.core.strings.traceroute_diff
|
||||
import org.meshtastic.core.strings.traceroute_direct
|
||||
import org.meshtastic.core.strings.traceroute_hops
|
||||
import org.meshtastic.core.strings.view_on_map
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
|
||||
@@ -80,10 +84,13 @@ 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 org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import java.text.DateFormat
|
||||
|
||||
private data class TracerouteDialog(val message: AnnotatedString, val requestId: Int, val overlay: TracerouteOverlay?)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
@@ -91,22 +98,25 @@ fun TracerouteLogScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
onNavigateUp: () -> Unit,
|
||||
onViewOnMap: (requestId: Int) -> Unit = {},
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) }
|
||||
|
||||
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
|
||||
|
||||
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
|
||||
var showDialog by remember { mutableStateOf<TracerouteDialog?>(null) }
|
||||
var errorMessageRes by remember { mutableStateOf<StringResource?>(null) }
|
||||
|
||||
if (showDialog != null) {
|
||||
val message = showDialog ?: AnnotatedString("") // Should not be null if dialog is shown
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.traceroute,
|
||||
text = { SelectionContainer { Text(text = message) } },
|
||||
onDismiss = { showDialog = null },
|
||||
)
|
||||
}
|
||||
TracerouteLogDialogs(
|
||||
dialog = showDialog,
|
||||
errorMessageRes = errorMessageRes,
|
||||
viewModel = viewModel,
|
||||
onViewOnMap = onViewOnMap,
|
||||
onShowErrorMessageRes = { errorMessageRes = it },
|
||||
onDismissDialog = { showDialog = null },
|
||||
onDismissError = { errorMessageRes = null },
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -154,6 +164,14 @@ fun TracerouteLogScreen(
|
||||
res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) }
|
||||
}
|
||||
}
|
||||
val overlay =
|
||||
route?.let {
|
||||
TracerouteOverlay(
|
||||
requestId = log.fromRadio.packet.id,
|
||||
forwardRoute = it.routeList,
|
||||
returnRoute = it.routeBackList,
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
TracerouteItem(
|
||||
@@ -161,14 +179,18 @@ fun TracerouteLogScreen(
|
||||
text = "$time - $text",
|
||||
modifier =
|
||||
Modifier.combinedClickable(onLongClick = { expanded = true }) {
|
||||
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)
|
||||
}
|
||||
val dialogMessage =
|
||||
tracerouteDetailsAnnotated
|
||||
?: result?.fromRadio?.packet?.getTracerouteResponse(::getUsername)?.let {
|
||||
AnnotatedString(it)
|
||||
}
|
||||
dialogMessage?.let {
|
||||
showDialog =
|
||||
TracerouteDialog(
|
||||
message = it,
|
||||
requestId = log.fromRadio.packet.id,
|
||||
overlay = overlay,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -184,6 +206,44 @@ fun TracerouteLogScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TracerouteLogDialogs(
|
||||
dialog: TracerouteDialog?,
|
||||
errorMessageRes: StringResource?,
|
||||
viewModel: MetricsViewModel,
|
||||
onViewOnMap: (requestId: Int) -> Unit,
|
||||
onShowErrorMessageRes: (StringResource) -> Unit,
|
||||
onDismissDialog: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
dialog?.let { dialogState ->
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.traceroute,
|
||||
text = { SelectionContainer { Text(text = dialogState.message) } },
|
||||
confirmText = stringResource(Res.string.view_on_map),
|
||||
onConfirm = {
|
||||
val availability =
|
||||
viewModel.tracerouteMapAvailability(
|
||||
forwardRoute = dialogState.overlay?.forwardRoute.orEmpty(),
|
||||
returnRoute = dialogState.overlay?.returnRoute.orEmpty(),
|
||||
)
|
||||
availability.toMessageRes()?.let(onShowErrorMessageRes) ?: onViewOnMap(dialogState.requestId)
|
||||
onDismissDialog()
|
||||
},
|
||||
onDismiss = onDismissDialog,
|
||||
)
|
||||
}
|
||||
|
||||
errorMessageRes?.let { res ->
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.traceroute,
|
||||
text = { Text(text = stringResource(res)) },
|
||||
dismissText = stringResource(Res.string.close),
|
||||
onDismiss = onDismissError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteItem(onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
@@ -205,13 +265,12 @@ private fun DeleteItem(onClick: () -> Unit) {
|
||||
@Composable
|
||||
private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 2.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(imageVector = icon, contentDescription = stringResource(Res.string.traceroute))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = text, style = MaterialTheme.typography.bodyLarge)
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(imageVector = icon, contentDescription = stringResource(Res.string.traceroute))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = text, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Route
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.traceroute
|
||||
import org.meshtastic.core.strings.traceroute_outgoing_route
|
||||
import org.meshtastic.core.strings.traceroute_return_route
|
||||
import org.meshtastic.core.strings.traceroute_showing_nodes
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.theme.TracerouteColors
|
||||
import org.meshtastic.feature.map.MapView
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
|
||||
@Composable
|
||||
fun TracerouteMapScreen(
|
||||
metricsViewModel: MetricsViewModel = hiltViewModel(),
|
||||
requestId: Int,
|
||||
onNavigateUp: () -> Unit,
|
||||
) {
|
||||
val state by metricsViewModel.state.collectAsStateWithLifecycle()
|
||||
val nodeTitle = state.node?.user?.longName ?: stringResource(Res.string.traceroute)
|
||||
val routeDiscovery =
|
||||
state.tracerouteResults
|
||||
.find { it.fromRadio.packet.decoded.requestId == requestId }
|
||||
?.fromRadio
|
||||
?.packet
|
||||
?.fullRouteDiscovery
|
||||
val overlayFromLogs =
|
||||
remember(routeDiscovery) {
|
||||
routeDiscovery?.let {
|
||||
TracerouteOverlay(requestId = requestId, forwardRoute = it.routeList, returnRoute = it.routeBackList)
|
||||
}
|
||||
}
|
||||
val overlayFromService = remember(requestId) { metricsViewModel.getTracerouteOverlay(requestId) }
|
||||
val overlay = overlayFromLogs ?: overlayFromService
|
||||
var tracerouteNodesShown by remember { mutableStateOf(0) }
|
||||
var tracerouteNodesTotal by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(Unit) { metricsViewModel.clearTracerouteResponse() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = nodeTitle,
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
MapView(
|
||||
navigateToNodeDetails = {},
|
||||
tracerouteOverlay = overlay,
|
||||
onTracerouteMappableCountChanged = { shown, total ->
|
||||
tracerouteNodesShown = shown
|
||||
tracerouteNodesTotal = total
|
||||
},
|
||||
)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.align(TracerouteMapOverlayInsets.overlayAlignment)
|
||||
.padding(TracerouteMapOverlayInsets.overlayPadding),
|
||||
horizontalAlignment = TracerouteMapOverlayInsets.contentHorizontalAlignment,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal)
|
||||
TracerouteLegend()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TracerouteLegend(modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
LegendRow(
|
||||
color = TracerouteColors.OutgoingRoute,
|
||||
label = stringResource(Res.string.traceroute_outgoing_route),
|
||||
)
|
||||
LegendRow(color = TracerouteColors.ReturnRoute, label = stringResource(Res.string.traceroute_return_route))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TracerouteNodeCount(modifier: Modifier = Modifier, shown: Int, total: Int) {
|
||||
Card(modifier = modifier) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
text = stringResource(Res.string.traceroute_showing_nodes, shown, total),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LegendRow(color: Color, label: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Route,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier.padding(end = 8.dp).size(18.dp),
|
||||
)
|
||||
Text(text = label, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user