From e92c52caa39f32206015a28df7cb8a37f08793d6 Mon Sep 17 00:00:00 2001 From: NickLD Date: Sat, 8 Aug 2026 20:05:17 -0500 Subject: [PATCH 1/6] Add RateUnit preference enum --- .../main/java/com/dataproxy/util/RateUnit.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 app/src/main/java/com/dataproxy/util/RateUnit.kt diff --git a/app/src/main/java/com/dataproxy/util/RateUnit.kt b/app/src/main/java/com/dataproxy/util/RateUnit.kt new file mode 100644 index 0000000..9993689 --- /dev/null +++ b/app/src/main/java/com/dataproxy/util/RateUnit.kt @@ -0,0 +1,19 @@ +package com.dataproxy.util + +/** + * User preference for how live transfer rates are displayed. + * + * [BytesPerSecond] is the historical binary (÷1024) B/s ladder. + * [Mbps] is the decimal (÷1000) bits-per-second ladder networking tools + * conventionally use for speed. The two are not simple scalar multiples of + * each other. Cycled by tapping the unit label on the Speed card. + */ +enum class RateUnit(val key: String) { + BytesPerSecond("bytes"), + Mbps("mbps"); + + companion object { + fun fromKey(key: String?): RateUnit = + entries.firstOrNull { it.key == key } ?: BytesPerSecond + } +} From b1f766559ce7336f42333a84cd8ba38b10c37c72 Mon Sep 17 00:00:00 2001 From: NickLD Date: Sat, 8 Aug 2026 20:13:04 -0500 Subject: [PATCH 2/6] Add unit-aware rate formatting to ByteFormatter Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01S55LqTjgjjwnYv9YVcYL4h --- .../java/com/dataproxy/util/ByteFormatter.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/dataproxy/util/ByteFormatter.kt b/app/src/main/java/com/dataproxy/util/ByteFormatter.kt index 9d8512a..e17ef7a 100644 --- a/app/src/main/java/com/dataproxy/util/ByteFormatter.kt +++ b/app/src/main/java/com/dataproxy/util/ByteFormatter.kt @@ -3,10 +3,15 @@ package com.dataproxy.util object ByteFormatter { private val sizeUnits = arrayOf("B", "KB", "MB", "GB", "TB") - private val rateUnits = arrayOf("B/s", "KB/s", "MB/s", "GB/s") + private val bytesPerSecUnits = arrayOf("B/s", "KB/s", "MB/s", "GB/s") + private val mbpsUnits = arrayOf("bps", "Kbps", "Mbps", "Gbps") fun bytes(n: Long): String = humanise(n, sizeUnits) - fun rate(bps: Long): String = humanise(bps, rateUnits) + + fun rate(bps: Long, unit: RateUnit): Pair = when (unit) { + RateUnit.BytesPerSecond -> humanisePair(bps.toDouble(), 1024.0, bytesPerSecUnits) + RateUnit.Mbps -> humanisePair(bps.toDouble() * 8.0, 1000.0, mbpsUnits) + } private fun humanise(n: Long, units: Array): String { if (n < 0) return "—" @@ -21,6 +26,20 @@ object ByteFormatter { } } + private fun humanisePair(value: Double, base: Double, units: Array): Pair { + if (value <= 0) return "0" to units[0] + var v = value + var i = 0 + while (v >= base && i < units.lastIndex) { v /= base; i++ } + val number = when { + i == 0 -> v.toLong().toString() + v >= 100 -> "%.0f".format(v) + v >= 10 -> "%.1f".format(v) + else -> "%.2f".format(v) + } + return number to units[i] + } + fun elapsed(sinceMs: Long, nowMs: Long = System.currentTimeMillis()): String { val secs = ((nowMs - sinceMs) / 1000L).coerceAtLeast(0L) return when { From 90576ce61f9cc4422511783d7446ff8187a9084f Mon Sep 17 00:00:00 2001 From: NickLD Date: Sat, 8 Aug 2026 20:20:33 -0500 Subject: [PATCH 3/6] Add rate unit preference to MainViewModel and ProxyService Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session --- .../main/java/com/dataproxy/service/ProxyService.kt | 4 ++++ .../java/com/dataproxy/ui/viewmodel/MainViewModel.kt | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/app/src/main/java/com/dataproxy/service/ProxyService.kt b/app/src/main/java/com/dataproxy/service/ProxyService.kt index 05731cd..a3d7ec2 100644 --- a/app/src/main/java/com/dataproxy/service/ProxyService.kt +++ b/app/src/main/java/com/dataproxy/service/ProxyService.kt @@ -369,6 +369,10 @@ class ProxyService : Service() { // BootReceiver so an auto-start uses the same endpoint as the UI. const val PREF_BIND_ADDRESS = "bind_address" const val PREF_PORT = "port" + // Written by MainViewModel; read here fresh on every notification + // rebuild so a toggle made while the proxy is running takes effect + // on the next update without a restart. + const val PREF_RATE_UNIT = "rate_unit" fun startIntent(ctx: Context, addr: String, port: Int) = Intent(ctx, ProxyService::class.java) diff --git a/app/src/main/java/com/dataproxy/ui/viewmodel/MainViewModel.kt b/app/src/main/java/com/dataproxy/ui/viewmodel/MainViewModel.kt index c863496..3cd1867 100644 --- a/app/src/main/java/com/dataproxy/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/dataproxy/ui/viewmodel/MainViewModel.kt @@ -18,6 +18,7 @@ import com.dataproxy.proxy.SpeedSampler import com.dataproxy.service.ProxyService import com.dataproxy.ui.theme.ThemeMode import com.dataproxy.util.AntiKillPreferences +import com.dataproxy.util.RateUnit import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -80,6 +81,11 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { ) val themeMode: StateFlow = _themeMode.asStateFlow() + private val _rateUnit = MutableStateFlow( + RateUnit.fromKey(prefs.getString(ProxyService.PREF_RATE_UNIT, null)) + ) + val rateUnit: StateFlow = _rateUnit.asStateFlow() + private val _autoStartOnBoot = MutableStateFlow(AntiKillPreferences.autoStartOnBoot(app)) val autoStartOnBoot: StateFlow = _autoStartOnBoot.asStateFlow() @@ -170,6 +176,12 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { prefs.edit().putString(KEY_THEME_MODE, next.key).apply() } + fun cycleRateUnit() { + val next = if (_rateUnit.value == RateUnit.BytesPerSecond) RateUnit.Mbps else RateUnit.BytesPerSecond + _rateUnit.value = next + prefs.edit().putString(ProxyService.PREF_RATE_UNIT, next.key).apply() + } + fun start() { runCatching { val ctx = getApplication() From 165ad5eb4f8cba26c49cde4a91731cfc951f1de4 Mon Sep 17 00:00:00 2001 From: NickLD Date: Sat, 8 Aug 2026 20:27:38 -0500 Subject: [PATCH 4/6] Make Speed card unit label tappable to cycle rate display unit --- .../ui/components/SpeedometerCard.kt | 32 +++++++++---------- .../com/dataproxy/ui/screens/HomeScreen.kt | 8 ++++- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/dataproxy/ui/components/SpeedometerCard.kt b/app/src/main/java/com/dataproxy/ui/components/SpeedometerCard.kt index d5765ea..59a6c5e 100644 --- a/app/src/main/java/com/dataproxy/ui/components/SpeedometerCard.kt +++ b/app/src/main/java/com/dataproxy/ui/components/SpeedometerCard.kt @@ -3,6 +3,7 @@ package com.dataproxy.ui.components import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -37,12 +38,16 @@ import com.dataproxy.ui.theme.OutlineSoft import com.dataproxy.ui.theme.TextMuted import com.dataproxy.ui.theme.TextPrimary import com.dataproxy.ui.theme.TextSecondary +import com.dataproxy.util.ByteFormatter +import com.dataproxy.util.RateUnit import kotlin.math.ln @Composable fun SpeedometerCard( upBps: Long, downBps: Long, + rateUnit: RateUnit, + onCycleRateUnit: () -> Unit, modifier: Modifier = Modifier, ) { SectionCard( @@ -59,6 +64,8 @@ fun SpeedometerCard( icon = Icons.Rounded.ArrowDownward, bps = downBps, color = Info, + rateUnit = rateUnit, + onUnitClick = onCycleRateUnit, modifier = Modifier.weight(1f), ) SpeedTile( @@ -66,6 +73,8 @@ fun SpeedometerCard( icon = Icons.Rounded.ArrowUpward, bps = upBps, color = Accent, + rateUnit = rateUnit, + onUnitClick = onCycleRateUnit, modifier = Modifier.weight(1f), ) } @@ -78,9 +87,11 @@ private fun SpeedTile( icon: ImageVector, bps: Long, color: Color, + rateUnit: RateUnit, + onUnitClick: () -> Unit, modifier: Modifier = Modifier, ) { - val displayValue = remember(bps) { formatRate(bps) } + val displayValue = remember(bps, rateUnit) { ByteFormatter.rate(bps, rateUnit) } val animated by animateFloatAsState( targetValue = normaliseBps(bps), animationSpec = tween(600), @@ -121,7 +132,9 @@ private fun SpeedTile( text = displayValue.second, color = TextSecondary, style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(bottom = 6.dp), + modifier = Modifier + .padding(bottom = 6.dp) + .clickable(onClick = onUnitClick), ) } Spacer(Modifier.height(8.dp)) @@ -156,18 +169,3 @@ private fun normaliseBps(bps: Long): Float { val score = ln(value + 1) / ln(ceiling + 1) return score.toFloat().coerceIn(0f, 1f) } - -private fun formatRate(bps: Long): Pair { - val units = arrayOf("B/s", "KB/s", "MB/s", "GB/s") - if (bps <= 0) return "0" to units[0] - var v = bps.toDouble() - var i = 0 - while (v >= 1024 && i < units.lastIndex) { v /= 1024; i++ } - val number = when { - i == 0 -> bps.toString() - v >= 100 -> "%.0f".format(v) - v >= 10 -> "%.1f".format(v) - else -> "%.2f".format(v) - } - return number to units[i] -} diff --git a/app/src/main/java/com/dataproxy/ui/screens/HomeScreen.kt b/app/src/main/java/com/dataproxy/ui/screens/HomeScreen.kt index a50d4d0..6b25a62 100644 --- a/app/src/main/java/com/dataproxy/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/dataproxy/ui/screens/HomeScreen.kt @@ -76,6 +76,7 @@ fun HomeScreen( val serviceState by viewModel.serviceState.collectAsStateWithLifecycle() val totals by viewModel.totals.collectAsStateWithLifecycle() val rates by viewModel.rates.collectAsStateWithLifecycle() + val rateUnit by viewModel.rateUnit.collectAsStateWithLifecycle() val cellular by viewModel.cellular.collectAsStateWithLifecycle() val cellularTech by viewModel.cellularTech.collectAsStateWithLifecycle() val bindAddress by viewModel.bindAddress.collectAsStateWithLifecycle() @@ -125,7 +126,12 @@ fun HomeScreen( ) } Spacer(Modifier.height(14.dp)) - SpeedometerCard(upBps = rates.upBps, downBps = rates.downBps) + SpeedometerCard( + upBps = rates.upBps, + downBps = rates.downBps, + rateUnit = rateUnit, + onCycleRateUnit = { viewModel.cycleRateUnit() }, + ) Spacer(Modifier.height(10.dp)) TrafficStatsCard( bytesUp = totals.bytesUp, From 451e5bc78d166128bb7b6c0363b0cf593ed30464 Mon Sep 17 00:00:00 2001 From: NickLD Date: Sat, 8 Aug 2026 20:35:12 -0500 Subject: [PATCH 5/6] Read rate unit preference in the notification Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01S55LqTjgjjwnYv9YVcYL4h --- .../java/com/dataproxy/service/ProxyService.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/dataproxy/service/ProxyService.kt b/app/src/main/java/com/dataproxy/service/ProxyService.kt index a3d7ec2..7f662b5 100644 --- a/app/src/main/java/com/dataproxy/service/ProxyService.kt +++ b/app/src/main/java/com/dataproxy/service/ProxyService.kt @@ -19,6 +19,8 @@ import com.dataproxy.proxy.AuthConfig import com.dataproxy.proxy.ConnectionRegistry import com.dataproxy.proxy.Socks5Server import com.dataproxy.proxy.SpeedSampler +import com.dataproxy.util.ByteFormatter +import com.dataproxy.util.RateUnit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -279,8 +281,11 @@ class ProxyService : Service() { ) val isPaused = _state.value is State.Paused val title = if (isPaused) "DataProxy · paused" else "DataProxy · $addr:$port" + val rateUnit = RateUnit.fromKey( + getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(PREF_RATE_UNIT, null) + ) val sub = if (isPaused) "Waiting for mobile data" - else "${totals.active} conn · ↑${humanRate(rates.upBps)} ↓${humanRate(rates.downBps)}" + else "${totals.active} conn · ↑${rateText(rates.upBps, rateUnit)} ↓${rateText(rates.downBps, rateUnit)}" return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_proxy) .setContentTitle(title) @@ -314,12 +319,9 @@ class ProxyService : Service() { } } - private fun humanRate(bps: Long): String { - val units = arrayOf("B/s", "KB/s", "MB/s", "GB/s") - var v = bps.toDouble() - var i = 0 - while (v >= 1024 && i < units.lastIndex) { v /= 1024; i++ } - return if (i == 0) "${bps}${units[i]}" else "%.1f%s".format(v, units[i]) + private fun rateText(bps: Long, unit: RateUnit): String { + val (number, label) = ByteFormatter.rate(bps, unit) + return "$number$label" } /** From d83e533adabd944ff4a47ff8ff1b91207058db6a Mon Sep 17 00:00:00 2001 From: NickLD Date: Sat, 8 Aug 2026 21:10:31 -0500 Subject: [PATCH 6/6] Address final review: fix tap-target/accessibility and clarify bytesPerSec naming Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01S55LqTjgjjwnYv9YVcYL4h --- app/src/main/java/com/dataproxy/service/ProxyService.kt | 4 ++-- .../java/com/dataproxy/ui/components/SpeedometerCard.kt | 5 +++-- app/src/main/java/com/dataproxy/util/ByteFormatter.kt | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/dataproxy/service/ProxyService.kt b/app/src/main/java/com/dataproxy/service/ProxyService.kt index 7f662b5..c85309d 100644 --- a/app/src/main/java/com/dataproxy/service/ProxyService.kt +++ b/app/src/main/java/com/dataproxy/service/ProxyService.kt @@ -319,8 +319,8 @@ class ProxyService : Service() { } } - private fun rateText(bps: Long, unit: RateUnit): String { - val (number, label) = ByteFormatter.rate(bps, unit) + private fun rateText(bytesPerSec: Long, unit: RateUnit): String { + val (number, label) = ByteFormatter.rate(bytesPerSec, unit) return "$number$label" } diff --git a/app/src/main/java/com/dataproxy/ui/components/SpeedometerCard.kt b/app/src/main/java/com/dataproxy/ui/components/SpeedometerCard.kt index 59a6c5e..bd5750f 100644 --- a/app/src/main/java/com/dataproxy/ui/components/SpeedometerCard.kt +++ b/app/src/main/java/com/dataproxy/ui/components/SpeedometerCard.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -133,8 +134,8 @@ private fun SpeedTile( color = TextSecondary, style = MaterialTheme.typography.labelMedium, modifier = Modifier - .padding(bottom = 6.dp) - .clickable(onClick = onUnitClick), + .clickable(onClick = onUnitClick, role = Role.Button) + .padding(bottom = 6.dp), ) } Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/com/dataproxy/util/ByteFormatter.kt b/app/src/main/java/com/dataproxy/util/ByteFormatter.kt index e17ef7a..27467f8 100644 --- a/app/src/main/java/com/dataproxy/util/ByteFormatter.kt +++ b/app/src/main/java/com/dataproxy/util/ByteFormatter.kt @@ -8,9 +8,9 @@ object ByteFormatter { fun bytes(n: Long): String = humanise(n, sizeUnits) - fun rate(bps: Long, unit: RateUnit): Pair = when (unit) { - RateUnit.BytesPerSecond -> humanisePair(bps.toDouble(), 1024.0, bytesPerSecUnits) - RateUnit.Mbps -> humanisePair(bps.toDouble() * 8.0, 1000.0, mbpsUnits) + fun rate(bytesPerSec: Long, unit: RateUnit): Pair = when (unit) { + RateUnit.BytesPerSecond -> humanisePair(bytesPerSec.toDouble(), 1024.0, bytesPerSecUnits) + RateUnit.Mbps -> humanisePair(bytesPerSec.toDouble() * 8.0, 1000.0, mbpsUnits) } private fun humanise(n: Long, units: Array): String {