diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/AdaptiveDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/AdaptiveDialogs.kt new file mode 100644 index 00000000..282b537b --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/AdaptiveDialogs.kt @@ -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, + /** + * 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." +} 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 35785456..28902e5d 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 @@ -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." -} 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 6c3c0dac..38b55888 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 @@ -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 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 baa16fc4..44c3334a 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 @@ -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 * 0–9), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified 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 0f9575ca..8e2c7dab 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 @@ -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),