Skip to content

Commit a23e073

Browse files
jamesarichCopilot
andcommitted
feat(discovery): mesh network discovery (#5275)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 285206a commit a23e073

90 files changed

Lines changed: 10848 additions & 55 deletions

File tree

Some content is hidden

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

.skills/compose-ui/strings-index.txt

Lines changed: 75 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

androidApp/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ dependencies {
225225
implementation(projects.feature.map)
226226
implementation(projects.feature.node)
227227
implementation(projects.feature.settings)
228+
implementation(projects.feature.discovery)
228229
implementation(projects.feature.docs)
229230
implementation(projects.feature.firmware)
230231
implementation(projects.feature.wifiProvision)
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: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
131+
val drawableId =
132+
if (node.isSensorNode) {
133+
org.meshtastic.app.R.drawable.ic_thermostat
134+
} else {
135+
org.meshtastic.app.R.drawable.ic_person
136+
}
137+
icon = context.getDrawable(drawableId)
138+
139+
// Default OSM marker handles color tinting via icon overlay or custom drawables if needed,
140+
// but setting the icon directly overrides the default teardrop pin.
141+
}
142+
map.overlays.add(marker)
143+
}
144+
145+
// Polylines from user to direct neighbors
146+
if (hasValidUserPosition) {
147+
validNodes
148+
.filter { it.neighborType == DiscoveryNeighborType.DIRECT }
149+
.forEach { node ->
150+
val polyline =
151+
Polyline().apply {
152+
setPoints(listOf(userGeoPoint, GeoPoint(node.latitude, node.longitude)))
153+
outlinePaint.apply {
154+
color = DiscoveryMapColors.DirectLine.toArgb()
155+
strokeWidth = with(density) { 3.dp.toPx() }
156+
strokeCap = Paint.Cap.ROUND
157+
style = Paint.Style.STROKE
158+
}
159+
}
160+
map.overlays.add(polyline)
161+
}
162+
}
163+
164+
map.invalidate()
165+
},
166+
)
167+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="960"
5+
android:viewportHeight="960">
6+
<path
7+
android:fillColor="#FFFFFFFF"
8+
android:pathData="M480,480Q414,480 367,433Q320,386 320,320Q320,254 367,207Q414,160 480,160Q546,160 593,207Q640,254 640,320Q640,386 593,433Q546,480 480,480ZM160,720L160,688Q160,654 177.5,625.5Q195,597 224,582Q286,551 350,535.5Q414,520 480,520Q546,520 610,535.5Q674,551 736,582Q765,597 782.5,625.5Q800,654 800,688L800,720Q800,753 776.5,776.5Q753,800 720,800L240,800Q207,800 183.5,776.5Q160,753 160,720Z"/>
9+
</vector>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="960"
5+
android:viewportHeight="960">
6+
<path
7+
android:fillColor="#FFFFFFFF"
8+
android:pathData="M560,440Q543,440 531.5,428.5Q520,417 520,400Q520,383 531.5,371.5Q543,360 560,360L680,360Q697,360 708.5,371.5Q720,383 720,400Q720,417 708.5,428.5Q697,440 680,440L560,440ZM560,280Q543,280 531.5,268.5Q520,257 520,240Q520,223 531.5,211.5Q543,200 560,200L800,200Q817,200 828.5,211.5Q840,223 840,240Q840,257 828.5,268.5Q817,280 800,280L560,280ZM320,840Q237,840 178.5,781.5Q120,723 120,640Q120,592 141,550.5Q162,509 200,480L200,240Q200,190 235,155Q270,120 320,120Q370,120 405,155Q440,190 440,240L440,480Q478,509 499,550.5Q520,592 520,640Q520,723 461.5,781.5Q403,840 320,840ZM200,640L440,640Q440,611 427.5,586Q415,561 392,544L360,520L360,240Q360,223 348.5,211.5Q337,200 320,200Q303,200 291.5,211.5Q280,223 280,240L280,520L248,544Q225,561 212.5,586Q200,611 200,640Z"/>
9+
</vector>

0 commit comments

Comments
 (0)