feat(client/android): the speed test, writing where the tested host actually reads

Android had no speed test at all — the one client where "what bitrate should
I use?" had no answer but guessing. It measures over the REAL data plane: a
minimal 720p connect, then the host bursts filler for two seconds, so the
answer is about the link this host's stream will take rather than generic
throughput. Two new JNI calls (`nativeSpeedTest` / `nativeProbeResult`) front
the core's probe, deliberately measure-only.

The measurement is the easy half. The half that was wrong on every client for
a long time is WHERE the answer goes. A measured bitrate belongs in the layer
the tested host actually resolves bitrate from (design §5.3): its bound
profile's override if it has one, the global if the host is unbound — and if
the host is bound to a profile that INHERITS bitrate, both are defensible, so
the user gets both buttons instead of us guessing. That target depends only on
the host, so it is known before the result lands and the button can say where
it will write: "Apply to “Travel”". Writing the global unconditionally — the
old behaviour everywhere — is what made measuring the slow box downstairs
quietly re-tune the desktop.

Reachable from the host card's overflow and from the console's host options: a
TV box on a powerline adapter is exactly the machine whose link is worth
measuring, even though profile editing stays off that surface.

While there: a successful write no longer renders in the error container. The
connect screen's one status line was red by design — correct for a failure,
a small lie for "75 Mbit/s set in “Travel”" — so confirmations got their own.

Verified on the emulator against a real host: 108 Mbit/s measured on a host
bound to a bitrate-setting profile, target resolved to that profile, and Apply
wrote 75397 kbps into the profile's overlay with the global untouched and the
profile's other overrides unmoved.
This commit is contained in:
2026-07-29 12:17:18 +02:00
parent f6f991648d
commit 02f7cfbb1d
11 changed files with 693 additions and 2 deletions
@@ -148,6 +148,7 @@ fun App(forceGamepadUi: Boolean = false) {
Tab.Connect -> ConnectScreen(
settings = settings,
onConnected = { session = it },
onSettingsChange = { settings = it; settingsStore.save(it) },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
)
@@ -243,6 +244,7 @@ fun GamepadShell(
GamepadScreen.Home -> ConnectScreen(
settings = settings,
onConnected = onConnected,
onSettingsChange = onSettingsChange,
deepLink = deepLink,
onDeepLinkHandled = onDeepLinkHandled,
gamepadUi = true,
@@ -467,3 +467,103 @@ internal fun EditHostDialog(
},
)
}
/**
* The network speed test, as a dialog: it narrates while it measures, then offers to apply the
* recommendation to the layer the tested host actually reads bitrate from — see [SpeedTestTarget]
* for why that is the interesting part. The apply buttons name their destination, so the write is
* never a surprise.
*/
@Composable
internal fun SpeedTestDialog(
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val done = phase as? SpeedTestPhase.Done
AlertDialog(
// Measuring can't be cancelled mid-burst (the host is already sending), so a stray tap
// outside shouldn't look like it did something.
onDismissRequest = { if (done != null || phase is SpeedTestPhase.Failed) onDismiss() },
title = { Text("Network speed test") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(hostName, style = MaterialTheme.typography.titleMedium)
when (phase) {
SpeedTestPhase.Connecting, SpeedTestPhase.Measuring -> Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text(
if (phase == SpeedTestPhase.Connecting) {
"Connecting…"
} else {
"Measuring — the host is bursting test traffic for two seconds."
},
)
}
is SpeedTestPhase.Failed -> Text(
phase.message,
color = MaterialTheme.colorScheme.error,
)
is SpeedTestPhase.Done -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"%.0f Mbit/s measured · %.1f %% loss".format(
phase.measuredMbps,
phase.lossPct,
),
style = MaterialTheme.typography.bodyLarge,
)
Text(
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
style = MaterialTheme.typography.bodyLarge,
)
Text(
speedTestTargetNote(target),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
confirmButton = {
if (done != null) {
TextButton(onClick = { onApply(true) }) {
Text(
when (target) {
SpeedTestTarget.Global -> "Apply"
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}"
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}"
},
)
}
}
},
dismissButton = {
Row {
// The both-are-defensible case: the user picks the layer, we don't guess.
if (done != null && target is SpeedTestTarget.Ask) {
TextButton(onClick = { onApply(false) }) { Text("Set as default") }
}
TextButton(onClick = onDismiss) { Text("Close") }
}
},
)
}
/** One line saying which layer an Apply will write to, and why that one. */
private fun speedTestTargetNote(target: SpeedTestTarget): String = when (target) {
SpeedTestTarget.Global ->
"This host uses the default settings, so the bitrate goes there."
is SpeedTestTarget.Profile ->
"This host streams with “${target.profile.name}”, which sets its own bitrate — " +
"that override is what it actually reads."
is SpeedTestTarget.Ask ->
"This host streams with “${target.profile.name}”, which currently inherits the default " +
"bitrate. Setting it in the profile affects only this host's profile; setting it as " +
"the default affects everything that inherits it."
}
@@ -109,6 +109,9 @@ private class ConnectAttempt(val hostName: String) {
fun ConnectScreen(
settings: Settings,
onConnected: (ActiveSession) -> Unit,
// Writes the global defaults back. Only the speed test uses it — that is the one action on this
// screen that can land in the defaults layer (design/client-settings-profiles.md §5.3).
onSettingsChange: (Settings) -> Unit = {},
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
@@ -129,6 +132,10 @@ fun ConnectScreen(
var port by remember { mutableStateOf("9777") }
var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
// A confirmation, as opposed to [status]'s failures — "75 Mbit/s set in “Travel”". Separate
// state because the two read completely differently: an error banner is red on purpose, and a
// successful write dressed as one is a small lie every time it appears.
var notice by remember { mutableStateOf<String?>(null) }
// A plain dial in flight (drives the "Connecting…" phase of the full-screen ConnectOverlay); null
// when idle or when the request-access / wake flows own the screen instead.
var attempt by remember { mutableStateOf<ConnectAttempt?>(null) }
@@ -330,6 +337,7 @@ fun ConnectScreen(
attempt = thisAttempt // shows the ConnectOverlay's "Connecting…" phase immediately
connecting = true
status = null
notice = null
discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch {
val handle =
@@ -550,6 +558,37 @@ fun ConnectScreen(
}
}
// A speed test in flight: which host+profile it is measuring, and how far it has got. The
// measurement is over a real connect, so it takes the same `connecting` gate every dial does.
var speedTest by remember { mutableStateOf<HostCardEntry?>(null) }
var speedTestPhase by remember { mutableStateOf<SpeedTestPhase>(SpeedTestPhase.Connecting) }
fun startSpeedTest(entry: HostCardEntry) {
val id = identity ?: run {
status = "Identity not ready yet — try again in a moment"
return
}
// The magic packet isn't the only thing LNP blocks: without the grant this would EPERM its
// way to a timeout and report a dead link on a perfectly good one.
if (!lnpGranted) {
lnpPrompt = true
return
}
speedTest = entry
speedTestPhase = SpeedTestPhase.Connecting
notice = null
connecting = true
discovery.stop() // a browse running through the burst would measure itself
scope.launch {
runSpeedTest(context, id, entry.host.address, entry.host.port, entry.host.fpHex) { p ->
// A dismissed dialog abandons the run; don't drag it back onto the screen.
if (speedTest != null) speedTestPhase = p
}
connecting = false
discovery.start()
}
}
// Toggle a host+profile pin. Presentation only: it never touches the profile itself and never
// changes the host's default binding.
fun togglePin(kh: KnownHost, profile: StreamProfile) {
@@ -567,6 +606,9 @@ fun ConnectScreen(
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
// lives in the Edit sheet instead.
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
if (pin == null) {
add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) })
}
if (profiles.isEmpty()) return@buildList
if (pin != null) {
add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) })
@@ -785,6 +827,23 @@ fun ConnectScreen(
)
Spacer(Modifier.height(24.dp))
notice?.let {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
Spacer(Modifier.height(16.dp))
}
status?.let {
// In-flight progress (connecting / waking) is the full-screen ConnectOverlay's
// job now, so `status` only ever carries a result/error here — a filled error
@@ -1060,6 +1119,11 @@ fun ConnectScreen(
} else {
null
},
onSpeedTest = if (pin == null) {
{ optionsTarget = null; startSpeedTest(HostCardEntry(kh, null)) }
} else {
null
},
onEdit = { optionsTarget = null; editTarget = kh },
onForget = {
knownHostStore.remove(kh)
@@ -1073,6 +1137,27 @@ fun ConnectScreen(
)
}
speedTest?.let { entry ->
val target = SpeedTestTarget.resolve(entry.host, entry.pin?.id, profileStore)
val dismiss = { speedTest = null }
val apply: (Boolean) -> Unit = { toProfile ->
val done = speedTestPhase as? SpeedTestPhase.Done
if (done != null) {
val where = applySpeedTestResult(
done.recommendedKbps, target, toProfile, profileStore, settings, onSettingsChange,
)
profiles = profileStore.all()
notice = "%.0f Mbit/s set in %s".format(done.recommendedMbps, where)
}
speedTest = null
}
if (gamepadUi) {
GamepadSpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
} else {
SpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
}
}
editTarget?.let { kh ->
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
@@ -213,6 +213,7 @@ fun GamepadHostOptionsDialog(
onEdit: () -> Unit,
onForget: () -> Unit,
onDismiss: () -> Unit,
onSpeedTest: (() -> Unit)? = null,
/**
* Non-null when this is a PINNED host+profile tile, whose only action is to unpin. A pin is a
* shortcut, not a second host — offering the host's destructive actions on it would blur
@@ -232,6 +233,7 @@ fun GamepadHostOptionsDialog(
}
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
if (canWake) add(DialogAction("Wake host", onClick = onWake))
if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest))
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
add(DialogAction("Forget", onClick = onForget))
add(DialogAction("Cancel", onClick = onDismiss))
@@ -248,6 +250,58 @@ fun GamepadHostOptionsDialog(
}
}
/**
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
* couch surface too, even though profile EDITING doesn't.
*/
@Composable
fun GamepadSpeedTestDialog(
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val done = phase as? SpeedTestPhase.Done
GamepadDialog(
title = "Network speed test",
onDismiss = onDismiss,
actions = buildList {
if (done != null) {
add(
DialogAction(
when (target) {
SpeedTestTarget.Global -> "Apply"
is SpeedTestTarget.Profile -> "Apply to “${target.profile.name}"
is SpeedTestTarget.Ask -> "Set in “${target.profile.name}"
},
primary = true,
) { onApply(true) },
)
if (target is SpeedTestTarget.Ask) {
add(DialogAction("Set as default") { onApply(false) })
}
}
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
},
) {
DialogText(hostName)
when (phase) {
SpeedTestPhase.Connecting -> DialogText("Connecting…")
SpeedTestPhase.Measuring ->
DialogText("Measuring — the host is bursting test traffic for two seconds.")
is SpeedTestPhase.Failed -> DialogText(phase.message)
is SpeedTestPhase.Done -> {
DialogText(
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
)
DialogText("Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps))
}
}
}
}
/** Console counterpart of [LocalNetworkDialog] — the Android 17+ ACCESS_LOCAL_NETWORK rationale. */
@Composable
fun GamepadLocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
@@ -0,0 +1,187 @@
package io.unom.punktfunk
import android.content.Context
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/**
* The network speed test: measure the path to a host **over the real data plane** — connect, ask
* the host to burst filler for two seconds, report goodput and loss, and offer to apply a
* recommended bitrate in one tap.
*
* The measurement is the easy half. The half that was wrong everywhere for a long time is *where
* the answer goes*: a measured bitrate belongs in the layer the tested host actually resolves
* bitrate from (design/client-settings-profiles.md §5.3). Writing it to the global — the
* long-standing behaviour — meant measuring the slow retro box downstairs quietly re-tuned the
* desktop too. [SpeedTestTarget] is that decision, and because it depends only on the host it is
* known *before* the result lands, so the button can say where it will write.
*/
sealed interface SpeedTestTarget {
/** No profile in play — the global default, i.e. what has always happened. */
data object Global : SpeedTestTarget
/** The profile this host uses already overrides bitrate, so that override is what it reads. */
data class Profile(val profile: StreamProfile) : SpeedTestTarget
/**
* The host uses a profile, but that profile inherits bitrate. Writing either layer is
* defensible, so the user gets both buttons rather than us guessing which they meant.
*/
data class Ask(val profile: StreamProfile) : SpeedTestTarget
companion object {
/**
* Resolved exactly the way a connect resolves it (see [ProfileStore.resolveFor]): the
* one-off pick this test was started from — a pinned card carries one — else the host's
* binding. A dangling binding resolves as no profile here too.
*/
fun resolve(
host: KnownHost?,
oneOffProfile: String?,
profiles: ProfileStore,
): SpeedTestTarget {
val profile = profiles.resolveFor(host, oneOffProfile) ?: return Global
return if (profile.overrides.bitrateKbps != null) Profile(profile) else Ask(profile)
}
}
}
/** Where the speed test is: it connects, it measures, then it has an answer or a reason. */
sealed interface SpeedTestPhase {
data object Connecting : SpeedTestPhase
data object Measuring : SpeedTestPhase
data class Failed(val message: String) : SpeedTestPhase
/**
* [recommendedKbps] is 70 % of the measured throughput — headroom for the FEC overhead and for
* the loss a real stream will meet, the same margin the desktop clients apply.
*/
data class Done(
val throughputKbps: Int,
val lossPct: Double,
val recommendedKbps: Int,
) : SpeedTestPhase {
val measuredMbps: Double get() = throughputKbps / 1000.0
val recommendedMbps: Double get() = recommendedKbps / 1000.0
}
}
/**
* Connect to [host]:[port], run one burst, and report. Blocking-ish (it suspends on IO) — call
* from a coroutine; [onPhase] is invoked as it progresses so the dialog can narrate.
*
* The connect is deliberately minimal: 1280×720@60, no launch, host-default bitrate. Nothing here
* presents a frame, and asking a host to spin up a 4K encode for a three-second measurement would
* be rude to it and slower for us.
*/
suspend fun runSpeedTest(
context: Context,
identity: ClientIdentity,
host: String,
port: Int,
pinHex: String,
onPhase: (SpeedTestPhase) -> Unit,
) {
onPhase(SpeedTestPhase.Connecting)
val probeSettings = Settings(
width = 1280,
height = 720,
hz = 60,
bitrateKbps = 0, // the host's default: this measures the link, not an encoder setting
hdrEnabled = false,
audioChannels = 2,
)
val handle = connectToHost(
context, probeSettings, identity, host, port, pinHex,
launch = null, timeoutMs = SPEED_TEST_CONNECT_TIMEOUT_MS,
)
if (handle == 0L) {
onPhase(
SpeedTestPhase.Failed(
ConnectErrors.connectMessage(NativeBridge.nativeTakeLastError(), requestAccess = false),
),
)
return
}
try {
onPhase(SpeedTestPhase.Measuring)
if (!NativeBridge.nativeSpeedTest(handle, TARGET_KBPS, BURST_MS)) {
onPhase(SpeedTestPhase.Failed("The host wouldn't start a measurement."))
return
}
var waited = 0
while (waited < POLL_BUDGET_MS) {
delay(POLL_INTERVAL_MS.toLong())
waited += POLL_INTERVAL_MS
val r = NativeBridge.nativeProbeResult(handle)
if (r == null || r.size < 3) {
onPhase(SpeedTestPhase.Failed("The session ended before the measurement finished."))
return
}
if (r[0] == 0.0) continue
// Let the last UDP shards land before tearing the session down, or the tail of the
// burst is counted as loss that never happened.
delay(SETTLE_MS)
val settled = NativeBridge.nativeProbeResult(handle) ?: r
val kbps = settled[1].toInt()
onPhase(
SpeedTestPhase.Done(
throughputKbps = kbps,
lossPct = settled[2],
// Integer arithmetic in this order (not `* 0.7`) so the recommendation matches
// the desktop clients' to the kilobit.
recommendedKbps = kbps / 10 * 7,
),
)
return
}
onPhase(SpeedTestPhase.Failed("The measurement timed out."))
} finally {
withContext(Dispatchers.IO) { NativeBridge.nativeClose(handle) }
}
}
/**
* Write a measured bitrate into the layer [target] names. [toProfile] picks the side of a
* [SpeedTestTarget.Ask]; it is ignored for the other targets, which have only one answer. Returns
* a human phrase naming where it went, for the confirmation.
*/
fun applySpeedTestResult(
kbps: Int,
target: SpeedTestTarget,
toProfile: Boolean,
profiles: ProfileStore,
settings: Settings,
onGlobalChange: (Settings) -> Unit,
): String {
val profile = when (target) {
is SpeedTestTarget.Profile -> target.profile
is SpeedTestTarget.Ask -> target.profile.takeIf { toProfile }
SpeedTestTarget.Global -> null
}
return if (profile == null) {
onGlobalChange(settings.copy(bitrateKbps = kbps))
"the default bitrate"
} else {
// Only the bitrate moves — a speed test has nothing to say about the rest of the profile.
// Re-read rather than trusting the copy this dialog was opened with, so a rename or another
// edit in between isn't clobbered.
val live = profiles.byId(profile.id) ?: profile
profiles.save(live.copy(overrides = live.overrides.copy(bitrateKbps = kbps)))
"${live.name}"
}
}
/** Ask for far more than any real link can carry, so the link is what limits the answer. */
private const val TARGET_KBPS = 3_000_000
/** Long enough to fill the pipe and settle, short enough not to interrupt anyone for long. */
private const val BURST_MS = 2_000
private const val POLL_INTERVAL_MS = 250
private const val POLL_BUDGET_MS = 10_000
private const val SETTLE_MS = 400L
private const val SPEED_TEST_CONNECT_TIMEOUT_MS = 15_000
@@ -0,0 +1,131 @@
package io.unom.punktfunk
import io.unom.punktfunk.kit.security.KnownHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/**
* Where a measured bitrate lands. The measurement itself is the host's job; the decision this code
* makes is which layer to write — and the long-standing wrong answer (always the global) is exactly
* what made measuring the slow box downstairs re-tune the desktop too
* (design/client-settings-profiles.md §5.3).
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class SpeedTestTest {
private val store get() = ProfileStore(RuntimeEnvironment.getApplication())
private fun host() = KnownHost("192.168.1.42", 9777, "Desk", "a".repeat(64), paired = true)
@Test
fun anUnboundHostTargetsTheGlobalDefault() {
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(host(), null, store))
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(null, null, store))
}
@Test
fun aProfileThatSetsBitrateIsTheLayerThatHostReads() {
val s = store
val game = newProfile("Game").copy(overrides = SettingsOverlay(bitrateKbps = 50_000))
s.save(game)
val target = SpeedTestTarget.resolve(host().copy(profileId = game.id), null, s)
assertEquals(game.id, (target as SpeedTestTarget.Profile).profile.id)
}
@Test
fun aProfileThatInheritsBitrateAsksWhichLayer() {
val s = store
val work = newProfile("Work") // overrides nothing
s.save(work)
val target = SpeedTestTarget.resolve(host().copy(profileId = work.id), null, s)
// Both layers are defensible here, so the user picks — we don't guess.
assertEquals(work.id, (target as SpeedTestTarget.Ask).profile.id)
}
@Test
fun theOneOffPickWinsAndTheEmptyOneForcesTheDefaults() {
val s = store
val game = newProfile("Game").copy(overrides = SettingsOverlay(bitrateKbps = 50_000))
val work = newProfile("Work")
listOf(game, work).forEach(s::save)
val bound = host().copy(profileId = work.id)
// Testing from a pinned card measures — and writes — that card's profile.
assertEquals(game.id, (SpeedTestTarget.resolve(bound, game.id, s) as SpeedTestTarget.Profile).profile.id)
// "Connect with: Default settings" is a real choice, so its speed test targets the global.
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(bound, "", s))
// A dangling binding resolves as no profile everywhere else; here too.
assertEquals(SpeedTestTarget.Global, SpeedTestTarget.resolve(host().copy(profileId = "gone"), null, s))
}
@Test
fun applyingWritesOnlyTheBitrate_andOnlyToTheChosenLayer() {
val s = store
val game = newProfile("Game").copy(
overrides = SettingsOverlay(bitrateKbps = 50_000, width = 3840, height = 2160),
)
s.save(game)
val globals = Settings(bitrateKbps = 20_000, codec = "hevc")
var savedGlobals: Settings? = null
val where = applySpeedTestResult(
kbps = 84_000,
target = SpeedTestTarget.Profile(game),
toProfile = true,
profiles = s,
settings = globals,
onGlobalChange = { savedGlobals = it },
)
assertEquals("“Game”", where)
assertNull("the global must not move when a profile was the target", savedGlobals)
val after = s.byId(game.id)!!.overrides
assertEquals(84_000, after.bitrateKbps)
// Nothing else in the overlay is a speed test's business.
assertEquals(3840, after.width)
assertEquals(2160, after.height)
}
@Test
fun theAskCaseHonoursWhichButtonWasPressed() {
val s = store
val work = newProfile("Work")
s.save(work)
val globals = Settings(bitrateKbps = 20_000)
var savedGlobals: Settings? = null
// "Set as default" writes the global and leaves the profile inheriting.
val whereGlobal = applySpeedTestResult(
42_000, SpeedTestTarget.Ask(work), toProfile = false, profiles = s,
settings = globals, onGlobalChange = { savedGlobals = it },
)
assertEquals("the default bitrate", whereGlobal)
assertEquals(42_000, savedGlobals!!.bitrateKbps)
assertNull(s.byId(work.id)!!.overrides.bitrateKbps)
// "Set in Work" records the override instead — and now that profile stops inheriting.
savedGlobals = null
val whereProfile = applySpeedTestResult(
42_000, SpeedTestTarget.Ask(work), toProfile = true, profiles = s,
settings = globals, onGlobalChange = { savedGlobals = it },
)
assertEquals("“Work”", whereProfile)
assertNull(savedGlobals)
assertEquals(42_000, s.byId(work.id)!!.overrides.bitrateKbps)
}
@Test
fun theRecommendationLeavesHeadroom() {
// 70 % of measured, in the desktop clients' integer order — a stream needs room for the
// FEC overhead and for the loss a burst measurement doesn't see.
val done = SpeedTestPhase.Done(throughputKbps = 100_000, lossPct = 0.4, recommendedKbps = 100_000 / 10 * 7)
assertEquals(70_000, done.recommendedKbps)
assertEquals(100.0, done.measuredMbps, 0.001)
assertEquals(70.0, done.recommendedMbps, 0.001)
assertTrue(done.recommendedKbps < done.throughputKbps)
}
}
@@ -112,6 +112,12 @@ class ScreenshotTest {
TrustDialog()
}
@Test
fun speedTest() = shootScreen("speed-test") {
HostsScene()
SpeedTestScene()
}
@Test
fun pair() = shootScreen("pair") {
HostsScene()
@@ -39,6 +39,9 @@ import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.StatsVerbosity
import io.unom.punktfunk.ProfileStore
import io.unom.punktfunk.SettingsOverlay
import io.unom.punktfunk.SpeedTestDialog
import io.unom.punktfunk.SpeedTestPhase
import io.unom.punktfunk.SpeedTestTarget
import io.unom.punktfunk.components.HostCard
import io.unom.punktfunk.components.HostMenuItem
import io.unom.punktfunk.components.SectionLabel
@@ -212,6 +215,22 @@ internal fun SettingsProfileScene() {
}
}
/**
* The speed test's result, in its most interesting shape: a host bound to a profile that INHERITS
* bitrate, so both layers are defensible and both buttons are offered. The note under the numbers
* is what stops "Apply" from being a write in an unknown direction.
*/
@Composable
internal fun SpeedTestScene() {
SpeedTestDialog(
hostName = "Living Room PC",
target = SpeedTestTarget.Ask(newProfile("Game")),
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),
onApply = {},
onDismiss = {},
)
}
/** The real TOFU AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.TRUST_NEW), shown over the host grid. */
@Composable
internal fun TrustDialog() {
@@ -145,6 +145,24 @@ object NativeBridge {
*/
external fun nativeProbe(host: String, port: Int, timeoutMs: Int): Boolean
/**
* Start a bandwidth speed test on [handle]: the host bursts filler over the real data plane at
* [targetKbps] of goodput for [durationMs] (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
* **briefly pausing video**. Measuring over the stream's own path is the point — the answer is
* about the link this host's stream will take, not about generic throughput.
*
* Non-blocking: poll [nativeProbeResult] until it reports done. Starting a probe resets any
* prior measurement. Returns false on a dead handle. Cheap; safe on the main thread.
*/
external fun nativeSpeedTest(handle: Long, targetKbps: Int, durationMs: Int): Boolean
/**
* The current speed-test measurement, partial until `[0] != 0.0`:
* `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`. Zeros before any
* probe, null on a dead handle. Cheap (one lock + a copy); safe to poll on the main thread.
*/
external fun nativeProbeResult(handle: Long): DoubleArray?
/**
* Apply the user's "Low-latency mode (experimental)" toggle to the process-wide transport
* defaults — today just DSCP/QoS marking on the media sockets. Must be called BEFORE
+3 -2
View File
@@ -11,8 +11,8 @@
//! Kotlin side), `nativeConnect` with identity + pin (TOFU / pinned), and `nativePair` (SPAKE2 PIN).
//!
//! Split by concern: [`connect`] (identity + connect/close + the trust surface), [`planes`]
//! (video/audio/mic start/stop + the stats drain), [`input`] (the input-plane shims). This module
//! keeps the shared infrastructure they all deref through.
//! (video/audio/mic start/stop + the stats drain), [`input`] (the input-plane shims), [`probe`]
//! (the bandwidth speed test). This module keeps the shared infrastructure they all deref through.
//!
//! TODO(M4 Android stage 1): client→host DualSense rich input (`send_rich_input`), mode
//! renegotiation. Port the remaining orchestration from `clients/linux`.
@@ -21,6 +21,7 @@ mod clipboard;
mod connect;
mod input;
mod planes;
mod probe;
use punktfunk_core::client::NativeClient;
use std::panic::AssertUnwindSafe;
@@ -0,0 +1,88 @@
//! The bandwidth speed test over an established session.
//!
//! The host bursts filler over the real data plane — the same path a stream uses, so the answer is
//! about the link the stream will actually take rather than about some generic throughput. It is
//! deliberately *measure-only*: which layer a measured bitrate belongs in (the global default, or a
//! host's bound profile) is a decision the UI makes with the user
//! (design/client-settings-profiles.md §5.3), never one this shim makes for them.
//!
//! Two calls: start, then poll. Both are cheap and non-blocking, so Kotlin can drive them from a
//! coroutine on the main thread the way it polls the stats HUD.
use super::{jni_guard, SessionHandle};
use jni::objects::JObject;
use jni::sys::{jboolean, jdoubleArray, jint, jlong};
use jni::JNIEnv;
/// The `DoubleArray` [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] returns. Kept in
/// one place because Kotlin indexes it positionally; see the Kotlin doc for the field order.
const PROBE_RESULT_LEN: usize = 6;
/// `NativeBridge.nativeSpeedTest(handle, targetKbps, durationMs): Boolean` — ask the host to burst
/// filler at `targetKbps` of goodput for `durationMs` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
/// **briefly pausing video**. Non-blocking: poll
/// [`Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult`] until its `done` element is 1.
/// Starting a probe resets any prior measurement. `false` on a `0` handle or a closed session.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSpeedTest(
_env: JNIEnv,
_this: JObject,
handle: jlong,
target_kbps: jint,
duration_ms: jint,
) -> jboolean {
jni_guard(0, || {
if handle == 0 {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let target = target_kbps.clamp(0, i32::MAX) as u32;
let duration = duration_ms.clamp(0, i32::MAX) as u32;
match h.client.request_probe(target, duration) {
Ok(()) => 1,
Err(e) => {
log::warn!("speed test: could not ask the host to probe: {e:?}");
0
}
}
})
}
/// `NativeBridge.nativeProbeResult(handle): DoubleArray?` — the current measurement, partial until
/// `[0] == 1`. Safe to poll; before any probe it reports zeros. `null` on a `0` handle.
///
/// Layout (doubles so one array carries both the counts and the percentages):
/// `[done, throughputKbps, lossPct, hostDropPct, elapsedMs, recvBytes]`.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeProbeResult<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jdoubleArray {
jni_guard(JObject::null().into_raw(), || {
if handle == 0 {
return JObject::null().into_raw();
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let r = h.client.probe_result();
let values: [f64; PROBE_RESULT_LEN] = [
f64::from(u8::from(r.done)),
f64::from(r.throughput_kbps),
f64::from(r.loss_pct),
f64::from(r.host_drop_pct),
f64::from(r.elapsed_ms),
r.recv_bytes as f64,
];
match env.new_double_array(PROBE_RESULT_LEN as i32) {
Ok(arr) => {
if env.set_double_array_region(&arr, 0, &values).is_err() {
return JObject::null().into_raw();
}
arr.into_raw()
}
Err(_) => JObject::null().into_raw(),
}
})
}