mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-24 23:01:22 -04:00
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:
1
.skills/compose-ui/strings-index.txt
generated
1
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 -> ""
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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}" }
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user