Skip to content

Commit 726bf80

Browse files
jamesarichCopilot
andcommitted
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>
1 parent 593c130 commit 726bf80

13 files changed

Lines changed: 94 additions & 8 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/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"formatVersion": 1,
33
"database": {
44
"version": 39,
5-
"identityHash": "e39ee4f34ed8da08f3cb21bfd4a5165c",
5+
"identityHash": "90335dadf5ace3b9f23b3818bd257f35",
66
"entities": [
77
{
88
"tableName": "my_node",
@@ -1149,7 +1149,7 @@
11491149
},
11501150
{
11511151
"tableName": "discovery_preset_result",
1152-
"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 )",
1152+
"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 )",
11531153
"fields": [
11541154
{
11551155
"fieldPath": "id",
@@ -1197,6 +1197,13 @@
11971197
"notNull": true,
11981198
"defaultValue": "0"
11991199
},
1200+
{
1201+
"fieldPath": "infrastructureNodeCount",
1202+
"columnName": "infrastructure_node_count",
1203+
"affinity": "INTEGER",
1204+
"notNull": true,
1205+
"defaultValue": "0"
1206+
},
12001207
{
12011208
"fieldPath": "messageCount",
12021209
"columnName": "message_count",
@@ -1341,7 +1348,7 @@
13411348
},
13421349
{
13431350
"tableName": "discovered_node",
1344-
"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 )",
1351+
"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 )",
13451352
"fields": [
13461353
{
13471354
"fieldPath": "id",
@@ -1427,6 +1434,13 @@
14271434
"affinity": "INTEGER",
14281435
"notNull": true,
14291436
"defaultValue": "0"
1437+
},
1438+
{
1439+
"fieldPath": "isInfrastructure",
1440+
"columnName": "is_infrastructure",
1441+
"affinity": "INTEGER",
1442+
"notNull": true,
1443+
"defaultValue": "0"
14301444
}
14311445
],
14321446
"primaryKey": {
@@ -1472,7 +1486,7 @@
14721486
],
14731487
"setupQueries": [
14741488
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
1475-
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e39ee4f34ed8da08f3cb21bfd4a5165c')"
1489+
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '90335dadf5ace3b9f23b3818bd257f35')"
14761490
]
14771491
}
14781492
}

core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ interface DiscoveryDao {
4848
@Query("DELETE FROM discovery_session WHERE id = :sessionId")
4949
suspend fun deleteSession(sessionId: Long)
5050

51+
@Query("UPDATE discovery_session SET completion_status = 'interrupted' WHERE completion_status = 'in_progress'")
52+
suspend fun markInterruptedSessions()
53+
5154
// endregion
5255

5356
// region Preset result operations

core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,5 @@ data class DiscoveredNodeEntity(
5050
@ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0,
5151
@ColumnInfo(name = "message_count", defaultValue = "0") val messageCount: Int = 0,
5252
@ColumnInfo(name = "sensor_packet_count", defaultValue = "0") val sensorPacketCount: Int = 0,
53+
@ColumnInfo(name = "is_infrastructure", defaultValue = "0") val isInfrastructure: Boolean = false,
5354
)

core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveryPresetResultEntity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ data class DiscoveryPresetResultEntity(
4343
@ColumnInfo(name = "unique_nodes", defaultValue = "0") val uniqueNodes: Int = 0,
4444
@ColumnInfo(name = "direct_neighbor_count", defaultValue = "0") val directNeighborCount: Int = 0,
4545
@ColumnInfo(name = "mesh_neighbor_count", defaultValue = "0") val meshNeighborCount: Int = 0,
46+
@ColumnInfo(name = "infrastructure_node_count", defaultValue = "0") val infrastructureNodeCount: Int = 0,
4647
@ColumnInfo(name = "message_count", defaultValue = "0") val messageCount: Int = 0,
4748
@ColumnInfo(name = "sensor_packet_count", defaultValue = "0") val sensorPacketCount: Int = 0,
4849
@ColumnInfo(name = "avg_channel_utilization", defaultValue = "0.0") val avgChannelUtilization: Double = 0.0,

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_default_key">channel uses default encryption key</string>
352353
<string name="discovery_start_scan_reason_no_presets">no presets selected</string>
353354
<string name="discovery_start_scan_reason_not_connected">device not connected</string>
354355
<string name="discovery_stat_analysis">Analysis</string>

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ class DiscoveryScanEngine(
129129
var hopCount: Int = 0,
130130
var messageCount: Int = 0,
131131
var sensorPacketCount: Int = 0,
132+
var isInfrastructure: Boolean = false,
132133
)
133134

134135
private data class DeviceMetricsEntry(val timestamp: Long, val channelUtil: Double, val airUtilTx: Double)
@@ -250,7 +251,7 @@ class DiscoveryScanEngine(
250251
}
251252
}
252253

253-
/** Backfills name and position from the local NodeDB when not yet received over-the-air. */
254+
/** Backfills name, position, and infrastructure role from the local NodeDB when not yet received over-the-air. */
254255
private fun enrichNodeFromDb(node: CollectedNodeData) {
255256
val dbNode = nodeRepository.nodeDBbyNum.value[node.nodeNum.toInt()] ?: return
256257
if (node.shortName == null || node.longName == null) {
@@ -263,6 +264,7 @@ class DiscoveryScanEngine(
263264
if (dbLat != null && dbLat != 0) node.latitude = dbLat.toDouble() / POSITION_DIVISOR
264265
if (dbLon != null && dbLon != 0) node.longitude = dbLon.toDouble() / POSITION_DIVISOR
265266
}
267+
node.isInfrastructure = dbNode.user.role in INFRASTRUCTURE_ROLES
266268
}
267269

268270
// endregion
@@ -482,6 +484,7 @@ class DiscoveryScanEngine(
482484
val (avgChannelUtil, avgAirUtil) = computeAverageMetrics()
483485
val directCount = collectedNodes.values.count { it.neighborType == "direct" }
484486
val meshCount = collectedNodes.values.count { it.neighborType == "mesh" }
487+
val infraCount = collectedNodes.values.count { it.isInfrastructure }
485488

486489
val packetsRx = lastLocalStats?.num_packets_rx ?: 0
487490
val packetsRxBad = lastLocalStats?.num_packets_rx_bad ?: 0
@@ -495,6 +498,7 @@ class DiscoveryScanEngine(
495498
uniqueNodes = collectedNodes.size,
496499
directNeighborCount = directCount,
497500
meshNeighborCount = meshCount,
501+
infrastructureNodeCount = infraCount,
498502
messageCount = collectedNodes.values.sumOf { it.messageCount },
499503
sensorPacketCount = collectedNodes.values.sumOf { it.sensorPacketCount },
500504
avgChannelUtilization = avgChannelUtil,
@@ -559,6 +563,7 @@ class DiscoveryScanEngine(
559563
rssi = rssi,
560564
messageCount = messageCount,
561565
sensorPacketCount = sensorPacketCount,
566+
isInfrastructure = isInfrastructure,
562567
)
563568
}
564569

@@ -660,5 +665,13 @@ class DiscoveryScanEngine(
660665
private const val POSITION_DIVISOR = 1e7
661666
private const val MIN_DEVICE_METRICS_PACKETS = 2
662667
private const val PERCENT_MULTIPLIER = 100.0
668+
669+
/** Node roles that indicate infrastructure (Router, RouterLate, ClientBase). */
670+
private val INFRASTRUCTURE_ROLES =
671+
setOf(
672+
Config.DeviceConfig.Role.ROUTER,
673+
Config.DeviceConfig.Role.ROUTER_LATE,
674+
Config.DeviceConfig.Role.CLIENT_BASE,
675+
)
663676
}
664677
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,22 @@ class DiscoveryViewModel(
6565
.map { it is ConnectionState.Connected }
6666
.stateInWhileSubscribed(initialValue = false)
6767

68+
/** True when the primary channel uses the default (well-known) PSK — scanning is unsafe. */
69+
val usesDefaultKey: StateFlow<Boolean> =
70+
radioConfigRepository.channelSetFlow
71+
.map { channelSet ->
72+
val primaryPsk = channelSet.settings.firstOrNull()?.psk
73+
primaryPsk == null || primaryPsk.size == 0 || (primaryPsk.size == 1 && primaryPsk[0].toInt() <= 1)
74+
}
75+
.stateInWhileSubscribed(initialValue = true)
76+
6877
val sessions: StateFlow<List<DiscoverySessionEntity>> =
6978
discoveryDao.getAllSessions().stateInWhileSubscribed(initialValue = emptyList())
7079

80+
init {
81+
safeLaunch(tag = "markInterruptedSessions") { discoveryDao.markInterruptedSessions() }
82+
}
83+
7184
fun togglePreset(preset: ChannelOption) {
7285
_selectedPresets.update { current ->
7386
val updated = if (preset in current) current - preset else current + preset

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

Lines changed: 6 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_default_key
8586
import org.meshtastic.core.resources.discovery_start_scan_reason_no_presets
8687
import org.meshtastic.core.resources.discovery_start_scan_reason_not_connected
8788
import org.meshtastic.core.resources.discovery_stop_scan
@@ -118,6 +119,7 @@ fun DiscoveryScanScreen(
118119
val selectedPresets by viewModel.selectedPresets.collectAsStateWithLifecycle()
119120
val dwellMinutes by viewModel.dwellDurationMinutes.collectAsStateWithLifecycle()
120121
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
122+
val usesDefaultKey by viewModel.usesDefaultKey.collectAsStateWithLifecycle()
121123
val currentSession by viewModel.currentSession.collectAsStateWithLifecycle()
122124
val homePreset by viewModel.homePreset.collectAsStateWithLifecycle()
123125

@@ -174,6 +176,7 @@ fun DiscoveryScanScreen(
174176
scanState = scanState,
175177
isConnected = isConnected,
176178
hasPresetsSelected = selectedPresets.isNotEmpty(),
179+
usesDefaultKey = usesDefaultKey,
177180
onStart = viewModel::startScan,
178181
onStop = viewModel::stopScan,
179182
)
@@ -329,6 +332,7 @@ private fun ScanButton(
329332
scanState: DiscoveryScanState,
330333
isConnected: Boolean,
331334
hasPresetsSelected: Boolean,
335+
usesDefaultKey: Boolean,
332336
onStart: () -> Unit,
333337
onStop: () -> Unit,
334338
modifier: Modifier = Modifier,
@@ -344,10 +348,11 @@ private fun ScanButton(
344348
Text(stringResource(Res.string.discovery_stop_scan), modifier = Modifier.padding(start = 8.dp))
345349
}
346350
} else {
347-
val isEnabled = isConnected && hasPresetsSelected
351+
val isEnabled = isConnected && hasPresetsSelected && !usesDefaultKey
348352
val disabledReason =
349353
when {
350354
!isConnected -> stringResource(Res.string.discovery_start_scan_reason_not_connected)
355+
usesDefaultKey -> stringResource(Res.string.discovery_start_scan_reason_default_key)
351356
!hasPresetsSelected -> stringResource(Res.string.discovery_start_scan_reason_no_presets)
352357
else -> ""
353358
}

feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryBehaviorTest.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,15 @@ private class HistoryTestDao : DiscoveryDao {
249249
.maxOrNull()
250250

251251
override suspend fun getSessionWithResults(sessionId: Long) = sessions[sessionId]
252+
253+
override suspend fun markInterruptedSessions() {
254+
sessions.keys.toList().forEach { key ->
255+
val session = sessions[key]!!
256+
if (session.completionStatus == "in_progress") {
257+
sessions[key] = session.copy(completionStatus = "interrupted")
258+
}
259+
}
260+
}
252261
}
253262

254263
// endregion

0 commit comments

Comments
 (0)