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