mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-02 16:44:33 -04:00
fix(ui): show byte sizes in decimal units, formatted for the locale (#7469)
This commit is contained in:
1 parent
9a34f78a8f
commit
30bfa48a7f
17 files changed
+528
-170
No files matched your search
Generated
-2
@@ -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
|
||||
|
||||
+122
@@ -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('', '', '', '', '', '', '', '', '')
|
||||
}
|
||||
}
|
||||
+82
@@ -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
|
||||
}
|
||||
+73
@@ -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
|
||||
+77
@@ -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))
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
+20
@@ -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
|
||||
+47
@@ -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>
|
||||
|
||||
+5
-5
@@ -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,
|
||||
|
||||
+3
-13
@@ -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
|
||||
|
||||
|
||||
+2
-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,
|
||||
|
||||
+8
-3
@@ -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
|
||||
|
||||
+6
-38
@@ -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"
|
||||
}
|
||||
}
|
||||
-104
@@ -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))
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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))
|
||||
}
|
||||
}
|
||||
+44
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user