git.iko.soy/parseable-android
on master help sign in
Format ingestion and storage sizes as human-readable units (#13)
c9b724fiko
app/src/main/java/com/parseable/android/data/FormatUtils.ktnew file+30
@@ -0,0 +1,30 @@
1+package com.parseable.android.data
2+
3+import java.util.Locale
4+
5+private val BYTE_UNITS = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
6+
7+/**
8+ * Converts a raw byte-count string (e.g. "123456789") to a human-readable
9+ * size (e.g. "117.7 MB"). If the string is not a valid number, it is
10+ * returned unchanged so pre-formatted values from the API pass through.
11+ */
12+fun formatBytes(raw: String?): String? {
13+ if (raw == null) return null
14+ val bytes = raw.trim().toDoubleOrNull() ?: return raw
15+ if (bytes < 0) return raw
16+ if (bytes < 1024) return String.format(Locale.US, "%.0f B", bytes)
17+ var value = bytes
18+ var unitIndex = 0
19+ while (value >= 1024 && unitIndex < BYTE_UNITS.size - 1) {
20+ value /= 1024
21+ unitIndex++
22+ }
23+ return if (value >= 100) {
24+ String.format(Locale.US, "%.0f %s", value, BYTE_UNITS[unitIndex])
25+ } else if (value >= 10) {
26+ String.format(Locale.US, "%.1f %s", value, BYTE_UNITS[unitIndex])
27+ } else {
28+ String.format(Locale.US, "%.2f %s", value, BYTE_UNITS[unitIndex])
29+ }
30+}
app/src/main/java/com/parseable/android/ui/screens/logviewer/StreamInfoScreen.kt+3−2
@@ -18,6 +18,7 @@ import androidx.lifecycle.Lifecycle
18import androidx.lifecycle.compose.LocalLifecycleOwner
18import androidx.lifecycle.compose.LocalLifecycleOwner
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
20import androidx.lifecycle.repeatOnLifecycle
20import androidx.lifecycle.repeatOnLifecycle
21+import com.parseable.android.data.formatBytes
21import kotlinx.serialization.json.Json
22import kotlinx.serialization.json.Json
22import kotlinx.serialization.json.JsonObject
23import kotlinx.serialization.json.JsonObject
23
24
@@ -94,8 +95,8 @@ fun StreamInfoScreen(
94 state.stats?.let { stats ->
95 state.stats?.let { stats ->
95 InfoSection(title = "Statistics") {
96 InfoSection(title = "Statistics") {
96 InfoRow("Event Count", stats.ingestion?.count?.toString() ?: stats.ingestion?.lifetimeCount?.toString() ?: "N/A")
97 InfoRow("Event Count", stats.ingestion?.count?.toString() ?: stats.ingestion?.lifetimeCount?.toString() ?: "N/A")
97− InfoRow("Ingestion Size", stats.ingestion?.size ?: stats.ingestion?.lifetimeSize ?: "N/A")
98+ InfoRow("Ingestion Size", formatBytes(stats.ingestion?.size) ?: formatBytes(stats.ingestion?.lifetimeSize) ?: "N/A")
98− InfoRow("Storage Size", stats.storage?.size ?: stats.storage?.lifetimeSize ?: "N/A")
99+ InfoRow("Storage Size", formatBytes(stats.storage?.size) ?: formatBytes(stats.storage?.lifetimeSize) ?: "N/A")
99 if (stats.ingestion?.lifetimeCount != null) {
100 if (stats.ingestion?.lifetimeCount != null) {
100 InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
101 InfoRow("Lifetime Events", stats.ingestion.lifetimeCount.toString())
101 }
102 }
app/src/main/java/com/parseable/android/ui/screens/streams/StreamsViewModel.kt+5−4
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import com.parseable.android.data.local.FavoriteStream
5import com.parseable.android.data.local.FavoriteStream
6import com.parseable.android.data.local.FavoriteStreamDao
6import com.parseable.android.data.local.FavoriteStreamDao
7+import com.parseable.android.data.formatBytes
7import com.parseable.android.data.model.AboutInfo
8import com.parseable.android.data.model.AboutInfo
8import com.parseable.android.data.model.ApiResult
9import com.parseable.android.data.model.ApiResult
9import com.parseable.android.data.model.LogStream
10import com.parseable.android.data.model.LogStream
@@ -153,10 +154,10 @@ class StreamsViewModel @Inject constructor(
153 val ui = StreamStatsUi(
154 val ui = StreamStatsUi(
154 eventCount = stats.ingestion?.count
155 eventCount = stats.ingestion?.count
155 ?: stats.ingestion?.lifetimeCount,
156 ?: stats.ingestion?.lifetimeCount,
156− ingestionSize = stats.ingestion?.size
157+ ingestionSize = formatBytes(stats.ingestion?.size)
157− ?: stats.ingestion?.lifetimeSize,
158+ ?: formatBytes(stats.ingestion?.lifetimeSize),
158− storageSize = stats.storage?.size
159+ storageSize = formatBytes(stats.storage?.size)
159− ?: stats.storage?.lifetimeSize,
160+ ?: formatBytes(stats.storage?.lifetimeSize),
160 )
161 )
161 _state.update {
162 _state.update {
162 it.copy(
163 it.copy(
app/src/test/java/com/parseable/android/FormatBytesTest.ktnew file+80
@@ -0,0 +1,80 @@
1+package com.parseable.android
2+
3+import com.parseable.android.data.formatBytes
4+import org.junit.Assert.*
5+import org.junit.Test
6+
7+class FormatBytesTest {
8+
9+ @Test
10+ fun `null input returns null`() {
11+ assertNull(formatBytes(null))
12+ }
13+
14+ @Test
15+ fun `zero bytes`() {
16+ assertEquals("0 B", formatBytes("0"))
17+ }
18+
19+ @Test
20+ fun `small byte values`() {
21+ assertEquals("512 B", formatBytes("512"))
22+ assertEquals("1 B", formatBytes("1"))
23+ }
24+
25+ @Test
26+ fun `kilobytes`() {
27+ assertEquals("1.00 KB", formatBytes("1024"))
28+ assertEquals("1.50 KB", formatBytes("1536"))
29+ }
30+
31+ @Test
32+ fun `megabytes`() {
33+ assertEquals("1.00 MB", formatBytes("1048576"))
34+ assertEquals("5.25 MB", formatBytes("5505024"))
35+ }
36+
37+ @Test
38+ fun `gigabytes`() {
39+ assertEquals("1.00 GB", formatBytes("1073741824"))
40+ assertEquals("2.50 GB", formatBytes("2684354560"))
41+ }
42+
43+ @Test
44+ fun `terabytes`() {
45+ assertEquals("1.00 TB", formatBytes("1099511627776"))
46+ }
47+
48+ @Test
49+ fun `large values use no decimals`() {
50+ // 150 GB = 161061273600
51+ assertEquals("150 GB", formatBytes("161061273600"))
52+ }
53+
54+ @Test
55+ fun `mid-range values use one decimal`() {
56+ // 15.5 MB = 16252928
57+ assertEquals("15.5 MB", formatBytes("16252928"))
58+ }
59+
60+ @Test
61+ fun `non-numeric string passes through unchanged`() {
62+ assertEquals("already formatted", formatBytes("already formatted"))
63+ assertEquals("1.5 GiB", formatBytes("1.5 GiB"))
64+ }
65+
66+ @Test
67+ fun `negative value passes through unchanged`() {
68+ assertEquals("-1024", formatBytes("-1024"))
69+ }
70+
71+ @Test
72+ fun `whitespace is trimmed before parsing`() {
73+ assertEquals("1.00 KB", formatBytes(" 1024 "))
74+ }
75+
76+ @Test
77+ fun `fractional bytes from API`() {
78+ assertEquals("1.50 KB", formatBytes("1536.0"))
79+ }
80+}