From 4d07bc6641335cbeaa1d279b755b9e3cd11fccaf Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:36:55 -0500 Subject: [PATCH] fix: derive phone-UI units from OS locale, not radio display config (#6101) Co-authored-by: Claude Sonnet 5 --- .../kotlin/org/meshtastic/app/map/MapView.kt | 2 - .../org/meshtastic/app/map/MapViewModel.kt | 7 --- .../core/common/util/LocaleUtils.android.kt | 19 -------- .../core/common/util/MetricFormatter.kt | 16 +++++-- .../core/common/util/MetricFormatterTest.kt | 18 +++++-- .../feature/map/BaseMapViewModel.kt | 12 +++-- .../usecase/CommonGetNodeDetailsUseCase.kt | 10 ++-- .../feature/node/list/NodeListViewModel.kt | 13 +++-- .../node/metrics/EnvironmentMetrics.kt | 48 ++++++++++++------- 9 files changed, 79 insertions(+), 66 deletions(-) diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 9ba0182f3..1fb0e79b2 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -143,7 +143,6 @@ import org.meshtastic.feature.map.LastHeardFilter import org.meshtastic.feature.map.component.EditWaypointDialog import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.component.MapControlsOverlay -import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.Waypoint import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable import org.osmdroid.config.Configuration @@ -372,7 +371,6 @@ fun MapView( fun MapView.onNodesChanged(nodes: Collection): List { val nodesWithPosition = nodes.filter { it.validPosition != null } val ourNode = mapViewModel.ourNodeInfo.value - val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly return nodesWithPosition.mapNotNull { node -> if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) { diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt index 549e13f91..c48b0d412 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -27,9 +27,7 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController -import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.proto.LocalConfig @Suppress("LongParameterList") @KoinViewModel @@ -58,10 +56,5 @@ class MapViewModel( mapPrefs.setMapStyle(value) } - val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) - - val config - get() = localConfig.value - val applicationId = buildConfigProvider.applicationId } diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt index 88f471373..8dbf358b5 100644 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt +++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt @@ -35,25 +35,6 @@ actual fun currentLocaleQualifier(): String { actual fun getSystemMeasurementSystem(): MeasurementSystem { val locale = Locale.getDefault() - // Android 14+ (API 34) introduced user-settable locale preferences. - if (Build.VERSION.SDK_INT >= 34) { - try { - val localePrefsClass = Class.forName("androidx.core.text.util.LocalePreferences") - val getMeasurementSystemMethod = - localePrefsClass.getMethod("getMeasurementSystem", Locale::class.java, Boolean::class.javaPrimitiveType) - val result = getMeasurementSystemMethod.invoke(null, locale, true) as String - return when (result) { - "us", - "uk", - -> MeasurementSystem.IMPERIAL - - else -> MeasurementSystem.METRIC - } - } catch (@Suppress("TooGenericExceptionCaught") ignored: Exception) { - // Fallback - } - } - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { when (LocaleData.getMeasurementSystem(ULocale.forLocale(locale))) { LocaleData.MeasurementSystem.SI -> MeasurementSystem.METRIC diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt index 51905ff41..d2944acfe 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt @@ -49,12 +49,20 @@ object MetricFormatter { fun rssi(value: Int): String = "$value dBm" - fun windSpeed(metersPerSecond: Float, decimalPlaces: Int = 1): String = - "${NumberFormatter.format(metersPerSecond, decimalPlaces)} m/s" + fun windSpeed(metersPerSecond: Float, isImperial: Boolean, decimalPlaces: Int = 1): String { + val value = if (isImperial) metersPerSecond * MPH_PER_MPS else metersPerSecond + val unit = if (isImperial) "mph" else "m/s" + return "${NumberFormatter.format(value, decimalPlaces)} $unit" + } - fun rainfall(millimeters: Float, decimalPlaces: Int = 1): String = - "${NumberFormatter.format(millimeters, decimalPlaces)} mm" + fun rainfall(millimeters: Float, isImperial: Boolean, decimalPlaces: Int = 1): String { + val value = if (isImperial) millimeters / MM_PER_INCH else millimeters + val unit = if (isImperial) "in" else "mm" + return "${NumberFormatter.format(value, decimalPlaces)} $unit" + } } private const val FAHRENHEIT_SCALE = 1.8f private const val FAHRENHEIT_OFFSET = 32 +private const val MPH_PER_MPS = 2.23694f +private const val MM_PER_INCH = 25.4f diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt index 94781fca3..fe23d914b 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt @@ -123,21 +123,31 @@ class MetricFormatterTest { @Test fun windSpeed() { - assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f)) + assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f, isImperial = false)) } @Test fun windSpeedZero() { - assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f)) + assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f, isImperial = false)) + } + + @Test + fun windSpeedImperial() { + assertEquals("27.6 mph", MetricFormatter.windSpeed(12.34f, isImperial = true)) } @Test fun rainfall() { - assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f)) + assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f, isImperial = false)) } @Test fun rainfallZero() { - assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f)) + assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f, isImperial = false)) + } + + @Test + fun rainfallImperial() { + assertEquals("0.10 in", MetricFormatter.rainfall(2.54f, isImperial = true, decimalPlaces = 2)) } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 58e1d44f6..b98333eb1 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -32,6 +32,7 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.geofence.activeWaypointPackets +import org.meshtastic.core.model.util.DistanceUnit import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository @@ -66,11 +67,12 @@ open class BaseMapViewModel( val myNodeInfo = nodeRepository.myNodeInfo - /** Device display units (metric/imperial) for distance/altitude/speed formatting across map surfaces. */ - val displayUnits: StateFlow = - radioConfigRepository.localConfigFlow - .map { it.display?.units ?: DisplayUnits.METRIC } - .stateInWhileSubscribed(initialValue = DisplayUnits.METRIC) + /** + * OS locale display units (metric/imperial) for distance/altitude/speed formatting across map surfaces. StateFlow + * kept for the existing collectAsState call sites; value is a one-time snapshot at construction and does not react + * to a mid-session locale change (ViewModel survives config changes). + */ + val displayUnits: StateFlow = MutableStateFlow(DistanceUnit.getFromLocale()).asStateFlow() val ourNodeInfo = nodeRepository.ourNodeInfo diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt index cd27746b7..c58825930 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt @@ -31,6 +31,7 @@ import org.meshtastic.core.model.DeviceLink import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node +import org.meshtastic.core.model.util.DistanceUnit import org.meshtastic.core.model.util.hasValidEnvironmentMetrics import org.meshtastic.core.model.util.isDirectSignal import org.meshtastic.core.repository.DeviceHardwareRepository @@ -48,7 +49,7 @@ import org.meshtastic.feature.node.detail.NodeRequestActions import org.meshtastic.feature.node.metrics.EnvironmentMetricsState import org.meshtastic.feature.node.model.LogsType import org.meshtastic.feature.node.model.MetricsState -import org.meshtastic.proto.Config +import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.DeviceProfile import org.meshtastic.proto.FirmwareEdition import org.meshtastic.proto.MeshPacket @@ -182,8 +183,7 @@ constructor( val isLocal = node.num == identity.ourNode?.num val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null - val moduleConfig = identity.profile.module_config - val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC + val displayUnits = DistanceUnit.getFromLocale() val metricsState = MetricsState( @@ -193,9 +193,7 @@ constructor( deviceLinks = deviceLinks, reportedTarget = pioEnv, isManaged = identity.profile.config?.security?.is_managed ?: false, - isFahrenheit = - moduleConfig?.telemetry?.environment_display_fahrenheit == true || - (displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL), + isFahrenheit = displayUnits == DisplayUnits.IMPERIAL, displayUnits = displayUnits, deviceMetrics = logs.telemetry.filter { it.device_metrics != null }, localStats = logs.telemetry.filter { it.local_stats != null }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index 6d028991e..df3745893 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -28,11 +28,14 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.common.util.MeasurementSystem +import org.meshtastic.core.common.util.getSystemMeasurementSystem import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.NodeListDensity import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.core.model.util.DistanceUnit import org.meshtastic.core.repository.AdminController import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.DeviceHardwareRepository @@ -127,13 +130,17 @@ class NodeListViewModel( excludeMqtt = excludeMqtt, ) } + + // OS locale rarely changes mid-session; snapshot once instead of per filter/sort emission. + private val distanceUnits = DistanceUnit.getFromLocale().value + private val tempInFahrenheit = getSystemMeasurementSystem() == MeasurementSystem.IMPERIAL val nodesUiState: StateFlow = - combine(nodeSortOption, nodeFilter, radioConfigRepository.deviceProfileFlow) { sort, nodeFilter, profile -> + combine(nodeSortOption, nodeFilter) { sort, nodeFilter -> NodesUiState( sort = sort, filter = nodeFilter, - distanceUnits = profile.config?.display?.units?.value ?: 0, - tempInFahrenheit = profile.module_config?.telemetry?.environment_display_fahrenheit ?: false, + distanceUnits = distanceUnits, + tempInFahrenheit = tempInFahrenheit, ) } .stateInWhileSubscribed(initialValue = NodesUiState()) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 3a73c0cb0..6d989cc54 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -123,6 +123,8 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un EnvironmentMetricsCard( telemetry = telemetry, environmentDisplayFahrenheit = state.isFahrenheit, + isImperial = + state.displayUnits == org.meshtastic.proto.Config.DisplayConfig.DisplayUnits.IMPERIAL, isSelected = telemetry.time.toDouble() == selectedX, onClick = { onCardClick(telemetry.time.toDouble()) }, ) @@ -368,21 +370,21 @@ private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics } @Composable -private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { +private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics, isImperial: Boolean) { val hasSpeed = envMetrics.wind_speed != null && !envMetrics.wind_speed!!.isNaN() val hasGust = envMetrics.wind_gust != null && !envMetrics.wind_gust!!.isNaN() val hasLull = envMetrics.wind_lull != null && !envMetrics.wind_lull!!.isNaN() if (hasSpeed || hasGust || hasLull) { Column(modifier = Modifier.fillMaxWidth()) { - if (hasSpeed) WindSpeedRow(envMetrics) - if (hasGust || hasLull) WindGustLullRow(envMetrics, hasGust, hasLull) + if (hasSpeed) WindSpeedRow(envMetrics, isImperial) + if (hasGust || hasLull) WindGustLullRow(envMetrics, isImperial, hasGust, hasLull) } } } @Composable -private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { +private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, isImperial: Boolean) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(verticalAlignment = Alignment.CenterVertically) { MetricIndicator(Environment.WIND_SPEED.color) @@ -390,9 +392,9 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { val dirText = if (envMetrics.wind_direction != null) { formatString( - "%s %.1f m/s (%s %d°)", + "%s %s (%s %d°)", stringResource(Res.string.wind_speed), - envMetrics.wind_speed!!, + MetricFormatter.windSpeed(envMetrics.wind_speed!!, isImperial), stringResource(Res.string.wind_direction), envMetrics.wind_direction!!, ) @@ -400,7 +402,7 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { formatString( "%s %s", stringResource(Res.string.wind_speed), - MetricFormatter.windSpeed(envMetrics.wind_speed!!), + MetricFormatter.windSpeed(envMetrics.wind_speed!!, isImperial), ) } Text( @@ -413,18 +415,29 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { } @Composable -private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, hasGust: Boolean, hasLull: Boolean) { +private fun WindGustLullRow( + envMetrics: org.meshtastic.proto.EnvironmentMetrics, + isImperial: Boolean, + hasGust: Boolean, + hasLull: Boolean, +) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { if (hasGust) { Text( - text = "${stringResource(Res.string.wind_gust)} ${MetricFormatter.windSpeed(envMetrics.wind_gust!!)}", + text = + "${stringResource(Res.string.wind_gust)} ${ + MetricFormatter.windSpeed(envMetrics.wind_gust!!, isImperial) + }", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) } if (hasLull) { Text( - text = "${stringResource(Res.string.wind_lull)} ${MetricFormatter.windSpeed(envMetrics.wind_lull!!)}", + text = + "${stringResource(Res.string.wind_lull)} ${ + MetricFormatter.windSpeed(envMetrics.wind_lull!!, isImperial) + }", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -433,7 +446,7 @@ private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, } @Composable -private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { +private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics, isImperial: Boolean) { val has1h = envMetrics.rainfall_1h != null && !envMetrics.rainfall_1h!!.isNaN() val has24h = envMetrics.rainfall_24h != null && !envMetrics.rainfall_24h!!.isNaN() @@ -444,7 +457,7 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) text = "${stringResource( Res.string.rainfall_1h, - )} ${MetricFormatter.rainfall(envMetrics.rainfall_1h!!)}", + )} ${MetricFormatter.rainfall(envMetrics.rainfall_1h!!, isImperial)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -454,7 +467,7 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) text = "${stringResource( Res.string.rainfall_24h, - )} ${MetricFormatter.rainfall(envMetrics.rainfall_24h!!)}", + )} ${MetricFormatter.rainfall(envMetrics.rainfall_24h!!, isImperial)}", color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -500,11 +513,12 @@ private fun OneWireTemperatureDisplay( private fun EnvironmentMetricsCard( telemetry: Telemetry, environmentDisplayFahrenheit: Boolean, + isImperial: Boolean, isSelected: Boolean, onClick: () -> Unit, ) { SelectableMetricCard(isSelected = isSelected, onClick = onClick) { - EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) + EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit, isImperial) } } @@ -512,6 +526,7 @@ private fun EnvironmentMetricsCard( private fun EnvironmentMetricsContent( telemetry: Telemetry, environmentDisplayFahrenheit: Boolean, + isImperial: Boolean, timeTextOverride: String? = null, ) { val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics() @@ -539,8 +554,8 @@ private fun EnvironmentMetricsContent( VoltageCurrentDisplay(envMetrics) RadiationDisplay(envMetrics) - WindDisplay(envMetrics) - RainfallDisplay(envMetrics) + WindDisplay(envMetrics, isImperial) + RainfallDisplay(envMetrics, isImperial) OneWireTemperatureDisplay(envMetrics, environmentDisplayFahrenheit) } } @@ -576,6 +591,7 @@ fun PreviewEnvironmentMetricsContent() { EnvironmentMetricsContent( telemetry = fakeTelemetry, environmentDisplayFahrenheit = false, + isImperial = false, timeTextOverride = "2023-11-14 22:13", ) }