diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index a7242928..43a66b76 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -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, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt index 3835236e..209b1d6e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectDialogs.kt @@ -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." +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index b6a2d8ef..4c22ef4e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -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(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(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(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(null) } + var speedTestPhase by remember { mutableStateOf(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 = 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`. diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index 42b66755..2aaf2bfe 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -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) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SpeedTest.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SpeedTest.kt new file mode 100644 index 00000000..28fb3fae --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SpeedTest.kt @@ -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 diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/SpeedTestTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/SpeedTestTest.kt new file mode 100644 index 00000000..641e2249 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/SpeedTestTest.kt @@ -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) + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 4d81f6e9..af300b58 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -112,6 +112,12 @@ class ScreenshotTest { TrustDialog() } + @Test + fun speedTest() = shootScreen("speed-test") { + HostsScene() + SpeedTestScene() + } + @Test fun pair() = shootScreen("pair") { HostsScene() diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 00637342..8f38f073 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -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() { diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 7596e2f2..1a5b53cd 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -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 diff --git a/clients/android/native/src/session/mod.rs b/clients/android/native/src/session/mod.rs index 7445d2db..9fc90216 100644 --- a/clients/android/native/src/session/mod.rs +++ b/clients/android/native/src/session/mod.rs @@ -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; diff --git a/clients/android/native/src/session/probe.rs b/clients/android/native/src/session/probe.rs new file mode 100644 index 00000000..62125c2d --- /dev/null +++ b/clients/android/native/src/session/probe.rs @@ -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(), + } + }) +}