From 9acfc096cd34f2b3f9e20d2cec9a2eb688fc80f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:28:22 +0000 Subject: [PATCH] Show the traffic rate over the last five seconds The connection sheet's rate was the average since connecting, so after streaming stopped it took ages to fall, and read as if data were still moving. It's now the rate over the last five seconds, from the readings the sheet already takes twice a second, and it's left out until there are two readings to compare. Release-note: The connection details show how fast data is moving now, rather than the average since connecting. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CTf1Z6GAbDbQeNJ5BkewC1 --- .../ui/shell/ConnectionSheet.kt | 22 ++++++---- .../clementineremote/ui/shell/TrafficRate.kt | 26 ++++++++++++ .../ui/shell/TrafficRateTest.kt | 42 +++++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/de/qspool/clementineremote/ui/shell/TrafficRate.kt create mode 100644 app/src/test/java/de/qspool/clementineremote/ui/shell/TrafficRateTest.kt diff --git a/app/src/main/java/de/qspool/clementineremote/ui/shell/ConnectionSheet.kt b/app/src/main/java/de/qspool/clementineremote/ui/shell/ConnectionSheet.kt index 5df30ba9..3aa58dbd 100644 --- a/app/src/main/java/de/qspool/clementineremote/ui/shell/ConnectionSheet.kt +++ b/app/src/main/java/de/qspool/clementineremote/ui/shell/ConnectionSheet.kt @@ -2,6 +2,7 @@ package de.qspool.clementineremote.ui.shell import android.content.Context import android.net.TrafficStats +import android.os.SystemClock import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -27,6 +28,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -57,7 +59,10 @@ internal data class ConnectionStats( val version: String, /** How long the connection has been open, as hours, minutes and seconds. */ val uptime: String, - /** Sent and received since connecting, and the average rate; null where Android can't say. */ + /** + * Sent and received since connecting, and the rate over the last few seconds; null where + * Android can't say. + */ val traffic: String?, ) @@ -82,10 +87,11 @@ interface ConnectionActions { fun ConnectionSheet(actions: ConnectionActions, onDismiss: () -> Unit) { val context = LocalContext.current // The uptime and traffic change by themselves, so they're read twice a second. - val stats by produceState(readConnectionStats(context)) { + val rate = remember { TrafficRate() } + val stats by produceState(readConnectionStats(context, rate)) { while (true) { delay(STATS_INTERVAL_MILLIS) - value = readConnectionStats(context) + value = readConnectionStats(context, rate) } } ModalBottomSheet( @@ -202,7 +208,8 @@ private fun Item(icon: Int, title: Int, summary: Int?, tag: String, onClick: () ) } -internal fun readConnectionStats(context: Context): ConnectionStats { +/** The connection now; [rate] keeps the traffic read lately, for how fast it's moving. */ +internal fun readConnectionStats(context: Context, rate: TrafficRate): ConnectionStats { val preferences = App.getPreferences() val ip = preferences.getString(SharedPreferencesKeys.SP_KEY_IP, "").orEmpty() val port = preferences.getString(SharedPreferencesKeys.SP_KEY_PORT, "")?.toIntOrNull() ?: 0 @@ -227,9 +234,10 @@ internal fun readConnectionStats(context: Context): ConnectionStats { } else { val tx = TrafficStats.getUidTxBytes(uid) - connection.startTx val rx = received - connection.startRx - val perSecond = if (seconds > 0) (tx + rx) / seconds else 0 - Utilities.humanReadableBytes(tx, true) + " / " + Utilities.humanReadableBytes(rx, true) + - " (" + Utilities.humanReadableBytes(perSecond, true) + "/s)" + val totals = Utilities.humanReadableBytes(tx, true) + " / " + Utilities.humanReadableBytes(rx, true) + // Over the last few seconds, so it settles soon after streaming stops. + val perSecond = rate.add(SystemClock.elapsedRealtime(), tx + rx) + if (perSecond == null) totals else totals + " (" + Utilities.humanReadableBytes(perSecond, true) + "/s)" } return ConnectionStats(host, ip, port, version, uptime, traffic) } diff --git a/app/src/main/java/de/qspool/clementineremote/ui/shell/TrafficRate.kt b/app/src/main/java/de/qspool/clementineremote/ui/shell/TrafficRate.kt new file mode 100644 index 00000000..26e85014 --- /dev/null +++ b/app/src/main/java/de/qspool/clementineremote/ui/shell/TrafficRate.kt @@ -0,0 +1,26 @@ +package de.qspool.clementineremote.ui.shell + +/** + * How fast data has moved lately: bytes a second over the last [windowMillis], from running totals + * read now and then. Unlike the average since connecting, it falls to nothing soon after traffic + * stops. + */ +internal class TrafficRate(private val windowMillis: Long = 5_000) { + private val samples = ArrayDeque>() + + /** + * Adds the running total [bytes] read at [timeMillis], and returns the rate since the oldest + * reading in the window; null until there are two readings to compare. + */ + fun add(timeMillis: Long, bytes: Long): Long? { + samples.addLast(timeMillis to bytes) + // Keep one reading at or beyond the window's start, so the rate spans the whole window. + while (samples.size > 2 && timeMillis - samples[1].first >= windowMillis) { + samples.removeFirst() + } + val (since, before) = samples.first() + val elapsed = timeMillis - since + if (elapsed <= 0) return null + return (bytes - before).coerceAtLeast(0) * 1000 / elapsed + } +} diff --git a/app/src/test/java/de/qspool/clementineremote/ui/shell/TrafficRateTest.kt b/app/src/test/java/de/qspool/clementineremote/ui/shell/TrafficRateTest.kt new file mode 100644 index 00000000..abd7d2b9 --- /dev/null +++ b/app/src/test/java/de/qspool/clementineremote/ui/shell/TrafficRateTest.kt @@ -0,0 +1,42 @@ +package de.qspool.clementineremote.ui.shell + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** The traffic rate is over the last few seconds, so it falls soon after traffic stops. */ +class TrafficRateTest { + @Test + fun needsTwoReadings() { + assertNull(TrafficRate().add(0, 1_000)) + } + + @Test + fun isTheRateOverTheWindow() { + val rate = TrafficRate(windowMillis = 5_000) + var last: Long? = null + // 100 KB a second for 10 seconds, read twice a second. + for (i in 0..20) last = rate.add(i * 500L, i * 50_000L) + assertEquals(100_000L, last) + } + + @Test + fun fallsToNothingOnceTrafficStops() { + val rate = TrafficRate(windowMillis = 5_000) + // A minute of streaming at 100 KB a second, then nothing. + for (i in 0..120) rate.add(i * 500L, i * 50_000L) + val total = 120 * 50_000L + + assertEquals(50_000L, rate.add(62_500, total)) + var last: Long? = null + for (i in 126..130) last = rate.add(i * 500L, total) + assertEquals(0L, last) + } + + @Test + fun aTotalThatGoesBackIsNoTraffic() { + val rate = TrafficRate() + rate.add(0, 1_000) + assertEquals(0L, rate.add(500, 0)) + } +}