Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions app/src/main/java/com/dataproxy/service/ProxyService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(bytesPerSec: Long, unit: RateUnit): String {
val (number, label) = ByteFormatter.rate(bytesPerSec, unit)
return "$number$label"
}

/**
Expand Down Expand Up @@ -369,6 +371,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)
Expand Down
33 changes: 16 additions & 17 deletions app/src/main/java/com/dataproxy/ui/components/SpeedometerCard.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,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
Expand All @@ -37,12 +39,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(
Expand All @@ -59,13 +65,17 @@ fun SpeedometerCard(
icon = Icons.Rounded.ArrowDownward,
bps = downBps,
color = Info,
rateUnit = rateUnit,
onUnitClick = onCycleRateUnit,
modifier = Modifier.weight(1f),
)
SpeedTile(
label = "Upload",
icon = Icons.Rounded.ArrowUpward,
bps = upBps,
color = Accent,
rateUnit = rateUnit,
onUnitClick = onCycleRateUnit,
modifier = Modifier.weight(1f),
)
}
Expand All @@ -78,9 +88,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),
Expand Down Expand Up @@ -121,7 +133,9 @@ private fun SpeedTile(
text = displayValue.second,
color = TextSecondary,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(bottom = 6.dp),
modifier = Modifier
.clickable(onClick = onUnitClick, role = Role.Button)
.padding(bottom = 6.dp),
)
}
Spacer(Modifier.height(8.dp))
Expand Down Expand Up @@ -156,18 +170,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<String, String> {
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]
}
8 changes: 7 additions & 1 deletion app/src/main/java/com/dataproxy/ui/screens/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/com/dataproxy/ui/viewmodel/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,6 +81,11 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
)
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()

private val _rateUnit = MutableStateFlow(
RateUnit.fromKey(prefs.getString(ProxyService.PREF_RATE_UNIT, null))
)
val rateUnit: StateFlow<RateUnit> = _rateUnit.asStateFlow()

private val _autoStartOnBoot = MutableStateFlow(AntiKillPreferences.autoStartOnBoot(app))
val autoStartOnBoot: StateFlow<Boolean> = _autoStartOnBoot.asStateFlow()

Expand Down Expand Up @@ -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<Application>()
Expand Down
23 changes: 21 additions & 2 deletions app/src/main/java/com/dataproxy/util/ByteFormatter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(bytesPerSec: Long, unit: RateUnit): Pair<String, String> = 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>): String {
if (n < 0) return "—"
Expand All @@ -21,6 +26,20 @@ object ByteFormatter {
}
}

private fun humanisePair(value: Double, base: Double, units: Array<String>): Pair<String, String> {
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 {
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/java/com/dataproxy/util/RateUnit.kt
Original file line number Diff line number Diff line change
@@ -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
}
}