diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index ff03a5edec..d48dc0c22c 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -338,6 +338,81 @@ discard_changes
disconnect
disconnected
discovered_network_devices
+### DISCOVERY ###
+discovery_analysing_results
+discovery_cancelling_scan
+discovery_connection_warning
+discovery_delete_session
+discovery_delete_session_confirm
+discovery_dwell_minutes
+discovery_dwell_progress
+discovery_dwell_time
+discovery_dwell_time_description
+discovery_empty_history
+discovery_export_report
+discovery_history
+discovery_keep_screen_awake
+discovery_keep_screen_awake_description
+discovery_local_mesh
+discovery_lora_presets
+discovery_lora_presets_description
+discovery_map
+discovery_not_connected
+discovery_not_connected_description
+discovery_paused
+discovery_preparing
+discovery_preset_home_label
+discovery_reconnecting
+discovery_rerun_analysis
+discovery_restoring_preset
+discovery_scan_complete
+discovery_scan_failed
+discovery_scan_history
+discovery_scan_incomplete
+discovery_scan_progress
+discovery_scan_summary
+discovery_session_detail
+discovery_shifting_to
+discovery_start_scan
+discovery_start_scan_disabled
+discovery_start_scan_reason_24ghz_unsupported
+discovery_start_scan_reason_default_key
+discovery_start_scan_reason_no_presets
+discovery_start_scan_reason_not_connected
+discovery_stat_analysis
+discovery_stat_avg_airtime_rate
+discovery_stat_avg_channel_utilization
+discovery_stat_bad_packets
+discovery_stat_channel_utilization
+discovery_stat_date
+discovery_stat_direct
+discovery_stat_duplicate_packets
+discovery_stat_dwelling_on
+discovery_stat_failure_rate
+discovery_stat_home_preset
+discovery_stat_mesh
+discovery_stat_messages
+discovery_stat_online_total_nodes
+discovery_stat_packets_rx
+discovery_stat_packets_tx
+discovery_stat_preset_results
+discovery_stat_presets_scanned
+discovery_stat_rf_health
+discovery_stat_selected
+discovery_stat_sensor_pkts
+discovery_stat_session_overview
+discovery_stat_status
+discovery_stat_success_rate
+discovery_stat_total_dwell_time
+discovery_stat_total_messages
+discovery_stat_total_unique_nodes
+discovery_stat_unique_nodes
+discovery_stat_unselected
+discovery_stop_scan
+discovery_summary_not_available
+discovery_time_remaining
+discovery_unique_nodes
+discovery_view_map
disk_free_indexed
### DISPLAY ###
display
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 9919e6b00f..f934cacab9 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -225,6 +225,7 @@ dependencies {
implementation(projects.feature.map)
implementation(projects.feature.node)
implementation(projects.feature.settings)
+ implementation(projects.feature.discovery)
implementation(projects.feature.docs)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt
new file mode 100644
index 0000000000..bc5c4ec597
--- /dev/null
+++ b/androidApp/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/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt
new file mode 100644
index 0000000000..8b1692bc1c
--- /dev/null
+++ b/androidApp/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/androidApp/src/fdroid/res/drawable/ic_person.xml b/androidApp/src/fdroid/res/drawable/ic_person.xml
new file mode 100644
index 0000000000..8e5be7ed10
--- /dev/null
+++ b/androidApp/src/fdroid/res/drawable/ic_person.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/androidApp/src/fdroid/res/drawable/ic_thermostat.xml b/androidApp/src/fdroid/res/drawable/ic_thermostat.xml
new file mode 100644
index 0000000000..5257f7fe68
--- /dev/null
+++ b/androidApp/src/fdroid/res/drawable/ic_thermostat.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt
new file mode 100644
index 0000000000..492fc84d3b
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt
@@ -0,0 +1,147 @@
+/*
+ * 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.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.Person
+import org.meshtastic.core.ui.icon.Temperature
+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) {
+ MeshtasticIcons.Temperature
+ } else {
+ 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/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt
new file mode 100644
index 0000000000..9dff450534
--- /dev/null
+++ b/androidApp/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/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMarkerChip.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMarkerChip.kt
new file mode 100644
index 0000000000..f1eaea7669
--- /dev/null
+++ b/androidApp/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/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 962b4acd81..1655c35921 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -70,6 +70,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
@@ -208,6 +209,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/androidApp/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt b/androidApp/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt
index 36b7a242a3..2e630fd0f3 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt
+++ b/androidApp/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.docs.di.FeatureDocsModule
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
import org.meshtastic.feature.intro.di.FeatureIntroModule
@@ -86,6 +87,7 @@ import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
FeatureConnectionsModule::class,
FeatureMapModule::class,
FeatureSettingsModule::class,
+ FeatureDiscoveryModule::class,
FeatureDocsModule::class,
FeatureFirmwareModule::class,
FeatureIntroModule::class,
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt
index 1c1fde70ae..26ac6cac11 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt
@@ -44,6 +44,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.docs.navigation.docsEntries
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
@@ -90,6 +91,7 @@ fun MainScreen() {
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
+ discoveryGraph(backStack)
settingsGraph(backStack)
docsEntries(backStack)
firmwareGraph(backStack)
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
index 8f3bf2c71c..d5349e8595 100644
--- a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
+++ b/androidApp/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 0000000000..1a4f50c525
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DiscoveryPacketCollectorRegistryImpl.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.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 24733e9d97..35308659e1 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
@@ -42,6 +42,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.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
@@ -99,6 +100,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(
handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
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/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
index 7c934af5e1..2bb95c3e01 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
@@ -106,6 +106,7 @@ class MeshDataHandlerTest {
storeForwardHandler = storeForwardHandler,
telemetryHandler = telemetryHandler,
adminPacketHandler = adminPacketHandler,
+ collectorRegistry = mock(MockMode.autofill),
scope = testScope,
)
diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/42.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/42.json
new file mode 100644
index 0000000000..b7e0fafa3d
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/42.json
@@ -0,0 +1,1582 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 42,
+ "identityHash": "2c8bcf0938019ea7f6b5613bf5561c13",
+ "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, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `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": "airQualityTelemetry",
+ "columnName": "air_quality_metrics",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ },
+ {
+ "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, `message_text` TEXT NOT NULL DEFAULT '')",
+ "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"
+ },
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ }
+ ],
+ "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": "packet_fts",
+ "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)",
+ "fields": [
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": []
+ },
+ "ftsVersion": "FTS5",
+ "ftsOptions": {
+ "tokenizer": "unicode61",
+ "tokenizerArgs": [],
+ "contentTable": "packet",
+ "languageIdColumnName": "",
+ "matchInfo": "FTS4",
+ "notIndexedColumns": [],
+ "prefixSizes": [],
+ "preferredOrder": "ASC",
+ "contentRowId": "",
+ "columnSize": true,
+ "detail": "FULL"
+ },
+ "contentSyncTriggers": [
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END"
+ ]
+ },
+ {
+ "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": "device_link",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `original_url` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, PRIMARY KEY(`short_code`))",
+ "fields": [
+ {
+ "fieldPath": "shortCode",
+ "columnName": "short_code",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "originalUrl",
+ "columnName": "original_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "linkDescription",
+ "columnName": "link_description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isVendor",
+ "columnName": "is_vendor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "regions",
+ "columnName": "regions",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "short_code"
+ ]
+ }
+ },
+ {
+ "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, `infrastructure_node_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": "infrastructureNodeCount",
+ "columnName": "infrastructure_node_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, `is_infrastructure` 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"
+ },
+ {
+ "fieldPath": "isInfrastructure",
+ "columnName": "is_infrastructure",
+ "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, '2c8bcf0938019ea7f6b5613bf5561c13')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt
new file mode 100644
index 0000000000..d39fa41ef9
--- /dev/null
+++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt
@@ -0,0 +1,260 @@
+/*
+ * 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.database.dao
+
+import androidx.room3.Room
+import androidx.sqlite.driver.bundled.BundledSQLiteDriver
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.database.MeshtasticDatabase
+import org.meshtastic.core.database.MeshtasticDatabaseConstructor
+import org.meshtastic.core.database.entity.DiscoveredNodeEntity
+import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
+import org.meshtastic.core.database.entity.DiscoverySessionEntity
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Migration coverage for discovery tables (D011).
+ *
+ * Verifies that the discovery schema (version 41→42 auto-migration) creates the expected tables, supports CRUD
+ * operations, enforces foreign key cascade behavior, and respects column defaults.
+ */
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [34])
+@Suppress("MagicNumber")
+class DiscoveryMigrationTest {
+ private lateinit var database: MeshtasticDatabase
+ private lateinit var discoveryDao: DiscoveryDao
+
+ @Before
+ fun createDb() {
+ database =
+ Room.inMemoryDatabaseBuilder(factory = { MeshtasticDatabaseConstructor.initialize() })
+ .setDriver(BundledSQLiteDriver())
+ .build()
+ discoveryDao = database.discoveryDao()
+ }
+
+ @After
+ fun closeDb() {
+ database.close()
+ }
+
+ // region Table creation and basic CRUD
+
+ @Test
+ fun discoverySessionTable_insertAndRetrieve() = runTest {
+ val session =
+ DiscoverySessionEntity(
+ timestamp = 1_000_000L,
+ presetsScanned = "LONG_FAST,SHORT_FAST",
+ homePreset = "LONG_FAST",
+ completionStatus = "complete",
+ )
+ val id = discoveryDao.insertSession(session)
+ assertTrue(id > 0, "Insert should return positive auto-generated ID")
+ val loaded = discoveryDao.getSession(id)
+ assertNotNull(loaded)
+ assertEquals("LONG_FAST,SHORT_FAST", loaded.presetsScanned)
+ assertEquals("complete", loaded.completionStatus)
+ }
+
+ @Test
+ fun discoveryPresetResultTable_insertAndRetrieve() = runTest {
+ val sessionId = discoveryDao.insertSession(testSession())
+ val result =
+ DiscoveryPresetResultEntity(
+ sessionId = sessionId,
+ presetName = "LONG_FAST",
+ dwellDurationSeconds = 30,
+ uniqueNodes = 5,
+ directNeighborCount = 3,
+ meshNeighborCount = 2,
+ )
+ val resultId = discoveryDao.insertPresetResult(result)
+ assertTrue(resultId > 0)
+ val results = discoveryDao.getPresetResults(sessionId)
+ assertEquals(1, results.size)
+ assertEquals("LONG_FAST", results[0].presetName)
+ assertEquals(5, results[0].uniqueNodes)
+ }
+
+ @Test
+ fun discoveredNodeTable_insertAndRetrieve() = runTest {
+ val sessionId = discoveryDao.insertSession(testSession())
+ val presetId = discoveryDao.insertPresetResult(testPresetResult(sessionId))
+ val node =
+ DiscoveredNodeEntity(
+ presetResultId = presetId,
+ nodeNum = 12345,
+ shortName = "TST",
+ longName = "Test Node",
+ neighborType = "direct",
+ latitude = 37.7749,
+ longitude = -122.4194,
+ snr = 8.5f,
+ rssi = -65,
+ )
+ val nodeId = discoveryDao.insertDiscoveredNode(node)
+ assertTrue(nodeId > 0)
+ val nodes = discoveryDao.getDiscoveredNodes(presetId)
+ assertEquals(1, nodes.size)
+ assertEquals(12345L, nodes[0].nodeNum)
+ assertEquals("direct", nodes[0].neighborType)
+ }
+
+ // endregion
+
+ // region Column defaults
+
+ @Test
+ fun sessionEntity_defaultValues() = runTest {
+ // Insert with only required fields — verify defaults
+ val session = DiscoverySessionEntity(timestamp = 1L, presetsScanned = "A", homePreset = "A")
+ val id = discoveryDao.insertSession(session)
+ val loaded = discoveryDao.getSession(id)!!
+ assertEquals(0, loaded.totalUniqueNodes)
+ assertEquals(0.0, loaded.avgChannelUtilization)
+ assertEquals(0, loaded.totalMessages)
+ assertEquals(0, loaded.totalSensorPackets)
+ assertEquals(0.0, loaded.furthestNodeDistance)
+ assertEquals("complete", loaded.completionStatus)
+ assertNull(loaded.aiSummary)
+ assertEquals(0.0, loaded.userLatitude)
+ assertEquals(0.0, loaded.userLongitude)
+ assertEquals(0L, loaded.totalDwellSeconds)
+ }
+
+ @Test
+ fun presetResultEntity_defaultValues() = runTest {
+ val sessionId = discoveryDao.insertSession(testSession())
+ val result = DiscoveryPresetResultEntity(sessionId = sessionId, presetName = "TEST")
+ val id = discoveryDao.insertPresetResult(result)
+ val loaded = discoveryDao.getPresetResults(sessionId).first { it.id == id }
+ assertEquals(0L, loaded.dwellDurationSeconds)
+ assertEquals(0, loaded.uniqueNodes)
+ assertEquals(0, loaded.directNeighborCount)
+ assertEquals(0, loaded.meshNeighborCount)
+ assertEquals(0, loaded.messageCount)
+ assertEquals(0, loaded.sensorPacketCount)
+ assertEquals(0.0, loaded.avgChannelUtilization)
+ assertEquals(0.0, loaded.avgAirtimeRate)
+ assertEquals(0.0, loaded.packetSuccessRate)
+ assertEquals(0.0, loaded.packetFailureRate)
+ assertEquals(0, loaded.numPacketsTx)
+ assertEquals(0, loaded.numPacketsRx)
+ assertEquals(0, loaded.numPacketsRxBad)
+ assertEquals(0, loaded.numRxDupe)
+ assertEquals(0, loaded.numTxRelay)
+ assertEquals(0, loaded.numTxRelayCanceled)
+ assertEquals(0, loaded.numOnlineNodes)
+ assertEquals(0, loaded.numTotalNodes)
+ assertEquals(0, loaded.uptimeSeconds)
+ assertNull(loaded.aiSummary)
+ }
+
+ @Test
+ fun discoveredNodeEntity_defaultValues() = runTest {
+ val sessionId = discoveryDao.insertSession(testSession())
+ val presetId = discoveryDao.insertPresetResult(testPresetResult(sessionId))
+ val node = DiscoveredNodeEntity(presetResultId = presetId, nodeNum = 1)
+ val nodeId = discoveryDao.insertDiscoveredNode(node)
+ val loaded = discoveryDao.getDiscoveredNodes(presetId).first { it.id == nodeId }
+ assertNull(loaded.shortName)
+ assertNull(loaded.longName)
+ assertEquals("direct", loaded.neighborType)
+ assertNull(loaded.latitude)
+ assertNull(loaded.longitude)
+ assertNull(loaded.distanceFromUser)
+ assertEquals(0, loaded.hopCount)
+ assertEquals(0f, loaded.snr)
+ assertEquals(0, loaded.rssi)
+ assertEquals(0, loaded.messageCount)
+ assertEquals(0, loaded.sensorPacketCount)
+ }
+
+ // endregion
+
+ // region Foreign key cascade
+
+ @Test
+ fun deleteSession_cascadesPresetResultsAndNodes() = runTest {
+ val sessionId = discoveryDao.insertSession(testSession())
+ val presetId = discoveryDao.insertPresetResult(testPresetResult(sessionId))
+ discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = presetId, nodeNum = 1))
+ discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = presetId, nodeNum = 2))
+
+ discoveryDao.deleteSession(sessionId)
+
+ assertNull(discoveryDao.getSession(sessionId))
+ assertTrue(discoveryDao.getPresetResults(sessionId).isEmpty())
+ assertTrue(discoveryDao.getDiscoveredNodes(presetId).isEmpty())
+ }
+
+ // endregion
+
+ // region Aggregate queries across migration-created schema
+
+ @Test
+ fun uniqueNodeCount_deduplicatesAcrossPresets() = runTest {
+ val sessionId = discoveryDao.insertSession(testSession())
+ val pre1 = discoveryDao.insertPresetResult(testPresetResult(sessionId, "LONG_FAST"))
+ val pre2 = discoveryDao.insertPresetResult(testPresetResult(sessionId, "SHORT_FAST"))
+ // Node 100 appears in both presets
+ discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = pre1, nodeNum = 100))
+ discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = pre1, nodeNum = 200))
+ discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = pre2, nodeNum = 100))
+ discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = pre2, nodeNum = 300))
+
+ assertEquals(3, discoveryDao.getUniqueNodeCount(sessionId))
+ }
+
+ @Test
+ fun getAllSessions_sortedNewestFirst() = runTest {
+ discoveryDao.insertSession(testSession(timestamp = 100))
+ discoveryDao.insertSession(testSession(timestamp = 300))
+ discoveryDao.insertSession(testSession(timestamp = 200))
+
+ val sessions = discoveryDao.getAllSessions().first()
+ assertEquals(listOf(300L, 200L, 100L), sessions.map { it.timestamp })
+ }
+
+ // endregion
+
+ // region Helpers
+
+ private fun testSession(timestamp: Long = 1_000_000L) = DiscoverySessionEntity(
+ timestamp = timestamp,
+ presetsScanned = "LONG_FAST",
+ homePreset = "LONG_FAST",
+ completionStatus = "in_progress",
+ )
+
+ private fun testPresetResult(sessionId: Long, presetName: String = "LONG_FAST") =
+ DiscoveryPresetResultEntity(sessionId = sessionId, presetName = presetName)
+
+ // endregion
+}
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 205d00f73f..8b8d470ceb 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
@@ -26,6 +26,7 @@ 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.DeviceLinkDao
+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
@@ -35,6 +36,9 @@ 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.DeviceLinkEntity
+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
@@ -62,6 +66,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
DeviceLinkEntity::class,
FirmwareReleaseEntity::class,
TracerouteNodePositionEntity::class,
+ DiscoverySessionEntity::class,
+ DiscoveryPresetResultEntity::class,
+ DiscoveredNodeEntity::class,
],
autoMigrations =
[
@@ -103,8 +110,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 38, to = 39),
AutoMigration(from = 39, to = 40),
AutoMigration(from = 40, to = 41),
+ AutoMigration(from = 41, to = 42),
],
- version = 41,
+ version = 42,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -127,6 +135,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 0000000000..7e4ebdf5ed
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt
@@ -0,0 +1,120 @@
+/*
+ * 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.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)
+
+ @Query("UPDATE discovery_session SET completion_status = 'interrupted' WHERE completion_status = 'in_progress'")
+ suspend fun markInterruptedSessions()
+
+ // 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
+
+ @Query(
+ """
+ SELECT MAX(distance_from_user) FROM discovered_node dn
+ INNER JOIN discovery_preset_result dpr ON dn.preset_result_id = dpr.id
+ WHERE dpr.session_id = :sessionId
+ """,
+ )
+ suspend fun getMaxDistance(sessionId: Long): Double?
+
+ @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 acae365da2..4328cfe6ee 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 0000000000..eeb8c7eb36
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.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 .
+ */
+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,
+ @ColumnInfo(name = "is_infrastructure", defaultValue = "0") val isInfrastructure: Boolean = false,
+)
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 0000000000..c957bc5c22
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveryPresetResultEntity.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.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 = "infrastructure_node_count", defaultValue = "0") val infrastructureNodeCount: 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 0000000000..1fdfc25e94
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoverySessionEntity.kt
@@ -0,0 +1,39 @@
+/*
+ * 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.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/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonDiscoveryDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonDiscoveryDaoTest.kt
new file mode 100644
index 0000000000..40e2b9e8b4
--- /dev/null
+++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonDiscoveryDaoTest.kt
@@ -0,0 +1,302 @@
+/*
+ * 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.core.database.dao
+
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.database.MeshtasticDatabase
+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.getInMemoryDatabaseBuilder
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+abstract class CommonDiscoveryDaoTest {
+ private lateinit var database: MeshtasticDatabase
+ private lateinit var dao: DiscoveryDao
+
+ suspend fun createDb() {
+ database = getInMemoryDatabaseBuilder().build()
+ dao = database.discoveryDao()
+ }
+
+ @AfterTest
+ fun closeDb() {
+ database.close()
+ }
+
+ // region Session CRUD
+
+ @Test
+ fun insertSession_returnsAutoGeneratedId() = runTest {
+ val session = testSession(timestamp = 1_000_000L)
+ val id = dao.insertSession(session)
+ assertTrue(id > 0, "Auto-generated id should be > 0")
+ }
+
+ @Test
+ fun getSession_returnsInsertedSession() = runTest {
+ val id = dao.insertSession(testSession(timestamp = 2_000_000L, homePreset = "MEDIUM_SLOW"))
+ val loaded = dao.getSession(id)
+ assertNotNull(loaded)
+ assertEquals(id, loaded.id)
+ assertEquals("MEDIUM_SLOW", loaded.homePreset)
+ assertEquals(2_000_000L, loaded.timestamp)
+ }
+
+ @Test fun getSession_returnsNullForMissing() = runTest { assertNull(dao.getSession(999L)) }
+
+ @Test
+ fun updateSession_modifiesExistingRow() = runTest {
+ val id = dao.insertSession(testSession(timestamp = 3_000_000L))
+ val original = dao.getSession(id)!!
+ dao.updateSession(original.copy(completionStatus = "stopped", totalUniqueNodes = 5))
+ val updated = dao.getSession(id)!!
+ assertEquals("stopped", updated.completionStatus)
+ assertEquals(5, updated.totalUniqueNodes)
+ }
+
+ @Test
+ fun deleteSession_removesRow() = runTest {
+ val id = dao.insertSession(testSession())
+ dao.deleteSession(id)
+ assertNull(dao.getSession(id))
+ }
+
+ // endregion
+
+ // region Session sort order (getAllSessions returns newest-first)
+
+ @Test
+ fun getAllSessions_orderedByTimestampDescending() = runTest {
+ dao.insertSession(testSession(timestamp = 100L))
+ dao.insertSession(testSession(timestamp = 300L))
+ dao.insertSession(testSession(timestamp = 200L))
+ val sessions = dao.getAllSessions().first()
+ assertEquals(3, sessions.size)
+ assertEquals(300L, sessions[0].timestamp)
+ assertEquals(200L, sessions[1].timestamp)
+ assertEquals(100L, sessions[2].timestamp)
+ }
+
+ // endregion
+
+ // region Preset result relation loading
+
+ @Test
+ fun getPresetResults_returnsResultsForSession() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ dao.insertPresetResult(testPresetResult(sessionId, presetName = "LONG_FAST"))
+ dao.insertPresetResult(testPresetResult(sessionId, presetName = "SHORT_FAST"))
+ val results = dao.getPresetResults(sessionId)
+ assertEquals(2, results.size)
+ assertTrue(results.any { it.presetName == "LONG_FAST" })
+ assertTrue(results.any { it.presetName == "SHORT_FAST" })
+ }
+
+ @Test
+ fun getPresetResults_doesNotReturnOtherSessionResults() = runTest {
+ val session1 = dao.insertSession(testSession(timestamp = 1L))
+ val session2 = dao.insertSession(testSession(timestamp = 2L))
+ dao.insertPresetResult(testPresetResult(session1, presetName = "A"))
+ dao.insertPresetResult(testPresetResult(session2, presetName = "B"))
+ val results = dao.getPresetResults(session1)
+ assertEquals(1, results.size)
+ assertEquals("A", results[0].presetName)
+ }
+
+ @Test
+ fun getPresetResultsFlow_emitsOnInsert() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ val initial = dao.getPresetResultsFlow(sessionId).first()
+ assertTrue(initial.isEmpty())
+ dao.insertPresetResult(testPresetResult(sessionId, presetName = "LONG_FAST"))
+ val updated = dao.getPresetResultsFlow(sessionId).first()
+ assertEquals(1, updated.size)
+ }
+
+ // endregion
+
+ // region Discovered node relation loading
+
+ @Test
+ fun getDiscoveredNodes_returnsNodesForPresetResult() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ val presetId = dao.insertPresetResult(testPresetResult(sessionId))
+ dao.insertDiscoveredNode(testNode(presetId, nodeNum = 100))
+ dao.insertDiscoveredNode(testNode(presetId, nodeNum = 200))
+ val nodes = dao.getDiscoveredNodes(presetId)
+ assertEquals(2, nodes.size)
+ }
+
+ @Test
+ fun insertDiscoveredNodes_batchInsert() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ val presetId = dao.insertPresetResult(testPresetResult(sessionId))
+ val batch =
+ listOf(testNode(presetId, nodeNum = 1), testNode(presetId, nodeNum = 2), testNode(presetId, nodeNum = 3))
+ dao.insertDiscoveredNodes(batch)
+ assertEquals(3, dao.getDiscoveredNodes(presetId).size)
+ }
+
+ @Test
+ fun updateDiscoveredNode_modifiesExistingRow() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ val presetId = dao.insertPresetResult(testPresetResult(sessionId))
+ val nodeId = dao.insertDiscoveredNode(testNode(presetId, nodeNum = 42))
+ val original = dao.getDiscoveredNodes(presetId).first { it.id == nodeId }
+ dao.updateDiscoveredNode(original.copy(snr = 12.5f, rssi = -55))
+ val updated = dao.getDiscoveredNodes(presetId).first { it.id == nodeId }
+ assertEquals(12.5f, updated.snr)
+ assertEquals(-55, updated.rssi)
+ }
+
+ // endregion
+
+ // region Cascade deletion
+
+ @Test
+ fun deleteSession_cascadesPresetResults() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ dao.insertPresetResult(testPresetResult(sessionId, presetName = "LONG_FAST"))
+ dao.insertPresetResult(testPresetResult(sessionId, presetName = "SHORT_FAST"))
+ dao.deleteSession(sessionId)
+ assertTrue(dao.getPresetResults(sessionId).isEmpty(), "Preset results should be cascade-deleted")
+ }
+
+ @Test
+ fun deleteSession_cascadesDiscoveredNodes() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ val presetId = dao.insertPresetResult(testPresetResult(sessionId))
+ dao.insertDiscoveredNode(testNode(presetId, nodeNum = 1))
+ dao.insertDiscoveredNode(testNode(presetId, nodeNum = 2))
+ dao.deleteSession(sessionId)
+ assertTrue(dao.getDiscoveredNodes(presetId).isEmpty(), "Discovered nodes should be cascade-deleted")
+ }
+
+ @Test
+ fun deleteSession_doesNotAffectOtherSessions() = runTest {
+ val session1 = dao.insertSession(testSession(timestamp = 1L))
+ val session2 = dao.insertSession(testSession(timestamp = 2L))
+ val preset1 = dao.insertPresetResult(testPresetResult(session1))
+ val preset2 = dao.insertPresetResult(testPresetResult(session2))
+ dao.insertDiscoveredNode(testNode(preset1, nodeNum = 1))
+ dao.insertDiscoveredNode(testNode(preset2, nodeNum = 2))
+ dao.deleteSession(session1)
+ assertNotNull(dao.getSession(session2))
+ assertEquals(1, dao.getPresetResults(session2).size)
+ assertEquals(1, dao.getDiscoveredNodes(preset2).size)
+ }
+
+ // endregion
+
+ // region Aggregate queries
+
+ @Test
+ fun getUniqueNodeCount_countsAcrossPresets() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ val preset1 = dao.insertPresetResult(testPresetResult(sessionId, presetName = "A"))
+ val preset2 = dao.insertPresetResult(testPresetResult(sessionId, presetName = "B"))
+ // Same node 100 appears in both presets
+ dao.insertDiscoveredNode(testNode(preset1, nodeNum = 100))
+ dao.insertDiscoveredNode(testNode(preset1, nodeNum = 200))
+ dao.insertDiscoveredNode(testNode(preset2, nodeNum = 100))
+ dao.insertDiscoveredNode(testNode(preset2, nodeNum = 300))
+ assertEquals(3, dao.getUniqueNodeCount(sessionId), "Node 100 appears in both presets but should count once")
+ }
+
+ @Test
+ fun getUniqueNodeNums_returnsDistinctNodeNums() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ val presetId = dao.insertPresetResult(testPresetResult(sessionId))
+ dao.insertDiscoveredNode(testNode(presetId, nodeNum = 10))
+ dao.insertDiscoveredNode(testNode(presetId, nodeNum = 20))
+ val nums = dao.getUniqueNodeNums(sessionId)
+ assertEquals(setOf(10L, 20L), nums.toSet())
+ }
+
+ @Test
+ fun getMaxDistance_returnsLargestDistance() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ val presetId = dao.insertPresetResult(testPresetResult(sessionId))
+ dao.insertDiscoveredNode(testNode(presetId, nodeNum = 1, distanceFromUser = 500.0))
+ dao.insertDiscoveredNode(testNode(presetId, nodeNum = 2, distanceFromUser = 15_000.0))
+ dao.insertDiscoveredNode(testNode(presetId, nodeNum = 3, distanceFromUser = 3_000.0))
+ assertEquals(15_000.0, dao.getMaxDistance(sessionId))
+ }
+
+ @Test
+ fun getMaxDistance_returnsNullWhenNoNodes() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ assertNull(dao.getMaxDistance(sessionId))
+ }
+
+ @Test
+ fun getMaxDistance_returnsNullWhenAllDistancesNull() = runTest {
+ val sessionId = dao.insertSession(testSession())
+ val presetId = dao.insertPresetResult(testPresetResult(sessionId))
+ dao.insertDiscoveredNode(testNode(presetId, nodeNum = 1, distanceFromUser = null))
+ assertNull(dao.getMaxDistance(sessionId))
+ }
+
+ // endregion
+
+ // region Flow queries
+
+ @Test
+ fun getSessionFlow_emitsUpdatesOnChange() = runTest {
+ val id = dao.insertSession(testSession(timestamp = 5_000_000L))
+ val initial = dao.getSessionFlow(id).first()
+ assertNotNull(initial)
+ assertEquals("in_progress", initial.completionStatus)
+ }
+
+ // endregion
+
+ // region Helpers
+
+ private fun testSession(timestamp: Long = 1_000_000L, homePreset: String = "LONG_FAST") = DiscoverySessionEntity(
+ timestamp = timestamp,
+ presetsScanned = "LONG_FAST,SHORT_FAST",
+ homePreset = homePreset,
+ completionStatus = "in_progress",
+ )
+
+ private fun testPresetResult(sessionId: Long, presetName: String = "LONG_FAST") = DiscoveryPresetResultEntity(
+ sessionId = sessionId,
+ presetName = presetName,
+ dwellDurationSeconds = 30,
+ uniqueNodes = 5,
+ )
+
+ private fun testNode(presetResultId: Long, nodeNum: Long, distanceFromUser: Double? = null) = DiscoveredNodeEntity(
+ presetResultId = presetResultId,
+ nodeNum = nodeNum,
+ snr = 5.0f,
+ rssi = -70,
+ distanceFromUser = distanceFromUser,
+ )
+
+ // endregion
+}
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
index 9335b6a544..6021b6f0e8 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
@@ -132,7 +132,7 @@ object DeepLinkRouter {
}
}
- @Suppress("ReturnCount", "MagicNumber")
+ @Suppress("MagicNumber", "ReturnCount")
private fun routeSettings(segments: List): List {
var destNum: Int? = null
var subRouteStr: String? = null
@@ -165,6 +165,20 @@ object DeepLinkRouter {
}
}
+ // Handle discovery session deep links: /settings/local-mesh-discovery/session/{sessionId}
+ if (subRouteStr in discoveryAliases && segments.size > 3 && segments[2].lowercase() == "session") {
+ val sessionId = segments[3].toLongOrNull()
+ return if (sessionId != null) {
+ listOf(
+ SettingsRoute.Settings(destNum),
+ DiscoveryRoute.DiscoveryGraph,
+ DiscoveryRoute.DiscoverySummary(sessionId),
+ )
+ } else {
+ listOf(SettingsRoute.Settings(destNum), DiscoveryRoute.DiscoveryGraph)
+ }
+ }
+
val subRoute = settingsSubRoutes[subRouteStr]
return if (subRoute != null) {
listOf(SettingsRoute.Settings(destNum), subRoute)
@@ -224,8 +238,13 @@ object DeepLinkRouter {
"filter-settings" to SettingsRoute.FilterSettings,
"helpdocs" to SettingsRoute.HelpDocs,
"help-docs" to SettingsRoute.HelpDocs,
+ "local-mesh-discovery" to DiscoveryRoute.DiscoveryGraph,
+ "localmeshdiscovery" to DiscoveryRoute.DiscoveryGraph,
)
+ /** URL path segments that map to the discovery feature. */
+ private val discoveryAliases = setOf("local-mesh-discovery", "localmeshdiscovery")
+
private val nodeDetailSubRoutes: Map Route> =
mapOf(
"device-metrics" to { destNum -> NodeDetailRoute.DeviceMetrics(destNum) },
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 146381c9d2..ac2286a43a 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 eeb3c3fddd..9623a3c280 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
@@ -198,3 +198,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/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt
index c6fc642bd2..dbdb145819 100644
--- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt
+++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt
@@ -380,6 +380,40 @@ class DeepLinkRouterTest {
// endregion
+ // region discovery deep links
+
+ @Test
+ fun `discovery settings sub-route navigates to discovery graph`() {
+ val result = route("/settings/local-mesh-discovery")
+ assertEquals(listOf(SettingsRoute.Settings(null), DiscoveryRoute.DiscoveryGraph), result)
+ }
+
+ @Test
+ fun `discovery session deep link resolves session ID`() {
+ val result = route("/settings/local-mesh-discovery/session/42")
+ assertEquals(
+ listOf(SettingsRoute.Settings(null), DiscoveryRoute.DiscoveryGraph, DiscoveryRoute.DiscoverySummary(42L)),
+ result,
+ )
+ }
+
+ @Test
+ fun `discovery alias localmeshdiscovery resolves session ID`() {
+ val result = route("/settings/localmeshdiscovery/session/99")
+ assertEquals(
+ listOf(SettingsRoute.Settings(null), DiscoveryRoute.DiscoveryGraph, DiscoveryRoute.DiscoverySummary(99L)),
+ result,
+ )
+ }
+
+ @Test
+ fun `discovery session with invalid ID falls back to graph`() {
+ val result = route("/settings/local-mesh-discovery/session/notanumber")
+ assertEquals(listOf(SettingsRoute.Settings(null), DiscoveryRoute.DiscoveryGraph), result)
+ }
+
+ // endregion
+
// region case insensitivity
@Test
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/DiscoveryPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/DiscoveryPrefsImpl.kt
new file mode 100644
index 0000000000..dbc0e4db45
--- /dev/null
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/DiscoveryPrefsImpl.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.prefs.discovery
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.Named
+import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.DiscoveryPrefs
+
+@Single
+class DiscoveryPrefsImpl(
+ @Named("UiDataStore") private val dataStore: DataStore,
+ dispatchers: CoroutineDispatchers,
+) : DiscoveryPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val dwellMinutes: StateFlow =
+ dataStore.data
+ .map { it[KEY_DWELL_MINUTES] ?: DiscoveryPrefs.DEFAULT_DWELL_MINUTES }
+ .stateIn(scope, SharingStarted.Eagerly, DiscoveryPrefs.DEFAULT_DWELL_MINUTES)
+
+ override fun setDwellMinutes(minutes: Int) {
+ scope.launch { dataStore.edit { it[KEY_DWELL_MINUTES] = minutes } }
+ }
+
+ override val selectedPresets: StateFlow> =
+ dataStore.data
+ .map { prefs ->
+ val raw = prefs[KEY_SELECTED_PRESETS] ?: ""
+ if (raw.isBlank()) emptySet() else raw.split(PRESET_DELIMITER).toSet()
+ }
+ .stateIn(scope, SharingStarted.Eagerly, emptySet())
+
+ override fun setSelectedPresets(presets: Set) {
+ scope.launch { dataStore.edit { it[KEY_SELECTED_PRESETS] = presets.joinToString(PRESET_DELIMITER) } }
+ }
+
+ override val aiEnabled: StateFlow =
+ dataStore.data.map { it[KEY_AI_ENABLED] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setAiEnabled(enabled: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_AI_ENABLED] = enabled } }
+ }
+
+ override val topologyOverlayEnabled: StateFlow =
+ dataStore.data.map { it[KEY_TOPOLOGY_OVERLAY] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun setTopologyOverlayEnabled(enabled: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_TOPOLOGY_OVERLAY] = enabled } }
+ }
+
+ companion object {
+ private val KEY_DWELL_MINUTES = intPreferencesKey("discovery_dwell_minutes")
+ private val KEY_SELECTED_PRESETS = stringPreferencesKey("discovery_selected_presets")
+ private val KEY_AI_ENABLED = booleanPreferencesKey("discovery_ai_enabled")
+ private val KEY_TOPOLOGY_OVERLAY = booleanPreferencesKey("discovery_topology_overlay")
+ private const val PRESET_DELIMITER = ","
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index c7f891a6f4..3289963cbb 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -354,4 +354,28 @@ interface AppPreferences {
val radio: RadioPrefs
val mesh: MeshPrefs
val tak: TakPrefs
+ val discovery: DiscoveryPrefs
+}
+
+/** Reactive interface for Local Mesh Discovery scan preferences. */
+interface DiscoveryPrefs {
+ val dwellMinutes: StateFlow
+
+ fun setDwellMinutes(minutes: Int)
+
+ val selectedPresets: StateFlow>
+
+ fun setSelectedPresets(presets: Set)
+
+ val aiEnabled: StateFlow
+
+ fun setAiEnabled(enabled: Boolean)
+
+ val topologyOverlayEnabled: StateFlow
+
+ fun setTopologyOverlayEnabled(enabled: Boolean)
+
+ companion object {
+ const val DEFAULT_DWELL_MINUTES = 15
+ }
}
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 0000000000..973abcd41b
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollector.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.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 0000000000..4be18a916d
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollectorRegistry.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.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/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 2f5b28838b..022ea3aeae 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -362,6 +362,81 @@
Disconnect
Disconnected
Discovered Network Devices
+
+ Analyzing results
+ Cancelling scan
+ Not connected. Connect to a Meshtastic device to start scanning.
+ Delete Session
+ Are you sure you want to delete this discovery session? This action cannot be undone.
+ %1$d min
+ Dwelling on %1$s, %2$s remaining
+ Dwell Time
+ Time to listen on each preset
+ No discovery sessions yet
+ Export report
+ Discovery History
+ Keep screen awake
+ Prevents Android Doze mode from dropping radio packets during long scans. Recommended.
+ Local Mesh Discovery
+ LoRa Presets
+ Select one or more presets to scan
+ Discovery Map
+ Not Connected
+ Connect to a Meshtastic device to start scanning.
+ Paused: %1$s
+ Preparing scan
+ %1$s (Home)
+ Reconnecting on %1$s
+ Re-run analysis
+ Restoring home preset
+ Session complete
+ Scan failed: %1$s
+ Scan History
+ Session incomplete
+ Scan Progress
+ Scan Summary
+ Session Detail
+ Shifting to %1$s
+ Start Scan
+ Start scan button disabled. %1$s
+ radio hardware does not support 2.4 GHz
+ channel uses default encryption key
+ no presets selected
+ device not connected
+ Analysis
+ Avg airtime rate
+ Avg channel utilization
+ Bad packets
+ Channel utilization
+ Date
+ Direct
+ Duplicate packets
+ Dwelling on %1$s
+ Failure rate
+ Home preset
+ Mesh
+ Messages
+ Online / Total nodes
+ Packets RX
+ Packets TX
+ Preset Results
+ Presets scanned
+ RF Health
+ Selected
+ Sensor pkts
+ Session Overview
+ Status
+ Success rate
+ Total dwell time
+ Total messages
+ Total unique nodes
+ Unique nodes
+ Not selected
+ Stop Scan
+ AI analysis not available
+ %1$s remaining
+ %1$d unique nodes
+ View map
Disk Free %1$d
Display
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index c83a5815a9..7c06dc39f7 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -413,6 +413,33 @@ class FakeAppPreferences : AppPreferences {
override val radio = FakeRadioPrefs()
override val mesh = FakeMeshPrefs()
override val tak = FakeTakPrefs()
+ override val discovery = FakeDiscoveryPrefs()
+}
+
+class FakeDiscoveryPrefs : org.meshtastic.core.repository.DiscoveryPrefs {
+ override val dwellMinutes = MutableStateFlow(org.meshtastic.core.repository.DiscoveryPrefs.DEFAULT_DWELL_MINUTES)
+
+ override fun setDwellMinutes(minutes: Int) {
+ dwellMinutes.value = minutes
+ }
+
+ override val selectedPresets = MutableStateFlow>(emptySet())
+
+ override fun setSelectedPresets(presets: Set) {
+ selectedPresets.value = presets
+ }
+
+ override val aiEnabled = MutableStateFlow(true)
+
+ override fun setAiEnabled(enabled: Boolean) {
+ aiEnabled.value = enabled
+ }
+
+ override val topologyOverlayEnabled = MutableStateFlow(false)
+
+ override fun setTopologyOverlayEnabled(enabled: Boolean) {
+ topologyOverlayEnabled.value = enabled
+ }
}
class FakeTakPrefs : org.meshtastic.core.repository.TakPrefs {
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 9304d5e2bb..1f8081e6c8 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 0000000000..e1b5352b0d
--- /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 0000000000..702f42975e
--- /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", "CompositionLocalAllowlist")
+val LocalDiscoveryMapProvider =
+ compositionLocalOf<
+ @Composable (
+ userLatitude: Double,
+ userLongitude: Double,
+ nodes: List,
+ modifier: Modifier,
+ ) -> Unit,
+ > {
+ { _, _, _, _ -> PlaceholderScreen("Discovery Map") }
+ }
diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts
index d684fafaef..13046b0adf 100644
--- a/desktopApp/build.gradle.kts
+++ b/desktopApp/build.gradle.kts
@@ -269,6 +269,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/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
index 2a1b0b3763..9c69c9867d 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
@@ -110,6 +110,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.docs.di.module as featureDocsModule
import org.meshtastic.feature.firmware.di.module as featureFirmwareModule
import org.meshtastic.feature.intro.di.module as featureIntroModule
@@ -151,6 +152,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.docs.di.FeatureDocsModule().featureDocsModule(),
org.meshtastic.feature.intro.di.FeatureIntroModule().featureIntroModule(),
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
index 6103ffdbf6..4d8f01b910 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
+++ b/desktopApp/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.docs.navigation.docsEntries
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
@@ -56,5 +57,6 @@ fun EntryProviderScope.desktopNavGraph(
docsEntries(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 0000000000..bfe4b06b77
--- /dev/null
+++ b/feature/discovery/build.gradle.kts
@@ -0,0 +1,57 @@
+/*
+ * 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) }
+
+ androidMain.dependencies { implementation(libs.mlkit.genai.prompt) }
+ }
+}
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 0000000000..6fc800ef49
--- /dev/null
+++ b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt
@@ -0,0 +1,114 @@
+/*
+ * 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.feature.discovery.ai
+
+import co.touchlab.kermit.Logger
+import com.google.mlkit.genai.prompt.Generation
+import com.google.mlkit.genai.prompt.GenerativeModel
+import com.google.mlkit.genai.prompt.TextPart
+import com.google.mlkit.genai.prompt.generateContentRequest
+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
+
+/**
+ * Android provider that uses Gemini Nano via ML Kit GenAI Prompt API for on-device AI summaries.
+ *
+ * Falls back to [DiscoverySummaryGenerator] when:
+ * - The on-device model is unavailable (unsupported hardware or not downloaded)
+ * - Generation fails for any reason
+ */
+@Single(binds = [DiscoverySummaryAiProvider::class])
+class GeminiNanoSummaryProvider(private val generator: DiscoverySummaryGenerator) : DiscoverySummaryAiProvider {
+
+ private val log = Logger.withTag("GeminiNanoSummary")
+
+ private val generativeModel: GenerativeModel? by lazy {
+ @Suppress("TooGenericExceptionCaught") // ML Kit throws undocumented RuntimeExceptions
+ try {
+ Generation.getClient()
+ } catch (e: Exception) {
+ log.w(e) { "Failed to get GenerativeModel client" }
+ null
+ }
+ }
+
+ override val isAvailable: Boolean
+ get() = checkAvailability()
+
+ override suspend fun generateSessionSummary(
+ session: DiscoverySessionEntity,
+ presetResults: List,
+ ): String {
+ val model = generativeModel
+ if (model == null || !isAvailable) {
+ log.d { "Gemini Nano unavailable, using algorithmic fallback" }
+ return generator.generateSessionSummary(session, presetResults)
+ }
+
+ val prompt = generator.buildSessionPrompt(session, presetResults)
+ return generateOrFallback(model, prompt) { generator.generateSessionSummary(session, presetResults) }
+ }
+
+ override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String {
+ val model = generativeModel
+ if (model == null || !isAvailable) {
+ return generator.generatePresetSummary(result)
+ }
+
+ val prompt = generator.buildPresetPrompt(result)
+ return generateOrFallback(model, prompt) { generator.generatePresetSummary(result) }
+ }
+
+ private suspend fun generateOrFallback(model: GenerativeModel, prompt: String, fallback: () -> String): String =
+ try {
+ val request =
+ generateContentRequest(TextPart(prompt)) {
+ temperature = TEMPERATURE
+ topK = TOP_K
+ maxOutputTokens = MAX_OUTPUT_TOKENS
+ }
+ val response = model.generateContent(request)
+ val text = response.candidates.firstOrNull()?.text
+ if (text.isNullOrBlank()) {
+ log.w { "Gemini Nano returned empty response, using fallback" }
+ fallback()
+ } else {
+ text
+ }
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ log.w(e) { "Gemini Nano generation failed, using fallback" }
+ fallback()
+ }
+
+ private fun checkAvailability(): Boolean = try {
+ // FeatureStatus is an IntDef — check synchronously via the lazy model field.
+ // Note: checkStatus() is suspend in the API; we use a non-suspend heuristic here
+ // by catching and falling back if unavailable. The actual availability is confirmed
+ // in generateOrFallback when the suspend call succeeds.
+ generativeModel != null
+ } catch (_: Exception) {
+ false
+ }
+
+ private companion object {
+ const val TEMPERATURE = 0.3f
+ const val TOP_K = 16
+ const val MAX_OUTPUT_TOKENS = 200
+ }
+}
diff --git a/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.android.kt b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.android.kt
new file mode 100644
index 0000000000..e8c8e53a4d
--- /dev/null
+++ b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.android.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.feature.discovery.export
+
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@Composable
+actual fun rememberExportSaver(): ExportSaverLauncher {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val pendingExport = remember { mutableStateOf(null) }
+
+ val launcher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val uri = result.data?.data ?: return@rememberLauncherForActivityResult
+ val export = pendingExport.value ?: return@rememberLauncherForActivityResult
+ pendingExport.value = null
+ scope.launch {
+ withContext(Dispatchers.IO) {
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ context.contentResolver.openOutputStream(uri)?.use { it.write(export.content) }
+ } catch (e: Exception) {
+ Logger.e(throwable = e) { "Failed to write export file" }
+ }
+ }
+ }
+ }
+
+ return ExportSaverLauncher { result ->
+ pendingExport.value = result
+ val intent =
+ Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = result.mimeType
+ putExtra(Intent.EXTRA_TITLE, result.fileName)
+ }
+ launcher.launch(intent)
+ }
+}
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 0000000000..95fe17a5e3
--- /dev/null
+++ b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/PdfDiscoveryExporter.kt
@@ -0,0 +1,230 @@
+/*
+ * 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.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 0000000000..2270e880c4
--- /dev/null
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryDetailViewModel.kt
@@ -0,0 +1,61 @@
+/*
+ * 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.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