diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 45de5390d..831e64465 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -221,6 +221,7 @@ dependencies {
implementation(projects.feature.map)
implementation(projects.feature.node)
implementation(projects.feature.settings)
+ implementation(projects.feature.discovery)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
implementation(projects.feature.widget)
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt
new file mode 100644
index 000000000..bc5c4ec59
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026 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 .
+ */
+package org.meshtastic.app.map.discovery
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.meshtastic.core.ui.util.DiscoveryMapNode
+
+/** Flavor-unified entry point for the discovery map. OSMDroid implementation. */
+@Composable
+fun DiscoveryMap(
+ userLatitude: Double,
+ userLongitude: Double,
+ nodes: List,
+ modifier: Modifier = Modifier,
+) {
+ DiscoveryOsmMap(userLatitude = userLatitude, userLongitude = userLongitude, nodes = nodes, modifier = modifier)
+}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt
new file mode 100644
index 000000000..8b1692bc1
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt
@@ -0,0 +1,167 @@
+/*
+ * Copyright (c) 2026 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 .
+ */
+@file:Suppress("MagicNumber")
+
+package org.meshtastic.app.map.discovery
+
+import android.graphics.Paint
+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.Modifier
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import org.meshtastic.app.map.addCopyright
+import org.meshtastic.app.map.addScaleBarOverlay
+import org.meshtastic.app.map.model.CustomTileSource
+import org.meshtastic.app.map.rememberMapViewWithLifecycle
+import org.meshtastic.app.map.zoomIn
+import org.meshtastic.core.ui.theme.DiscoveryMapColors
+import org.meshtastic.core.ui.util.DiscoveryMapNode
+import org.meshtastic.core.ui.util.DiscoveryNeighborType
+import org.osmdroid.util.BoundingBox
+import org.osmdroid.util.GeoPoint
+import org.osmdroid.views.overlay.Marker
+import org.osmdroid.views.overlay.Polyline
+
+private const val SINGLE_POINT_ZOOM = 14.0
+private const val ZOOM_OUT_LEVELS = 0.5
+
+/**
+ * OSMDroid implementation of the discovery map. Renders discovered node markers color-coded by neighbor type (green =
+ * direct, blue = mesh) with polylines from the user position to direct neighbors. Auto-zooms to fit all markers.
+ */
+@Composable
+fun DiscoveryOsmMap(
+ userLatitude: Double,
+ userLongitude: Double,
+ nodes: List,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val density = LocalDensity.current
+ val hasValidUserPosition = userLatitude != 0.0 || userLongitude != 0.0
+ val userGeoPoint = remember(userLatitude, userLongitude) { GeoPoint(userLatitude, userLongitude) }
+ val validNodes = remember(nodes) { nodes.filter { it.latitude != 0.0 || it.longitude != 0.0 } }
+
+ // Build bounding box from all points
+ val allGeoPoints =
+ remember(validNodes, hasValidUserPosition) {
+ buildList {
+ if (hasValidUserPosition) add(userGeoPoint)
+ validNodes.forEach { add(GeoPoint(it.latitude, it.longitude)) }
+ }
+ }
+ val initialBounds =
+ remember(allGeoPoints) {
+ if (allGeoPoints.isEmpty()) BoundingBox() else BoundingBox.fromGeoPoints(allGeoPoints)
+ }
+
+ var hasCentered by remember { mutableStateOf(false) }
+
+ val mapView =
+ rememberMapViewWithLifecycle(
+ applicationId = context.packageName,
+ box = initialBounds,
+ tileSource = CustomTileSource.getTileSource(0),
+ )
+
+ // Camera auto-center once
+ LaunchedEffect(allGeoPoints) {
+ if (hasCentered || allGeoPoints.isEmpty()) return@LaunchedEffect
+ if (allGeoPoints.size == 1) {
+ mapView.controller.setCenter(allGeoPoints.first())
+ mapView.controller.setZoom(SINGLE_POINT_ZOOM)
+ } else {
+ mapView.zoomToBoundingBox(BoundingBox.fromGeoPoints(allGeoPoints).zoomIn(-ZOOM_OUT_LEVELS), true)
+ }
+ hasCentered = true
+ }
+
+ AndroidView(
+ modifier = modifier,
+ factory = { mapView.apply { setDestroyMode(false) } },
+ update = { map ->
+ map.overlays.clear()
+ map.addCopyright()
+ map.addScaleBarOverlay(density)
+
+ // User position marker
+ if (hasValidUserPosition) {
+ val userMarker =
+ Marker(map).apply {
+ position = userGeoPoint
+ setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
+ title = "Your Position"
+ icon = context.getDrawable(android.R.drawable.ic_menu_mylocation)
+ }
+ map.overlays.add(userMarker)
+ }
+
+ // Node markers
+ validNodes.forEach { node ->
+ val nodeGeoPoint = GeoPoint(node.latitude, node.longitude)
+ val marker =
+ Marker(map).apply {
+ position = nodeGeoPoint
+ setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
+ title = node.longName ?: node.shortName ?: "Unknown"
+ snippet = "SNR: ${node.snr} dB / RSSI: ${node.rssi} dBm"
+
+ val drawableId =
+ if (node.isSensorNode) {
+ org.meshtastic.app.R.drawable.ic_thermostat
+ } else {
+ org.meshtastic.app.R.drawable.ic_person
+ }
+ icon = context.getDrawable(drawableId)
+
+ // Default OSM marker handles color tinting via icon overlay or custom drawables if needed,
+ // but setting the icon directly overrides the default teardrop pin.
+ }
+ map.overlays.add(marker)
+ }
+
+ // Polylines from user to direct neighbors
+ if (hasValidUserPosition) {
+ validNodes
+ .filter { it.neighborType == DiscoveryNeighborType.DIRECT }
+ .forEach { node ->
+ val polyline =
+ Polyline().apply {
+ setPoints(listOf(userGeoPoint, GeoPoint(node.latitude, node.longitude)))
+ outlinePaint.apply {
+ color = DiscoveryMapColors.DirectLine.toArgb()
+ strokeWidth = with(density) { 3.dp.toPx() }
+ strokeCap = Paint.Cap.ROUND
+ style = Paint.Style.STROKE
+ }
+ }
+ map.overlays.add(polyline)
+ }
+ }
+
+ map.invalidate()
+ },
+ )
+}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt
new file mode 100644
index 000000000..d474a4f73
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) 2026 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 .
+ */
+@file:Suppress("MagicNumber")
+
+package org.meshtastic.app.map.discovery
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import com.google.android.gms.maps.CameraUpdateFactory
+import com.google.android.gms.maps.model.CameraPosition
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps.model.LatLngBounds
+import com.google.maps.android.compose.ComposeMapColorScheme
+import com.google.maps.android.compose.GoogleMap
+import com.google.maps.android.compose.MapUiSettings
+import com.google.maps.android.compose.MapsComposeExperimentalApi
+import com.google.maps.android.compose.MarkerComposable
+import com.google.maps.android.compose.Polyline
+import com.google.maps.android.compose.rememberCameraPositionState
+import com.google.maps.android.compose.rememberUpdatedMarkerState
+import org.meshtastic.core.ui.util.DiscoveryMapNode
+import org.meshtastic.core.ui.util.DiscoveryNeighborType
+
+private const val DEFAULT_ZOOM = 12f
+private const val BOUNDS_PADDING_PX = 100
+
+private val DirectColor = Color(0xFF4CAF50)
+private val MeshColor = Color(0xFF2196F3)
+private val UserColor = Color(0xFFFF9800)
+private val DirectLineColor = Color(0xFF4CAF50).copy(alpha = 0.5f)
+
+/**
+ * Google Maps implementation of the discovery map. Renders discovered node markers color-coded by neighbor type (green
+ * = direct, blue = mesh) with polylines from the user position to direct neighbors. Auto-zooms to fit all markers.
+ */
+@OptIn(MapsComposeExperimentalApi::class)
+@Composable
+fun DiscoveryGoogleMap(
+ userLatitude: Double,
+ userLongitude: Double,
+ nodes: List,
+ modifier: Modifier = Modifier,
+) {
+ val dark = isSystemInDarkTheme()
+ val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT
+
+ val userLatLng = remember(userLatitude, userLongitude) { LatLng(userLatitude, userLongitude) }
+ val hasValidUserPosition = userLatitude != 0.0 || userLongitude != 0.0
+ val validNodes = remember(nodes) { nodes.filter { it.latitude != 0.0 || it.longitude != 0.0 } }
+
+ val cameraState = rememberCameraPositionState {
+ position =
+ CameraPosition.fromLatLngZoom(if (hasValidUserPosition) userLatLng else LatLng(0.0, 0.0), DEFAULT_ZOOM)
+ }
+
+ // Auto-fit bounds on first composition
+ LaunchedEffect(validNodes, hasValidUserPosition) {
+ val allPoints = buildList {
+ if (hasValidUserPosition) add(userLatLng)
+ validNodes.forEach { add(LatLng(it.latitude, it.longitude)) }
+ }
+ if (allPoints.size >= 2) {
+ val boundsBuilder = LatLngBounds.builder()
+ allPoints.forEach { boundsBuilder.include(it) }
+ cameraState.animate(CameraUpdateFactory.newLatLngBounds(boundsBuilder.build(), BOUNDS_PADDING_PX))
+ } else if (allPoints.size == 1) {
+ cameraState.animate(CameraUpdateFactory.newLatLngZoom(allPoints.first(), DEFAULT_ZOOM))
+ }
+ }
+
+ GoogleMap(
+ mapColorScheme = mapColorScheme,
+ modifier = modifier,
+ uiSettings =
+ MapUiSettings(
+ zoomControlsEnabled = true,
+ mapToolbarEnabled = false,
+ compassEnabled = true,
+ myLocationButtonEnabled = false,
+ ),
+ cameraPositionState = cameraState,
+ ) {
+ // User position marker
+ if (hasValidUserPosition) {
+ MarkerComposable(state = rememberUpdatedMarkerState(position = userLatLng), title = "Your Position") {
+ DiscoveryMarkerChip(label = "You", color = UserColor)
+ }
+ }
+
+ // Node markers
+ validNodes.forEach { node ->
+ val nodeLatLng = LatLng(node.latitude, node.longitude)
+ val markerColor =
+ when (node.neighborType) {
+ DiscoveryNeighborType.DIRECT -> DirectColor
+ DiscoveryNeighborType.MESH -> MeshColor
+ }
+ val nodeIcon =
+ if (node.isSensorNode) {
+ org.meshtastic.core.ui.icon.MeshtasticIcons.Temperature
+ } else {
+ org.meshtastic.core.ui.icon.MeshtasticIcons.Person
+ }
+ MarkerComposable(
+ state = rememberUpdatedMarkerState(position = nodeLatLng),
+ title = node.longName ?: node.shortName ?: "Unknown",
+ snippet = "SNR: ${node.snr} dB / RSSI: ${node.rssi} dBm",
+ ) {
+ DiscoveryMarkerChip(label = node.shortName ?: "?", color = markerColor, icon = nodeIcon)
+ }
+ }
+
+ // Polylines from user to direct neighbors
+ if (hasValidUserPosition) {
+ validNodes
+ .filter { it.neighborType == DiscoveryNeighborType.DIRECT }
+ .forEach { node ->
+ Polyline(
+ points = listOf(userLatLng, LatLng(node.latitude, node.longitude)),
+ color = DirectLineColor,
+ width = 4f,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt
new file mode 100644
index 000000000..9dff45053
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026 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 .
+ */
+package org.meshtastic.app.map.discovery
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.meshtastic.core.ui.util.DiscoveryMapNode
+
+/** Flavor-unified entry point for the discovery map. Google Maps implementation. */
+@Composable
+fun DiscoveryMap(
+ userLatitude: Double,
+ userLongitude: Double,
+ nodes: List,
+ modifier: Modifier = Modifier,
+) {
+ DiscoveryGoogleMap(userLatitude = userLatitude, userLongitude = userLongitude, nodes = nodes, modifier = modifier)
+}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMarkerChip.kt b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMarkerChip.kt
new file mode 100644
index 000000000..f1eaea766
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMarkerChip.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2026 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 .
+ */
+@file:Suppress("MagicNumber")
+
+package org.meshtastic.app.map.discovery
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+/** Compact chip rendered as a Google Maps marker icon for discovery nodes. */
+@Composable
+fun DiscoveryMarkerChip(label: String, color: Color, modifier: Modifier = Modifier, icon: ImageVector? = null) {
+ Box(
+ modifier =
+ modifier
+ .background(color = color, shape = RoundedCornerShape(12.dp))
+ .border(width = 1.dp, color = Color.White, shape = RoundedCornerShape(12.dp))
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (icon != null) {
+ Icon(imageVector = icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(16.dp))
+ } else {
+ Text(text = label, style = MaterialTheme.typography.labelSmall, color = Color.White)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 78e8ce559..7b7e2216d 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -69,6 +69,7 @@ import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
+import org.meshtastic.core.ui.util.LocalDiscoveryMapProvider
import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
@@ -180,6 +181,7 @@ class MainActivity : AppCompatActivity() {
@Suppress("LongMethod")
@Composable
+ @Suppress("LongMethod")
private fun AppCompositionLocals(content: @Composable () -> Unit) {
val eventEdition by model.eventEdition.collectAsStateWithLifecycle()
CompositionLocalProvider(
@@ -211,6 +213,10 @@ class MainActivity : AppCompatActivity() {
modifier = modifier,
)
},
+ LocalDiscoveryMapProvider provides
+ { userLat, userLon, nodes, modifier ->
+ org.meshtastic.app.map.discovery.DiscoveryMap(userLat, userLon, nodes, modifier)
+ },
LocalNodeMapScreenProvider provides
{ destNum, onNavigateUp ->
val vm = koinViewModel()
diff --git a/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt
index 09f38eaef..0451b4f50 100644
--- a/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt
@@ -47,6 +47,7 @@ import org.meshtastic.core.service.di.CoreServiceModule
import org.meshtastic.core.takserver.di.CoreTakServerModule
import org.meshtastic.core.ui.di.CoreUiModule
import org.meshtastic.feature.connections.di.FeatureConnectionsModule
+import org.meshtastic.feature.discovery.di.FeatureDiscoveryModule
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
import org.meshtastic.feature.intro.di.FeatureIntroModule
import org.meshtastic.feature.map.di.FeatureMapModule
@@ -85,6 +86,7 @@ import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
FeatureConnectionsModule::class,
FeatureMapModule::class,
FeatureSettingsModule::class,
+ FeatureDiscoveryModule::class,
FeatureFirmwareModule::class,
FeatureIntroModule::class,
FeatureWidgetModule::class,
diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
index 46409b14e..08b6b4adb 100644
--- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
@@ -43,6 +43,7 @@ import org.meshtastic.core.ui.component.MeshtasticNavDisplay
import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
+import org.meshtastic.feature.discovery.navigation.discoveryGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
@@ -87,6 +88,7 @@ fun MainScreen() {
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
+ discoveryGraph(backStack)
settingsGraph(backStack)
firmwareGraph(backStack)
wifiProvisionGraph(backStack)
diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml
new file mode 100644
index 000000000..8e5be7ed1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_person.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_thermostat.xml b/app/src/main/res/drawable/ic_thermostat.xml
new file mode 100644
index 000000000..5257f7fe6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_thermostat.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
index 1fd4b39ce..4f265c5fa 100644
--- a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
@@ -26,6 +26,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.feature.connections.navigation.connectionsGraph
+import org.meshtastic.feature.discovery.navigation.discoveryGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
@@ -50,6 +51,7 @@ class NavigationAssemblyTest {
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
+ discoveryGraph(backStack)
settingsGraph(backStack)
firmwareGraph(backStack)
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DiscoveryPacketCollectorRegistryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DiscoveryPacketCollectorRegistryImpl.kt
new file mode 100644
index 000000000..0ed518ec0
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DiscoveryPacketCollectorRegistryImpl.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2025-2026 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 .
+ */
+package org.meshtastic.core.data.manager
+
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.DiscoveryPacketCollector
+import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
+
+@Single
+class DiscoveryPacketCollectorRegistryImpl : DiscoveryPacketCollectorRegistry {
+ override var collector: DiscoveryPacketCollector? = null
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index fa935473a..dd66a84d8 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -37,6 +37,7 @@ import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.DataPair
+import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MessageFilter
@@ -96,6 +97,7 @@ class MeshDataHandlerImpl(
private val storeForwardHandler: StoreForwardPacketHandler,
private val telemetryHandler: TelemetryPacketHandler,
private val adminPacketHandler: AdminPacketHandler,
+ private val collectorRegistry: DiscoveryPacketCollectorRegistry,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshDataHandler {
@@ -118,6 +120,13 @@ class MeshDataHandlerImpl(
serviceBroadcasts.broadcastReceivedData(dataPacket)
}
analytics.track("num_data_receive", DataPair("num_data_receive", 1))
+
+ // Forward to discovery scan collector if active
+ collectorRegistry.collector?.let { collector ->
+ if (collector.isActive) {
+ scope.handledLaunch { collector.onPacketReceived(packet, dataPacket) }
+ }
+ }
}
private fun handleDataPacket(
diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json
new file mode 100644
index 000000000..0d6f55c07
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json
@@ -0,0 +1,1478 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 39,
+ "identityHash": "e39ee4f34ed8da08f3cb21bfd4a5165c",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pioEnv",
+ "columnName": "pioEnv",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "nodeStatus",
+ "columnName": "node_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastTransport",
+ "columnName": "last_transport",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ },
+ {
+ "name": "index_nodes_public_key",
+ "unique": false,
+ "columnNames": [
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "filtered",
+ "columnName": "filtered",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ },
+ {
+ "name": "index_packet_received_time",
+ "unique": false,
+ "columnNames": [
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)"
+ },
+ {
+ "name": "index_packet_filtered",
+ "unique": false,
+ "columnNames": [
+ "filtered"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)"
+ },
+ {
+ "name": "index_packet_read",
+ "unique": false,
+ "columnNames": [
+ "read"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)"
+ }
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filteringDisabled",
+ "columnName": "filtering_disabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "platformio_target"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovery_session",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetsScanned",
+ "columnName": "presets_scanned",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "homePreset",
+ "columnName": "home_preset",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalUniqueNodes",
+ "columnName": "total_unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalMessages",
+ "columnName": "total_messages",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "totalSensorPackets",
+ "columnName": "total_sensor_packets",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "furthestNodeDistance",
+ "columnName": "furthest_node_distance",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "completionStatus",
+ "columnName": "completion_status",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'complete'"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "userLatitude",
+ "columnName": "user_latitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "userLongitude",
+ "columnName": "user_longitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalDwellSeconds",
+ "columnName": "total_dwell_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "discovery_preset_result",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionId",
+ "columnName": "session_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetName",
+ "columnName": "preset_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dwellDurationSeconds",
+ "columnName": "dwell_duration_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uniqueNodes",
+ "columnName": "unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "directNeighborCount",
+ "columnName": "direct_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "meshNeighborCount",
+ "columnName": "mesh_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "avgAirtimeRate",
+ "columnName": "avg_airtime_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetSuccessRate",
+ "columnName": "packet_success_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetFailureRate",
+ "columnName": "packet_failure_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "numPacketsTx",
+ "columnName": "num_packets_tx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRx",
+ "columnName": "num_packets_rx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRxBad",
+ "columnName": "num_packets_rx_bad",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numRxDupe",
+ "columnName": "num_rx_dupe",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelay",
+ "columnName": "num_tx_relay",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelayCanceled",
+ "columnName": "num_tx_relay_canceled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numOnlineNodes",
+ "columnName": "num_online_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTotalNodes",
+ "columnName": "num_total_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uptimeSeconds",
+ "columnName": "uptime_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovery_preset_result_session_id",
+ "unique": false,
+ "columnNames": [
+ "session_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_session",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "session_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovered_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetResultId",
+ "columnName": "preset_result_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "neighborType",
+ "columnName": "neighbor_type",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'direct'"
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "distanceFromUser",
+ "columnName": "distance_from_user",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "hopCount",
+ "columnName": "hop_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovered_node_preset_result_id",
+ "unique": false,
+ "columnNames": [
+ "preset_result_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)"
+ },
+ {
+ "name": "index_discovered_node_node_num",
+ "unique": false,
+ "columnNames": [
+ "node_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_preset_result",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "preset_result_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e39ee4f34ed8da08f3cb21bfd4a5165c')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index d329d184c..bcfbe8a26 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -25,6 +25,7 @@ import androidx.room3.TypeConverters
import androidx.room3.migration.AutoMigrationSpec
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.dao.DeviceHardwareDao
+import org.meshtastic.core.database.dao.DiscoveryDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.dao.NodeInfoDao
@@ -33,6 +34,9 @@ import org.meshtastic.core.database.dao.QuickChatActionDao
import org.meshtastic.core.database.dao.TracerouteNodePositionDao
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.DeviceHardwareEntity
+import org.meshtastic.core.database.entity.DiscoveredNodeEntity
+import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
+import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
@@ -57,6 +61,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
DeviceHardwareEntity::class,
FirmwareReleaseEntity::class,
TracerouteNodePositionEntity::class,
+ DiscoverySessionEntity::class,
+ DiscoveryPresetResultEntity::class,
+ DiscoveredNodeEntity::class,
],
autoMigrations =
[
@@ -95,8 +102,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 35, to = 36),
AutoMigration(from = 36, to = 37),
AutoMigration(from = 37, to = 38),
+ AutoMigration(from = 38, to = 39),
],
- version = 38,
+ version = 39,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -117,6 +125,8 @@ abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun tracerouteNodePositionDao(): TracerouteNodePositionDao
+ abstract fun discoveryDao(): DiscoveryDao
+
companion object {
/** Configures a [RoomDatabase.Builder] with standard settings for this project. */
fun RoomDatabase.Builder.configureCommon(): RoomDatabase.Builder =
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt
new file mode 100644
index 000000000..dbf59a88b
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2025-2026 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 .
+ */
+package org.meshtastic.core.database.dao
+
+import androidx.room3.Dao
+import androidx.room3.Insert
+import androidx.room3.Query
+import androidx.room3.Transaction
+import androidx.room3.Update
+import kotlinx.coroutines.flow.Flow
+import org.meshtastic.core.database.entity.DiscoveredNodeEntity
+import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
+import org.meshtastic.core.database.entity.DiscoverySessionEntity
+
+@Dao
+@Suppress("TooManyFunctions")
+interface DiscoveryDao {
+
+ // region Session operations
+
+ @Insert suspend fun insertSession(session: DiscoverySessionEntity): Long
+
+ @Update suspend fun updateSession(session: DiscoverySessionEntity)
+
+ @Query("SELECT * FROM discovery_session ORDER BY timestamp DESC")
+ fun getAllSessions(): Flow>
+
+ @Query("SELECT * FROM discovery_session WHERE id = :sessionId")
+ suspend fun getSession(sessionId: Long): DiscoverySessionEntity?
+
+ @Query("SELECT * FROM discovery_session WHERE id = :sessionId")
+ fun getSessionFlow(sessionId: Long): Flow
+
+ @Query("DELETE FROM discovery_session WHERE id = :sessionId")
+ suspend fun deleteSession(sessionId: Long)
+
+ // endregion
+
+ // region Preset result operations
+
+ @Insert suspend fun insertPresetResult(result: DiscoveryPresetResultEntity): Long
+
+ @Update suspend fun updatePresetResult(result: DiscoveryPresetResultEntity)
+
+ @Query("SELECT * FROM discovery_preset_result WHERE session_id = :sessionId")
+ suspend fun getPresetResults(sessionId: Long): List
+
+ @Query("SELECT * FROM discovery_preset_result WHERE session_id = :sessionId")
+ fun getPresetResultsFlow(sessionId: Long): Flow>
+
+ // endregion
+
+ // region Discovered node operations
+
+ @Insert suspend fun insertDiscoveredNode(node: DiscoveredNodeEntity): Long
+
+ @Insert suspend fun insertDiscoveredNodes(nodes: List)
+
+ @Update suspend fun updateDiscoveredNode(node: DiscoveredNodeEntity)
+
+ @Query("SELECT * FROM discovered_node WHERE preset_result_id = :presetResultId")
+ suspend fun getDiscoveredNodes(presetResultId: Long): List
+
+ @Query("SELECT * FROM discovered_node WHERE preset_result_id = :presetResultId")
+ fun getDiscoveredNodesFlow(presetResultId: Long): Flow>
+
+ @Query(
+ """
+ SELECT DISTINCT node_num FROM discovered_node dn
+ INNER JOIN discovery_preset_result dpr ON dn.preset_result_id = dpr.id
+ WHERE dpr.session_id = :sessionId
+ """,
+ )
+ suspend fun getUniqueNodeNums(sessionId: Long): List
+
+ // endregion
+
+ // region Aggregate queries
+
+ @Query(
+ """
+ SELECT COUNT(DISTINCT node_num) FROM discovered_node dn
+ INNER JOIN discovery_preset_result dpr ON dn.preset_result_id = dpr.id
+ WHERE dpr.session_id = :sessionId
+ """,
+ )
+ suspend fun getUniqueNodeCount(sessionId: Long): Int
+
+ @Transaction
+ @Query("SELECT * FROM discovery_session WHERE id = :sessionId")
+ suspend fun getSessionWithResults(sessionId: Long): DiscoverySessionEntity?
+
+ // endregion
+}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt
index acae365da..4328cfe6e 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt
@@ -17,10 +17,13 @@
package org.meshtastic.core.database.di
import org.koin.core.annotation.ComponentScan
+import org.koin.core.annotation.Factory
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
+import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.createDatabaseDataStore
+import org.meshtastic.core.database.dao.DiscoveryDao
@Module
@ComponentScan("org.meshtastic.core.database")
@@ -28,4 +31,8 @@ class CoreDatabaseModule {
@Single
@Named("DatabaseDataStore")
fun provideDatabaseDataStore() = createDatabaseDataStore("db-manager-prefs")
+
+ @Factory
+ fun provideDiscoveryDao(databaseProvider: DatabaseProvider): DiscoveryDao =
+ databaseProvider.currentDb.value.discoveryDao()
}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt
new file mode 100644
index 000000000..ca22fe775
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2025-2026 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 .
+ */
+package org.meshtastic.core.database.entity
+
+import androidx.room3.ColumnInfo
+import androidx.room3.Entity
+import androidx.room3.ForeignKey
+import androidx.room3.Index
+import androidx.room3.PrimaryKey
+
+@Entity(
+ tableName = "discovered_node",
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = DiscoveryPresetResultEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["preset_result_id"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [Index(value = ["preset_result_id"]), Index(value = ["node_num"])],
+)
+data class DiscoveredNodeEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ @ColumnInfo(name = "preset_result_id") val presetResultId: Long,
+ @ColumnInfo(name = "node_num") val nodeNum: Long,
+ @ColumnInfo(name = "short_name") val shortName: String? = null,
+ @ColumnInfo(name = "long_name") val longName: String? = null,
+ @ColumnInfo(name = "neighbor_type", defaultValue = "'direct'") val neighborType: String = "direct",
+ @ColumnInfo(name = "latitude") val latitude: Double? = null,
+ @ColumnInfo(name = "longitude") val longitude: Double? = null,
+ @ColumnInfo(name = "distance_from_user") val distanceFromUser: Double? = null,
+ @ColumnInfo(name = "hop_count", defaultValue = "0") val hopCount: Int = 0,
+ @ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
+ @ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0,
+ @ColumnInfo(name = "message_count", defaultValue = "0") val messageCount: Int = 0,
+ @ColumnInfo(name = "sensor_packet_count", defaultValue = "0") val sensorPacketCount: Int = 0,
+)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveryPresetResultEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveryPresetResultEntity.kt
new file mode 100644
index 000000000..e7fe4fd10
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveryPresetResultEntity.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2025-2026 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 .
+ */
+package org.meshtastic.core.database.entity
+
+import androidx.room3.ColumnInfo
+import androidx.room3.Entity
+import androidx.room3.ForeignKey
+import androidx.room3.Index
+import androidx.room3.PrimaryKey
+
+@Entity(
+ tableName = "discovery_preset_result",
+ foreignKeys =
+ [
+ ForeignKey(
+ entity = DiscoverySessionEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["session_id"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [Index(value = ["session_id"])],
+)
+data class DiscoveryPresetResultEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ @ColumnInfo(name = "session_id") val sessionId: Long,
+ @ColumnInfo(name = "preset_name") val presetName: String,
+ @ColumnInfo(name = "dwell_duration_seconds", defaultValue = "0") val dwellDurationSeconds: Long = 0,
+ @ColumnInfo(name = "unique_nodes", defaultValue = "0") val uniqueNodes: Int = 0,
+ @ColumnInfo(name = "direct_neighbor_count", defaultValue = "0") val directNeighborCount: Int = 0,
+ @ColumnInfo(name = "mesh_neighbor_count", defaultValue = "0") val meshNeighborCount: Int = 0,
+ @ColumnInfo(name = "message_count", defaultValue = "0") val messageCount: Int = 0,
+ @ColumnInfo(name = "sensor_packet_count", defaultValue = "0") val sensorPacketCount: Int = 0,
+ @ColumnInfo(name = "avg_channel_utilization", defaultValue = "0.0") val avgChannelUtilization: Double = 0.0,
+ @ColumnInfo(name = "avg_airtime_rate", defaultValue = "0.0") val avgAirtimeRate: Double = 0.0,
+ @ColumnInfo(name = "packet_success_rate", defaultValue = "0.0") val packetSuccessRate: Double = 0.0,
+ @ColumnInfo(name = "packet_failure_rate", defaultValue = "0.0") val packetFailureRate: Double = 0.0,
+ @ColumnInfo(name = "ai_summary") val aiSummary: String? = null,
+ @ColumnInfo(name = "num_packets_tx", defaultValue = "0") val numPacketsTx: Int = 0,
+ @ColumnInfo(name = "num_packets_rx", defaultValue = "0") val numPacketsRx: Int = 0,
+ @ColumnInfo(name = "num_packets_rx_bad", defaultValue = "0") val numPacketsRxBad: Int = 0,
+ @ColumnInfo(name = "num_rx_dupe", defaultValue = "0") val numRxDupe: Int = 0,
+ @ColumnInfo(name = "num_tx_relay", defaultValue = "0") val numTxRelay: Int = 0,
+ @ColumnInfo(name = "num_tx_relay_canceled", defaultValue = "0") val numTxRelayCanceled: Int = 0,
+ @ColumnInfo(name = "num_online_nodes", defaultValue = "0") val numOnlineNodes: Int = 0,
+ @ColumnInfo(name = "num_total_nodes", defaultValue = "0") val numTotalNodes: Int = 0,
+ @ColumnInfo(name = "uptime_seconds", defaultValue = "0") val uptimeSeconds: Int = 0,
+)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoverySessionEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoverySessionEntity.kt
new file mode 100644
index 000000000..b480b826e
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoverySessionEntity.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2025-2026 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 .
+ */
+package org.meshtastic.core.database.entity
+
+import androidx.room3.ColumnInfo
+import androidx.room3.Entity
+import androidx.room3.PrimaryKey
+
+@Entity(tableName = "discovery_session")
+data class DiscoverySessionEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ @ColumnInfo(name = "timestamp") val timestamp: Long,
+ @ColumnInfo(name = "presets_scanned") val presetsScanned: String,
+ @ColumnInfo(name = "home_preset") val homePreset: String,
+ @ColumnInfo(name = "total_unique_nodes", defaultValue = "0") val totalUniqueNodes: Int = 0,
+ @ColumnInfo(name = "avg_channel_utilization", defaultValue = "0.0") val avgChannelUtilization: Double = 0.0,
+ @ColumnInfo(name = "total_messages", defaultValue = "0") val totalMessages: Int = 0,
+ @ColumnInfo(name = "total_sensor_packets", defaultValue = "0") val totalSensorPackets: Int = 0,
+ @ColumnInfo(name = "furthest_node_distance", defaultValue = "0.0") val furthestNodeDistance: Double = 0.0,
+ @ColumnInfo(name = "completion_status", defaultValue = "'complete'") val completionStatus: String = "complete",
+ @ColumnInfo(name = "ai_summary") val aiSummary: String? = null,
+ @ColumnInfo(name = "user_latitude", defaultValue = "0.0") val userLatitude: Double = 0.0,
+ @ColumnInfo(name = "user_longitude", defaultValue = "0.0") val userLongitude: Double = 0.0,
+ @ColumnInfo(name = "total_dwell_seconds", defaultValue = "0") val totalDwellSeconds: Long = 0,
+)
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt
index 146381c9d..ac2286a43 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt
@@ -40,6 +40,7 @@ val MeshtasticNavSavedStateConfig = SavedStateConfiguration {
subclassesOfSealed()
subclassesOfSealed()
subclassesOfSealed()
+ subclassesOfSealed()
}
}
}
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
index 418ddd58a..030d8686c 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -194,3 +194,18 @@ sealed interface WifiProvisionRoute : Route {
@Serializable data class WifiProvision(val address: String? = null) : WifiProvisionRoute
}
+
+@Serializable
+sealed interface DiscoveryRoute : Route {
+ @Serializable data object DiscoveryGraph : DiscoveryRoute, Graph
+
+ @Serializable data object DiscoveryScan : DiscoveryRoute
+
+ @Serializable data class DiscoverySummary(val sessionId: Long) : DiscoveryRoute
+
+ @Serializable data object DiscoveryHistory : DiscoveryRoute
+
+ @Serializable data class DiscoveryHistoryDetail(val sessionId: Long) : DiscoveryRoute
+
+ @Serializable data class DiscoveryMap(val sessionId: Long) : DiscoveryRoute
+}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
index 95512ecf4..9419862d0 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
@@ -368,7 +368,7 @@ class BleRadioTransport(
Logger.d { "[$address] Requested high BLE connection priority" }
// Wait for the connection parameter update to succeed before starting the heavy traffic
// in onConnect(). Otherwise, the Android BLE stack may disconnect with GATT 147.
- delay(1.seconds)
+ delay(2.seconds)
}
this@BleRadioTransport.callback.onConnect()
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollector.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollector.kt
new file mode 100644
index 000000000..f0ac50553
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollector.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2025-2026 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 .
+ */
+package org.meshtastic.core.repository
+
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.proto.MeshPacket
+
+/**
+ * Interface for collecting packets during an active discovery scan. The scan engine implements this interface and
+ * registers/unregisters with the packet handler to receive packets during dwell windows.
+ */
+interface DiscoveryPacketCollector {
+ /** Whether this collector is currently active (scan in progress). */
+ val isActive: Boolean
+
+ /**
+ * Called when a mesh packet is received during an active scan. Implementations should classify and aggregate the
+ * packet data.
+ *
+ * @param meshPacket The raw mesh packet from the radio
+ * @param dataPacket The decoded data packet with routing info
+ */
+ suspend fun onPacketReceived(meshPacket: MeshPacket, dataPacket: DataPacket)
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollectorRegistry.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollectorRegistry.kt
new file mode 100644
index 000000000..704713b0f
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollectorRegistry.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2025-2026 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 .
+ */
+package org.meshtastic.core.repository
+
+/**
+ * Registry for discovery packet collectors. The scan engine registers itself when a scan starts and unregisters when it
+ * stops. The packet handler checks for an active collector and forwards packets to it.
+ */
+interface DiscoveryPacketCollectorRegistry {
+ /** The currently registered collector, or null if no scan is active. */
+ var collector: DiscoveryPacketCollector?
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
index 9304d5e2b..1f8081e6c 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
@@ -201,6 +201,14 @@ object StatusColors {
}
}
+@Suppress("MagicNumber")
+object DiscoveryMapColors {
+ val DirectNode = Color(0xFF4CAF50)
+ val MeshNode = Color(0xFF2196F3)
+ val UserPosition = Color(0xFFFF9800)
+ val DirectLine = Color(0x804CAF50)
+}
+
object MessageItemColors {
val Red = Color(0x4DFF0000)
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/DiscoveryMapNode.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/DiscoveryMapNode.kt
new file mode 100644
index 000000000..e1b5352b0
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/DiscoveryMapNode.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2026 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 .
+ */
+package org.meshtastic.core.ui.util
+
+/** Neighbor type classification for discovery map markers. */
+enum class DiscoveryNeighborType {
+ DIRECT,
+ MESH,
+}
+
+/**
+ * Platform-neutral representation of a discovered node for map rendering. Contains only the data needed to place and
+ * style a marker — no Room entities or platform types leak into the map provider API.
+ */
+data class DiscoveryMapNode(
+ val latitude: Double,
+ val longitude: Double,
+ val shortName: String?,
+ val longName: String?,
+ val neighborType: DiscoveryNeighborType,
+ val snr: Float = 0f,
+ val rssi: Int = 0,
+ val messageCount: Int = 0,
+ val sensorPacketCount: Int = 0,
+) {
+ /**
+ * FR-011: Map icon classification. If environment packets > text messages, return true (sensor). Otherwise return
+ * false (social/chat).
+ */
+ val isSensorNode: Boolean
+ get() = sensorPacketCount > messageCount
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalDiscoveryMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalDiscoveryMapProvider.kt
new file mode 100644
index 000000000..37651a6ac
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalDiscoveryMapProvider.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2026 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 .
+ */
+package org.meshtastic.core.ui.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.Modifier
+import org.meshtastic.core.ui.component.PlaceholderScreen
+
+/**
+ * Provides an embeddable discovery map composable that renders discovered node markers and topology polylines for a
+ * Local Mesh Discovery scan session. Unlike [LocalMapViewProvider], this does **not** include node clustering,
+ * waypoints, location tracking, or any main-map features — it is designed to be embedded inside the discovery summary
+ * scaffold.
+ *
+ * Parameters:
+ * - `userLatitude` / `userLongitude`: The scanner's position at scan time (orange marker).
+ * - `nodes`: Platform-neutral [DiscoveryMapNode] list for marker placement and styling.
+ * - `modifier`: Compose modifier for the map.
+ *
+ * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
+ */
+@Suppress("Wrapping")
+val LocalDiscoveryMapProvider =
+ compositionLocalOf<
+ @Composable (
+ userLatitude: Double,
+ userLongitude: Double,
+ nodes: List,
+ modifier: Modifier,
+ ) -> Unit,
+ > {
+ { _, _, _, _ -> PlaceholderScreen("Discovery Map") }
+ }
diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts
index 4fa09179f..fcaba82ea 100644
--- a/desktop/build.gradle.kts
+++ b/desktop/build.gradle.kts
@@ -268,6 +268,7 @@ dependencies {
implementation(projects.feature.messaging)
implementation(projects.feature.connections)
implementation(projects.feature.map)
+ implementation(projects.feature.discovery)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
implementation(projects.feature.intro)
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
index 59f468f82..97913af23 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
@@ -96,6 +96,7 @@ import org.meshtastic.core.takserver.di.module as coreTakServerModule
import org.meshtastic.core.ui.di.module as coreUiModule
import org.meshtastic.desktop.di.module as desktopDiModule
import org.meshtastic.feature.connections.di.module as featureConnectionsModule
+import org.meshtastic.feature.discovery.di.module as featureDiscoveryModule
import org.meshtastic.feature.firmware.di.module as featureFirmwareModule
import org.meshtastic.feature.intro.di.module as featureIntroModule
import org.meshtastic.feature.map.di.module as featureMapModule
@@ -136,6 +137,7 @@ fun desktopModule() = module {
org.meshtastic.feature.messaging.di.FeatureMessagingModule().featureMessagingModule(),
org.meshtastic.feature.connections.di.FeatureConnectionsModule().featureConnectionsModule(),
org.meshtastic.feature.map.di.FeatureMapModule().featureMapModule(),
+ org.meshtastic.feature.discovery.di.FeatureDiscoveryModule().featureDiscoveryModule(),
org.meshtastic.feature.firmware.di.FeatureFirmwareModule().featureFirmwareModule(),
org.meshtastic.feature.intro.di.FeatureIntroModule().featureIntroModule(),
org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule().featureWifiProvisionModule(),
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
index d7581cc9c..108fff3d1 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
@@ -23,6 +23,7 @@ import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
+import org.meshtastic.feature.discovery.navigation.discoveryGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
@@ -54,5 +55,6 @@ fun EntryProviderScope.desktopNavGraph(
settingsGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
+ discoveryGraph(backStack)
wifiProvisionGraph(backStack)
}
diff --git a/feature/discovery/build.gradle.kts b/feature/discovery/build.gradle.kts
new file mode 100644
index 000000000..02351a3f1
--- /dev/null
+++ b/feature/discovery/build.gradle.kts
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2025-2026 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 .
+ */
+
+plugins {
+ alias(libs.plugins.meshtastic.kmp.feature)
+ alias(libs.plugins.meshtastic.kotlinx.serialization)
+}
+
+kotlin {
+ jvm()
+
+ @Suppress("UnstableApiUsage")
+ android {
+ namespace = "org.meshtastic.feature.discovery"
+ androidResources.enable = false
+ withHostTest { isIncludeAndroidResources = true }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ implementation(libs.jetbrains.navigation3.ui)
+ implementation(projects.core.common)
+ implementation(projects.core.data)
+ implementation(projects.core.database)
+ implementation(projects.core.di)
+ implementation(projects.core.model)
+ implementation(projects.core.navigation)
+ implementation(projects.core.network)
+ implementation(projects.core.prefs)
+ implementation(projects.core.proto)
+ implementation(projects.core.repository)
+ implementation(projects.core.resources)
+ implementation(projects.core.service)
+ implementation(projects.core.ui)
+
+ implementation(libs.kotlinx.collections.immutable)
+ }
+
+ commonTest.dependencies { implementation(projects.core.testing) }
+ }
+}
diff --git a/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/.gitkeep b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt
new file mode 100644
index 000000000..3406db489
--- /dev/null
+++ b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2025-2026 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 .
+ */
+package org.meshtastic.feature.discovery.ai
+
+import org.koin.core.annotation.Single
+import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
+import org.meshtastic.core.database.entity.DiscoverySessionEntity
+import org.meshtastic.feature.discovery.DiscoverySummaryGenerator
+
+// TODO: Replace with real Gemini Nano on-device implementation once
+// `com.google.ai.edge:aicore` or `com.google.android.gms:play-services-generativeai`
+// is added to libs.versions.toml. The implementation should:
+// 1. Check model availability via GenerativeModel.isAvailable()
+// 2. Build a structured prompt with session metrics (nodes, utilization, presets)
+// 3. Call generateContent() with the prompt
+// 4. Fall back to the algorithmic generator on any error
+
+/**
+ * Android provider that will use Gemini Nano for on-device AI summaries.
+ *
+ * Currently delegates to [DiscoverySummaryGenerator] because the Gemini Nano SDK dependency is not yet in the version
+ * catalog.
+ */
+@Single(binds = [DiscoverySummaryAiProvider::class])
+class GeminiNanoSummaryProvider(private val generator: DiscoverySummaryGenerator) : DiscoverySummaryAiProvider {
+
+ // Delegates to DiscoverySummaryGenerator (algorithmic) so results are always available.
+ // When real Gemini Nano SDK is wired, this should check GenerativeModel.isAvailable() at runtime.
+ override val isAvailable: Boolean = true
+
+ override suspend fun generateSessionSummary(
+ session: DiscoverySessionEntity,
+ presetResults: List,
+ ): String = generator.generateSessionSummary(session, presetResults)
+
+ override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String =
+ generator.generatePresetSummary(result)
+}
diff --git a/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/PdfDiscoveryExporter.kt b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/PdfDiscoveryExporter.kt
new file mode 100644
index 000000000..3b4445e6e
--- /dev/null
+++ b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/PdfDiscoveryExporter.kt
@@ -0,0 +1,230 @@
+/*
+ * Copyright (c) 2025-2026 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 .
+ */
+package org.meshtastic.feature.discovery.export
+
+import android.graphics.Paint
+import android.graphics.pdf.PdfDocument
+import kotlinx.coroutines.withContext
+import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.ioDispatcher
+import java.io.ByteArrayOutputStream
+
+private const val PAGE_WIDTH = 612
+private const val PAGE_HEIGHT = 792
+private const val MARGIN_LEFT = 40f
+private const val MARGIN_TOP = 50f
+private const val LINE_HEIGHT = 18f
+private const val SECTION_GAP = 12f
+private const val TITLE_SIZE = 18f
+private const val HEADING_SIZE = 14f
+private const val BODY_SIZE = 10f
+private const val LABEL_SIZE = 9f
+private const val FOOTER_SIZE = 8f
+private const val PAGE_BOTTOM_MARGIN = 60f
+private const val LABEL_COLUMN_WIDTH = 160f
+
+@Single
+class PdfDiscoveryExporter : DiscoveryExporter {
+
+ override suspend fun export(data: DiscoveryExportData): ExportResult = withContext(ioDispatcher) {
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ val bytes = renderPdf(data)
+ val fileName = DiscoveryReportFormatter.generateFileName(data.session, "pdf")
+ ExportResult.Success(content = bytes, mimeType = "application/pdf", fileName = fileName)
+ } catch (e: Exception) {
+ ExportResult.Error("PDF generation failed: ${e.message}")
+ }
+ }
+
+ private fun renderPdf(data: DiscoveryExportData): ByteArray {
+ val document = PdfDocument()
+ val renderer = PageRenderer(document)
+
+ renderer.drawTitle("Meshtastic Discovery Report")
+ renderer.advanceLine()
+
+ // Session overview
+ renderer.drawHeading("Session Overview")
+ for ((label, value) in DiscoveryReportFormatter.formatSessionOverviewLines(data.session)) {
+ renderer.drawLabelValue(label, value)
+ }
+ renderer.advanceSection()
+
+ // Per-preset sections
+ for (result in data.presetResults) {
+ renderer.drawHeading("Preset: ${result.presetName}")
+ for ((label, value) in DiscoveryReportFormatter.formatPresetLines(result)) {
+ renderer.drawLabelValue(label, value)
+ }
+
+ val nodes = data.nodesByPreset[result.id].orEmpty()
+ if (nodes.isNotEmpty()) {
+ renderer.advanceLine()
+ renderer.drawSubheading("Discovered Nodes (${nodes.size})")
+ for (node in nodes) {
+ renderer.drawBody(DiscoveryReportFormatter.formatNodeLine(node))
+ }
+ }
+ renderer.advanceSection()
+ }
+
+ // AI summary
+ val summary = data.session.aiSummary
+ if (!summary.isNullOrBlank()) {
+ renderer.drawHeading("AI Analysis")
+ renderer.drawWrappedBody(summary)
+ renderer.advanceSection()
+ }
+
+ renderer.drawFooter("Generated by Meshtastic Android")
+ renderer.finishCurrentPage()
+
+ val outputStream = ByteArrayOutputStream()
+ document.writeTo(outputStream)
+ document.close()
+ return outputStream.toByteArray()
+ }
+
+ @Suppress("TooManyFunctions")
+ private class PageRenderer(private val document: PdfDocument) {
+ private var pageNumber = 0
+ private var currentPage: PdfDocument.Page? = null
+ private var yPosition = MARGIN_TOP
+
+ private val titlePaint =
+ Paint().apply {
+ textSize = TITLE_SIZE
+ isFakeBoldText = true
+ isAntiAlias = true
+ }
+ private val headingPaint =
+ Paint().apply {
+ textSize = HEADING_SIZE
+ isFakeBoldText = true
+ isAntiAlias = true
+ }
+ private val bodyPaint =
+ Paint().apply {
+ textSize = BODY_SIZE
+ isAntiAlias = true
+ }
+ private val labelPaint =
+ Paint().apply {
+ textSize = LABEL_SIZE
+ isAntiAlias = true
+ color = android.graphics.Color.DKGRAY
+ }
+ private val footerPaint =
+ Paint().apply {
+ textSize = FOOTER_SIZE
+ isAntiAlias = true
+ color = android.graphics.Color.GRAY
+ }
+
+ private fun ensurePage() {
+ if (currentPage == null) {
+ pageNumber++
+ val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, pageNumber).create()
+ currentPage = document.startPage(pageInfo)
+ yPosition = MARGIN_TOP
+ }
+ }
+
+ private fun checkPageBreak(linesNeeded: Int = 1) {
+ if (yPosition + linesNeeded * LINE_HEIGHT > PAGE_HEIGHT - PAGE_BOTTOM_MARGIN) {
+ finishCurrentPage()
+ ensurePage()
+ }
+ }
+
+ fun finishCurrentPage() {
+ currentPage?.let { document.finishPage(it) }
+ currentPage = null
+ }
+
+ fun drawTitle(text: String) {
+ ensurePage()
+ currentPage?.canvas?.drawText(text, MARGIN_LEFT, yPosition, titlePaint)
+ yPosition += LINE_HEIGHT + SECTION_GAP
+ }
+
+ fun drawHeading(text: String) {
+ checkPageBreak(linesNeeded = 2)
+ ensurePage()
+ currentPage?.canvas?.drawText(text, MARGIN_LEFT, yPosition, headingPaint)
+ yPosition += LINE_HEIGHT
+ }
+
+ fun drawSubheading(text: String) {
+ checkPageBreak()
+ ensurePage()
+ currentPage?.canvas?.drawText(text, MARGIN_LEFT, yPosition, bodyPaint.apply { isFakeBoldText = true })
+ bodyPaint.isFakeBoldText = false
+ yPosition += LINE_HEIGHT
+ }
+
+ fun drawBody(text: String) {
+ checkPageBreak()
+ ensurePage()
+ currentPage?.canvas?.drawText(text, MARGIN_LEFT, yPosition, bodyPaint)
+ yPosition += LINE_HEIGHT
+ }
+
+ fun drawLabelValue(label: String, value: String) {
+ checkPageBreak()
+ ensurePage()
+ currentPage?.canvas?.let { canvas ->
+ canvas.drawText("$label:", MARGIN_LEFT, yPosition, labelPaint)
+ canvas.drawText(value, MARGIN_LEFT + LABEL_COLUMN_WIDTH, yPosition, bodyPaint)
+ }
+ yPosition += LINE_HEIGHT
+ }
+
+ fun drawWrappedBody(text: String) {
+ val maxWidth = PAGE_WIDTH - MARGIN_LEFT * 2
+ val words = text.split(" ")
+ var currentLine = StringBuilder()
+
+ for (word in words) {
+ val testLine = if (currentLine.isEmpty()) word else "$currentLine $word"
+ if (bodyPaint.measureText(testLine) > maxWidth && currentLine.isNotEmpty()) {
+ drawBody(currentLine.toString())
+ currentLine = StringBuilder(word)
+ } else {
+ currentLine = StringBuilder(testLine)
+ }
+ }
+ if (currentLine.isNotEmpty()) {
+ drawBody(currentLine.toString())
+ }
+ }
+
+ fun drawFooter(text: String) {
+ ensurePage()
+ currentPage?.canvas?.drawText(text, MARGIN_LEFT, PAGE_HEIGHT - MARGIN_TOP / 2, footerPaint)
+ }
+
+ fun advanceLine() {
+ yPosition += LINE_HEIGHT
+ }
+
+ fun advanceSection() {
+ yPosition += SECTION_GAP
+ }
+ }
+}
diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryDetailViewModel.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryDetailViewModel.kt
new file mode 100644
index 000000000..75b98d826
--- /dev/null
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryDetailViewModel.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2025-2026 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 .
+ */
+package org.meshtastic.feature.discovery
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.koin.core.annotation.InjectedParam
+import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.database.dao.DiscoveryDao
+import org.meshtastic.core.database.entity.DiscoveredNodeEntity
+import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
+import org.meshtastic.core.database.entity.DiscoverySessionEntity
+import org.meshtastic.core.ui.viewmodel.safeLaunch
+import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
+
+@KoinViewModel
+class DiscoveryHistoryDetailViewModel(
+ @InjectedParam private val sessionId: Long,
+ private val discoveryDao: DiscoveryDao,
+) : ViewModel() {
+
+ val session: StateFlow =
+ discoveryDao.getSessionFlow(sessionId).stateInWhileSubscribed(initialValue = null)
+
+ val presetResults: StateFlow> =
+ discoveryDao.getPresetResultsFlow(sessionId).stateInWhileSubscribed(initialValue = emptyList())
+
+ private val _nodesByPreset = MutableStateFlow