feat(discovery): add Apple parity fixes - infrastructure tracking, session recovery, default key guard

- Track infrastructure nodes (ROUTER, ROUTER_LATE, CLIENT_BASE roles)
  in DiscoveredNodeEntity and DiscoveryPresetResultEntity
- Add markInterruptedSessions() DAO query for cold-start recovery
- Add usesDefaultKey StateFlow to DiscoveryViewModel that checks
  primary channel PSK and disables scan when using default/cleartext key
- Wire default key guard into ScanButton with accessibility description
- Add discovery_start_scan_reason_default_key string resource
- Update all test DAO fakes with KMP-compatible implementations

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-19 09:05:09 -05:00
parent 08885b791c
commit 57eaa3c22d
13 changed files with 94 additions and 8 deletions

View File

@@ -325,6 +325,7 @@ discovery_session_detail
discovery_shifting_to
discovery_start_scan
discovery_start_scan_disabled
discovery_start_scan_reason_default_key
discovery_start_scan_reason_no_presets
discovery_start_scan_reason_not_connected
discovery_stat_analysis

View File

@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 39,
"identityHash": "e39ee4f34ed8da08f3cb21bfd4a5165c",
"identityHash": "90335dadf5ace3b9f23b3818bd257f35",
"entities": [
{
"tableName": "my_node",
@@ -1149,7 +1149,7 @@
},
{
"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, `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 )",
"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",
@@ -1197,6 +1197,13 @@
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "infrastructureNodeCount",
"columnName": "infrastructure_node_count",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "messageCount",
"columnName": "message_count",
@@ -1341,7 +1348,7 @@
},
{
"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, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"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",
@@ -1427,6 +1434,13 @@
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isInfrastructure",
"columnName": "is_infrastructure",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
@@ -1472,7 +1486,7 @@
],
"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, 'e39ee4f34ed8da08f3cb21bfd4a5165c')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '90335dadf5ace3b9f23b3818bd257f35')"
]
}
}

View File

@@ -48,6 +48,9 @@ interface DiscoveryDao {
@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

View File

@@ -50,4 +50,5 @@ data class DiscoveredNodeEntity(
@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,
)

View File

@@ -43,6 +43,7 @@ data class DiscoveryPresetResultEntity(
@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,

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_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>
<string name="discovery_stat_analysis">Analysis</string>

View File

@@ -129,6 +129,7 @@ class DiscoveryScanEngine(
var hopCount: Int = 0,
var messageCount: Int = 0,
var sensorPacketCount: Int = 0,
var isInfrastructure: Boolean = false,
)
private data class DeviceMetricsEntry(val timestamp: Long, val channelUtil: Double, val airUtilTx: Double)
@@ -250,7 +251,7 @@ class DiscoveryScanEngine(
}
}
/** Backfills name and position from the local NodeDB when not yet received over-the-air. */
/** Backfills name, position, and infrastructure role from the local NodeDB when not yet received over-the-air. */
private fun enrichNodeFromDb(node: CollectedNodeData) {
val dbNode = nodeRepository.nodeDBbyNum.value[node.nodeNum.toInt()] ?: return
if (node.shortName == null || node.longName == null) {
@@ -263,6 +264,7 @@ class DiscoveryScanEngine(
if (dbLat != null && dbLat != 0) node.latitude = dbLat.toDouble() / POSITION_DIVISOR
if (dbLon != null && dbLon != 0) node.longitude = dbLon.toDouble() / POSITION_DIVISOR
}
node.isInfrastructure = dbNode.user.role in INFRASTRUCTURE_ROLES
}
// endregion
@@ -482,6 +484,7 @@ class DiscoveryScanEngine(
val (avgChannelUtil, avgAirUtil) = computeAverageMetrics()
val directCount = collectedNodes.values.count { it.neighborType == "direct" }
val meshCount = collectedNodes.values.count { it.neighborType == "mesh" }
val infraCount = collectedNodes.values.count { it.isInfrastructure }
val packetsRx = lastLocalStats?.num_packets_rx ?: 0
val packetsRxBad = lastLocalStats?.num_packets_rx_bad ?: 0
@@ -495,6 +498,7 @@ class DiscoveryScanEngine(
uniqueNodes = collectedNodes.size,
directNeighborCount = directCount,
meshNeighborCount = meshCount,
infrastructureNodeCount = infraCount,
messageCount = collectedNodes.values.sumOf { it.messageCount },
sensorPacketCount = collectedNodes.values.sumOf { it.sensorPacketCount },
avgChannelUtilization = avgChannelUtil,
@@ -559,6 +563,7 @@ class DiscoveryScanEngine(
rssi = rssi,
messageCount = messageCount,
sensorPacketCount = sensorPacketCount,
isInfrastructure = isInfrastructure,
)
}
@@ -660,5 +665,13 @@ class DiscoveryScanEngine(
private const val POSITION_DIVISOR = 1e7
private const val MIN_DEVICE_METRICS_PACKETS = 2
private const val PERCENT_MULTIPLIER = 100.0
/** Node roles that indicate infrastructure (Router, RouterLate, ClientBase). */
private val INFRASTRUCTURE_ROLES =
setOf(
Config.DeviceConfig.Role.ROUTER,
Config.DeviceConfig.Role.ROUTER_LATE,
Config.DeviceConfig.Role.CLIENT_BASE,
)
}
}

View File

@@ -65,9 +65,22 @@ class DiscoveryViewModel(
.map { it is ConnectionState.Connected }
.stateInWhileSubscribed(initialValue = false)
/** True when the primary channel uses the default (well-known) PSK — scanning is unsafe. */
val usesDefaultKey: StateFlow<Boolean> =
radioConfigRepository.channelSetFlow
.map { channelSet ->
val primaryPsk = channelSet.settings.firstOrNull()?.psk
primaryPsk == null || primaryPsk.size == 0 || (primaryPsk.size == 1 && primaryPsk[0].toInt() <= 1)
}
.stateInWhileSubscribed(initialValue = true)
val sessions: StateFlow<List<DiscoverySessionEntity>> =
discoveryDao.getAllSessions().stateInWhileSubscribed(initialValue = emptyList())
init {
safeLaunch(tag = "markInterruptedSessions") { discoveryDao.markInterruptedSessions() }
}
fun togglePreset(preset: ChannelOption) {
_selectedPresets.update { current ->
val updated = if (preset in current) current - preset else current + preset

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_default_key
import org.meshtastic.core.resources.discovery_start_scan_reason_no_presets
import org.meshtastic.core.resources.discovery_start_scan_reason_not_connected
import org.meshtastic.core.resources.discovery_stop_scan
@@ -118,6 +119,7 @@ fun DiscoveryScanScreen(
val selectedPresets by viewModel.selectedPresets.collectAsStateWithLifecycle()
val dwellMinutes by viewModel.dwellDurationMinutes.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val usesDefaultKey by viewModel.usesDefaultKey.collectAsStateWithLifecycle()
val currentSession by viewModel.currentSession.collectAsStateWithLifecycle()
val homePreset by viewModel.homePreset.collectAsStateWithLifecycle()
@@ -174,6 +176,7 @@ fun DiscoveryScanScreen(
scanState = scanState,
isConnected = isConnected,
hasPresetsSelected = selectedPresets.isNotEmpty(),
usesDefaultKey = usesDefaultKey,
onStart = viewModel::startScan,
onStop = viewModel::stopScan,
)
@@ -329,6 +332,7 @@ private fun ScanButton(
scanState: DiscoveryScanState,
isConnected: Boolean,
hasPresetsSelected: Boolean,
usesDefaultKey: Boolean,
onStart: () -> Unit,
onStop: () -> Unit,
modifier: Modifier = Modifier,
@@ -344,10 +348,11 @@ private fun ScanButton(
Text(stringResource(Res.string.discovery_stop_scan), modifier = Modifier.padding(start = 8.dp))
}
} else {
val isEnabled = isConnected && hasPresetsSelected
val isEnabled = isConnected && hasPresetsSelected && !usesDefaultKey
val disabledReason =
when {
!isConnected -> stringResource(Res.string.discovery_start_scan_reason_not_connected)
usesDefaultKey -> stringResource(Res.string.discovery_start_scan_reason_default_key)
!hasPresetsSelected -> stringResource(Res.string.discovery_start_scan_reason_no_presets)
else -> ""
}

View File

@@ -249,6 +249,15 @@ private class HistoryTestDao : DiscoveryDao {
.maxOrNull()
override suspend fun getSessionWithResults(sessionId: Long) = sessions[sessionId]
override suspend fun markInterruptedSessions() {
sessions.keys.toList().forEach { key ->
val session = sessions[key]!!
if (session.completionStatus == "in_progress") {
sessions[key] = session.copy(completionStatus = "interrupted")
}
}
}
}
// endregion

View File

@@ -234,6 +234,15 @@ private class MapTestDao : DiscoveryDao {
.maxOrNull()
override suspend fun getSessionWithResults(sessionId: Long) = sessions[sessionId]
override suspend fun markInterruptedSessions() {
sessions.keys.toList().forEach { key ->
val session = sessions[key]!!
if (session.completionStatus == "in_progress") {
sessions[key] = session.copy(completionStatus = "interrupted")
}
}
}
}
// endregion

View File

@@ -414,6 +414,13 @@ private class InMemoryDiscoveryDao : DiscoveryDao {
.maxOrNull()
override suspend fun getSessionWithResults(sessionId: Long) = sessions[sessionId]
}
// endregion
override suspend fun markInterruptedSessions() {
sessions.keys.toList().forEach { key ->
val session = sessions[key]!!
if (session.completionStatus == "in_progress") {
sessions[key] = session.copy(completionStatus = "interrupted")
}
}
}
}

View File

@@ -154,6 +154,15 @@ private class FakeDiscoveryDao : DiscoveryDao {
.maxOrNull()
override suspend fun getSessionWithResults(sessionId: Long): DiscoverySessionEntity? = sessions[sessionId]
override suspend fun markInterruptedSessions() {
sessions.keys.toList().forEach { key ->
val session = sessions[key]!!
if (session.completionStatus == "in_progress") {
sessions[key] = session.copy(completionStatus = "interrupted")
}
}
}
}
/** Simple fake collector registry that tracks registration. */