feat(discovery): wire 2.4 GHz gating and export file-save, update spec

Critical spec gaps resolved:
- Wire Check24GhzCapability into DiscoveryViewModel; expose is24GhzBlocked
  and isLora24Region states; scan button disabled when on LORA_24 region
  with unsupported hardware
- Implement rememberExportSaver expect/actual composable:
  Android uses SAF ACTION_CREATE_DOCUMENT, Desktop uses JFileChooser,
  iOS stub logs warning. Summary screen now saves export result to disk.
- Add discovery_start_scan_reason_24ghz_unsupported string resource

Spec updates:
- Mark US5 (2.4 GHz gating) and Export as complete
- Document 8 features implemented beyond original spec
- Add remaining map UI gaps to Known Divergences table
- Update design repo status

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-20 17:04:44 -05:00
parent 57eaa3c22d
commit 48c58f340c
10 changed files with 228 additions and 6 deletions

View File

@@ -325,6 +325,7 @@ 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

View File

@@ -349,6 +349,7 @@
<string name="discovery_shifting_to">Shifting to %1$s</string>
<string name="discovery_start_scan">Start Scan</string>
<string name="discovery_start_scan_disabled">Start scan button disabled. %1$s</string>
<string name="discovery_start_scan_reason_24ghz_unsupported">radio hardware does not support 2.4 GHz</string>
<string name="discovery_start_scan_reason_default_key">channel uses default encryption key</string>
<string name="discovery_start_scan_reason_no_presets">no presets selected</string>
<string name="discovery_start_scan_reason_not_connected">device not connected</string>

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ExportResult.Success?>(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)
}
}

View File

@@ -32,12 +32,16 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.discovery.scan.Check24GhzCapability
import org.meshtastic.feature.discovery.scan.HardwareCapabilityResult
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
@KoinViewModel
class DiscoveryViewModel(
private val scanEngine: DiscoveryScanEngine,
private val serviceRepository: ServiceRepository,
private val discoveryPrefs: DiscoveryPrefs,
private val check24GhzCapability: Check24GhzCapability,
radioConfigRepository: RadioConfigRepository,
discoveryDao: DiscoveryDao,
) : ViewModel() {
@@ -54,6 +58,16 @@ class DiscoveryViewModel(
}
.stateInWhileSubscribed(initialValue = ChannelOption.DEFAULT)
/** True when the radio is configured for LORA_24 region but hardware doesn't support 2.4 GHz. */
private val _is24GhzBlocked = MutableStateFlow(false)
val is24GhzBlocked: StateFlow<Boolean> = _is24GhzBlocked.asStateFlow()
/** True when the radio is on the LORA_24 region. */
val isLora24Region: StateFlow<Boolean> =
radioConfigRepository.localConfigFlow
.map { it.lora?.region == RegionCode.LORA_24 }
.stateInWhileSubscribed(initialValue = false)
private val _selectedPresets = MutableStateFlow<Set<ChannelOption>>(restoreSelectedPresets())
val selectedPresets: StateFlow<Set<ChannelOption>> = _selectedPresets.asStateFlow()
@@ -79,6 +93,11 @@ class DiscoveryViewModel(
init {
safeLaunch(tag = "markInterruptedSessions") { discoveryDao.markInterruptedSessions() }
safeLaunch(tag = "check24GhzCapability") {
val result = check24GhzCapability()
_is24GhzBlocked.value =
result is HardwareCapabilityResult.Unsupported || result is HardwareCapabilityResult.Unknown
}
}
fun togglePreset(preset: ChannelOption) {

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.discovery.export
import androidx.compose.runtime.Composable
/**
* Returns a launcher that saves [ExportResult.Success] content to the platform's file system.
*
* On Android this opens a SAF document-picker (ACTION_CREATE_DOCUMENT). On Desktop this writes to a user-chosen file
* via a file dialog.
*/
@Composable expect fun rememberExportSaver(): ExportSaverLauncher
/** Platform-agnostic handle for triggering a file-save from export data. */
fun interface ExportSaverLauncher {
fun save(result: ExportResult.Success)
}

View File

@@ -82,6 +82,7 @@ import org.meshtastic.core.resources.discovery_scan_progress
import org.meshtastic.core.resources.discovery_shifting_to
import org.meshtastic.core.resources.discovery_start_scan
import org.meshtastic.core.resources.discovery_start_scan_disabled
import org.meshtastic.core.resources.discovery_start_scan_reason_24ghz_unsupported
import org.meshtastic.core.resources.discovery_start_scan_reason_default_key
import org.meshtastic.core.resources.discovery_start_scan_reason_no_presets
import org.meshtastic.core.resources.discovery_start_scan_reason_not_connected
@@ -120,6 +121,8 @@ fun DiscoveryScanScreen(
val dwellMinutes by viewModel.dwellDurationMinutes.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val usesDefaultKey by viewModel.usesDefaultKey.collectAsStateWithLifecycle()
val is24GhzBlocked by viewModel.is24GhzBlocked.collectAsStateWithLifecycle()
val isLora24Region by viewModel.isLora24Region.collectAsStateWithLifecycle()
val currentSession by viewModel.currentSession.collectAsStateWithLifecycle()
val homePreset by viewModel.homePreset.collectAsStateWithLifecycle()
@@ -177,6 +180,7 @@ fun DiscoveryScanScreen(
isConnected = isConnected,
hasPresetsSelected = selectedPresets.isNotEmpty(),
usesDefaultKey = usesDefaultKey,
is24GhzUnsupported = isLora24Region && is24GhzBlocked,
onStart = viewModel::startScan,
onStop = viewModel::stopScan,
)
@@ -333,6 +337,7 @@ private fun ScanButton(
isConnected: Boolean,
hasPresetsSelected: Boolean,
usesDefaultKey: Boolean,
is24GhzUnsupported: Boolean,
onStart: () -> Unit,
onStop: () -> Unit,
modifier: Modifier = Modifier,
@@ -348,11 +353,12 @@ private fun ScanButton(
Text(stringResource(Res.string.discovery_stop_scan), modifier = Modifier.padding(start = 8.dp))
}
} else {
val isEnabled = isConnected && hasPresetsSelected && !usesDefaultKey
val isEnabled = isConnected && hasPresetsSelected && !usesDefaultKey && !is24GhzUnsupported
val disabledReason =
when {
!isConnected -> stringResource(Res.string.discovery_start_scan_reason_not_connected)
usesDefaultKey -> stringResource(Res.string.discovery_start_scan_reason_default_key)
is24GhzUnsupported -> stringResource(Res.string.discovery_start_scan_reason_24ghz_unsupported)
!hasPresetsSelected -> stringResource(Res.string.discovery_start_scan_reason_no_presets)
else -> ""
}

View File

@@ -73,6 +73,7 @@ import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Share
import org.meshtastic.feature.discovery.DiscoverySummaryViewModel
import org.meshtastic.feature.discovery.export.ExportResult
import org.meshtastic.feature.discovery.export.rememberExportSaver
import org.meshtastic.feature.discovery.scan.PresetRanking
import org.meshtastic.feature.discovery.ui.component.PresetResultCard
@@ -91,11 +92,12 @@ fun DiscoverySummaryScreen(
val presetAiSummaries by viewModel.presetAiSummaries.collectAsStateWithLifecycle()
val isGeneratingAi by viewModel.isGeneratingAi.collectAsStateWithLifecycle()
val exportResult by viewModel.exportResult.collectAsStateWithLifecycle()
val exportSaver = rememberExportSaver()
LaunchedEffect(exportResult) {
when (exportResult) {
when (val result = exportResult) {
is ExportResult.Success -> {
// TODO: Wire platform share intent (Android) / file-save dialog (Desktop)
exportSaver.save(result)
viewModel.clearExportResult()
}

View File

@@ -0,0 +1,25 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.discovery.export
import androidx.compose.runtime.Composable
import co.touchlab.kermit.Logger
@Composable
actual fun rememberExportSaver(): ExportSaverLauncher = ExportSaverLauncher { result ->
Logger.w { "Export save not yet implemented on iOS: ${result.fileName}" }
}

View File

@@ -0,0 +1,53 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.discovery.export
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
@Composable
actual fun rememberExportSaver(): ExportSaverLauncher {
val scope = rememberCoroutineScope()
return ExportSaverLauncher { result ->
scope.launch {
withContext(Dispatchers.IO) {
@Suppress("TooGenericExceptionCaught")
try {
val chooser =
JFileChooser().apply {
dialogTitle = "Save Discovery Report"
selectedFile = File(result.fileName)
val ext = result.fileName.substringAfterLast('.', "txt")
fileFilter = FileNameExtensionFilter("${ext.uppercase()} files", ext)
}
if (chooser.showSaveDialog(null) == JFileChooser.APPROVE_OPTION) {
chooser.selectedFile.writeBytes(result.content)
}
} catch (e: Exception) {
Logger.e(throwable = e) { "Failed to save export file on desktop" }
}
}
}
}
}

View File

@@ -374,8 +374,8 @@ If two presets still tie after all heuristics, the UI labels them as tied and av
| US2 — Map Visualization | ✅ Complete | CompositionLocal map, preset filter, topology overlay, direct/mesh color-coding |
| US3 — Summary + AI | ✅ Complete (AI fallback only) | Deterministic 6-level ranking, per-preset AI summaries field, Gemini Nano provider stubbed (delegates to algorithmic) |
| US4 — Persistence & History | ✅ Complete | Room KMP, cascade delete, history list, detail view |
| US5 — 2.4 GHz Gating | ⚠️ Logic only | `Check24GhzCapability` implemented + tested; not wired to PresetPickerCard UI gates |
| Export/Share | ⚠️ Partial | `PdfDiscoveryExporter` + `TextDiscoveryExporter` implemented; UI hookup pending |
| US5 — 2.4 GHz Gating | ✅ Complete | `Check24GhzCapability` checks hardware; ViewModel exposes `is24GhzBlocked`/`isLora24Region`; scan button disabled when region is LORA_24 on unsupported hardware |
| Export/Share | ✅ Complete | `PdfDiscoveryExporter` (Android) + `TextDiscoveryExporter` (Desktop); `rememberExportSaver` wires platform file-save (SAF on Android, JFileChooser on Desktop) |
### Implementation Divergences from Original Spec
@@ -416,6 +416,21 @@ Nodes are classified as `"direct"` (seen via their own packets) or `"mesh"` (dis
| SwitchingPreset | Shifting | Matches "Shifting to [preset]" UX text |
| Completed (terminal) | Complete | Differentiated by `completionStatus` on session entity |
#### Additional Implemented Features (Not in Original Spec)
These features were added during implementation for safety, reliability, and cross-platform parity:
| Feature | Description | File(s) |
|---|---|---|
| Default PSK safety check | `usesDefaultKey: StateFlow<Boolean>` blocks scanning when primary channel uses default/cleartext encryption. Prevents exposing network topology on unprotected channels. | `DiscoveryViewModel.kt` |
| Interrupted session recovery | `markInterruptedSessions()` DAO query on ViewModel init marks any lingering `in_progress` sessions as `interrupted`. Handles app process death mid-scan. | `DiscoveryDao.kt`, `DiscoveryViewModel.kt` |
| Paused scan state | `DiscoveryScanState.Paused` provides a recoverable grace period during BLE reconnect before transitioning to `Failed`. Original spec only had direct `WaitingForReconnect → Failed`. | `DiscoveryScanState.kt` |
| Infrastructure node classification | Nodes with `ROUTER`, `ROUTER_LATE`, or `CLIENT_BASE` roles flagged via `isInfrastructure` on entity. `infrastructureNodeCount` aggregated per preset result. Aligns with Apple's relay/infrastructure tracking. | `DiscoveryScanEngine.kt`, `DiscoveredNodeEntity.kt`, `DiscoveryPresetResultEntity.kt` |
| Active NeighborInfo request | Engine actively requests `NeighborInfo` at dwell start and mid-dwell via `radioController.requestNeighborInfo()`. Original spec mentioned only passive collection. | `DiscoveryScanEngine.kt` |
| Deprecated preset filtering | `VERY_LONG_SLOW` and `LONG_SLOW` presets hidden from picker per meshtastic/design standards deprecation. | `PresetPickerCard.kt` |
| LoRa preset reference data | `LoRaPresetReference.kt` contains static range/throughput/capacity characteristics for all LoRa presets used by the deterministic summary generator. | `ai/LoRaPresetReference.kt` |
| Traffic minimum threshold | `TRAFFIC_MIN_PACKET_THRESHOLD = 5` prevents noise in traffic-mix classification when packet counts are too low. | `DiscoverySummaryGenerator.kt` |
---
## Cross-Platform Alignment with Meshtastic-Apple
@@ -464,11 +479,14 @@ The Apple implementation (`meshtastic/Meshtastic-Apple`) is merged to `main` and
| Dwell picker specific values | `[1, 5, 15, 30, 45, 60, 90, 120, 180]` min | Slider with 15-min minimum | 🟡 Low — UX preference |
| Historical sessions fed to AI | Trend/cross-session analysis | Session-level only currently | 🟡 Medium — future enhancement |
| Reconnect timeout default | 60 seconds explicit | Configurable, no spec'd default | 🟢 Low — uses BleReconnectPolicy defaults |
| Map filter chips in UI | Rendered in map toolbar | ViewModel has filter logic; UI not yet rendering filter chips | 🟡 Medium |
| Topology overlay toggle | Toggle in map settings | ViewModel has toggle; UI not yet wired | 🟡 Medium |
| Node detail sheet on map tap | Bottom sheet on marker tap | Markers rendered without tap callbacks | 🟡 Medium |
### Design Repo Status
The `meshtastic/design` repo (`standards/audits/cross-platform-spec-audit.md`) confirms:
- Android: 50/51 tasks complete on `feat/discovery` — remaining: D048 full verification
- Android: All user stories complete on `feat/discovery`
- Apple: ✅ Implemented on main
- No feature-level design spec exists (design repo is visual standards only)
- Design standard color palette (Success green `#3FB86D`, Info blue `#5C6BC0`) should be used for direct/mesh node map colors