Skip to content

Commit c213146

Browse files
committed
Add Local Mesh Discovery feature
* Introduce a new `:feature:discovery` module for scanning mesh topology across multiple LoRa presets * Add `DiscoveryScanEngine` to manage scan lifecycles, preset shifting, and packet collection * Update database schema to version 39 with tables for discovery sessions, preset results, and discovered nodes * Implement UI screens for scan configuration, real-time progress, and historical session management * Add flavor-specific discovery maps (Google Maps and OSM) for visualizing node positions and topology * Include algorithmic and AI-powered summary generation for analyzing LoRa preset performance * Add report export functionality for Text and PDF formats * Integrate discovery entry point into the settings screen and navigation graphs
1 parent a24f786 commit c213146

65 files changed

Lines changed: 6356 additions & 1 deletion

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ dependencies {
233233
implementation(projects.feature.map)
234234
implementation(projects.feature.node)
235235
implementation(projects.feature.settings)
236+
implementation(projects.feature.discovery)
236237
implementation(projects.feature.firmware)
237238
implementation(projects.feature.wifiProvision)
238239
implementation(projects.feature.widget)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.app.map.discovery
18+
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.ui.Modifier
21+
import org.meshtastic.core.ui.util.DiscoveryMapNode
22+
23+
/** Flavor-unified entry point for the discovery map. OSMDroid implementation. */
24+
@Composable
25+
fun DiscoveryMap(
26+
userLatitude: Double,
27+
userLongitude: Double,
28+
nodes: List<DiscoveryMapNode>,
29+
modifier: Modifier = Modifier,
30+
) {
31+
DiscoveryOsmMap(userLatitude = userLatitude, userLongitude = userLongitude, nodes = nodes, modifier = modifier)
32+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
@file:Suppress("MagicNumber")
18+
19+
package org.meshtastic.app.map.discovery
20+
21+
import android.graphics.Paint
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.LaunchedEffect
24+
import androidx.compose.runtime.getValue
25+
import androidx.compose.runtime.mutableStateOf
26+
import androidx.compose.runtime.remember
27+
import androidx.compose.runtime.setValue
28+
import androidx.compose.ui.Modifier
29+
import androidx.compose.ui.graphics.toArgb
30+
import androidx.compose.ui.platform.LocalContext
31+
import androidx.compose.ui.platform.LocalDensity
32+
import androidx.compose.ui.unit.dp
33+
import androidx.compose.ui.viewinterop.AndroidView
34+
import org.meshtastic.app.map.addCopyright
35+
import org.meshtastic.app.map.addScaleBarOverlay
36+
import org.meshtastic.app.map.model.CustomTileSource
37+
import org.meshtastic.app.map.rememberMapViewWithLifecycle
38+
import org.meshtastic.app.map.zoomIn
39+
import org.meshtastic.core.ui.theme.DiscoveryMapColors
40+
import org.meshtastic.core.ui.util.DiscoveryMapNode
41+
import org.meshtastic.core.ui.util.DiscoveryNeighborType
42+
import org.osmdroid.util.BoundingBox
43+
import org.osmdroid.util.GeoPoint
44+
import org.osmdroid.views.overlay.Marker
45+
import org.osmdroid.views.overlay.Polyline
46+
47+
private const val SINGLE_POINT_ZOOM = 14.0
48+
private const val ZOOM_OUT_LEVELS = 0.5
49+
50+
/**
51+
* OSMDroid implementation of the discovery map. Renders discovered node markers color-coded by neighbor type (green =
52+
* direct, blue = mesh) with polylines from the user position to direct neighbors. Auto-zooms to fit all markers.
53+
*/
54+
@Composable
55+
fun DiscoveryOsmMap(
56+
userLatitude: Double,
57+
userLongitude: Double,
58+
nodes: List<DiscoveryMapNode>,
59+
modifier: Modifier = Modifier,
60+
) {
61+
val context = LocalContext.current
62+
val density = LocalDensity.current
63+
val hasValidUserPosition = userLatitude != 0.0 || userLongitude != 0.0
64+
val userGeoPoint = remember(userLatitude, userLongitude) { GeoPoint(userLatitude, userLongitude) }
65+
val validNodes = remember(nodes) { nodes.filter { it.latitude != 0.0 || it.longitude != 0.0 } }
66+
67+
// Build bounding box from all points
68+
val allGeoPoints =
69+
remember(validNodes, hasValidUserPosition) {
70+
buildList {
71+
if (hasValidUserPosition) add(userGeoPoint)
72+
validNodes.forEach { add(GeoPoint(it.latitude, it.longitude)) }
73+
}
74+
}
75+
val initialBounds =
76+
remember(allGeoPoints) {
77+
if (allGeoPoints.isEmpty()) BoundingBox() else BoundingBox.fromGeoPoints(allGeoPoints)
78+
}
79+
80+
var hasCentered by remember { mutableStateOf(false) }
81+
82+
val mapView =
83+
rememberMapViewWithLifecycle(
84+
applicationId = context.packageName,
85+
box = initialBounds,
86+
tileSource = CustomTileSource.getTileSource(0),
87+
)
88+
89+
// Camera auto-center once
90+
LaunchedEffect(allGeoPoints) {
91+
if (hasCentered || allGeoPoints.isEmpty()) return@LaunchedEffect
92+
if (allGeoPoints.size == 1) {
93+
mapView.controller.setCenter(allGeoPoints.first())
94+
mapView.controller.setZoom(SINGLE_POINT_ZOOM)
95+
} else {
96+
mapView.zoomToBoundingBox(BoundingBox.fromGeoPoints(allGeoPoints).zoomIn(-ZOOM_OUT_LEVELS), true)
97+
}
98+
hasCentered = true
99+
}
100+
101+
AndroidView(
102+
modifier = modifier,
103+
factory = { mapView.apply { setDestroyMode(false) } },
104+
update = { map ->
105+
map.overlays.clear()
106+
map.addCopyright()
107+
map.addScaleBarOverlay(density)
108+
109+
// User position marker
110+
if (hasValidUserPosition) {
111+
val userMarker =
112+
Marker(map).apply {
113+
position = userGeoPoint
114+
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
115+
title = "Your Position"
116+
icon = context.getDrawable(android.R.drawable.ic_menu_mylocation)
117+
}
118+
map.overlays.add(userMarker)
119+
}
120+
121+
// Node markers
122+
validNodes.forEach { node ->
123+
val nodeGeoPoint = GeoPoint(node.latitude, node.longitude)
124+
val marker =
125+
Marker(map).apply {
126+
position = nodeGeoPoint
127+
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
128+
title = node.longName ?: node.shortName ?: "Unknown"
129+
snippet = "SNR: ${node.snr} dB / RSSI: ${node.rssi} dBm"
130+
icon = context.getDrawable(android.R.drawable.ic_menu_mapmode)
131+
}
132+
map.overlays.add(marker)
133+
}
134+
135+
// Polylines from user to direct neighbors
136+
if (hasValidUserPosition) {
137+
validNodes
138+
.filter { it.neighborType == DiscoveryNeighborType.DIRECT }
139+
.forEach { node ->
140+
val polyline =
141+
Polyline().apply {
142+
setPoints(listOf(userGeoPoint, GeoPoint(node.latitude, node.longitude)))
143+
outlinePaint.apply {
144+
color = DiscoveryMapColors.DirectLine.toArgb()
145+
strokeWidth = with(density) { 3.dp.toPx() }
146+
strokeCap = Paint.Cap.ROUND
147+
style = Paint.Style.STROKE
148+
}
149+
}
150+
map.overlays.add(polyline)
151+
}
152+
}
153+
154+
map.invalidate()
155+
},
156+
)
157+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
@file:Suppress("MagicNumber")
18+
19+
package org.meshtastic.app.map.discovery
20+
21+
import androidx.compose.foundation.isSystemInDarkTheme
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.LaunchedEffect
24+
import androidx.compose.runtime.remember
25+
import androidx.compose.ui.Modifier
26+
import androidx.compose.ui.graphics.Color
27+
import com.google.android.gms.maps.CameraUpdateFactory
28+
import com.google.android.gms.maps.model.CameraPosition
29+
import com.google.android.gms.maps.model.LatLng
30+
import com.google.android.gms.maps.model.LatLngBounds
31+
import com.google.maps.android.compose.ComposeMapColorScheme
32+
import com.google.maps.android.compose.GoogleMap
33+
import com.google.maps.android.compose.MapUiSettings
34+
import com.google.maps.android.compose.MapsComposeExperimentalApi
35+
import com.google.maps.android.compose.MarkerComposable
36+
import com.google.maps.android.compose.Polyline
37+
import com.google.maps.android.compose.rememberCameraPositionState
38+
import com.google.maps.android.compose.rememberUpdatedMarkerState
39+
import org.meshtastic.core.ui.util.DiscoveryMapNode
40+
import org.meshtastic.core.ui.util.DiscoveryNeighborType
41+
42+
private const val DEFAULT_ZOOM = 12f
43+
private const val BOUNDS_PADDING_PX = 100
44+
45+
private val DirectColor = Color(0xFF4CAF50)
46+
private val MeshColor = Color(0xFF2196F3)
47+
private val UserColor = Color(0xFFFF9800)
48+
private val DirectLineColor = Color(0xFF4CAF50).copy(alpha = 0.5f)
49+
50+
/**
51+
* Google Maps implementation of the discovery map. Renders discovered node markers color-coded by neighbor type (green
52+
* = direct, blue = mesh) with polylines from the user position to direct neighbors. Auto-zooms to fit all markers.
53+
*/
54+
@OptIn(MapsComposeExperimentalApi::class)
55+
@Composable
56+
fun DiscoveryGoogleMap(
57+
userLatitude: Double,
58+
userLongitude: Double,
59+
nodes: List<DiscoveryMapNode>,
60+
modifier: Modifier = Modifier,
61+
) {
62+
val dark = isSystemInDarkTheme()
63+
val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT
64+
65+
val userLatLng = remember(userLatitude, userLongitude) { LatLng(userLatitude, userLongitude) }
66+
val hasValidUserPosition = userLatitude != 0.0 || userLongitude != 0.0
67+
val validNodes = remember(nodes) { nodes.filter { it.latitude != 0.0 || it.longitude != 0.0 } }
68+
69+
val cameraState = rememberCameraPositionState {
70+
position =
71+
CameraPosition.fromLatLngZoom(if (hasValidUserPosition) userLatLng else LatLng(0.0, 0.0), DEFAULT_ZOOM)
72+
}
73+
74+
// Auto-fit bounds on first composition
75+
LaunchedEffect(validNodes, hasValidUserPosition) {
76+
val allPoints = buildList {
77+
if (hasValidUserPosition) add(userLatLng)
78+
validNodes.forEach { add(LatLng(it.latitude, it.longitude)) }
79+
}
80+
if (allPoints.size >= 2) {
81+
val boundsBuilder = LatLngBounds.builder()
82+
allPoints.forEach { boundsBuilder.include(it) }
83+
cameraState.animate(CameraUpdateFactory.newLatLngBounds(boundsBuilder.build(), BOUNDS_PADDING_PX))
84+
} else if (allPoints.size == 1) {
85+
cameraState.animate(CameraUpdateFactory.newLatLngZoom(allPoints.first(), DEFAULT_ZOOM))
86+
}
87+
}
88+
89+
GoogleMap(
90+
mapColorScheme = mapColorScheme,
91+
modifier = modifier,
92+
uiSettings =
93+
MapUiSettings(
94+
zoomControlsEnabled = true,
95+
mapToolbarEnabled = false,
96+
compassEnabled = true,
97+
myLocationButtonEnabled = false,
98+
),
99+
cameraPositionState = cameraState,
100+
) {
101+
// User position marker
102+
if (hasValidUserPosition) {
103+
MarkerComposable(state = rememberUpdatedMarkerState(position = userLatLng), title = "Your Position") {
104+
DiscoveryMarkerChip(label = "You", color = UserColor)
105+
}
106+
}
107+
108+
// Node markers
109+
validNodes.forEach { node ->
110+
val nodeLatLng = LatLng(node.latitude, node.longitude)
111+
val markerColor =
112+
when (node.neighborType) {
113+
DiscoveryNeighborType.DIRECT -> DirectColor
114+
DiscoveryNeighborType.MESH -> MeshColor
115+
}
116+
MarkerComposable(
117+
state = rememberUpdatedMarkerState(position = nodeLatLng),
118+
title = node.longName ?: node.shortName ?: "Unknown",
119+
snippet = "SNR: ${node.snr} dB / RSSI: ${node.rssi} dBm",
120+
) {
121+
DiscoveryMarkerChip(label = node.shortName ?: "?", color = markerColor)
122+
}
123+
}
124+
125+
// Polylines from user to direct neighbors
126+
if (hasValidUserPosition) {
127+
validNodes
128+
.filter { it.neighborType == DiscoveryNeighborType.DIRECT }
129+
.forEach { node ->
130+
Polyline(
131+
points = listOf(userLatLng, LatLng(node.latitude, node.longitude)),
132+
color = DirectLineColor,
133+
width = 4f,
134+
)
135+
}
136+
}
137+
}
138+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.app.map.discovery
18+
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.ui.Modifier
21+
import org.meshtastic.core.ui.util.DiscoveryMapNode
22+
23+
/** Flavor-unified entry point for the discovery map. Google Maps implementation. */
24+
@Composable
25+
fun DiscoveryMap(
26+
userLatitude: Double,
27+
userLongitude: Double,
28+
nodes: List<DiscoveryMapNode>,
29+
modifier: Modifier = Modifier,
30+
) {
31+
DiscoveryGoogleMap(userLatitude = userLatitude, userLongitude = userLongitude, nodes = nodes, modifier = modifier)
32+
}

0 commit comments

Comments
 (0)