refactor(android): the prompts that say the same thing in both interfaces are now one prompt

WP9.1 of the console visual-refresh plan — dialog unification.

Six prompts existed twice: a Material `AlertDialog` in `ConnectDialogs.kt` and a console glass card
in `GamepadDialogs.kt`, maintained by hand. They had drifted, and always in the same direction —
the console losing something:

* "Pair with PIN…" and "Use a PIN…" lost their ellipses, so the console said the buttons finished
  something the touch UI said would open another step;
* "if no prompt appears when you tap Allow" became "after Allow";
* the speed test dropped `speedTestTargetNote` entirely, leaving a console user — often on a TV box,
  which is exactly the machine whose link is worth measuring — no statement of which layer "Apply"
  was about to write to. That is a write in an unknown direction.

What is shared now is the DESCRIPTION of a prompt (a title, a list of `DialogAction`s, a body) and
what stays per-interface is only how it is drawn. `PunktfunkDialog` takes that description and
renders it as an AlertDialog or as the existing console modal. Actions are ordered primary-first:
the console stacks them that way with the cursor on the first, and the touch renderer lifts the
same first action into `confirmButton`. One order, two idioms.

The two renderers cannot be one tree — an AlertDialog composes into its own platform window while
the console modal is a Box in the calling tree, which is why one needs a `BackHandler` and the
caller's `navActive` gate and the other needs neither.

Deliberately NOT unified, and they belong apart: the PIN ceremony (a keyboard field and an editable
device name against four D-pad digit slots is a different input model, not a different skin),
Add/Edit Host (a bottom sheet against a full screen with its own on-screen keyboard), and the host
action list (an anchored dropdown against a modal stack that also grows a row per profile).

Twelve composables become six. `ConnectScreen`'s dialog block loses ten `if (gamepadUi)` branches.
This commit is contained in:
2026-08-10 19:30:34 +02:00
parent 4b48748b8a
commit 430c9831ef
5 changed files with 380 additions and 432 deletions
@@ -0,0 +1,350 @@
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.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import io.unom.punktfunk.models.PendingTrust
// The prompts that say the SAME thing in both interfaces.
//
// Every one of these existed twice — a Material `AlertDialog` in ConnectDialogs.kt and a console
// glass card in GamepadDialogs.kt — with the two copies maintained by hand. Predictably they
// drifted, and always in the direction of the console losing something: "Pair with PIN…" lost its
// ellipsis, "if no prompt appears when you tap Allow" became "after Allow", and the speed test
// stopped telling console users which layer Apply would write to at all.
//
// What is shared here is the DESCRIPTION of a prompt — a title, a list of [DialogAction]s and a
// body — and what stays per-interface is only how that description is drawn. That split is the
// whole point: a copy change now lands in both places because there is only one place.
//
// ⚠ Deliberately NOT unified, and they belong apart: the PIN ceremony (a numeric keyboard field
// and an editable device name on touch; four D-pad digit slots on the console — different input
// models, not different skins), Add/Edit Host (a bottom sheet and a full screen with its own
// on-screen keyboard), and the host action list (an anchored dropdown vs a modal stack, and the
// touch one grows a row per profile).
/**
* One prompt, drawn as whichever interface is running.
*
* [actions] is ordered PRIMARY FIRST — the console stacks them in that order with the cursor on
* the first, and the touch renderer lifts that same first action into `confirmButton` and lays the
* rest out beside it. One order, two idioms, no per-dialog bookkeeping.
*
* The two renderers cannot be one tree: an `AlertDialog` composes into its own platform window
* while [ConsoleModal] is a plain Box in the calling tree — which is also why the console one
* needs a `BackHandler` and the caller's `navActive` gate while the touch one needs neither.
*/
@Composable
fun PunktfunkDialog(
gamepadUi: Boolean,
title: String,
onDismiss: () -> Unit,
actions: List<DialogAction>,
/**
* False pins the prompt open against a stray tap outside it — for a dialog sitting over work
* in flight, where a mis-tap would abandon it. Console-side there is no outside to tap, so
* this only reaches the touch renderer.
*/
dismissOnOutsideTap: Boolean = true,
body: @Composable ColumnScope.() -> Unit,
) {
if (gamepadUi) {
GamepadDialog(title = title, onDismiss = onDismiss, actions = actions, body = body)
return
}
val primary = actions.firstOrNull { it.primary } ?: actions.firstOrNull()
val rest = actions.filter { it !== primary }
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(dismissOnClickOutside = dismissOnOutsideTap),
title = { Text(title) },
text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { body() } },
confirmButton = {
primary?.let { a ->
TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) }
}
},
dismissButton = {
if (rest.isNotEmpty()) {
Row {
rest.forEach { a ->
TextButton(onClick = a.onClick, enabled = a.enabled) { Text(a.label) }
}
}
}
},
)
}
/** A prompt's body paragraph, dimmed to sit under the title in either interface. */
@Composable
private fun PromptText(text: String, gamepadUi: Boolean) {
val ink = LocalGamepadInk.current
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = if (gamepadUi) ink.fg(0.7f) else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
@Composable
fun TrustNewHostPrompt(
gamepadUi: Boolean,
pt: PendingTrust,
onTrust: () -> Unit,
onPairInstead: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Trust this host?",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
DialogAction("Pair with PIN…", onClick = onPairInstead),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText("First connection to ${pt.host}:${pt.port}.", gamepadUi)
pt.advertisedFp?.let { PromptText("Fingerprint ${it.take(16)}", gamepadUi) }
PromptText(
"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.",
gamepadUi,
)
}
}
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
@Composable
fun FingerprintChangedPrompt(
gamepadUi: Boolean,
pt: PendingTrust,
onRepair: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Host identity changed",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Re-pair", primary = true, onClick = onRepair),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText(
"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.",
gamepadUi,
)
}
}
/**
* 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
fun RequestAccessPrompt(
gamepadUi: Boolean,
pt: PendingTrust,
onRequestAccess: () -> Unit,
onUsePin: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Pairing required",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Request access", primary = true, onClick = onRequestAccess),
DialogAction("Use a PIN…", onClick = onUsePin),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText("${pt.host}:${pt.port} requires pairing before it will stream.", gamepadUi)
PromptText(
"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.",
gamepadUi,
)
}
}
/**
* 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.
*
* Outside taps are ignored: a connect is parked on the host, and a stray tap beside the card is not
* a decision to abandon it.
*/
@Composable
fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -> Unit) {
val ink = LocalGamepadInk.current
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Waiting for approval",
onDismiss = onCancel,
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
dismissOnOutsideTap = false,
) {
val deviceName = Build.MODEL ?: "this device"
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.primary,
)
Text(
"Approve this device on $hostLabel.",
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
)
}
PromptText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects " +
"automatically once you approve — no PIN needed.",
gamepadUi,
)
}
}
/**
* Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and
* every connect are dead — offer the system prompt again and a settings deep link (a permanently-
* denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough).
*/
@Composable
fun LocalNetworkPrompt(
gamepadUi: Boolean,
onAllow: () -> Unit,
onSettings: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Allow local network access",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Allow", primary = true, onClick = onAllow),
DialogAction("Open settings", onClick = onSettings),
DialogAction("Not now", onClick = onDismiss),
),
) {
PromptText(
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
"or reach any host until you allow it.",
gamepadUi,
)
PromptText(
"If no prompt appears after you allow it, enable “Nearby devices” for Punktfunk in " +
"system settings.",
gamepadUi,
)
}
}
/**
* The link measurement and what to do with the result. A TV box on a powerline adapter is exactly
* the machine whose link is worth measuring, so this belongs on the couch surface too — and so
* does [speedTestTargetNote], which the console used to omit, leaving a console user to guess
* which layer Apply would write to.
*/
@Composable
fun SpeedTestPrompt(
gamepadUi: Boolean,
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
val done = phase as? SpeedTestPhase.Done
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Network speed test",
onDismiss = onDismiss,
// Measuring bursts traffic for two seconds; a tap outside must not abandon it midway.
dismissOnOutsideTap = phase !is SpeedTestPhase.Measuring,
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))
},
) {
PromptText(hostName, gamepadUi)
when (phase) {
SpeedTestPhase.Connecting -> PromptText("Connecting…", gamepadUi)
SpeedTestPhase.Measuring ->
PromptText(
"Measuring — the host is bursting test traffic for two seconds.",
gamepadUi,
)
is SpeedTestPhase.Failed -> Text(
phase.message,
style = MaterialTheme.typography.bodyMedium,
color = if (gamepadUi) ink.danger else MaterialTheme.colorScheme.error,
)
is SpeedTestPhase.Done -> {
Text(
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
)
PromptText(
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
gamepadUi,
)
PromptText(speedTestTargetNote(target), gamepadUi)
}
}
}
}
/** 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."
}
@@ -123,130 +123,6 @@ internal fun AddHostSheet(
}
}
/** 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") }
}
},
)
}
/**
* Android 17+ Local Network Protection rationale: ACCESS_LOCAL_NETWORK was denied, so discovery and
* every connect are dead — offer the system prompt again and a settings deep link (a permanently-
* denied request returns instantly without ever showing the prompt, so "Allow" alone isn't enough).
*/
@Composable
internal fun LocalNetworkDialog(onAllow: () -> Unit, onSettings: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Allow local network access") },
text = {
Text(
"Android blocks Punktfunk from talking to devices on your network, so it can't " +
"find or reach any host until you allow it. If no prompt appears when you tap " +
"Allow, enable “Nearby devices” for Punktfunk in system settings.",
)
},
confirmButton = {
TextButton(onClick = onAllow) { Text("Allow") }
},
dismissButton = {
Row {
TextButton(onClick = onSettings) { Text("Open settings") }
TextButton(onClick = onDismiss) { Text("Not now") }
}
},
)
}
/** 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
@@ -318,41 +194,6 @@ internal fun PairPinDialog(
)
}
/**
* 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") }
},
)
}
/**
* Edit a saved host: name, address, port, the Wake-on-LAN MAC, and the per-host settings the record
* owns — shared clipboard (a trust decision about THIS machine, so it was never really a global).
@@ -467,103 +308,3 @@ 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."
}
@@ -1141,16 +1141,27 @@ fun ConnectScreen(
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
}
// Three of the four say the same thing in both interfaces, so they are ONE prompt that
// knows which one is running. Only the PIN ceremony genuinely differs — a keyboard field
// against four D-pad digit slots is a different input model, not a different skin.
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW ->
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
PendingTrust.Kind.TRUST_NEW -> TrustNewHostPrompt(
gamepadUi, pt,
onTrust = {
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch)
},
onPairInstead = onPair,
onDismiss = { pendingTrust = null },
)
PendingTrust.Kind.FP_CHANGED ->
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
PendingTrust.Kind.REQUEST_ACCESS ->
if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
FingerprintChangedPrompt(gamepadUi, pt, onPair) { pendingTrust = null }
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessPrompt(
gamepadUi, pt,
onRequestAccess = { pendingTrust = null; requestAccess(pt) },
onUsePin = onPair,
onDismiss = { pendingTrust = null },
)
PendingTrust.Kind.PAIR ->
if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
@@ -1164,8 +1175,7 @@ fun ConnectScreen(
connecting = false
discovery.start() // the request may still be pending on the host; keep scanning
}
if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel)
else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel)
AwaitingApprovalPrompt(gamepadUi, hostLabel = req.target.name, onCancel = onCancel)
}
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
@@ -1229,11 +1239,7 @@ fun ConnectScreen(
}
speedTest = null
}
if (gamepadUi) {
GamepadSpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
} else {
SpeedTestDialog(entry.host.name, target, speedTestPhase, apply, dismiss)
}
SpeedTestPrompt(gamepadUi, entry.host.name, target, speedTestPhase, apply, dismiss)
}
editTarget?.let { kh ->
@@ -1285,11 +1291,12 @@ fun ConnectScreen(
),
)
}
if (gamepadUi) {
GamepadLocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
} else {
LocalNetworkDialog(onAllow = onAllow, onSettings = onSettings, onDismiss = { lnpPrompt = false })
}
LocalNetworkPrompt(
gamepadUi,
onAllow = onAllow,
onSettings = onSettings,
onDismiss = { lnpPrompt = false },
)
}
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
@@ -383,157 +383,6 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: ()
}
}
/**
* 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) {
GamepadDialog(
title = "Allow local network access",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Allow", primary = true, onClick = onAllow),
DialogAction("Open settings", onClick = onSettings),
DialogAction("Not now", onClick = onDismiss),
),
) {
DialogText(
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
"or reach any host until you allow it.",
)
DialogText(
"If no prompt appears after Allow, enable “Nearby devices” for Punktfunk in " +
"system settings.",
)
}
}
@Composable
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Trust this host?",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Pair with PIN", onClick = onPairInstead),
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
),
) {
DialogText("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}") }
DialogText(
"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.",
)
}
}
@Composable
fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Host identity changed",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Re-pair", primary = true, onClick = onRepair),
),
) {
DialogText(
"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.",
)
}
}
@Composable
fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Pairing required",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Use a PIN", onClick = onUsePin),
DialogAction("Request access", primary = true, onClick = onRequestAccess),
),
) {
DialogText("${pt.host}:${pt.port} requires pairing before it will stream.")
DialogText(
"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.",
)
}
}
@Composable
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
val ink = LocalGamepadInk.current
GamepadDialog(
title = "Waiting for approval",
onDismiss = onCancel,
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
) {
val deviceName = Build.MODEL ?: "this device"
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg)
Text("Approve this device on $hostLabel.", color = ink.fg)
}
DialogText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
"once you approve — no PIN needed.",
)
}
}
/**
* Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes
* 09), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
@@ -49,7 +49,7 @@ import io.unom.punktfunk.StreamStartBanner
import io.unom.punktfunk.ProfileEditorFields
import io.unom.punktfunk.ProfileStore
import io.unom.punktfunk.SettingsOverlay
import io.unom.punktfunk.SpeedTestDialog
import io.unom.punktfunk.SpeedTestPrompt
import io.unom.punktfunk.SpeedTestPhase
import io.unom.punktfunk.SpeedTestTarget
import io.unom.punktfunk.components.HostCard
@@ -245,7 +245,8 @@ internal fun SettingsProfileScene() {
*/
@Composable
internal fun SpeedTestScene() {
SpeedTestDialog(
SpeedTestPrompt(
gamepadUi = false,
hostName = "Living Room PC",
target = SpeedTestTarget.Ask(newProfile("Game")),
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),