refactor(android): split session JNI into modules, HUD-gated stats, AAudio open retry

- native: the 756-line session.rs becomes session/{mod,connect,input,planes}.rs
  around a SessionHandle (connect lifecycle + trust, input plane shims, plane
  start/stop + stats drain).
- Decode-stats sampling is HUD-gated (nativeSetVideoStatsEnabled): with the
  overlay hidden the decode thread skips the per-AU clock read + lock; enabling
  resets the measurement window.
- audio: the AAudio open path is a per-sharing-mode try_open closure — the
  realtime callback state (ring, prime, free-list) is rebuilt per attempt, so a
  failed exclusive-mode try can't leak state into the shared-mode retry.
- Kotlin: ConnectScreen/StreamScreen slimmed by extracting ConnectDialogs,
  StatsOverlay and TouchInput.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 11:04:43 +02:00
parent 3678c182d5
commit bd4e15b68d
18 changed files with 1922 additions and 1532 deletions
@@ -0,0 +1,355 @@
package io.unom.punktfunk
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingTrust
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The "Add host" bottom sheet: optional name + address + port, then connect at [modeLabel]. Field
* state stays hoisted in ConnectScreen so a dismissed sheet keeps its half-typed values.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AddHostSheet(
hostName: String,
onHostNameChange: (String) -> Unit,
host: String,
onHostChange: (String) -> Unit,
port: String,
onPortChange: (String) -> Unit,
connecting: Boolean,
modeLabel: String,
onDismiss: () -> Unit,
onConnect: (host: String, port: Int, name: String) -> Unit,
) {
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
) {
Text("Add a host", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(4.dp))
Text(
"Enter its address. You'll pair with the host's PIN on first connect.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(20.dp))
OutlinedTextField(
value = hostName,
onValueChange = onHostNameChange,
label = { Text("Name (optional)") },
placeholder = { Text("e.g. Living Room") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = host,
onValueChange = onHostChange,
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = port,
onValueChange = { v -> onPortChange(v.filter { it.isDigit() }.take(5)) },
label = { Text("Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(20.dp))
Button(
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
onClick = {
val h = host.trim()
val p = port.toIntOrNull() ?: 9777
val n = hostName
scope.launch { sheetState.hide() }.invokeOnCompletion {
onDismiss()
onConnect(h, p, n)
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Connect ($modeLabel)") }
}
}
}
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
@Composable
internal fun TrustNewHostDialog(
pt: PendingTrust,
onTrust: () -> Unit,
onPairInstead: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Trust this host?") },
text = {
Column {
Text("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}") }
Text(
"This host allows trust-on-first-use, but that can't tell an impostor " +
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
)
}
},
confirmButton = {
TextButton(onClick = onTrust) { Text("Trust (TOFU)") }
},
dismissButton = {
Row {
TextButton(onClick = onPairInstead) { Text("Pair with PIN…") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
@Composable
internal fun FingerprintChangedDialog(
pt: PendingTrust,
onRepair: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Host identity changed") },
text = {
Text(
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
"advertises. This can mean a host reinstall — or an impostor. Re-pair " +
"with the host's PIN to continue.",
)
},
confirmButton = {
TextButton(onClick = onRepair) { Text("Re-pair") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
/**
* A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request access" is
* the no-PIN path — connect and wait for the operator to click Approve in the host's console;
* "Use a PIN…" switches to the SPAKE2 ceremony.
*/
@Composable
internal fun RequestAccessDialog(
pt: PendingTrust,
onRequestAccess: () -> Unit,
onUsePin: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Pairing required") },
text = {
Column {
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
Text(
"Request access and approve this device in the host's console (or web " +
"UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.",
)
}
},
confirmButton = {
TextButton(onClick = onRequestAccess) { Text("Request access") }
},
dismissButton = {
Row {
TextButton(onClick = onUsePin) { Text("Use a PIN…") }
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}
/**
* The SPAKE2 PIN ceremony dialog. Runs [NativeBridge.nativePair] off the UI thread itself (the
* pin/name/error state is dialog-local); on success hands the host's verified fingerprint to
* [onPaired], which saves + connects. Dismissal is blocked while a pair attempt is in flight.
*/
@Composable
internal fun PairPinDialog(
pt: PendingTrust,
identity: ClientIdentity?,
onPaired: (fpHex: String) -> Unit,
onDismiss: () -> Unit,
) {
val scope = rememberCoroutineScope()
var pin by remember(pt) { mutableStateOf("") }
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { if (!pairing) onDismiss() },
title = { Text("Pair with PIN") },
text = {
Column {
Text("Enter the 4-digit PIN shown on the host.")
OutlinedTextField(
value = pin,
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
label = { Text("PIN") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("This device") },
singleLine = true,
)
err?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(
enabled = !pairing && pin.length == 4 && identity != null,
onClick = {
val id = identity
if (id != null) {
pairing = true
err = null
scope.launch {
val fp = withContext(Dispatchers.IO) {
NativeBridge.nativePair(
pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name,
)
}
pairing = false
if (fp.isNotEmpty()) {
onPaired(fp) // verified host fp — caller saves + connects
} else {
err = "Pairing failed — wrong PIN, or the host isn't armed."
}
}
}
},
) { Text(if (pairing) "Pairing…" else "Pair") }
},
dismissButton = {
TextButton(enabled = !pairing, onClick = onDismiss) { Text("Cancel") }
},
)
}
/**
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
* approval is torn down silently (see ConnectScreen.requestAccess) and resumes discovery.
*/
@Composable
internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Waiting for approval") },
text = {
val deviceName = Build.MODEL ?: "this device"
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text("Approve this device on $hostLabel.")
}
Text(
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
"automatically once you approve — no PIN needed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onCancel) { Text("Cancel") }
},
)
}
/**
* Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
* friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
*/
@Composable
internal fun RenameHostDialog(
target: KnownHost,
onRename: (String) -> Unit,
onDismiss: () -> Unit,
) {
var newName by remember(target) { mutableStateOf(target.name) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename host") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("Name") },
placeholder = { Text(target.address) },
singleLine = true,
)
},
confirmButton = {
TextButton(
enabled = newName.isNotBlank(),
onClick = { onRename(newName.trim()) },
) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@@ -6,11 +6,6 @@ import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -27,24 +22,14 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -56,7 +41,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -99,7 +83,6 @@ private class RequestAccessState(val target: PendingTrust) {
val cancelled = AtomicBoolean(false)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val scope = rememberCoroutineScope()
@@ -162,6 +145,26 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
// it survives a DHCP address change; else by address:port). Mirrors the Apple client.
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
// The one place the full nativeConnect is issued (shared by the normal connect and the
// request-access path), including the HDR/gamepad derivation both need.
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long {
// Advertise HDR only when the user enabled it AND this device's display can present it
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
// explicit choice is passed through unchanged.
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
return withContext(Dispatchers.IO) {
NativeBridge.nativeConnect(
targetHost, targetPort, w, h, hz,
id.certPem, id.privateKeyPem, pinHex,
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
)
}
}
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
// straight through and it appears in the saved-hosts list.
@@ -175,22 +178,7 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
status = "Connecting to $targetHost:$targetPort"
discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch {
// Advertise HDR only when the user enabled it AND this device's display can present it
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
// explicit choice is passed through unchanged.
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
val handle = withContext(Dispatchers.IO) {
NativeBridge.nativeConnect(
targetHost, targetPort, w, h, hz,
id.certPem, id.privateKeyPem, pinHex ?: "",
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, settings.audioChannels, settings.preferredCodec(),
CONNECT_TIMEOUT_MS,
)
}
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
connecting = false
if (handle != 0L) {
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
@@ -225,19 +213,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
status = null
discovery.stop() // free the Wi-Fi radio before the (parked) stream session
scope.launch {
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
// Pin the advertised fingerprint for a discovered host (defence against an impostor while
// we wait); a manually-typed host has none, so trust-on-first-use.
val pinHex = target.advertisedFp ?: ""
val handle = withContext(Dispatchers.IO) {
NativeBridge.nativeConnect(
target.host, target.port, w, h, hz,
id.certPem, id.privateKeyPem, pinHex,
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, settings.audioChannels, REQUEST_ACCESS_TIMEOUT_MS,
)
}
val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS)
// Cancelled while we were parked: tear the (possibly just-approved) session down and
// don't touch UI a fresh action may now own.
if (req.cancelled.get()) {
@@ -296,7 +275,6 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
}
val sheetState = rememberModalBottomSheetState()
var showManualSheet by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
@@ -428,291 +406,87 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
}
AnimatedVisibility(
visible = true, // Static for now, could be based on scroll if needed
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
ExtendedFloatingActionButton(
onClick = { showManualSheet = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Add host") },
expanded = !connecting,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
) {
ExtendedFloatingActionButton(
onClick = { showManualSheet = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text("Add host") },
expanded = !connecting,
)
}
.padding(20.dp),
)
}
if (showManualSheet) {
ModalBottomSheet(
onDismissRequest = { showManualSheet = false },
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
) {
Text("Add a host", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(4.dp))
Text(
"Enter its address. You'll pair with the host's PIN on first connect.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(20.dp))
OutlinedTextField(
value = hostName,
onValueChange = { hostName = it },
label = { Text("Name (optional)") },
placeholder = { Text("e.g. Living Room") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = port,
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
label = { Text("Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(20.dp))
Button(
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
onClick = {
val h = host.trim()
val p = port.toIntOrNull() ?: 9777
val n = hostName
scope.launch { sheetState.hide() }.invokeOnCompletion {
showManualSheet = false
connect(h, p, manualName = n)
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Connect ($w×$h@$hz)") }
}
}
AddHostSheet(
hostName = hostName,
onHostNameChange = { hostName = it },
host = host,
onHostChange = { host = it },
port = port,
onPortChange = { port = it },
connecting = connecting,
modeLabel = "$w×$h@$hz",
onDismiss = { showManualSheet = false },
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
)
}
pendingTrust?.let { pt ->
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW -> AlertDialog(
onDismissRequest = { pendingTrust = null },
title = { Text("Trust this host?") },
text = {
Column {
Text("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { Text("Fingerprint ${it.take(16)}") }
Text(
"This host allows trust-on-first-use, but that can't tell an impostor " +
"from the real host. Pairing with a PIN is stronger — it proves both sides.",
)
}
},
confirmButton = {
TextButton({ pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }) {
Text("Trust (TOFU)")
}
},
dismissButton = {
Row {
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
Text("Pair with PIN…")
}
TextButton({ pendingTrust = null }) { Text("Cancel") }
}
},
PendingTrust.Kind.TRUST_NEW -> TrustNewHostDialog(
pt = pt,
onTrust = { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) },
onPairInstead = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
onDismiss = { pendingTrust = null },
)
PendingTrust.Kind.FP_CHANGED -> AlertDialog(
onDismissRequest = { pendingTrust = null },
title = { Text("Host identity changed") },
text = {
Text(
"The pinned fingerprint for ${pt.host} no longer matches what it now " +
"advertises. This can mean a host reinstall — or an impostor. Re-pair " +
"with the host's PIN to continue.",
)
},
confirmButton = {
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) { Text("Re-pair") }
},
dismissButton = {
TextButton({ pendingTrust = null }) { Text("Cancel") }
},
PendingTrust.Kind.FP_CHANGED -> FingerprintChangedDialog(
pt = pt,
onRepair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
onDismiss = { pendingTrust = null },
)
// A fresh pair=required (or manual/unknown-policy) host: offer the two ways in. "Request
// access" is the no-PIN path — connect and wait for the operator to click Approve in the
// host's console; "Use a PIN…" switches to the SPAKE2 ceremony.
PendingTrust.Kind.REQUEST_ACCESS -> AlertDialog(
onDismissRequest = { pendingTrust = null },
title = { Text("Pairing required") },
text = {
Column {
Text("${pt.host}:${pt.port} requires pairing before it will stream.")
Text(
"Request access and approve this device in the host's console (or web " +
"UI) — no PIN needed. Or pair with the 4-digit PIN the host displays.",
)
}
},
confirmButton = {
TextButton({ pendingTrust = null; requestAccess(pt) }) { Text("Request access") }
},
dismissButton = {
Row {
TextButton({ pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }) {
Text("Use a PIN…")
}
TextButton({ pendingTrust = null }) { Text("Cancel") }
}
},
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessDialog(
pt = pt,
onRequestAccess = { pendingTrust = null; requestAccess(pt) },
onUsePin = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
onDismiss = { pendingTrust = null },
)
PendingTrust.Kind.PAIR -> PairPinDialog(
pt = pt,
identity = identity,
onPaired = { fp ->
// Verified host fp — save as a paired known host, then connect pinned.
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
savedHosts = knownHostStore.all()
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp)
},
onDismiss = { pendingTrust = null },
)
PendingTrust.Kind.PAIR -> {
var pin by remember(pt) { mutableStateOf("") }
var name by remember(pt) { mutableStateOf(Build.MODEL ?: "Android") }
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = { if (!pairing) pendingTrust = null },
title = { Text("Pair with PIN") },
text = {
Column {
Text("Enter the 4-digit PIN shown on the host.")
OutlinedTextField(
value = pin,
onValueChange = { v -> pin = v.filter { it.isDigit() }.take(4) },
label = { Text("PIN") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("This device") },
singleLine = true,
)
err?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = {
TextButton(
enabled = !pairing && pin.length == 4 && identity != null,
onClick = {
val id = identity
if (id != null) {
pairing = true
err = null
scope.launch {
val fp = withContext(Dispatchers.IO) {
NativeBridge.nativePair(
pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name,
)
}
pairing = false
if (fp.isNotEmpty()) {
// Verified host fp — save as a paired known host.
knownHostStore.save(
KnownHost(pt.host, pt.port, pt.name, fp, paired = true),
)
savedHosts = knownHostStore.all()
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp)
} else {
err = "Pairing failed — wrong PIN, or the host isn't armed."
}
}
}
},
) { Text(if (pairing) "Pairing…" else "Pair") }
},
dismissButton = {
TextButton(enabled = !pairing, onClick = { pendingTrust = null }) { Text("Cancel") }
},
)
}
}
}
// The no-PIN "request access" wait: the connect is parked on the host until the operator
// approves this device. Cancel returns the UI immediately — it trips the per-attempt flag so a
// late approval is torn down silently (see requestAccess) and resumes discovery.
awaiting?.let { req ->
fun cancel() {
req.cancelled.set(true)
awaiting = null
connecting = false
discovery.start() // the request may still be pending on the host; keep scanning
}
AlertDialog(
onDismissRequest = { cancel() },
title = { Text("Waiting for approval") },
text = {
val deviceName = Build.MODEL ?: "this device"
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text("Approve this device on ${req.target.name}.")
}
Text(
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
"automatically once you approve — no PIN needed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { cancel() }) { Text("Cancel") }
AwaitingApprovalDialog(
hostLabel = req.target.name,
onCancel = {
req.cancelled.set(true)
awaiting = null
connecting = false
discovery.start() // the request may still be pending on the host; keep scanning
},
)
}
// Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
// friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
renameTarget?.let { kh ->
var newName by remember(kh) { mutableStateOf(kh.name) }
AlertDialog(
onDismissRequest = { renameTarget = null },
title = { Text("Rename host") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("Name") },
placeholder = { Text(kh.address) },
singleLine = true,
)
},
confirmButton = {
TextButton(
enabled = newName.isNotBlank(),
onClick = {
knownHostStore.rename(kh.address, kh.port, newName.trim())
savedHosts = knownHostStore.all()
renameTarget = null
},
) { Text("Save") }
},
dismissButton = {
TextButton(onClick = { renameTarget = null }) { Text("Cancel") }
RenameHostDialog(
target = kh,
onRename = { newName ->
knownHostStore.rename(kh.address, kh.port, newName)
savedHosts = knownHostStore.all()
renameTarget = null
},
onDismiss = { renameTarget = null },
)
}
}
@@ -0,0 +1,98 @@
package io.unom.punktfunk
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.roundToInt
/**
* The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from
* [NativeBridge.nativeVideoStats]:
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the
* negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it.
*/
@Composable
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
if (s.size < 10) return
val w = s[6].toInt()
val h = s[7].toInt()
val hz = s[8].toInt()
val latValid = s[4] != 0.0
val skew = s[5] != 0.0
val dropped = s[9].toLong()
Column(
modifier = modifier
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
videoFeedLine(s)?.let { feed ->
Text(
feed,
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
if (latValid) {
val tag = if (skew) "" else " (same-host)"
Text(
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag",
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
if (dropped > 0) {
Text(
"dropped $dropped",
color = Color(0xFFFFB0B0),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
}
}
/**
* Format the negotiated video-feed descriptor from the trailing four stats doubles
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
* Android decoder is always HEVC (`video/hevc`).
*/
private fun videoFeedLine(s: DoubleArray): String? {
if (s.size < 14) return null
val bitDepth = s[10].toInt()
val primaries = s[11].toInt()
val transfer = s[12].toInt()
val chromaIdc = s[13].toInt()
val depthLabel = if (bitDepth > 0) "$bitDepth-bit" else "8-bit"
val (dynamicRange, colorSpace) = when (transfer) {
16 -> "HDR" to "BT.2020 PQ"
18 -> "HDR" to "BT.2020 HLG"
else -> "SDR" to if (primaries == 9) "BT.2020" else "BT.709"
}
val chromaLabel = when (chromaIdc) {
3 -> "4:4:4"
2 -> "4:2:2"
else -> "4:2:0"
}
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
}
@@ -7,15 +7,9 @@ import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -25,12 +19,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
@@ -41,25 +32,6 @@ import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.NativeBridge
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.roundToInt
// Touch-gesture tuning (px / ms). TAP_SLOP: movement under this still counts as a tap, not a drag.
// TAP_DRAG_MS: a new touch within this long after a tap starts a left-button drag. SCROLL_DIV: px of
// two-finger pan per wheel notch (smaller = faster scroll).
private const val TAP_SLOP = 12f
private const val TAP_DRAG_MS = 250L
private const val SCROLL_DIV = 4f
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
// px/ms, capped at ACCEL_MAX (so a fast swipe can't fling the cursor uncontrollably).
private const val POINTER_SENS = 1.3f
private const val ACCEL_GAIN = 0.6f
private const val ACCEL_SPEED_FLOOR = 0.3f
private const val ACCEL_MAX = 3.0f
@Composable
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
@@ -76,18 +48,25 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
// Live decode stats for the HUD. Poll once a second for the whole stream (cheap, and each call
// drains+resets the native window so it never grows unbounded even while the overlay is hidden);
// `showStats` only gates rendering. A 3-finger tap toggles it live; the default comes from Settings.
// Live decode stats for the HUD. `showStats` gates the whole pipeline: the native per-frame
// sampling (nativeSetVideoStatsEnabled — hidden HUD costs one atomic load per frame) AND the
// 1 s poll loop, which only runs while the overlay is visible. Enabling resets the native
// window, so re-showing never renders stale data. A 3-finger tap toggles it live; the default
// comes from Settings.
val initialSettings = remember { SettingsStore(context).load() }
var stats by remember { mutableStateOf<DoubleArray?>(null) }
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val trackpad = initialSettings.trackpadMode
LaunchedEffect(handle) {
while (true) {
delay(1000)
stats = NativeBridge.nativeVideoStats(handle)
LaunchedEffect(handle, showStats) {
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
if (showStats) {
while (true) {
delay(1000)
stats = NativeBridge.nativeVideoStats(handle)
}
} else {
stats = null // drop the last snapshot so a re-show never flashes stale numbers
}
}
@@ -169,240 +148,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
if (showStats) {
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) }
}
// Touch → mouse. Two models, chosen by the Trackpad-mode setting:
// • trackpad (default): the cursor STAYS where it is on touch-down and moves by the finger's
// relative delta (MouseMove) with mild pointer acceleration — swipe to nudge, lift and
// re-swipe to walk it across, tap to click where it is. This is what makes the cursor
// reachable on a small screen.
// • direct (opt-out): the cursor jumps to the finger and follows it (MouseMoveAbs,
// host-normalized against the overlay size), the old "direct pointing" behaviour.
// Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
// two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
// windows); three-finger tap = toggle the stats HUD.
// Touch → mouse (trackpad vs. direct pointing + the shared gesture vocabulary — see
// streamTouchInput in TouchInput.kt).
Box(
Modifier.fillMaxSize().pointerInput(handle, trackpad) {
var lastTapUp = 0L
var lastTapX = 0f
var lastTapY = 0f
fun moveAbs(x: Float, y: Float) {
val sw = size.width
val sh = size.height
if (sw <= 0 || sh <= 0) return
NativeBridge.nativeSendPointerAbs(
handle,
x.coerceIn(0f, (sw - 1).toFloat()).roundToInt(),
y.coerceIn(0f, (sh - 1).toFloat()).roundToInt(),
sw,
sh,
)
}
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val startX = down.position.x
val startY = down.position.y
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
// button for this whole gesture (laptop-trackpad convention).
val isDrag = down.uptimeMillis - lastTapUp < TAP_DRAG_MS &&
abs(startX - lastTapX) < TAP_SLOP && abs(startY - lastTapY) < TAP_SLOP
lastTapUp = 0L // consume the arming either way
// Direct mode jumps the cursor to the finger; trackpad mode leaves it put (the
// whole point — you nudge it with swipes instead).
if (!trackpad) moveAbs(startX, startY)
if (isDrag) NativeBridge.nativeSendPointerButton(handle, 1, true)
var moved = false
var maxFingers = 1
var scrolling = false
var prevCx = startX
var prevCy = startY
var upTime = down.uptimeMillis
// Trackpad relative-motion state: the tracked finger, its last position/time, and
// the sub-pixel remainder so a slow drag isn't lost to Int truncation.
var trackId = down.id
var prevX = startX
var prevY = startY
var prevT = down.uptimeMillis
var accX = 0f
var accY = 0f
while (true) {
val ev = awaitPointerEvent()
val pressed = ev.changes.filter { it.pressed }
if (pressed.isEmpty()) {
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
break
}
if (pressed.size > maxFingers) maxFingers = pressed.size
if (pressed.size >= 2) {
// Two fingers → scroll by the centroid delta; never move the cursor.
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
if (!scrolling) {
scrolling = true
prevCx = cx
prevCy = cy
}
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
if (sy != 0) {
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
prevCy = cy
moved = true
}
if (sx != 0) {
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
prevCx = cx
moved = true
}
} else if (!scrolling) {
// One finger (skipped once a gesture turned into a scroll, so dropping
// back to one finger doesn't jerk the cursor).
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
if (abs(p.position.x - startX) > TAP_SLOP ||
abs(p.position.y - startY) > TAP_SLOP
) {
moved = true
}
if (trackpad) {
// Relative: move by the finger delta × (sensitivity × acceleration),
// carrying the sub-pixel remainder. Re-anchor (zero delta this frame)
// if the tracked finger changed, so lifting one of several fingers
// never jumps the cursor.
if (p.id != trackId) {
trackId = p.id
prevX = p.position.x
prevY = p.position.y
prevT = p.uptimeMillis
}
val dx = p.position.x - prevX
val dy = p.position.y - prevY
val dt = (p.uptimeMillis - prevT).coerceAtLeast(1L)
prevX = p.position.x
prevY = p.position.y
prevT = p.uptimeMillis
val speed = hypot(dx, dy) / dt // finger px per ms
val accel = (1f + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).coerceAtLeast(0f))
.coerceAtMost(ACCEL_MAX)
accX += dx * POINTER_SENS * accel
accY += dy * POINTER_SENS * accel
val outX = accX.toInt() // truncates toward zero → remainder kept w/ sign
val outY = accY.toInt()
if (outX != 0 || outY != 0) {
NativeBridge.nativeSendPointerMove(handle, outX, outY)
accX -= outX
accY -= outY
}
} else {
moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
}
}
ev.changes.forEach { it.consume() }
}
if (isDrag) {
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
} else if (!moved) {
when {
maxFingers >= 3 -> showStats = !showStats // in-stream HUD toggle
maxFingers == 2 -> { // two-finger tap → right click
NativeBridge.nativeSendPointerButton(handle, 3, true)
NativeBridge.nativeSendPointerButton(handle, 3, false)
}
else -> { // tap → left click (at the cursor's current spot), arm tap-drag
NativeBridge.nativeSendPointerButton(handle, 1, true)
NativeBridge.nativeSendPointerButton(handle, 1, false)
lastTapUp = upTime
lastTapX = startX
lastTapY = startY
}
}
}
}
streamTouchInput(handle, trackpad, onToggleStats = { showStats = !showStats })
},
)
}
}
/**
* The live stats overlay — mirrors the Apple client's HUD. Reads the 14-double layout from
* [NativeBridge.nativeVideoStats]:
* `[fps, mbps, latP50Ms, latP95Ms, latValid, skew, w, h, hz, dropped, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc]`. The trailing four (present on a current native lib) describe the
* negotiated video feed and render as a codec/depth/colour/chroma line; older layouts just omit it.
*/
@Composable
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
if (s.size < 10) return
val w = s[6].toInt()
val h = s[7].toInt()
val hz = s[8].toInt()
val latValid = s[4] != 0.0
val skew = s[5] != 0.0
val dropped = s[9].toLong()
Column(
modifier = modifier
.background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s",
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
videoFeedLine(s)?.let { feed ->
Text(
feed,
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
if (latValid) {
val tag = if (skew) "" else " (same-host)"
Text(
"capture→client ${"%.1f".format(s[2])}/${"%.1f".format(s[3])} ms p50/p95$tag",
color = Color.White,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
if (dropped > 0) {
Text(
"dropped $dropped",
color = Color(0xFFFFB0B0),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
}
}
/**
* Format the negotiated video-feed descriptor from the trailing four stats doubles
* `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g.
* `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles)
* so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else
* SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The
* Android decoder is always HEVC (`video/hevc`).
*/
private fun videoFeedLine(s: DoubleArray): String? {
if (s.size < 14) return null
val bitDepth = s[10].toInt()
val primaries = s[11].toInt()
val transfer = s[12].toInt()
val chromaIdc = s[13].toInt()
val depthLabel = if (bitDepth > 0) "$bitDepth-bit" else "8-bit"
val (dynamicRange, colorSpace) = when (transfer) {
16 -> "HDR" to "BT.2020 PQ"
18 -> "HDR" to "BT.2020 HLG"
else -> "SDR" to if (primaries == 9) "BT.2020" else "BT.709"
}
val chromaLabel = when (chromaIdc) {
3 -> "4:4:4"
2 -> "4:2:2"
else -> "4:2:0"
}
return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel"
}
@@ -0,0 +1,184 @@
package io.unom.punktfunk
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.input.pointer.PointerInputScope
import io.unom.punktfunk.kit.NativeBridge
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.roundToInt
// Touch-gesture tuning (px / ms). TAP_SLOP: movement under this still counts as a tap, not a drag.
// TAP_DRAG_MS: a new touch within this long after a tap starts a left-button drag. SCROLL_DIV: px of
// two-finger pan per wheel notch (smaller = faster scroll).
private const val TAP_SLOP = 12f
private const val TAP_DRAG_MS = 250L
private const val SCROLL_DIV = 4f
// Trackpad-mode pointer ballistics (relative one-finger motion). POINTER_SENS: base finger-px →
// host-px gain (~1:1, never twitchy). The rest is mild acceleration so a flick crosses the screen
// while a slow drag stays precise: above ACCEL_SPEED_FLOOR px/ms the gain ramps by ACCEL_GAIN per
// px/ms, capped at ACCEL_MAX (so a fast swipe can't fling the cursor uncontrollably).
private const val POINTER_SENS = 1.3f
private const val ACCEL_GAIN = 0.6f
private const val ACCEL_SPEED_FLOOR = 0.3f
private const val ACCEL_MAX = 3.0f
/**
* Touch → mouse, run inside the stream overlay's `pointerInput`. Two models, chosen by the
* Trackpad-mode setting:
* * trackpad (default): the cursor STAYS where it is on touch-down and moves by the finger's
* relative delta (MouseMove) with mild pointer acceleration — swipe to nudge, lift and
* re-swipe to walk it across, tap to click where it is. This is what makes the cursor
* reachable on a small screen.
* * direct (opt-out): the cursor jumps to the finger and follows it (MouseMoveAbs,
* host-normalized against the overlay size), the old "direct pointing" behaviour.
*
* Both share the same gesture vocabulary: tap = left click; two-finger tap = right click;
* two-finger drag = scroll; tap-then-press-and-drag = left-drag (text selection / moving
* windows); three-finger tap = [onToggleStats] (the stats HUD).
*/
internal suspend fun PointerInputScope.streamTouchInput(
handle: Long,
trackpad: Boolean,
onToggleStats: () -> Unit,
) {
var lastTapUp = 0L
var lastTapX = 0f
var lastTapY = 0f
fun moveAbs(x: Float, y: Float) {
val sw = size.width
val sh = size.height
if (sw <= 0 || sh <= 0) return
NativeBridge.nativeSendPointerAbs(
handle,
x.coerceIn(0f, (sw - 1).toFloat()).roundToInt(),
y.coerceIn(0f, (sh - 1).toFloat()).roundToInt(),
sw,
sh,
)
}
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val startX = down.position.x
val startY = down.position.y
// A touch landing just after a quick tap nearby = tap-and-drag: hold the left
// button for this whole gesture (laptop-trackpad convention).
val isDrag = down.uptimeMillis - lastTapUp < TAP_DRAG_MS &&
abs(startX - lastTapX) < TAP_SLOP && abs(startY - lastTapY) < TAP_SLOP
lastTapUp = 0L // consume the arming either way
// Direct mode jumps the cursor to the finger; trackpad mode leaves it put (the
// whole point — you nudge it with swipes instead).
if (!trackpad) moveAbs(startX, startY)
if (isDrag) NativeBridge.nativeSendPointerButton(handle, 1, true)
var moved = false
var maxFingers = 1
var scrolling = false
var prevCx = startX
var prevCy = startY
var upTime = down.uptimeMillis
// Trackpad relative-motion state: the tracked finger, its last position/time, and
// the sub-pixel remainder so a slow drag isn't lost to Int truncation.
var trackId = down.id
var prevX = startX
var prevY = startY
var prevT = down.uptimeMillis
var accX = 0f
var accY = 0f
while (true) {
val ev = awaitPointerEvent()
val pressed = ev.changes.filter { it.pressed }
if (pressed.isEmpty()) {
upTime = ev.changes.firstOrNull()?.uptimeMillis ?: upTime
break
}
if (pressed.size > maxFingers) maxFingers = pressed.size
if (pressed.size >= 2) {
// Two fingers → scroll by the centroid delta; never move the cursor.
val cx = (pressed.sumOf { it.position.x.toDouble() } / pressed.size).toFloat()
val cy = (pressed.sumOf { it.position.y.toDouble() } / pressed.size).toFloat()
if (!scrolling) {
scrolling = true
prevCx = cx
prevCy = cy
}
val sy = ((prevCy - cy) / SCROLL_DIV).toInt() // finger up → wheel up
val sx = ((cx - prevCx) / SCROLL_DIV).toInt()
if (sy != 0) {
NativeBridge.nativeSendScroll(handle, 0, sy * 120)
prevCy = cy
moved = true
}
if (sx != 0) {
NativeBridge.nativeSendScroll(handle, 1, sx * 120)
prevCx = cx
moved = true
}
} else if (!scrolling) {
// One finger (skipped once a gesture turned into a scroll, so dropping
// back to one finger doesn't jerk the cursor).
val p = pressed.firstOrNull { it.id == down.id } ?: pressed.first()
if (abs(p.position.x - startX) > TAP_SLOP ||
abs(p.position.y - startY) > TAP_SLOP
) {
moved = true
}
if (trackpad) {
// Relative: move by the finger delta × (sensitivity × acceleration),
// carrying the sub-pixel remainder. Re-anchor (zero delta this frame)
// if the tracked finger changed, so lifting one of several fingers
// never jumps the cursor.
if (p.id != trackId) {
trackId = p.id
prevX = p.position.x
prevY = p.position.y
prevT = p.uptimeMillis
}
val dx = p.position.x - prevX
val dy = p.position.y - prevY
val dt = (p.uptimeMillis - prevT).coerceAtLeast(1L)
prevX = p.position.x
prevY = p.position.y
prevT = p.uptimeMillis
val speed = hypot(dx, dy) / dt // finger px per ms
val accel = (1f + ACCEL_GAIN * (speed - ACCEL_SPEED_FLOOR).coerceAtLeast(0f))
.coerceAtMost(ACCEL_MAX)
accX += dx * POINTER_SENS * accel
accY += dy * POINTER_SENS * accel
val outX = accX.toInt() // truncates toward zero → remainder kept w/ sign
val outY = accY.toInt()
if (outX != 0 || outY != 0) {
NativeBridge.nativeSendPointerMove(handle, outX, outY)
accX -= outX
accY -= outY
}
} else {
moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger
}
}
ev.changes.forEach { it.consume() }
}
if (isDrag) {
NativeBridge.nativeSendPointerButton(handle, 1, false) // end the drag
} else if (!moved) {
when {
maxFingers >= 3 -> onToggleStats() // in-stream HUD toggle
maxFingers == 2 -> { // two-finger tap → right click
NativeBridge.nativeSendPointerButton(handle, 3, true)
NativeBridge.nativeSendPointerButton(handle, 3, false)
}
else -> { // tap → left click (at the cursor's current spot), arm tap-drag
NativeBridge.nativeSendPointerButton(handle, 1, true)
NativeBridge.nativeSendPointerButton(handle, 1, false)
lastTapUp = upTime
lastTapX = startX
lastTapY = startY
}
}
}
}
}