Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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?,
)

Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pair<Long, Long>>()

/**
* 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
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading