mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-24 23:01:22 -04:00
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:
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_default_key
|
||||
discovery_start_scan_reason_no_presets
|
||||
discovery_start_scan_reason_not_connected
|
||||
discovery_stat_analysis
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -> ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user