From 0a3e000252133ae877d4dd1afcff6bb037e8d89e Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:41:54 -0500 Subject: [PATCH] fix: address 2.8.0 release-audit findings (#6189) Co-authored-by: Claude Fable 5 --- .skills/compose-ui/strings-index.txt | 4 ++ androidApp/build.gradle.kts | 2 +- .../org/meshtastic/app/di/NetworkModule.kt | 10 +++- .../core/database/DatabaseMerger.kt | 31 +++++++++--- .../core/database/dao/QuickChatActionDao.kt | 4 ++ .../radio/BaseRadioTransportFactory.kt | 8 +++- .../composeResources/values/strings.xml | 4 ++ .../meshtastic/core/ui/util/PlatformUtils.kt | 12 +++-- .../meshtastic/core/ui/component/NodeItem.kt | 10 ++-- .../domain/usecase/TcpDiscoveryHelpers.kt | 3 ++ .../navigation/ConnectionsNavigation.kt | 47 ++++++++++++++++--- .../domain/usecase/TcpDiscoveryHelpersTest.kt | 17 +++++++ .../feature/firmware/FirmwareFileHandler.kt | 6 ++- .../firmware/IsValidFirmwareFileTest.kt | 15 ++++++ .../feature/node/metrics/AirQualityMetrics.kt | 17 +++++-- .../feature/node/metrics/PowerMetrics.kt | 9 ++-- gradle/libs.versions.toml | 2 +- 17 files changed, 165 insertions(+), 36 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 1aed471c5..166083b16 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 ### diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index efd73d9cd..86261a4a8 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -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) diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/androidApp/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt index 81db475a0..5f8725cab 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt @@ -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) { diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt index 3dfda6384..630b7c142 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt @@ -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 diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/QuickChatActionDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/QuickChatActionDao.kt index 40b333134..1834a016f 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/QuickChatActionDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/QuickChatActionDao.kt @@ -29,6 +29,10 @@ interface QuickChatActionDao { @Query("Select * from quick_chat order by position asc") fun getAll(): Flow> + /** 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 + @Upsert suspend fun upsert(action: QuickChatAction) @Query("Delete from quick_chat") diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt index 50848da13..4e681345f 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt @@ -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) } } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 60e5985cd..727db4166 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -333,6 +333,10 @@ Store mesh logs App logs Packets + A link is asking this app to connect to the device at %1$s. Only continue if you trust where the link came from. + Connect to this device? + A link is asking this app to disconnect from your device. Only continue if you trust where the link came from. + Disconnect from your device? Default mqtt.meshtastic.org diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt index d081cf802..062b54f0a 100644 --- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -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 = diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt index a23dc0e13..3968a579e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt @@ -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) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpers.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpers.kt index 93f13c619..0380898d9 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpers.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpers.kt @@ -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 } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt index 59f81e1fd..02b1f014c 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt @@ -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.connectionsGraph(backStack: NavBackStack) { entry { key -> val scanModel = koinViewModel() - // 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(), diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpersTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpersTest.kt index 3191ac0a1..a221e55e9 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpersTest.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpersTest.kt @@ -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") diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt index 9e3dde71e..fafa48104 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt @@ -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) } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt index 3b76a7872..4dec220ce 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt @@ -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. diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt index 140728d70..1e46e7e10 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt @@ -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, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index d47a0d184..b9d3465a6 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 670253dbc..f37453303 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }