From 48c58f340c14d9feac882744538357ae5a77f2d3 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 20 May 2026 17:04:44 -0500 Subject: [PATCH] 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> --- .skills/compose-ui/strings-index.txt | 1 + .../composeResources/values/strings.xml | 1 + .../discovery/export/ExportSaver.android.kt | 65 +++++++++++++++++++ .../feature/discovery/DiscoveryViewModel.kt | 19 ++++++ .../discovery/export/ExportSaverLauncher.kt | 32 +++++++++ .../discovery/ui/DiscoveryScanScreen.kt | 8 ++- .../discovery/ui/DiscoverySummaryScreen.kt | 6 +- .../discovery/export/ExportSaver.ios.kt | 25 +++++++ .../discovery/export/ExportSaver.jvm.kt | 53 +++++++++++++++ .../spec.md | 24 ++++++- 10 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.android.kt create mode 100644 feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaverLauncher.kt create mode 100644 feature/discovery/src/iosMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.ios.kt create mode 100644 feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.jvm.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 9cf5ec54e..0d69443c0 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 2c7eb83d0..f34fa0b88 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -349,6 +349,7 @@ 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 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 000000000..e8c8e53a4 --- /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/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt index 64235b508..87095e587 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt @@ -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 = _is24GhzBlocked.asStateFlow() + + /** True when the radio is on the LORA_24 region. */ + val isLora24Region: StateFlow = + radioConfigRepository.localConfigFlow + .map { it.lora?.region == RegionCode.LORA_24 } + .stateInWhileSubscribed(initialValue = false) + private val _selectedPresets = MutableStateFlow>(restoreSelectedPresets()) val selectedPresets: StateFlow> = _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) { diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaverLauncher.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaverLauncher.kt new file mode 100644 index 000000000..44c5af186 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaverLauncher.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.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) +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt index f17913cba..336f5b1f5 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt @@ -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 -> "" } diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt index 05eebe75c..63a5d184a 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt @@ -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() } diff --git a/feature/discovery/src/iosMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.ios.kt b/feature/discovery/src/iosMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.ios.kt new file mode 100644 index 000000000..5f9777a7a --- /dev/null +++ b/feature/discovery/src/iosMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.ios.kt @@ -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 . + */ +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}" } +} diff --git a/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.jvm.kt b/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.jvm.kt new file mode 100644 index 000000000..35be6d095 --- /dev/null +++ b/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.jvm.kt @@ -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 . + */ +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" } + } + } + } + } +} diff --git a/specs/20260507-161658-local-mesh-discovery/spec.md b/specs/20260507-161658-local-mesh-discovery/spec.md index 6117b3ac7..c0895d285 100644 --- a/specs/20260507-161658-local-mesh-discovery/spec.md +++ b/specs/20260507-161658-local-mesh-discovery/spec.md @@ -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` 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