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() + } +}