fix: address 2.8.0 release-audit findings (#6189)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-10 07:41:54 -05:00
committed by GitHub
parent de37a8cc28
commit 0a3e000252
17 changed files with 165 additions and 36 deletions

View File

@@ -312,6 +312,10 @@ debug_store_logs_summary
debug_store_logs_title
debug_tab_app_logs
debug_tab_packets
deep_link_connect_message
deep_link_connect_title
deep_link_disconnect_message
deep_link_disconnect_title
default_
default_mqtt_address
### DELETE ###

View File

@@ -266,7 +266,7 @@ dependencies {
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
implementation(libs.jetbrains.lifecycle.runtime.compose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)

View File

@@ -32,7 +32,7 @@ import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import coil3.util.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
@@ -95,9 +95,15 @@ class NetworkModule {
.crossfade(enable = true)
.build()
/**
* Uses the OkHttp engine, not `Android`. The `Android` engine is backed by [java.net.HttpURLConnection], which on
* device routes through the platform's bundled OkHttp 2.x fork (`com.android.okhttp`). Cancelling a request while
* its gzip/chunked response body is still being read trips a re-entrant `AsyncTimeout.enter()` in that fork and
* crashes with "Unbalanced enter/exit" (Crashlytics 97ae5ea1, 2.8.0 only). Square's OkHttp has no such bug.
*/
@Single
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
HttpClient(engineFactory = Android) {
HttpClient(engineFactory = OkHttp) {
install(plugin = ContentNegotiation) { json(json) }
install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
install(plugin = HttpTimeout) {

View File

@@ -26,12 +26,13 @@ import co.touchlab.kermit.Logger
* the first time a secondary transport learns a `myNodeNum` that another transport already claimed.
*
* Every per-device table is unified so switching transport is seamless: messages (+FTS), reactions, contact mute/read
* settings, nodes and their notes, per-node metadata, the audit log (each session's received-packet history — the
* source of the telemetry timelines, position history, and traceroute results the UI reconstructs), traceroute
* positions, and discovery sessions. Where the same row exists on both sides the destination is preferred (it will be
* refreshed by the same radio's re-dump on connect); source-only rows and strictly newer history are brought over.
* Packets/reactions in [source] and [dest] already share the same `myNodeNum` (same node), so no key remapping is
* needed there; autoincrement-keyed rows (packets, discovery) are re-inserted with fresh ids to avoid collisions.
* settings, nodes and their notes, per-node metadata, quick-chat actions, the audit log (each session's received-packet
* history — the source of the telemetry timelines, position history, and traceroute results the UI reconstructs),
* traceroute positions, and discovery sessions. Where the same row exists on both sides the destination is preferred
* (it will be refreshed by the same radio's re-dump on connect); source-only rows and strictly newer history are
* brought over. Packets/reactions in [source] and [dest] already share the same `myNodeNum` (same node), so no key
* remapping is needed there; autoincrement-keyed rows (packets, discovery) are re-inserted with fresh ids to avoid
* collisions.
*/
object DatabaseMerger {
@@ -49,6 +50,7 @@ object DatabaseMerger {
mergeContactSettings(source, dest)
mergeNodes(source, dest)
mergeMetadata(source, dest)
mergeQuickChat(source, dest)
// Logs must precede traceroute positions: the latter has a CASCADE foreign key onto log.uuid.
mergeLogs(source, dest)
mergeTraceroutePositions(source, dest)
@@ -110,6 +112,23 @@ object DatabaseMerger {
}
}
/**
* User-authored quick-chat buttons. `uuid`/`position` are per-DB, so append the source's actions after the
* destination's last one with fresh ids, skipping any the destination already has (same name + message + mode).
* Without this the source's buttons are lost outright when [DatabaseManager] retires it.
*/
private suspend fun mergeQuickChat(source: MeshtasticDatabase, dest: MeshtasticDatabase) {
val destActions = dest.quickChatActionDao().getAllSnapshot()
val existing = destActions.mapTo(mutableSetOf()) { Triple(it.name, it.message, it.mode) }
var nextPosition = (destActions.maxOfOrNull { it.position } ?: -1) + 1
source.quickChatActionDao().getAllSnapshot().forEach { action ->
if (existing.add(Triple(action.name, action.message, action.mode))) {
dest.quickChatActionDao().upsert(action.copy(uuid = 0L, position = nextPosition))
nextPosition++
}
}
}
/**
* The audit log holds each transport session's received-packet history — the source of the telemetry timelines,
* position history, and node-info history the UI reconstructs per node/port. This is the only place that time

View File

@@ -29,6 +29,10 @@ interface QuickChatActionDao {
@Query("Select * from quick_chat order by position asc")
fun getAll(): Flow<List<QuickChatAction>>
/** Snapshot used by DatabaseMerger to fold one transport's DB into another for the same node. */
@Query("Select * from quick_chat order by position asc")
suspend fun getAllSnapshot(): List<QuickChatAction>
@Upsert suspend fun upsert(action: QuickChatAction)
@Query("Delete from quick_chat")

View File

@@ -42,11 +42,15 @@ abstract class BaseRadioTransportFactory(
InterfaceId.TCP.id,
InterfaceId.SERIAL.id,
InterfaceId.BLUETOOTH.id,
InterfaceId.MOCK.id,
InterfaceId.REPLAY.id,
'!',
-> true
// Virtual transports are development aids. A release build must never bind one: `connections?address=m` is
// reachable from any web page through the verified meshtastic.org app link.
InterfaceId.MOCK.id,
InterfaceId.REPLAY.id,
-> isMockTransport()
else -> isPlatformAddressValid(address)
}
}

View File

@@ -333,6 +333,10 @@
<string name="debug_store_logs_title">Store mesh logs</string>
<string name="debug_tab_app_logs">App logs</string>
<string name="debug_tab_packets">Packets</string>
<string name="deep_link_connect_message">A link is asking this app to connect to the device at %1$s. Only continue if you trust where the link came from.</string>
<string name="deep_link_connect_title">Connect to this device?</string>
<string name="deep_link_disconnect_message">A link is asking this app to disconnect from your device. Only continue if you trust where the link came from.</string>
<string name="deep_link_disconnect_title">Disconnect from your device?</string>
<string name="default_">Default</string>
<string name="default_mqtt_address" translatable="false">mqtt.meshtastic.org</string>
<!-- DELETE -->

View File

@@ -395,10 +395,14 @@ actual fun rememberLocationPermissionState(): PermissionUiState = rememberRuntim
@Composable
actual fun rememberBluetoothPermissionState(): PermissionUiState {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
// Pre-Android 12 has no runtime Bluetooth permission — BLE scanning is gated by the location permission, which
// callers request separately (the intro Location screen, the map/Privacy location flows). Report granted here
// so the Bluetooth surface itself is a no-op rather than masquerading as a location request.
return rememberGrantedPermissionState()
// Pre-Android 12 has no runtime Bluetooth permission — the platform gates BLE scanning on fine location
// instead. Reporting "granted" here left a user who declined location with an empty device list and no way to
// re-prompt, so surface the permission that actually blocks the scan. Fine specifically: API 29/30 return zero
// scan results on a coarse-only grant.
return rememberRuntimePermissionState(
permissions = arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION),
requireAll = true,
)
}
return rememberRuntimePermissionState(
permissions =

View File

@@ -361,14 +361,14 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
items.add { PaxcountInfo(pax = "B:${pax.ble} W:${pax.wifi}", contentColor = contentColor) }
}
if ((env.temperature ?: 0f) != 0f) {
val temp = MetricFormatter.temperature(env.temperature ?: 0f, tempInFahrenheit)
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
} else if ((aq.co2_temperature ?: 0f) != 0f) {
val temp = MetricFormatter.temperature(aq.co2_temperature ?: 0f, tempInFahrenheit)
// Temperature carries presence, so `null` already means "no sensor" — testing against 0 hid an ordinary 0 °C
// reading, which the temperature chart plots. Prefer the environment sensor, then the SCD4x CO₂ sensor's own.
(env.temperature ?: aq.co2_temperature)?.let { temperature ->
val temp = MetricFormatter.temperature(temperature, tempInFahrenheit)
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
}
// Humidity keeps its zero-guard: 0% RH is not physically reachable, and the humidity chart filters it too.
if ((env.relative_humidity ?: 0f) != 0f) {
items.add {
HumidityInfo(humidity = MetricFormatter.humidity(env.relative_humidity ?: 0f), contentColor = contentColor)

View File

@@ -52,6 +52,9 @@ internal fun processTcpServices(
}
DeviceListEntry.Tcp(displayName, address)
}
// mDNS can resolve two services to one host:port (e.g. re-announce before the old record expires). The device
// list keys on fullAddress, and duplicate LazyColumn keys are a hard crash.
.distinctBy { it.fullAddress }
.sortedBy { it.name }
}

View File

@@ -16,13 +16,26 @@
*/
package org.meshtastic.feature.connections.navigation
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ConnectionsRoute
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.connect
import org.meshtastic.core.resources.deep_link_connect_message
import org.meshtastic.core.resources.deep_link_connect_title
import org.meshtastic.core.resources.deep_link_disconnect_message
import org.meshtastic.core.resources.deep_link_disconnect_title
import org.meshtastic.core.resources.disconnect
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.feature.connections.NO_DEVICE_SELECTED
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.ui.ConnectionsScreen
@@ -32,13 +45,33 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>) {
entry<ConnectionsRoute.Connections> { key ->
val scanModel = koinViewModel<ScannerViewModel>()
// Lets a deep link (e.g. from AI/automation tooling) trigger a connection, or a disconnect via `n`, without
// manual device selection.
LaunchedEffect(key.address) {
key.address?.takeIf(String::isNotBlank)?.let { address ->
if (address == NO_DEVICE_SELECTED) scanModel.disconnect() else scanModel.changeDeviceAddress(address)
}
// A deep link (e.g. from AI/automation tooling) may name a device address, or `n` to disconnect. The
// `connections` path is a verified https://meshtastic.org app link, so any web page can fire one at us —
// always confirm before re-pointing or dropping the radio connection.
var pendingAddress by rememberSaveable(key.address) { mutableStateOf(key.address?.takeIf(String::isNotBlank)) }
pendingAddress?.let { address ->
val isDisconnect = address == NO_DEVICE_SELECTED
MeshtasticDialog(
titleRes =
if (isDisconnect) Res.string.deep_link_disconnect_title else Res.string.deep_link_connect_title,
message =
if (isDisconnect) {
stringResource(Res.string.deep_link_disconnect_message)
} else {
stringResource(Res.string.deep_link_connect_message, address)
},
confirmTextRes = if (isDisconnect) Res.string.disconnect else Res.string.connect,
onConfirm = {
if (isDisconnect) scanModel.disconnect() else scanModel.changeDeviceAddress(address)
pendingAddress = null
},
dismissTextRes = Res.string.cancel,
onDismiss = { pendingAddress = null },
)
}
ConnectionsScreen(
scanModel = scanModel,
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),

View File

@@ -116,6 +116,23 @@ class TcpDiscoveryHelpersTest {
result[1].name shouldBe "Zulu"
}
@Test
fun `processTcpServices dedupes services resolving to the same address`() {
// Duplicate mDNS announcements (multi-interface, re-resolution) yield the same host/port twice
val service =
DiscoveredService(
name = "Meshtastic_abcd",
hostAddress = "192.168.1.10",
port = 4403,
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!abcd".encodeToByteArray()),
)
val result = processTcpServices(listOf(service, service.copy(name = "Meshtastic_abcd (2)")), emptyList())
result.size shouldBe 1
result[0].fullAddress shouldBe "t192.168.1.10"
}
@Test
fun `matchDiscoveredTcpNodes matches node by device id`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")

View File

@@ -129,7 +129,11 @@ internal fun isValidFirmwareFile(filename: String, target: String, fileExtension
val targetToken = Regex.escape(target)
val extensionToken = Regex.escape(fileExtension)
val targetPattern = Regex("(^|.*[\\-_])$targetToken(([\\-_.](v?\\d|firmware)).*$extensionToken$|$extensionToken$)")
// What follows the target must be a dotted version (`-2.7.17`, `-v2.7.17`), the literal `firmware`, or the
// extension itself. Accepting a bare digit here let digit-led sibling variants through — `tlora-v2` matched
// `firmware-tlora-v2-1-1_6-2.7.14.bin`, which belongs to `tlora-v2-1-1_6` — and flashed the wrong variant.
val versionOrFirmware = "([\\-_]v?\\d+\\.\\d+|[\\-_.]firmware)"
val targetPattern = Regex("(^|.*[\\-_])$targetToken($versionOrFirmware.*$extensionToken$|$extensionToken$)")
return targetPattern.matches(filename)
}

View File

@@ -123,6 +123,21 @@ class IsValidFirmwareFileTest {
assertTrue(isValidFirmwareFile("firmware-nano-g1-explorer-2.8.0.bin", "nano-g1-explorer", ".bin"))
}
@Test
fun `rejects digit-led sibling variants`() {
// The variant suffix starts with a digit, so it used to be mistaken for the version and flash the wrong board.
assertFalse(isValidFirmwareFile("firmware-tlora-v2-1-1_6-2.7.14.bin", "tlora-v2", ".bin"))
assertFalse(isValidFirmwareFile("firmware-tlora-v1-3-2.7.17.bin", "tlora-v1", ".bin"))
// The variants' own targets still match.
assertTrue(isValidFirmwareFile("firmware-tlora-v2-1-1_6-2.7.14.bin", "tlora-v2-1-1_6", ".bin"))
assertTrue(isValidFirmwareFile("firmware-tlora-v1-3-2.7.17.bin", "tlora-v1-3", ".bin"))
}
@Test
fun `accepts a v-prefixed version after the target`() {
assertTrue(isValidFirmwareFile("firmware-heltec-v3-v2.7.17.bin", "heltec-v3", ".bin"))
}
@Test
fun `accepts older local firmware filename shapes`() {
// Older naming where "firmware" appears as a segment after the target.

View File

@@ -63,9 +63,11 @@ import org.meshtastic.core.resources.air_quality_metrics_log
import org.meshtastic.core.resources.co2
import org.meshtastic.core.resources.co2_humidity
import org.meshtastic.core.resources.co2_temperature
import org.meshtastic.core.resources.micrograms_per_cubic_meter
import org.meshtastic.core.resources.pm10
import org.meshtastic.core.resources.pm1_0
import org.meshtastic.core.resources.pm2_5
import org.meshtastic.core.resources.ppm
import org.meshtastic.core.ui.component.Co2Severity
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.GraphColors.Blue
@@ -304,18 +306,25 @@ private fun AirQualityMetricsCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
val ugm3 = stringResource(Res.string.micrograms_per_cubic_meter)
val ppmUnit = stringResource(Res.string.ppm)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
// Present-and-zero is a valid clean-air reading; only `?.` (absent field) hides a row.
aq.pm10_standard?.let { Text("PM1.0: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
aq.pm25_standard?.let { Text("PM2.5: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
aq.pm100_standard?.let { Text("PM10: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
listOfNotNull(
aq.pm10_standard?.let { Res.string.pm1_0 to it },
aq.pm25_standard?.let { Res.string.pm2_5 to it },
aq.pm100_standard?.let { Res.string.pm10 to it },
)
.forEach { (label, value) ->
Text("${stringResource(label)}: $value $ugm3", style = MaterialTheme.typography.bodySmall)
}
}
Column {
aq.co2?.let { co2 ->
val severity = Co2Severity.fromPpm(co2)
Text(
text = "CO₂: $co2 ppm",
text = "${stringResource(Res.string.co2)}: $co2 $ppmUnit",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = severity?.color ?: MaterialTheme.colorScheme.onSurface,

View File

@@ -246,13 +246,16 @@ private fun PowerMetricsChart(
->
val currentColor = PowerMetric.CURRENT.color
val voltageColor = PowerMetric.VOLTAGE.color
// The formatter runs outside composition, so resolve the labels here.
val currentLabel = stringResource(Res.string.current)
val voltageLabel = stringResource(Res.string.voltage)
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color) {
currentColor -> "Current: ${MetricFormatter.current(value.toFloat(), 0)}"
voltageColor -> "Voltage: ${NumberFormatter.format(value.toFloat(), 1)} V"
currentColor -> "$currentLabel: ${MetricFormatter.current(value.toFloat(), 0)}"
voltageColor -> "$voltageLabel: ${MetricFormatter.voltage(value.toFloat(), 1)}"
else -> NumberFormatter.format(value.toFloat(), 1)
}
},
@@ -322,7 +325,7 @@ private fun PowerMetricsChart(
if (voltageData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> "${NumberFormatter.format(value.toFloat(), 1)} V" },
valueFormatter = { _, value, _ -> MetricFormatter.voltage(value.toFloat(), 1) },
)
} else {
null

View File

@@ -224,11 +224,11 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
kotlinx-serialization-json-okio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "kotlinx-serialization" }
# Networking
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }