fix(ui): show byte sizes in decimal units, formatted for the locale (#7469)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-30 00:56:48 +00:00
1 parent 9a34f78a8f
commit 30bfa48a7f
17 files changed
+528 -170

No files matched your search

-2
View File
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Long>) {
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('‎', '‏', '‪', '‫', '‬', '⁦', '⁧', '⁨', '⁩')
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, MeasureFormat>())
/**
* 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
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}
@@ -999,9 +999,7 @@
<string name="manual_position_request">Manual position request required</string>
<!-- MAP -->
<string name="map">Mesh Map</string>
<string name="map_cache_info">Cache Capacity: %1$d MB\nCache Usage: %2$d MB</string>
<string name="map_cache_manager">Cache Manager</string>
<string name="map_cache_megabytes">%1$s MB</string>
<string name="map_cache_size">Current Cache size</string>
<string name="map_cache_tiles">%1$d tiles</string>
<string name="map_clear_tiles">Clear Downloaded Tiles</string>
@@ -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<OfflinePack>): 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,
@@ -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
@@ -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,
@@ -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<Telemetry>): 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
@@ -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"
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}
@@ -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))
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}