Skip to content

Commit 48c58f3

Browse files
jamesarichCopilot
andcommitted
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>
1 parent 57eaa3c commit 48c58f3

10 files changed

Lines changed: 228 additions & 6 deletions

File tree

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

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

core/resources/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@
349349
<string name="discovery_shifting_to">Shifting to %1$s</string>
350350
<string name="discovery_start_scan">Start Scan</string>
351351
<string name="discovery_start_scan_disabled">Start scan button disabled. %1$s</string>
352+
<string name="discovery_start_scan_reason_24ghz_unsupported">radio hardware does not support 2.4 GHz</string>
352353
<string name="discovery_start_scan_reason_default_key">channel uses default encryption key</string>
353354
<string name="discovery_start_scan_reason_no_presets">no presets selected</string>
354355
<string name="discovery_start_scan_reason_not_connected">device not connected</string>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.discovery.export
18+
19+
import android.content.Intent
20+
import androidx.activity.compose.rememberLauncherForActivityResult
21+
import androidx.activity.result.contract.ActivityResultContracts
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.mutableStateOf
24+
import androidx.compose.runtime.remember
25+
import androidx.compose.runtime.rememberCoroutineScope
26+
import androidx.compose.ui.platform.LocalContext
27+
import co.touchlab.kermit.Logger
28+
import kotlinx.coroutines.Dispatchers
29+
import kotlinx.coroutines.launch
30+
import kotlinx.coroutines.withContext
31+
32+
@Composable
33+
actual fun rememberExportSaver(): ExportSaverLauncher {
34+
val context = LocalContext.current
35+
val scope = rememberCoroutineScope()
36+
val pendingExport = remember { mutableStateOf<ExportResult.Success?>(null) }
37+
38+
val launcher =
39+
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
40+
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
41+
val export = pendingExport.value ?: return@rememberLauncherForActivityResult
42+
pendingExport.value = null
43+
scope.launch {
44+
withContext(Dispatchers.IO) {
45+
@Suppress("TooGenericExceptionCaught")
46+
try {
47+
context.contentResolver.openOutputStream(uri)?.use { it.write(export.content) }
48+
} catch (e: Exception) {
49+
Logger.e(throwable = e) { "Failed to write export file" }
50+
}
51+
}
52+
}
53+
}
54+
55+
return ExportSaverLauncher { result ->
56+
pendingExport.value = result
57+
val intent =
58+
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
59+
addCategory(Intent.CATEGORY_OPENABLE)
60+
type = result.mimeType
61+
putExtra(Intent.EXTRA_TITLE, result.fileName)
62+
}
63+
launcher.launch(intent)
64+
}
65+
}

feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,16 @@ import org.meshtastic.core.repository.RadioConfigRepository
3232
import org.meshtastic.core.repository.ServiceRepository
3333
import org.meshtastic.core.ui.viewmodel.safeLaunch
3434
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
35+
import org.meshtastic.feature.discovery.scan.Check24GhzCapability
36+
import org.meshtastic.feature.discovery.scan.HardwareCapabilityResult
37+
import org.meshtastic.proto.Config.LoRaConfig.RegionCode
3538

3639
@KoinViewModel
3740
class DiscoveryViewModel(
3841
private val scanEngine: DiscoveryScanEngine,
3942
private val serviceRepository: ServiceRepository,
4043
private val discoveryPrefs: DiscoveryPrefs,
44+
private val check24GhzCapability: Check24GhzCapability,
4145
radioConfigRepository: RadioConfigRepository,
4246
discoveryDao: DiscoveryDao,
4347
) : ViewModel() {
@@ -54,6 +58,16 @@ class DiscoveryViewModel(
5458
}
5559
.stateInWhileSubscribed(initialValue = ChannelOption.DEFAULT)
5660

61+
/** True when the radio is configured for LORA_24 region but hardware doesn't support 2.4 GHz. */
62+
private val _is24GhzBlocked = MutableStateFlow(false)
63+
val is24GhzBlocked: StateFlow<Boolean> = _is24GhzBlocked.asStateFlow()
64+
65+
/** True when the radio is on the LORA_24 region. */
66+
val isLora24Region: StateFlow<Boolean> =
67+
radioConfigRepository.localConfigFlow
68+
.map { it.lora?.region == RegionCode.LORA_24 }
69+
.stateInWhileSubscribed(initialValue = false)
70+
5771
private val _selectedPresets = MutableStateFlow<Set<ChannelOption>>(restoreSelectedPresets())
5872
val selectedPresets: StateFlow<Set<ChannelOption>> = _selectedPresets.asStateFlow()
5973

@@ -79,6 +93,11 @@ class DiscoveryViewModel(
7993

8094
init {
8195
safeLaunch(tag = "markInterruptedSessions") { discoveryDao.markInterruptedSessions() }
96+
safeLaunch(tag = "check24GhzCapability") {
97+
val result = check24GhzCapability()
98+
_is24GhzBlocked.value =
99+
result is HardwareCapabilityResult.Unsupported || result is HardwareCapabilityResult.Unknown
100+
}
82101
}
83102

84103
fun togglePreset(preset: ChannelOption) {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.discovery.export
18+
19+
import androidx.compose.runtime.Composable
20+
21+
/**
22+
* Returns a launcher that saves [ExportResult.Success] content to the platform's file system.
23+
*
24+
* On Android this opens a SAF document-picker (ACTION_CREATE_DOCUMENT). On Desktop this writes to a user-chosen file
25+
* via a file dialog.
26+
*/
27+
@Composable expect fun rememberExportSaver(): ExportSaverLauncher
28+
29+
/** Platform-agnostic handle for triggering a file-save from export data. */
30+
fun interface ExportSaverLauncher {
31+
fun save(result: ExportResult.Success)
32+
}

feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import org.meshtastic.core.resources.discovery_scan_progress
8282
import org.meshtastic.core.resources.discovery_shifting_to
8383
import org.meshtastic.core.resources.discovery_start_scan
8484
import org.meshtastic.core.resources.discovery_start_scan_disabled
85+
import org.meshtastic.core.resources.discovery_start_scan_reason_24ghz_unsupported
8586
import org.meshtastic.core.resources.discovery_start_scan_reason_default_key
8687
import org.meshtastic.core.resources.discovery_start_scan_reason_no_presets
8788
import org.meshtastic.core.resources.discovery_start_scan_reason_not_connected
@@ -120,6 +121,8 @@ fun DiscoveryScanScreen(
120121
val dwellMinutes by viewModel.dwellDurationMinutes.collectAsStateWithLifecycle()
121122
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
122123
val usesDefaultKey by viewModel.usesDefaultKey.collectAsStateWithLifecycle()
124+
val is24GhzBlocked by viewModel.is24GhzBlocked.collectAsStateWithLifecycle()
125+
val isLora24Region by viewModel.isLora24Region.collectAsStateWithLifecycle()
123126
val currentSession by viewModel.currentSession.collectAsStateWithLifecycle()
124127
val homePreset by viewModel.homePreset.collectAsStateWithLifecycle()
125128

@@ -177,6 +180,7 @@ fun DiscoveryScanScreen(
177180
isConnected = isConnected,
178181
hasPresetsSelected = selectedPresets.isNotEmpty(),
179182
usesDefaultKey = usesDefaultKey,
183+
is24GhzUnsupported = isLora24Region && is24GhzBlocked,
180184
onStart = viewModel::startScan,
181185
onStop = viewModel::stopScan,
182186
)
@@ -333,6 +337,7 @@ private fun ScanButton(
333337
isConnected: Boolean,
334338
hasPresetsSelected: Boolean,
335339
usesDefaultKey: Boolean,
340+
is24GhzUnsupported: Boolean,
336341
onStart: () -> Unit,
337342
onStop: () -> Unit,
338343
modifier: Modifier = Modifier,
@@ -348,11 +353,12 @@ private fun ScanButton(
348353
Text(stringResource(Res.string.discovery_stop_scan), modifier = Modifier.padding(start = 8.dp))
349354
}
350355
} else {
351-
val isEnabled = isConnected && hasPresetsSelected && !usesDefaultKey
356+
val isEnabled = isConnected && hasPresetsSelected && !usesDefaultKey && !is24GhzUnsupported
352357
val disabledReason =
353358
when {
354359
!isConnected -> stringResource(Res.string.discovery_start_scan_reason_not_connected)
355360
usesDefaultKey -> stringResource(Res.string.discovery_start_scan_reason_default_key)
361+
is24GhzUnsupported -> stringResource(Res.string.discovery_start_scan_reason_24ghz_unsupported)
356362
!hasPresetsSelected -> stringResource(Res.string.discovery_start_scan_reason_no_presets)
357363
else -> ""
358364
}

feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import org.meshtastic.core.ui.icon.Refresh
7373
import org.meshtastic.core.ui.icon.Share
7474
import org.meshtastic.feature.discovery.DiscoverySummaryViewModel
7575
import org.meshtastic.feature.discovery.export.ExportResult
76+
import org.meshtastic.feature.discovery.export.rememberExportSaver
7677
import org.meshtastic.feature.discovery.scan.PresetRanking
7778
import org.meshtastic.feature.discovery.ui.component.PresetResultCard
7879

@@ -91,11 +92,12 @@ fun DiscoverySummaryScreen(
9192
val presetAiSummaries by viewModel.presetAiSummaries.collectAsStateWithLifecycle()
9293
val isGeneratingAi by viewModel.isGeneratingAi.collectAsStateWithLifecycle()
9394
val exportResult by viewModel.exportResult.collectAsStateWithLifecycle()
95+
val exportSaver = rememberExportSaver()
9496

9597
LaunchedEffect(exportResult) {
96-
when (exportResult) {
98+
when (val result = exportResult) {
9799
is ExportResult.Success -> {
98-
// TODO: Wire platform share intent (Android) / file-save dialog (Desktop)
100+
exportSaver.save(result)
99101
viewModel.clearExportResult()
100102
}
101103

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.discovery.export
18+
19+
import androidx.compose.runtime.Composable
20+
import co.touchlab.kermit.Logger
21+
22+
@Composable
23+
actual fun rememberExportSaver(): ExportSaverLauncher = ExportSaverLauncher { result ->
24+
Logger.w { "Export save not yet implemented on iOS: ${result.fileName}" }
25+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.discovery.export
18+
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.rememberCoroutineScope
21+
import co.touchlab.kermit.Logger
22+
import kotlinx.coroutines.Dispatchers
23+
import kotlinx.coroutines.launch
24+
import kotlinx.coroutines.withContext
25+
import java.io.File
26+
import javax.swing.JFileChooser
27+
import javax.swing.filechooser.FileNameExtensionFilter
28+
29+
@Composable
30+
actual fun rememberExportSaver(): ExportSaverLauncher {
31+
val scope = rememberCoroutineScope()
32+
return ExportSaverLauncher { result ->
33+
scope.launch {
34+
withContext(Dispatchers.IO) {
35+
@Suppress("TooGenericExceptionCaught")
36+
try {
37+
val chooser =
38+
JFileChooser().apply {
39+
dialogTitle = "Save Discovery Report"
40+
selectedFile = File(result.fileName)
41+
val ext = result.fileName.substringAfterLast('.', "txt")
42+
fileFilter = FileNameExtensionFilter("${ext.uppercase()} files", ext)
43+
}
44+
if (chooser.showSaveDialog(null) == JFileChooser.APPROVE_OPTION) {
45+
chooser.selectedFile.writeBytes(result.content)
46+
}
47+
} catch (e: Exception) {
48+
Logger.e(throwable = e) { "Failed to save export file on desktop" }
49+
}
50+
}
51+
}
52+
}
53+
}

specs/20260507-161658-local-mesh-discovery/spec.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,8 +374,8 @@ If two presets still tie after all heuristics, the UI labels them as tied and av
374374
| US2 — Map Visualization | ✅ Complete | CompositionLocal map, preset filter, topology overlay, direct/mesh color-coding |
375375
| US3 — Summary + AI | ✅ Complete (AI fallback only) | Deterministic 6-level ranking, per-preset AI summaries field, Gemini Nano provider stubbed (delegates to algorithmic) |
376376
| US4 — Persistence & History | ✅ Complete | Room KMP, cascade delete, history list, detail view |
377-
| US5 — 2.4 GHz Gating | ⚠️ Logic only | `Check24GhzCapability` implemented + tested; not wired to PresetPickerCard UI gates |
378-
| Export/Share | ⚠️ Partial | `PdfDiscoveryExporter` + `TextDiscoveryExporter` implemented; UI hookup pending |
377+
| US5 — 2.4 GHz Gating | ✅ Complete | `Check24GhzCapability` checks hardware; ViewModel exposes `is24GhzBlocked`/`isLora24Region`; scan button disabled when region is LORA_24 on unsupported hardware |
378+
| Export/Share | ✅ Complete | `PdfDiscoveryExporter` (Android) + `TextDiscoveryExporter` (Desktop); `rememberExportSaver` wires platform file-save (SAF on Android, JFileChooser on Desktop) |
379379

380380
### Implementation Divergences from Original Spec
381381

@@ -416,6 +416,21 @@ Nodes are classified as `"direct"` (seen via their own packets) or `"mesh"` (dis
416416
| SwitchingPreset | Shifting | Matches "Shifting to [preset]" UX text |
417417
| Completed (terminal) | Complete | Differentiated by `completionStatus` on session entity |
418418

419+
#### Additional Implemented Features (Not in Original Spec)
420+
421+
These features were added during implementation for safety, reliability, and cross-platform parity:
422+
423+
| Feature | Description | File(s) |
424+
|---|---|---|
425+
| 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` |
426+
| 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` |
427+
| 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` |
428+
| 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` |
429+
| Active NeighborInfo request | Engine actively requests `NeighborInfo` at dwell start and mid-dwell via `radioController.requestNeighborInfo()`. Original spec mentioned only passive collection. | `DiscoveryScanEngine.kt` |
430+
| Deprecated preset filtering | `VERY_LONG_SLOW` and `LONG_SLOW` presets hidden from picker per meshtastic/design standards deprecation. | `PresetPickerCard.kt` |
431+
| 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` |
432+
| Traffic minimum threshold | `TRAFFIC_MIN_PACKET_THRESHOLD = 5` prevents noise in traffic-mix classification when packet counts are too low. | `DiscoverySummaryGenerator.kt` |
433+
419434
---
420435

421436
## Cross-Platform Alignment with Meshtastic-Apple
@@ -464,11 +479,14 @@ The Apple implementation (`meshtastic/Meshtastic-Apple`) is merged to `main` and
464479
| Dwell picker specific values | `[1, 5, 15, 30, 45, 60, 90, 120, 180]` min | Slider with 15-min minimum | 🟡 Low — UX preference |
465480
| Historical sessions fed to AI | Trend/cross-session analysis | Session-level only currently | 🟡 Medium — future enhancement |
466481
| Reconnect timeout default | 60 seconds explicit | Configurable, no spec'd default | 🟢 Low — uses BleReconnectPolicy defaults |
482+
| Map filter chips in UI | Rendered in map toolbar | ViewModel has filter logic; UI not yet rendering filter chips | 🟡 Medium |
483+
| Topology overlay toggle | Toggle in map settings | ViewModel has toggle; UI not yet wired | 🟡 Medium |
484+
| Node detail sheet on map tap | Bottom sheet on marker tap | Markers rendered without tap callbacks | 🟡 Medium |
467485

468486
### Design Repo Status
469487

470488
The `meshtastic/design` repo (`standards/audits/cross-platform-spec-audit.md`) confirms:
471-
- Android: 50/51 tasks complete on `feat/discovery` — remaining: D048 full verification
489+
- Android: All user stories complete on `feat/discovery`
472490
- Apple: ✅ Implemented on main
473491
- No feature-level design spec exists (design repo is visual standards only)
474492
- Design standard color palette (Success green `#3FB86D`, Info blue `#5C6BC0`) should be used for direct/mesh node map colors

0 commit comments

Comments
 (0)