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)) + } +}