From 30bfa48a7f0e54e42d4de375805260b3d43dd1d0 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 30 Sep 2026 00:56:48 +0000
Subject: [PATCH] fix(ui): show byte sizes in decimal units, formatted for the
locale (#7469)
---
.skills/compose-ui/strings-index.txt | 2 -
.../util/ByteSizeFormattingAndroidTest.kt | 122 ++++++++++++++++++
.../common/util/ByteSizeFormatting.android.kt | 82 ++++++++++++
.../core/common/util/ByteSizeFormatting.kt | 73 +++++++++++
.../common/util/ByteSizeFormattingTest.kt | 77 +++++++++++
.../common/util/ByteSizeFormatting.ios.kt | 20 +++
.../common/util/ByteSizeFormatting.jvm.kt | 20 +++
.../common/util/ByteSizeFormattingJvmTest.kt | 47 +++++++
.../composeResources/values/strings.xml | 2 -
.../map/maplibre/component/CacheUsage.kt | 10 +-
.../maplibre/component/OfflineMapTarget.kt | 16 +--
.../component/OfflineTerrainSection.kt | 4 +-
.../feature/node/metrics/HostMetricsChart.kt | 11 +-
.../feature/node/metrics/HostMetricsLog.kt | 44 +------
.../feature/node/metrics/FormatBytesTest.kt | 104 ---------------
.../feature/node/metrics/HostMetricsTest.kt | 20 ++-
.../node/metrics/HostMetricsCardBytesTest.kt | 44 +++++++
17 files changed, 528 insertions(+), 170 deletions(-)
create mode 100644 core/common/src/androidHostTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingAndroidTest.kt
create mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.android.kt
create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.kt
create mode 100644 core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingTest.kt
create mode 100644 core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.ios.kt
create mode 100644 core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.jvm.kt
create mode 100644 core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingJvmTest.kt
delete mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt
create mode 100644 feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/metrics/HostMetricsCardBytesTest.kt
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index fc4841260e..d789ca1bf7 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -957,9 +957,7 @@ manage_map_layers
manual_position_request
### MAP ###
map
-map_cache_info
map_cache_manager
-map_cache_megabytes
map_cache_size
map_cache_tiles
map_clear_tiles
diff --git a/core/common/src/androidHostTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingAndroidTest.kt b/core/common/src/androidHostTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingAndroidTest.kt
new file mode 100644
index 0000000000..6b4b581a86
--- /dev/null
+++ b/core/common/src/androidHostTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingAndroidTest.kt
@@ -0,0 +1,122 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.common.util
+
+import android.content.res.Configuration
+import android.text.format.Formatter
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import java.util.Locale
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+
+/**
+ * The ICU engine renders what the platform's own `Formatter.formatFileSize` renders for the same locale, bidi wrap
+ * included. Below 901 bytes Formatter prints the framework's translated byte symbol, which an app cannot read, so
+ * non-English locales are compared from 901 up. Pinned strings are CLDR output from probe; a change on an ICU update is
+ * a rendering change to review, not automatically a bug.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class ByteSizeFormattingAndroidTest {
+
+ private val original: Locale = Locale.getDefault()
+
+ @After
+ fun tearDown() {
+ Locale.setDefault(original)
+ }
+
+ @Test
+ fun `English matches the platform formatter at every threshold`() {
+ assertMatchesPlatform(Locale.US, BYTE_SIZES + SIZES)
+ }
+
+ @Test
+ fun `French matches the platform formatter`() {
+ assertMatchesPlatform(Locale.FRANCE, SIZES)
+ }
+
+ @Test
+ fun `German matches the platform formatter`() {
+ assertMatchesPlatform(Locale.GERMANY, SIZES)
+ }
+
+ @Test
+ fun `Russian matches the platform formatter`() {
+ assertMatchesPlatform(Locale.forLanguageTag("ru-RU"), SIZES)
+ }
+
+ @Test
+ fun `Arabic matches the platform formatter`() {
+ assertMatchesPlatform(Locale.forLanguageTag("ar"), SIZES)
+ }
+
+ @Test
+ fun `Hebrew matches the platform formatter`() {
+ assertMatchesPlatform(Locale.forLanguageTag("he"), SIZES)
+ }
+
+ @Test
+ fun `French unit labels are CLDR's`() {
+ Locale.setDefault(Locale.FRANCE)
+ assertEquals("1,50 ko", formatByteSize(1_500L))
+ assertEquals("17 180 Mo", formatMegabytes(17_179.869184, 0))
+ }
+
+ @Test
+ fun `a right-to-left locale wraps the value in bidi controls`() {
+ Locale.setDefault(Locale.forLanguageTag("he"))
+ val formatted = formatByteSize(1_500L)
+
+ assertNotEquals("1.50 kB", formatted)
+ assertEquals("1.50 kB", formatted.filterNot { it in BIDI_CONTROLS })
+ }
+
+ private fun assertMatchesPlatform(locale: Locale, sizes: List) {
+ Locale.setDefault(locale)
+ val app = RuntimeEnvironment.getApplication()
+ val config = Configuration(app.resources.configuration).apply { setLocale(locale) }
+ val context = app.createConfigurationContext(config)
+ sizes.forEach { bytes ->
+ assertEquals(Formatter.formatFileSize(context, bytes), formatByteSize(bytes), "$bytes bytes in $locale")
+ }
+ }
+
+ private companion object {
+ val BYTE_SIZES = listOf(0L, 1L, 900L)
+ val SIZES =
+ listOf(
+ 901L,
+ 1_024L,
+ 1_500L,
+ 99_500L,
+ 100_000L,
+ 900_000L,
+ 900_001L,
+ 1_500_000L,
+ 17_179_869_184L,
+ 250_000_000_000L,
+ 1_000_000_000_000L,
+ )
+ val BIDI_CONTROLS = setOf('', '', '', '', '', '', '', '', '')
+ }
+}
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.android.kt
new file mode 100644
index 0000000000..eabf9bc9fd
--- /dev/null
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.android.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.common.util
+
+import android.icu.math.BigDecimal
+import android.icu.text.MeasureFormat
+import android.icu.text.NumberFormat
+import android.icu.util.Measure
+import android.icu.util.MeasureUnit
+import android.text.BidiFormatter
+import android.text.TextUtils
+import android.util.LayoutDirection
+import kotlinx.atomicfu.atomic
+import kotlinx.atomicfu.update
+import java.util.Locale
+
+/** MeasureFormat is immutable and thread-safe, so one instance per locale and digit count serves every row. */
+private val measureFormatCache = atomic(mapOf())
+
+/**
+ * The rendering `Formatter.formatFileSize` does, ICU's short measure format wrapped for bidi in right-to-left locales,
+ * but on [Locale.getDefault], the app locale. Formatter reads the Context's resources instead, which on API 26 to 32
+ * can still carry the system locale after an in-app language is chosen. Under a plain host test's stub `android.jar`
+ * ICU throws or returns null, and either lands the caller on its fallback.
+ */
+internal actual fun formatByteSizeLocalized(value: Double, unit: ByteUnit, fractionDigits: Int): String? = runCatching {
+ val locale = Locale.getDefault()
+ val formatted: String? =
+ when (unit) {
+ // CLDR spells this unit out ("0 byte"); Formatter prints the symbol, and so does this.
+ ByteUnit.BYTE -> "${numberFormat(locale, fractionDigits).format(value)} ${unit.symbol}"
+
+ else -> measureFormat(locale, fractionDigits).format(Measure(value, unit.toMeasureUnit()))
+ }
+ formatted?.takeIf { it.isNotEmpty() }?.let { bidiWrap(locale, it) }
+}
+ .getOrNull()
+
+private fun bidiWrap(locale: Locale, text: String): String? =
+ if (TextUtils.getLayoutDirectionFromLocale(locale) == LayoutDirection.RTL) {
+ BidiFormatter.getInstance(true).unicodeWrap(text)
+ } else {
+ text
+ }
+
+private fun measureFormat(locale: Locale, fractionDigits: Int): MeasureFormat {
+ val key = "${locale.toLanguageTag()}|$fractionDigits"
+ measureFormatCache.value[key]?.let {
+ return it
+ }
+ val built = MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.SHORT, numberFormat(locale, fractionDigits))
+ measureFormatCache.update { it + (key to built) }
+ return built
+}
+
+private fun numberFormat(locale: Locale, fractionDigits: Int): NumberFormat = NumberFormat.getInstance(locale).apply {
+ minimumFractionDigits = fractionDigits
+ maximumFractionDigits = fractionDigits
+ roundingMode = BigDecimal.ROUND_HALF_UP
+}
+
+private fun ByteUnit.toMeasureUnit(): MeasureUnit = when (this) {
+ ByteUnit.BYTE -> MeasureUnit.BYTE
+ ByteUnit.KILOBYTE -> MeasureUnit.KILOBYTE
+ ByteUnit.MEGABYTE -> MeasureUnit.MEGABYTE
+ ByteUnit.GIGABYTE -> MeasureUnit.GIGABYTE
+ ByteUnit.TERABYTE -> MeasureUnit.TERABYTE
+}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.kt
new file mode 100644
index 0000000000..f1f81e5747
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.common.util
+
+import kotlin.math.abs
+
+/** Decimal, as every byte figure in the app is: 1 MB is 1,000,000 bytes, never 1,048,576. */
+const val BYTES_PER_MEGABYTE = 1_000_000.0
+
+/** A decimal byte unit. [symbol] is the fallback label where the platform has no localized one. */
+internal enum class ByteUnit(val symbol: String) {
+ BYTE("B"),
+ KILOBYTE("kB"),
+ MEGABYTE("MB"),
+ GIGABYTE("GB"),
+ TERABYTE("TB"),
+}
+
+/**
+ * Formats a byte count for display with the thresholds and digits of Android's `Formatter.formatFileSize`: decimal
+ * units, the next unit once the value passes 900, two fraction digits below 100 and none from 100 up. So 1,500 bytes is
+ * "1.50 kB" and 16 GiB is "17.18 GB". The number follows the locale; so does the unit label where the platform can
+ * localize it.
+ */
+fun formatByteSize(bytes: Long): String {
+ var value = abs(bytes.toDouble())
+ var unit = ByteUnit.BYTE
+ // Stops at TB, where Formatter goes on to PB: ICU's PETABYTE needs API 30.
+ while (value > PROMOTE_ABOVE && unit != ByteUnit.TERABYTE) {
+ value /= BYTES_PER_KILOBYTE
+ unit = ByteUnit.entries[unit.ordinal + 1]
+ }
+ val fractionDigits = if (unit == ByteUnit.BYTE || value >= WHOLE_FROM) 0 else FRACTION_DIGITS
+ return formatInByteUnit(if (bytes < 0) -value else value, unit, fractionDigits)
+}
+
+/**
+ * Formats a value already in megabytes, in megabytes whatever its size. For places that must not change unit per value,
+ * such as the ticks of one chart axis.
+ */
+fun formatMegabytes(megabytes: Double, fractionDigits: Int): String =
+ formatInByteUnit(megabytes, ByteUnit.MEGABYTE, fractionDigits)
+
+private fun formatInByteUnit(value: Double, unit: ByteUnit, fractionDigits: Int): String {
+ if (value.isNaN() || value.isInfinite()) return NumberFormatter.format(value, fractionDigits)
+ return formatByteSizeLocalized(value, unit, fractionDigits)
+ ?: "${formatDecimalLocalized(value, fractionDigits)} ${unit.symbol}"
+}
+
+/**
+ * Renders [value] in [unit] with the platform's localized unit label, or null where the platform has none and the
+ * caller renders the fixed [ByteUnit.symbol] instead.
+ */
+internal expect fun formatByteSizeLocalized(value: Double, unit: ByteUnit, fractionDigits: Int): String?
+
+private const val BYTES_PER_KILOBYTE = 1000.0
+private const val PROMOTE_ABOVE = 900.0
+private const val WHOLE_FROM = 100.0
+private const val FRACTION_DIGITS = 2
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingTest.kt
new file mode 100644
index 0000000000..143eebd1f6
--- /dev/null
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingTest.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.common.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/** Thresholds and digits under the en-US locale the test JVMs are pinned to, on the fixed-symbol path. */
+class ByteSizeFormattingTest {
+
+ @Test
+ fun `up to 900 bytes stays in bytes with no fraction`() {
+ assertEquals("0 B", formatByteSize(0L))
+ assertEquals("1 B", formatByteSize(1L))
+ assertEquals("900 B", formatByteSize(900L))
+ }
+
+ @Test
+ fun `past 900 the next unit takes over with two fraction digits`() {
+ assertEquals("0.90 kB", formatByteSize(901L))
+ assertEquals("0.90 MB", formatByteSize(900_001L))
+ }
+
+ @Test
+ fun `a kilobyte is 1000 bytes and not 1024`() {
+ assertEquals("1.00 kB", formatByteSize(1_000L))
+ assertEquals("1.02 kB", formatByteSize(1_024L))
+ assertEquals("1.50 kB", formatByteSize(1_500L))
+ assertEquals("1.50 MB", formatByteSize(1_500_000L))
+ }
+
+ @Test
+ fun `a 16 GiB count reads in decimal gigabytes`() {
+ assertEquals("17.18 GB", formatByteSize(17_179_869_184L))
+ }
+
+ @Test
+ fun `from 100 the value has no fraction`() {
+ assertEquals("99.50 kB", formatByteSize(99_500L))
+ assertEquals("100 kB", formatByteSize(100_000L))
+ assertEquals("900 kB", formatByteSize(900_000L))
+ assertEquals("250 GB", formatByteSize(250_000_000_000L))
+ }
+
+ @Test
+ fun `terabytes are the largest unit`() {
+ assertEquals("1.00 TB", formatByteSize(1_000_000_000_000L))
+ assertEquals("2,000 TB", formatByteSize(2_000_000_000_000_000L))
+ }
+
+ @Test
+ fun `a negative count keeps its sign`() {
+ assertEquals("-1.50 kB", formatByteSize(-1_500L))
+ assertEquals("-9,223,372 TB", formatByteSize(Long.MIN_VALUE))
+ }
+
+ @Test
+ fun `megabytes stay in megabytes at any size`() {
+ assertEquals("0.5 MB", formatMegabytes(0.5, 1))
+ assertEquals("1.3 MB", formatMegabytes(1.25, 1))
+ assertEquals("17,180 MB", formatMegabytes(17_179.869184, 0))
+ }
+}
diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.ios.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.ios.kt
new file mode 100644
index 0000000000..816110b027
--- /dev/null
+++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.ios.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.common.util
+
+// No ICU units data on this platform, as in LocalizedUnitFormatting.ios.kt; callers print the fixed symbols.
+internal actual fun formatByteSizeLocalized(value: Double, unit: ByteUnit, fractionDigits: Int): String? = null
diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.jvm.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.jvm.kt
new file mode 100644
index 0000000000..e7bcf6df40
--- /dev/null
+++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/ByteSizeFormatting.jvm.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.common.util
+
+// No ICU units data on this platform, as in LocalizedUnitFormatting.jvm.kt; callers print the fixed symbols.
+internal actual fun formatByteSizeLocalized(value: Double, unit: ByteUnit, fractionDigits: Int): String? = null
diff --git a/core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingJvmTest.kt b/core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingJvmTest.kt
new file mode 100644
index 0000000000..e36fdc2f31
--- /dev/null
+++ b/core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/ByteSizeFormattingJvmTest.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.common.util
+
+import java.util.Locale
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/** The desktop has no localized unit labels, but its digits still follow the locale. */
+class ByteSizeFormattingJvmTest {
+
+ private lateinit var originalLocale: Locale
+
+ @BeforeTest
+ fun setUp() {
+ originalLocale = Locale.getDefault()
+ Locale.setDefault(Locale.GERMANY)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Locale.setDefault(originalLocale)
+ }
+
+ @Test
+ fun `a comma-decimal locale gets its own separators`() {
+ assertEquals("1,50 kB", formatByteSize(1_500L))
+ assertEquals("17,18 GB", formatByteSize(17_179_869_184L))
+ assertEquals("17.180 MB", formatMegabytes(17_179.869184, 0))
+ }
+}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index e5a4f55106..d7b7bc5565 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -999,9 +999,7 @@
Manual position request required
Mesh Map
- Cache Capacity: %1$d MB\nCache Usage: %2$d MB
Cache Manager
- %1$s MB
Current Cache size
%1$d tiles
Clear Downloaded Tiles
diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/CacheUsage.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/CacheUsage.kt
index c769f4c50a..019441f578 100644
--- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/CacheUsage.kt
+++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/CacheUsage.kt
@@ -27,8 +27,8 @@ import kotlinx.coroutines.flow.flowOf
import org.jetbrains.compose.resources.stringResource
import org.maplibre.compose.offline.DownloadProgress
import org.maplibre.compose.offline.OfflinePack
+import org.meshtastic.core.common.util.formatByteSize
import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.map_cache_megabytes
import org.meshtastic.core.resources.map_cache_size
import org.meshtastic.core.resources.map_cache_tiles
@@ -65,9 +65,9 @@ internal fun rememberCacheUsage(packs: Set): CacheUsage {
/**
* How much disk the downloaded packs occupy.
*
- * The OSMdroid map reported this in MB, which is the number that answers "is this filling my phone". No capacity beside
- * it: OSMdroid had one bounded SQLite cache, whereas MapLibre has explicitly downloaded packs the user deletes by hand
- * plus a separate ambient cache, and quoting a ceiling that governs neither would be a lie.
+ * In the phone's own decimal units, since this is the number that answers "is this filling my phone". No capacity
+ * beside it: OSMdroid had one bounded SQLite cache, whereas MapLibre has explicitly downloaded packs the user deletes
+ * by hand plus a separate ambient cache, and quoting a ceiling that governs neither would be a lie.
*/
@Composable
internal fun CacheUsageLine(storedBytes: Long, storedTiles: Long) {
@@ -75,7 +75,7 @@ internal fun CacheUsageLine(storedBytes: Long, storedTiles: Long) {
text =
stringResource(Res.string.map_cache_size) +
": " +
- stringResource(Res.string.map_cache_megabytes, storedBytes.megabytes()) +
+ formatByteSize(storedBytes) +
" · " +
stringResource(Res.string.map_cache_tiles, storedTiles.toInt()),
style = MaterialTheme.typography.bodySmall,
diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineMapTarget.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineMapTarget.kt
index 4f67df2eca..be3e16b768 100644
--- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineMapTarget.kt
+++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineMapTarget.kt
@@ -47,13 +47,12 @@ import org.maplibre.compose.offline.OfflineManager
import org.maplibre.compose.offline.OfflinePack
import org.maplibre.compose.offline.OfflinePackDefinition
import org.maplibre.spatialk.geojson.BoundingBox
-import org.meshtastic.core.common.util.NumberFormatter
+import org.meshtastic.core.common.util.formatByteSize
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.map_cache_manager
-import org.meshtastic.core.resources.map_cache_megabytes
import org.meshtastic.core.resources.map_cache_tiles
import org.meshtastic.core.resources.map_download_status_complete
import org.meshtastic.core.resources.map_download_status_downloading
@@ -267,7 +266,7 @@ private fun DownloadProgress.fraction(): Float = when (this) {
* One line describing a pack's state, assembled from resources rather than written in English.
*
* `status.name` went straight into the UI before, so every locale read the library's own enum constants. The tile count
- * and byte size reuse the strings the cache figures above already use, which keeps one set of units to translate.
+ * and byte size render as the cache figures above render them.
*/
@Composable
private fun DownloadProgress.summary(): String = when (this) {
@@ -281,7 +280,7 @@ private fun DownloadProgress.summary(): String = when (this) {
},
),
stringResource(Res.string.map_cache_tiles, completedTileCount.toInt()),
- stringResource(Res.string.map_cache_megabytes, completedResourceBytes.megabytes()),
+ formatByteSize(completedResourceBytes),
)
.joinToString(SUMMARY_SEPARATOR)
@@ -293,21 +292,12 @@ private fun DownloadProgress.summary(): String = when (this) {
DownloadProgress.Unknown -> EM_DASH
}
-/**
- * Bytes as megabytes, to one decimal place.
- *
- * Decimal megabytes rather than mebibytes: this number sits next to a phone's own storage figures, and those are
- * decimal.
- */
-internal fun Long.megabytes(): String = NumberFormatter.format(this.toDouble() / BYTES_PER_MEGABYTE, 1)
-
private fun Double.round(): String {
// Rounded, not truncated, so a negative coordinate labels the same way as its positive twin.
val scaled = (this * COORD_SCALE).roundToInt() / COORD_SCALE
return scaled.toString()
}
-private const val BYTES_PER_MEGABYTE = 1_000_000.0
private const val PACK_ROW_TEXT_FRACTION = 0.8f
private const val PACK_EXTRA_ZOOM_LEVELS = 2
diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineTerrainSection.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineTerrainSection.kt
index 374a544c0a..c00e1d2abe 100644
--- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineTerrainSection.kt
+++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/OfflineTerrainSection.kt
@@ -40,9 +40,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.maplibre.spatialk.geojson.BoundingBox
+import org.meshtastic.core.common.util.formatByteSize
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.delete
-import org.meshtastic.core.resources.map_cache_megabytes
import org.meshtastic.core.resources.map_cache_size
import org.meshtastic.core.resources.map_cache_tiles
import org.meshtastic.core.resources.map_select_download_region
@@ -201,7 +201,7 @@ private fun DownloadedTerrainRow(region: OfflineTerrainRegion, onShow: () -> Uni
stringResource(
Res.string.offline_terrain_cache_detail,
stringResource(Res.string.map_cache_size),
- stringResource(Res.string.map_cache_megabytes, region.byteSize.megabytes()),
+ formatByteSize(region.byteSize),
stringResource(Res.string.map_cache_tiles, region.tileCount.toInt()),
),
style = MaterialTheme.typography.bodyMedium,
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt
index f8cfc219ae..4feac4d34e 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsChart.kt
@@ -29,6 +29,8 @@ import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineModel
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
+import org.meshtastic.core.common.util.BYTES_PER_MEGABYTE
+import org.meshtastic.core.common.util.formatMegabytes
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.free_memory
@@ -123,10 +125,13 @@ internal fun buildHostMetricsChartData(data: List): HostMetricsChartD
telemetry.host_metrics
?.freemem_bytes
?.takeIf { it > 0 }
- ?.let { HostMetricsChartPoint(time = telemetry.time, value = it.toDouble() / BYTES_IN_MB) }
+ ?.let { HostMetricsChartPoint(time = telemetry.time, value = it.toDouble() / BYTES_PER_MEGABYTE) }
},
)
+/** Free memory is plotted in megabytes; one unit for every tick keeps the axis comparable. */
+internal fun freeMemoryChartLabel(megabytes: Double): String = formatMegabytes(megabytes, 0)
+
/**
* Vico chart composable that renders load averages (1m, 5m, 15m) and free memory as dual-axis line series: load on the
* start axis (fixed min 0), free memory in MB on the end axis.
@@ -186,7 +191,7 @@ internal fun HostMetricsChart(
load1Color -> formatString("L1: %.2f", value)
load5Color -> formatString("L5: %.2f", value)
load15Color -> formatString("L15: %.2f", value)
- else -> formatString("Mem: %.0f MB", value)
+ else -> formatString("Mem: %s", freeMemoryChartLabel(value))
}
},
)
@@ -233,7 +238,7 @@ internal fun HostMetricsChart(
if (memData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = memColor),
- valueFormatter = { _, value, _ -> formatString("%.0f MB", value) },
+ valueFormatter = { _, value, _ -> freeMemoryChartLabel(value) },
)
} else {
null
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt
index 6e3c10efe6..916b83757e 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.common.util.formatByteSize
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
@@ -155,7 +156,7 @@ private fun HostMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick:
/** Card body showing timestamp, load averages with progress bars, memory, disk, and uptime. */
@Composable
-private fun HostMetricsCardContent(time: String, hostMetrics: org.meshtastic.proto.HostMetrics?) {
+internal fun HostMetricsCardContent(time: String, hostMetrics: org.meshtastic.proto.HostMetrics?) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = time, style = MaterialTheme.typography.titleMediumEmphasized, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
@@ -164,18 +165,18 @@ private fun HostMetricsCardContent(time: String, hostMetrics: org.meshtastic.pro
LogLine(label = stringResource(Res.string.uptime), value = formatDuration(it.toLong()))
}
hostMetrics?.freemem_bytes?.let {
- LogLine(label = stringResource(Res.string.free_memory), value = formatBytes(it))
+ LogLine(label = stringResource(Res.string.free_memory), value = formatByteSize(it))
}
// Disk free rows
hostMetrics?.diskfree1_bytes?.let {
- LogLine(label = stringResource(Res.string.disk_free_indexed, 1), value = formatBytes(it))
+ LogLine(label = stringResource(Res.string.disk_free_indexed, 1), value = formatByteSize(it))
}
hostMetrics?.diskfree2_bytes?.let {
- LogLine(label = stringResource(Res.string.disk_free_indexed, 2), value = formatBytes(it))
+ LogLine(label = stringResource(Res.string.disk_free_indexed, 2), value = formatByteSize(it))
}
hostMetrics?.diskfree3_bytes?.let {
- LogLine(label = stringResource(Res.string.disk_free_indexed, 3), value = formatBytes(it))
+ LogLine(label = stringResource(Res.string.disk_free_indexed, 3), value = formatByteSize(it))
}
// Load averages with coloured indicators and progress bars
@@ -229,36 +230,3 @@ fun LogLine(modifier: Modifier = Modifier, label: String, value: String) {
Text(text = value)
}
}
-
-const val BYTES_IN_KB = 1024.0
-const val BYTES_IN_MB = BYTES_IN_KB * 1024.0
-const val BYTES_IN_GB = BYTES_IN_MB * 1024.0
-
-private const val DECIMAL_FACTOR_1 = 10.0
-private const val DECIMAL_FACTOR_2 = 100.0
-
-fun formatBytes(bytes: Long, decimalPlaces: Int = 2): String {
- fun formatValue(value: Double): String {
- // Simple decimal formatting without java.text.DecimalFormat
- val factor =
- when (decimalPlaces) {
- 0 -> 1.0
- 1 -> DECIMAL_FACTOR_1
- else -> DECIMAL_FACTOR_2
- }
- val rounded = kotlin.math.round(value * factor) / factor
- return if (rounded == rounded.toLong().toDouble()) {
- rounded.toLong().toString()
- } else {
- rounded.toString()
- }
- }
- return when {
- bytes < 0 -> "N/A"
- bytes == 0L -> "0 B"
- bytes >= BYTES_IN_GB -> "${formatValue(bytes / BYTES_IN_GB)} GB"
- bytes >= BYTES_IN_MB -> "${formatValue(bytes / BYTES_IN_MB)} MB"
- bytes >= BYTES_IN_KB -> "${formatValue(bytes / BYTES_IN_KB)} KB"
- else -> "$bytes B"
- }
-}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt
deleted file mode 100644
index 8d9a61c020..0000000000
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/FormatBytesTest.kt
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.feature.node.metrics
-
-import kotlin.test.Test
-import kotlin.test.assertEquals
-
-/** Tests for [formatBytes] — the pure function that formats byte counts into human-readable strings. */
-class FormatBytesTest {
-
- @Test
- fun zero_bytes() {
- assertEquals("0 B", formatBytes(0L))
- }
-
- @Test
- fun small_byte_values() {
- assertEquals("1 B", formatBytes(1L))
- assertEquals("512 B", formatBytes(512L))
- assertEquals("1023 B", formatBytes(1023L))
- }
-
- @Test
- fun kilobyte_boundary() {
- assertEquals("1 KB", formatBytes(1024L))
- }
-
- @Test
- fun kilobyte_with_decimals() {
- // 1536 bytes = 1.5 KB
- assertEquals("1.5 KB", formatBytes(1536L))
- }
-
- @Test
- fun kilobytes_just_below_megabyte_boundary_round_up_without_switching_units() {
- assertEquals("1024 KB", formatBytes(1_048_575L))
- }
-
- @Test
- fun megabyte_boundary() {
- assertEquals("1 MB", formatBytes(1024L * 1024))
- }
-
- @Test
- fun megabyte_with_decimals() {
- // 1.5 MB = 1572864 bytes
- assertEquals("1.5 MB", formatBytes(1_572_864L))
- }
-
- @Test
- fun gigabyte_boundary() {
- assertEquals("1 GB", formatBytes(1024L * 1024 * 1024))
- }
-
- @Test
- fun gigabyte_with_decimals() {
- // 2.5 GB
- assertEquals("2.5 GB", formatBytes((2.5 * 1024 * 1024 * 1024).toLong()))
- }
-
- @Test
- fun negative_bytes_returns_na() {
- assertEquals("N/A", formatBytes(-1L))
- assertEquals("N/A", formatBytes(-1024L))
- }
-
- @Test
- fun large_values() {
- // 100 GB
- assertEquals("100 GB", formatBytes(100L * 1024 * 1024 * 1024))
- }
-
- @Test
- fun custom_decimal_places_zero() {
- // 1536 bytes = 1.5 KB, with 0 decimal places → 2 KB (rounded)
- assertEquals("2 KB", formatBytes(1536L, decimalPlaces = 0))
- }
-
- @Test
- fun custom_decimal_places_one() {
- // 1536 bytes = 1.5 KB, with 1 decimal place → 1.5 KB
- assertEquals("1.5 KB", formatBytes(1536L, decimalPlaces = 1))
- }
-
- @Test
- fun default_rounding_keeps_two_decimal_places_without_trailing_zeroes() {
- assertEquals("1.46 KB", formatBytes(1500L))
- assertEquals("1.5 KB", formatBytes(1536L))
- }
-}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HostMetricsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HostMetricsTest.kt
index 3dd78655f9..be945087f4 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HostMetricsTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/HostMetricsTest.kt
@@ -45,7 +45,7 @@ class HostMetricsTest {
wb.load1 = 150
wb.load5 = 0
wb.load15 = 225
- wb.freemem_bytes = 2_097_152L
+ wb.freemem_bytes = 2_000_000L
}
.build(),
),
@@ -99,4 +99,22 @@ class HostMetricsTest {
assertTrue(chartData.load15.isEmpty())
assertTrue(chartData.freeMemoryMb.isEmpty())
}
+
+ @Test
+ fun free_memory_plots_and_labels_in_decimal_megabytes() {
+ val sixteenGib = 17_179_869_184L
+ val chartData =
+ buildHostMetricsChartData(
+ listOf(
+ telemetry(
+ time = 100,
+ hostMetrics = HostMetrics.Builder().also { wb -> wb.freemem_bytes = sixteenGib }.build(),
+ ),
+ ),
+ )
+
+ val point = chartData.freeMemoryMb.single()
+ assertEquals(17_179.869184, point.value)
+ assertEquals("17,180 MB", freeMemoryChartLabel(point.value))
+ }
}
diff --git a/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/metrics/HostMetricsCardBytesTest.kt b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/metrics/HostMetricsCardBytesTest.kt
new file mode 100644
index 0000000000..512ea28bb6
--- /dev/null
+++ b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/metrics/HostMetricsCardBytesTest.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.metrics
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.v2.runComposeUiTest
+import org.meshtastic.proto.HostMetrics
+import kotlin.test.Test
+
+@OptIn(ExperimentalTestApi::class)
+class HostMetricsCardBytesTest {
+
+ @Test
+ fun `free memory and disk rows read in decimal units`() = runComposeUiTest {
+ val hostMetrics =
+ HostMetrics.Builder()
+ .also { wb ->
+ wb.freemem_bytes = 17_179_869_184L
+ wb.diskfree1_bytes = 1_500_000_000L
+ }
+ .build()
+ setContent { MaterialTheme { HostMetricsCardContent(time = "", hostMetrics = hostMetrics) } }
+
+ onNodeWithText("17.18 GB").assertExists()
+ onNodeWithText("1.50 GB").assertExists()
+ onNodeWithText("16 GB").assertDoesNotExist()
+ }
+}