diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index f5aa8590..eda12c3f 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -193,9 +193,16 @@ jobs: # screenshot scenes, which are a release-artifact job (android-screenshots.yml, gated to v* # tags) and have no business adding a minute to every push. The filter is what lets the # contract gate here without dragging the rest of the app suite in with it. - - name: console parity vectors + - name: console parity vectors + app-module logic tests working-directory: clients/android - run: ./gradlew :app:testDebugUnitTest --tests 'io.unom.punktfunk.ConsoleVectorsTest' --stacktrace + run: >- + ./gradlew :app:testDebugUnitTest + --tests 'io.unom.punktfunk.ConsoleVectorsTest' + --tests 'io.unom.punktfunk.HomeTilesTest' + --tests 'io.unom.punktfunk.GamepadSettingsLayoutTest' + --tests 'io.unom.punktfunk.ConsoleSubScreenRowsTest' + --tests 'io.unom.punktfunk.ConsoleSubScreenRoutesTest' + --stacktrace - name: assembleDebug (cargo-ndk → jniLibs → APK) working-directory: clients/android diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index 75a53c21..3b1e0d85 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -11,6 +11,9 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row @@ -19,12 +22,16 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf @@ -33,8 +40,10 @@ import androidx.compose.runtime.LaunchedEffect 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.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -49,6 +58,7 @@ import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.models.ActiveSession import io.unom.punktfunk.models.Tab import kotlin.math.roundToInt +import kotlinx.coroutines.launch @Composable fun App(forceGamepadUi: Boolean = false) { @@ -252,6 +262,11 @@ private enum class GamepadScreen(val depth: Int) { Home(0), Settings(1), Library(1), + // Reached FROM Settings, not from Home, so they sit a level deeper again — which is precisely + // what makes Settings → Controllers travel like a push and the way back like a pop. Give one of + // these depth 1 and the transition would read as a sideways swap between two peers. + Controllers(2), + Licenses(2), } /** @@ -276,6 +291,12 @@ fun GamepadShell( val context = LocalContext.current var screen by remember { mutableStateOf(GamepadScreen.Home) } var libraryHost by remember { mutableStateOf(null) } + // Where the settings screen was when a sub-screen took over. The shell's AnimatedContent + // discards a screen's `remember`s the moment it stops being the target, so a trip out to the + // Controllers view and back would otherwise land on the Stream tab's first row — the couch + // equivalent of a browser losing your scroll position on Back. Held here because this is the + // only thing that outlives the screen. + var settingsPlace by remember { mutableStateOf(null) } // Consume the "come back to this library" intent once, on entry. Keyed on the id so a second // game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out. @@ -362,7 +383,23 @@ fun GamepadShell( GamepadScreen.Settings -> GamepadSettingsScreen( initial = settings, onChange = onSettingsChange, - onBack = { screen = GamepadScreen.Home }, + // Leaving for HOME forgets the place: coming back in from the carousel should start + // at the top of the first section, exactly as it always has. Only a sub-screen's + // Back is a return. + onBack = { screen = GamepadScreen.Home; settingsPlace = null }, + navActive = s == screen, + resume = settingsPlace, + onPlace = { settingsPlace = it }, + onOpenControllers = { screen = GamepadScreen.Controllers }, + onOpenLicenses = { screen = GamepadScreen.Licenses }, + ) + GamepadScreen.Controllers -> ConsoleControllersScreen( + gamepadSetting = settings.gamepad, + onBack = { screen = GamepadScreen.Settings }, + navActive = s == screen, + ) + GamepadScreen.Licenses -> ConsoleLicensesScreen( + onBack = { screen = GamepadScreen.Settings }, navActive = s == screen, ) GamepadScreen.Library -> libraryHost?.let { host -> @@ -381,3 +418,128 @@ fun GamepadShell( /** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */ private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f + +// --- Showing a TOUCH-written screen on the console's field ------------------------------------- +// +// Two screens (Controllers, Licenses) exist once and are shown in both interfaces. They live beside +// the shell rather than in `GamepadChrome.kt` because they are about the SHELL's job — putting a +// screen that was written for one interface onto the other's field — rather than about the console's +// own material. + +/** + * Re-inks a screen written against the TOUCH theme so it can be shown on the console's field. + * + * `ControllersScreen` alone pulls `MaterialTheme.colorScheme` at 27 explicit sites, plus implicitly + * through every `OutlinedCard`, `Switch`, `OutlinedButton` and `LinearProgressIndicator` it draws. + * Dropped into the shell those keep the touch palette — light-grey body text with no background of + * its own, which over the six PALE console palettes (`GamepadPalette`, `light = true`) is grey on + * pastel: technically painted, in practice unreadable. That is the same class of bug as the console + * dialogs that spent a release rendering dark ink on a dark card. + * + * The fix is deliberately ONE derived colour scheme rather than 27 call-site branches: + * * a call-site branch cannot reach the IMPLICIT pulls at all — a `Switch`'s track and an + * `OutlinedCard`'s border are resolved inside Material, not here; + * * two colours per site is exactly the shape that drifts, and it would leave the touch screen + * carrying console vocabulary it has no use for. + * + * The alternative — give the console presentation an opaque backdrop and let the touch theme read on + * its own ground — was rejected because it splits the screen's material in two: an opaque touch-grey + * slab under a palette-inked header and legend, with a visible seam between them, on a field whose + * whole point is that one look runs through it. + * + * The base scheme follows the field's lightness, so anything not overridden here (a container role + * some Material component reaches for) still lands on the right side of the contrast line. + */ +@Composable +internal fun ConsoleInkedTheme(content: @Composable () -> Unit) { + val ink = LocalGamepadInk.current + val scheme = remember(ink) { + val base = if (ink.isLight) lightColorScheme() else darkColorScheme() + base.copy( + primary = ink.accent, + onPrimary = ink.onAccent, + // A card becomes a PANE over the aurora rather than a slab on top of it: the console's + // own glass fill, so an OutlinedCard here is cut from the material the settings rows are. + surface = ink.glass, + onSurface = ink.fg, + surfaceVariant = ink.fg(0.12f), + onSurfaceVariant = ink.fg(0.68f), + outline = ink.fg(0.30f), + outlineVariant = ink.fg(0.16f), + // Nothing here paints a background — the aurora is the ground — but a component that + // resolves `background` (or the content colour for it) must still land on the palette. + background = Color.Transparent, + onBackground = ink.fg, + ) + } + // The typography and shapes are the app's, not Material's defaults: this swaps the INK, not the + // brand typeface. And `LocalContentColor` has to be provided by hand — outside a Surface or a + // Scaffold it defaults to BLACK, which is how an unstyled `Text` would vanish into a dark field. + MaterialTheme( + colorScheme = scheme, + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes, + ) { + CompositionLocalProvider(LocalContentColor provides ink.fg, content = content) + } +} + +/** + * The console's scroll route for a screen that is a WALL of content rather than a list of focusable + * rows. + * + * Compose only scrolls a container to keep a FOCUSED child visible, so a screen whose body holds no + * focusable nodes (the licenses notices are one enormous `Text`) simply cannot be scrolled by a + * controller: the D-pad has nothing to move to. These screens therefore drive the scroll state + * directly — up/down steps, the shoulders page. + * + * Returned as a plain function so a screen's nav callbacks read `scroll(-1, page = false)` rather + * than each screen minting its own coroutine + viewport arithmetic (which is how the two would end + * up scrolling at different speeds). + */ +@Composable +internal fun rememberConsoleScroller(scroll: ScrollState): (dir: Int, page: Boolean) -> Unit { + val scope = rememberCoroutineScope() + val animated = animationsEnabled() + return remember(scroll, animated) { + { dir, page -> + val delta = consoleScrollDelta(scroll.viewportSize.toFloat(), page, dir) + if (delta != 0f) { + scope.launch { + // Auto-repeat fires every 150 ms while a direction is held, so each animation is + // short enough to have landed (or nearly) before the next one cancels it — + // otherwise a held D-pad crawls, each step restarting from where the last was + // interrupted. + if (animated) { + scroll.animateScrollBy( + delta, + ConsoleMotion.ease( + if (page) ConsoleMotion.TRANSITION_MS else ConsoleMotion.FOCUS_MS, + ), + ) + } else { + scroll.scrollBy(delta) + } + } + } + } + } +} + +/** + * How far one console scroll press travels: [dir] is -1 (up/left) or +1 (down/right), [page] picks + * the shoulders' full page over a D-pad step. Zero while the viewport is unmeasured — a first press + * that arrived before layout must do nothing rather than fling the content by zero-times-nothing. + */ +internal fun consoleScrollDelta(viewportPx: Float, page: Boolean, dir: Int): Float = + if (viewportPx <= 0f) 0f else viewportPx * (if (page) CONSOLE_PAGE else CONSOLE_STEP) * dir + +/** + * A page keeps a band of what you were reading on screen rather than jumping a clean screenful — the + * overlap every reader has used since the printed page, and the difference between "I moved down" + * and "where was I". + */ +private const val CONSOLE_PAGE = 0.88f + +/** A D-pad step is about a quarter screen, so holding the direction walks the wall rather than flicking it. */ +private const val CONSOLE_STEP = 0.28f diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectGrid.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectGrid.kt new file mode 100644 index 00000000..ed8155df --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectGrid.kt @@ -0,0 +1,339 @@ +package io.unom.punktfunk + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.layout.width +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.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.style.TextAlign +import androidx.compose.ui.unit.dp +import io.unom.punktfunk.components.EmptyHostsState +import io.unom.punktfunk.components.HostCard +import io.unom.punktfunk.components.HostMenuItem +import io.unom.punktfunk.components.SectionLabel +import io.unom.punktfunk.kit.discovery.DiscoveredHost +import io.unom.punktfunk.kit.security.KnownHost +import io.unom.punktfunk.models.HostStatus + +/** + * The touch home: the saved/discovered host grid with the Add-host FAB over it — everything + * `ConnectScreen` draws when the console UI is off, and the counterpart of [buildHomeTiles] + + * `GamepadHome` when it is on. + * + * Pure display: every action arrives as a callback, because they all end in state the screen owns + * (a dial in flight, the trust prompt, the host store). What this file DOES own is the arrangement + * — which sections exist, in what order, and which actions a given card offers — and the two rules + * that are easy to get wrong from the outside: a pinned card is a shortcut and so withholds the + * host's destructive actions, and every card in a section reserves the profile chip's space as soon + * as one of them needs it. + */ +@Composable +internal fun ConnectGrid( + savedHosts: List, + /** Every live advert — the OS mark prefers it over the stored one, and "searching…" reads it. */ + discovered: List, + /** Adverts with no saved record behind them, de-duped by the caller (it needs them too). */ + discoveredUnsaved: List, + /** Saved hosts answering the QUIC probe, "address:port" — the routed half of "online". */ + reachable: Set, + profiles: List, + pinsFor: (KnownHost) -> List, + connecting: Boolean, + /** A confirmation ("75 Mbit/s set in …"); [status] is the failure line. Never the same thing. */ + notice: String?, + status: String?, + lnpGranted: Boolean, + /** Raise the local-network-permission prompt — the banner's "Allow…" and the wake guard. */ + onAskLocalNetwork: () -> Unit, + /** + * Dial a saved host. The second argument is `connect`'s one-off profile reference: null follows + * the host's binding (a plain tap), a profile id forces that profile, and the empty string + * forces the global defaults — a real, different action on a bound host, which is why it has to + * survive as a value rather than collapsing into "unset". + */ + onConnect: (KnownHost, String?) -> Unit, + onConnectDiscovered: (DiscoveredHost) -> Unit, + onForget: (KnownHost) -> Unit, + onEdit: (KnownHost) -> Unit, + onWake: (KnownHost) -> Unit, + onSpeedTest: (KnownHost) -> Unit, + onCopyLink: (KnownHost, StreamProfile?) -> Unit, + onTogglePin: (KnownHost, StreamProfile) -> Unit, + onRescan: () -> Unit, + onAddHost: () -> Unit, +) { + // The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a + // user who never wants this feature sees no new clutter anywhere but the settings scope chips. + // "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding + // lives in the Edit sheet instead. + fun hostMenu(kh: KnownHost, pin: StreamProfile?): List = buildList { + if (pin == null) { + add(HostMenuItem("Network speed test") { onSpeedTest(kh) }) + } + add(HostMenuItem("Copy link") { onCopyLink(kh, pin) }) + if (profiles.isEmpty()) return@buildList + if (pin != null) { + add(HostMenuItem("Unpin card", startsSection = true) { onTogglePin(kh, pin) }) + } + add( + HostMenuItem("Connect with: Default settings", startsSection = true) { + // The empty reference is "force the defaults", not "unset" — on a bound host that + // is a real, different action from a plain tap. + onConnect(kh, "") + }, + ) + profiles.forEach { p -> + add(HostMenuItem("Connect with: ${p.name}") { onConnect(kh, p.id) }) + } + if (pin == null) { + profiles.forEachIndexed { i, p -> + val pinned = p.id in kh.pinnedProfileIds + add( + HostMenuItem( + if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}", + startsSection = i == 0, + ) { onTogglePin(kh, p) }, + ) + } + } + } + + // The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a + // pinned combination is a plain one-click connect instead of a trip through a menu. + val savedCards = savedHosts.flatMap { kh -> + listOf(HostCardEntry(kh, null)) + pinsFor(kh).map { HostCardEntry(kh, it) } + } + // Cards in one grid row must be the same height (the grid won't stretch them), so as soon as + // ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use + // profiles ever sees the gap. + val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null } + + Box(Modifier.fillMaxSize()) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(8.dp)) + Text("Punktfunk", style = MaterialTheme.typography.headlineLarge) + Text( + "stream a remote desktop", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + + notice?.let { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + Spacer(Modifier.height(16.dp)) + } + + status?.let { + // In-flight progress (connecting / waking) is the full-screen ConnectOverlay's + // job now, so `status` only ever carries a result/error here — a filled error + // container reads as a real failure banner, not just red text lost in the layout. + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + Spacer(Modifier.height(16.dp)) + } + } + } + + if (!lnpGranted) { + // Local network access denied: discovery can't ever find anything and every connect + // would time out — say so at the top, with the fix one tap away, instead of letting + // the screen look idle/broken. + item(span = { GridItemSpan(maxLineSpan) }) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Local network access is off", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + "Android blocks Punktfunk from finding or reaching hosts until you allow it.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + textAlign = TextAlign.Center, + ) + TextButton(onClick = onAskLocalNetwork) { Text("Allow…") } + } + } + Spacer(Modifier.height(12.dp)) + } + } + + if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + EmptyHostsState() + } + } + + if (savedHosts.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + SectionLabel("Saved hosts") + } + items(savedCards, key = { it.key }) { entry -> + val kh = entry.host + val pin = entry.pin + val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } } + HostCard( + name = kh.name, + address = "${kh.address}:${kh.port}", + status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU, + online = kh.isOnline(discovered, reachable), + // Live advert preferred (the store lags a discovery tick), else stored. + os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os + ?: kh.os, + enabled = !connecting, + // A pinned card connects with ITS profile; the host's own card follows the + // binding, which is exactly what its chip says it will do. + onConnect = { onConnect(kh, pin?.id) }, + // Edit / Forget / Wake live on the host's own card only: a pinned card is a + // shortcut, not a second host, and offering destructive host actions on it + // would blur exactly that. + onForget = if (pin != null) null else ({ onForget(kh) }), + onEdit = if (pin != null) null else ({ onEdit(kh) }), + // Explicit wake-only: offered when the host is offline and we have a MAC. The + // screen runs it through the WakeController so it shows the "Waking…" overlay + // and waits for the host to come online (matched by fingerprint, so a new DHCP + // address on a cold boot still counts as "up") rather than firing a single + // silent packet. + onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) { + ({ onWake(kh) }) + } else { + null + }, + profileLabel = pin?.name ?: bound?.name, + profileProminent = pin != null, + accent = accentColor(pin?.accent ?: bound?.accent), + menuItems = hostMenu(kh, pin), + reserveProfileSlot = anyProfileChip, + ) + } + } + + if (discoveredUnsaved.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Spacer(Modifier.height(12.dp)) + SectionLabel("Discovered on the network") + } + items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh -> + HostCard( + name = dh.name, + address = "${dh.host}:${dh.port}", + status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU, + online = true, // in the discovered list ⇒ live on mDNS right now + os = dh.os, + enabled = !connecting, + onConnect = { onConnectDiscovered(dh) }, + onForget = null, + ) + } + } + + // Active-discovery hint: discovery runs whenever this screen is up, so while it's + // scanning but nothing's turned up yet (and we're not mid-connect), show it's working + // rather than looking idle/empty. Suppressed while local network access is denied — + // a spinner would be a lie there (the browse can't receive anything); the banner above + // owns that state. + // Scan again is offered whether or not anything turned up: the case that sends people + // here is ONE expected host missing, not an empty list, and a browse that quietly went + // deaf (blocked when it started, or backed off to its hour-long re-query) looks + // exactly like a network without that host on it. + if (lnpGranted && !connecting) { + item(span = { GridItemSpan(maxLineSpan) }) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (discovered.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text( + "Searching the local network…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + } + TextButton(onClick = onRescan) { Text("Scan again") } + } + } + } + + item(span = { GridItemSpan(maxLineSpan) }) { + Spacer(Modifier.height(96.dp)) + } + } + + ExtendedFloatingActionButton( + onClick = onAddHost, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text("Add host") }, + expanded = !connecting, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(20.dp), + ) + } +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectPrompts.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectPrompts.kt new file mode 100644 index 00000000..801bf0dd --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectPrompts.kt @@ -0,0 +1,195 @@ +package io.unom.punktfunk + +import androidx.compose.runtime.Composable +import io.unom.punktfunk.kit.security.ClientIdentity +import io.unom.punktfunk.kit.security.KnownHost +import io.unom.punktfunk.models.PendingTrust + +/** + * Everything `ConnectScreen` puts ON TOP of whichever home it drew — the trust and pairing + * ceremony, the parked "Waiting for approval…", the console's host options, the speed test, the + * edit form, the local-network rationale, and finally the connect takeover. + * + * They live together because their ORDER is the contract: this is a stack of siblings in one tree, + * so the last one drawn is the one on top, and [ConnectOverlay] is last on purpose — a dial can + * start from any of the prompts above it, and its takeover has to cover the prompt that started it. + * + * Only the state each prompt reads comes in; every action goes back out as a callback, because they + * all end in the connect/pair engine or in the host store, which the screen owns. Nothing in here + * decides anything — it decides only what is visible. + */ +@Composable +internal fun ConnectPrompts( + gamepadUi: Boolean, + /** The client identity — the PIN ceremony needs it to run SPAKE2; null while it is still minting. */ + identity: ClientIdentity?, + profiles: List, + isOnline: (KnownHost) -> Boolean, + // ---- trust / pairing -------------------------------------------------------------------- + pendingTrust: PendingTrust?, + /** Dismiss (null) or re-aim the SAME decision at another kind — "Pair with PIN…" does that. */ + onPendingTrustChange: (PendingTrust?) -> Unit, + /** Trust-on-first-use accepted: dial with no pin. Offered only for a `pair=optional` host. */ + onTrustNew: (PendingTrust) -> Unit, + /** The PIN ceremony completed with this host fingerprint — save as paired, then dial. */ + onPaired: (PendingTrust, String) -> Unit, + onRequestAccess: (PendingTrust) -> Unit, + // ---- the parked no-PIN request ---------------------------------------------------------- + /** Non-null while a "request access" connect sits parked on the host awaiting approval. */ + awaitingHostName: String?, + onCancelApproval: () -> Unit, + // ---- console host options (Up on a saved carousel tile) --------------------------------- + optionsTarget: HostCardEntry?, + onDismissOptions: () -> Unit, + libraryEnabled: Boolean, + onOpenLibrary: (KnownHost) -> Unit, + onWake: (KnownHost) -> Unit, + onSpeedTest: (KnownHost) -> Unit, + onCopyLink: (KnownHost, StreamProfile?) -> Unit, + onEditHost: (KnownHost) -> Unit, + onForgetHost: (KnownHost) -> Unit, + onTogglePin: (KnownHost, StreamProfile) -> Unit, + // ---- speed test -------------------------------------------------------------------------- + speedTest: HostCardEntry?, + /** Which layer Apply writes to. Resolved by the caller (it holds the store); set with [speedTest]. */ + speedTestTarget: SpeedTestTarget?, + speedTestPhase: SpeedTestPhase, + /** true = write the measured bitrate to the profile, false = to the global default. */ + onApplySpeedTest: (Boolean) -> Unit, + onDismissSpeedTest: () -> Unit, + // ---- edit host --------------------------------------------------------------------------- + editTarget: KnownHost?, + /** A MAC from the live advert, for a host whose own is not learned yet. */ + editSuggestedMacs: List, + onSaveHost: (KnownHost) -> Unit, + onDismissEdit: () -> Unit, + // ---- local network permission ------------------------------------------------------------ + lnpPrompt: Boolean, + onAllowLocalNetwork: () -> Unit, + onOpenSystemSettings: () -> Unit, + onDismissLnpPrompt: () -> Unit, + // ---- the connect takeover ---------------------------------------------------------------- + connectingHostName: String?, + waker: WakeController, + onCancelConnect: () -> Unit, +) { + pendingTrust?.let { pt -> + // Same trust/pairing logic, console-styled + controller-navigable in gamepad mode. + val onPair = { onPendingTrustChange(pt.copy(kind = PendingTrust.Kind.PAIR)) } + // 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 -> TrustNewHostPrompt( + gamepadUi, pt, + onTrust = { onTrustNew(pt) }, + onPairInstead = onPair, + onDismiss = { onPendingTrustChange(null) }, + ) + PendingTrust.Kind.FP_CHANGED -> + FingerprintChangedPrompt(gamepadUi, pt, onPair) { onPendingTrustChange(null) } + PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessPrompt( + gamepadUi, pt, + onRequestAccess = { onRequestAccess(pt) }, + onUsePin = onPair, + onDismiss = { onPendingTrustChange(null) }, + ) + PendingTrust.Kind.PAIR -> { + val onSavePaired = { fp: String -> onPaired(pt, fp) } + if (gamepadUi) { + GamepadPairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) } + } else { + PairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) } + } + } + } + } + + awaitingHostName?.let { hostLabel -> + AwaitingApprovalPrompt(gamepadUi, hostLabel = hostLabel, onCancel = onCancelApproval) + } + + // Console host options (Up on a saved carousel tile): Wake / Edit / Forget. + optionsTarget?.let { entry -> + val kh = entry.host + val pin = entry.pin + val offline = !isOnline(kh) + GamepadHostOptionsDialog( + hostName = kh.name, + canWake = kh.mac.isNotEmpty() && offline, + onWake = { onDismissOptions(); onWake(kh) }, + // A saved host always has a library (it's a knownHost) → offer it when the setting's on, + // so a TV remote reaches the library here instead of via the Y face button. + onLibrary = if (libraryEnabled && pin == null) { + { onDismissOptions(); onOpenLibrary(kh) } + } else { + null + }, + onSpeedTest = if (pin == null) { + { onDismissOptions(); onSpeedTest(kh) } + } else { + null + }, + onCopyLink = { onDismissOptions(); onCopyLink(kh, pin) }, + onEdit = { onDismissOptions(); onEditHost(kh) }, + onForget = { onForgetHost(kh); onDismissOptions() }, + onDismiss = onDismissOptions, + // A pin's only action: unpinning touches neither the host nor the profile. + onUnpin = pin?.let { p -> { onTogglePin(kh, p); onDismissOptions() } }, + profileName = pin?.name, + ) + } + + if (speedTest != null && speedTestTarget != null) { + SpeedTestPrompt( + gamepadUi, speedTest.host.name, speedTestTarget, speedTestPhase, + onApplySpeedTest, onDismissSpeedTest, + ) + } + + editTarget?.let { kh -> + if (gamepadUi) { + // Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the + // host with an extra MAC row; the action SAVES instead of connecting. + GamepadAddHostScreen( + onAdd = { _, _, _ -> }, + onDismiss = onDismissEdit, + editHost = kh, + suggestedMacs = editSuggestedMacs, + onSave = onSaveHost, + // Shared clipboard and the profile binding — the two host decisions that used to + // exist only in the touch edit sheet, which a TV box has no way to reach. + profiles = profiles, + ) + } else { + EditHostDialog( + target = kh, + suggestedMacs = editSuggestedMacs, + profiles = profiles, + onSave = onSaveHost, + onDismiss = onDismissEdit, + ) + } + } + + if (lnpPrompt) { + // Android 17+ local-network-permission rationale: re-request (a permanently-denied request + // returns instantly without a system prompt — hence the settings deep link alongside). + LocalNetworkPrompt( + gamepadUi, + onAllow = onAllowLocalNetwork, + onSettings = onOpenSystemSettings, + onDismiss = onDismissLnpPrompt, + ) + } + + // Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing + // seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch + // grid and the console home. + ConnectOverlay( + connectingHostName = connectingHostName, + waker = waker, + gamepadUi = gamepadUi, + onCancelConnect = onCancelConnect, + ) +} 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 38b55888..7116fe91 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 @@ -11,31 +11,6 @@ import android.os.Build import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -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.layout.width -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.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -45,19 +20,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState 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.style.TextAlign -import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner -import io.unom.punktfunk.components.EmptyHostsState -import io.unom.punktfunk.components.HostCard -import io.unom.punktfunk.components.HostMenuItem -import io.unom.punktfunk.components.SectionLabel import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.discovery.DiscoveredHost @@ -73,7 +40,6 @@ import io.unom.punktfunk.kit.security.KnownHost import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.kit.security.obtainIdentity import io.unom.punktfunk.models.ActiveSession -import io.unom.punktfunk.models.HostStatus import io.unom.punktfunk.models.PendingTrust import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.Dispatchers @@ -108,6 +74,20 @@ private class ConnectAttempt(val hostName: String) { val cancelled = AtomicBoolean(false) } +/** + * The connect screen — discovery, trust and the dial itself, under either interface. + * + * What is left in this file is the STATE and the engine: the mDNS browse and the permission that + * gates it, the identity, the host and profile stores, the trust decision, the dial and its wake + * fallback, and the `punktfunk://` router. What was drawn from that state now lives beside it — + * `buildHomeTiles` (the console carousel's contents), `ConnectGrid` (the touch home) and + * `ConnectPrompts` (everything modal, plus the connect takeover). They hold no state of their own, + * which is why they could leave: each one takes what it displays and hands back what was pressed. + * + * The engine did NOT leave, and shouldn't until it has somewhere to live: it closes over ~20 locals + * that a dozen callbacks read and write, and hoisting it means inventing a state holder — a second + * refactor, and a second thing to get wrong. + */ @Composable fun ConnectScreen( settings: Settings, @@ -651,52 +631,6 @@ fun ConnectScreen( } } - // The profile rows a card's overflow menu grows. With no profiles at all it stays empty — a - // user who never wants this feature sees no new clutter anywhere but the settings scope chips. - // "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding - // lives in the Edit sheet instead. - fun hostMenu(kh: KnownHost, pin: StreamProfile?): List = buildList { - if (pin == null) { - add(HostMenuItem("Network speed test") { startSpeedTest(HostCardEntry(kh, null)) }) - } - add(HostMenuItem("Copy link") { copyLink(kh, pin) }) - if (profiles.isEmpty()) return@buildList - if (pin != null) { - add(HostMenuItem("Unpin card", startsSection = true) { togglePin(kh, pin) }) - } - add( - HostMenuItem("Connect with: Default settings", startsSection = true) { - // The empty reference is "force the defaults", not "unset" — on a bound host that - // is a real, different action from a plain tap. - connect(kh.address, kh.port, oneOffProfile = "") - }, - ) - profiles.forEach { p -> - add(HostMenuItem("Connect with: ${p.name}") { connect(kh.address, kh.port, oneOffProfile = p.id) }) - } - if (pin == null) { - profiles.forEachIndexed { i, p -> - val pinned = p.id in kh.pinnedProfileIds - add( - HostMenuItem( - if (pinned) "Unpin card: ${p.name}" else "Pin as card: ${p.name}", - startsSection = i == 0, - ) { togglePin(kh, p) }, - ) - } - } - } - - // The saved-hosts grid: each host's own card, then one card per profile it has pinned, so a - // pinned combination is a plain one-click connect instead of a trip through a menu. - val savedCards = savedHosts.flatMap { kh -> - listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) } - } - // Cards in one grid row must be the same height (the grid won't stretch them), so as soon as - // ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use - // profiles ever sees the gap. - val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null } - // ---- punktfunk:// routing (design/client-deep-links.md §3) -------------------------------- // // The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust @@ -782,86 +716,61 @@ fun ConnectScreen( var showManualSheet by remember { mutableStateOf(false) } + // Wake a saved host on demand — the touch card's Wake item and the console options dialog run + // the same action. Through the WakeController, so it shows the "Waking…" overlay and waits for + // the host to come back rather than firing one silent packet at it. + fun wakeHost(kh: KnownHost) { + // The magic packet is UDP broadcast — LNP-blocked like everything else. + if (!lnpGranted) { + lnpPrompt = true + return + } + waker.start( + hostName = kh.name, + connectsAfter = false, + macs = kh.mac, + lastIp = kh.address, + // "Back up" is mDNS presence ONLY — narrower than the [isOnline] that decides whether to + // OFFER Wake, which also counts a QUIC probe answer. Matched through `matches`, so a + // cold boot onto a new DHCP address still ends the wait. + isOnline = { discovered.any { kh.matches(it) } }, + onOnline = {}, + ) + } + + fun forgetHost(kh: KnownHost) { + knownHostStore.remove(kh) + savedHosts = knownHostStore.all() + } + if (gamepadUi) { // Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares // every action above; the trailing Add Host tile opens the same manual-entry sheet. - val tiles = buildList { - savedHosts.forEach { kh -> - val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } } - add( - HomeTile( - id = "saved-${kh.id}", - title = kh.name, - subtitle = "${kh.address}:${kh.port}", - filled = true, - online = kh.isOnline(discovered, reachable), - paired = kh.paired, - knownHost = kh, - // The binding is what a press will actually do, so the tile says so — the - // console can't edit profiles, but it must never lie about which one it - // uses. It rides in the card's own chip now rather than as a "· Name" tail - // on the address, which is where it read as an afterthought. - profileName = bound?.name, - profileAccent = accentColor(bound?.accent), - activate = { connect(kh.address, kh.port) }, - ), - ) - // Pinned host+profile combinations, right after their host: one focus-and-press - // each, which is the affordance a controller surface does well (menus are not). - profileStore.pinsFor(kh).forEach { p -> - add( - HomeTile( - id = "pin-${kh.id}-${p.id}", - title = kh.name, - // The address, like every other card — the PROFILE is what makes this - // card different, and it now says so in the chip instead of standing - // in for the subtitle, which left a pin card unable to say where it - // pointed. - subtitle = "${kh.address}:${kh.port}", - filled = true, - online = kh.isOnline(discovered, reachable), - paired = kh.paired, - knownHost = kh, - pinnedProfileId = p.id, - profileName = p.name, - profileAccent = accentColor(p.accent), - activate = { connect(kh.address, kh.port, oneOffProfile = p.id) }, - ), - ) - } - } - discoveredUnsaved.forEach { dh -> - add( - HomeTile( - id = "disc-${dh.host}:${dh.port}", - title = dh.name, - subtitle = "${dh.host}:${dh.port}", - online = true, - activate = { connect(dh.host, dh.port, dh) }, - ), - ) - } - add( - HomeTile( - id = "add", - title = "Add Host", - subtitle = "Register a host by address", - isAdd = true, - activate = { showManualSheet = true }, - ), - ) - } GamepadHome( - tiles = tiles, + tiles = buildHomeTiles( + savedHosts = savedHosts, + profiles = profiles, + pinsFor = profileStore::pinsFor, + discoveredUnsaved = discoveredUnsaved, + isOnline = { it.isOnline(discovered, reachable) }, + onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) }, + onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) }, + onAddHost = { showManualSheet = true }, + ), libraryEnabled = settings.libraryEnabled, controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name, // Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen, // while a connect is in flight (else a second A launches a concurrent connect that leaks a // handle — the touch grid guards the same way with enabled=!connecting), or while the whole // console home is cross-fading out. + // ⚠ `speedTest` belongs in this list and was missing. It LOOKED covered by `!connecting`, + // and is — right up until the measurement finishes: `startSpeedTest` clears `connecting` + // before its Done/Failed card is dismissed, so from that moment the card AND the + // carousel underneath both consumed the pad. One A then dismissed the card and started + // a connect. Every other modal on this screen is named here for exactly this reason. navActive = navGate && !connecting && !showManualSheet && pendingTrust == null && awaiting == null && editTarget == null && optionsTarget == null && - waker.waking == null && !lnpPrompt, + speedTest == null && waker.waking == null && !lnpPrompt, onActivate = { it.activate() }, onOpenLibrary = { it.knownHost?.let(onOpenLibrary) }, onOpenSettings = onOpenSettings, @@ -872,239 +781,35 @@ fun ConnectScreen( }, ) } else { - Box(Modifier.fillMaxSize()) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 160.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - item(span = { GridItemSpan(maxLineSpan) }) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Spacer(Modifier.height(8.dp)) - Text("Punktfunk", style = MaterialTheme.typography.headlineLarge) - Text( - "stream a remote desktop", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(24.dp)) - - notice?.let { - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } - Spacer(Modifier.height(16.dp)) - } - - status?.let { - // In-flight progress (connecting / waking) is the full-screen ConnectOverlay's - // job now, so `status` only ever carries a result/error here — a filled error - // container reads as a real failure banner, not just red text lost in the layout. - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } - Spacer(Modifier.height(16.dp)) - } - } - } - - if (!lnpGranted) { - // Local network access denied: discovery can't ever find anything and every connect - // would time out — say so at the top, with the fix one tap away, instead of letting - // the screen look idle/broken. - item(span = { GridItemSpan(maxLineSpan) }) { - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Column( - Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "Local network access is off", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - "Android blocks Punktfunk from finding or reaching hosts until you allow it.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - textAlign = TextAlign.Center, - ) - TextButton(onClick = { lnpPrompt = true }) { Text("Allow…") } - } - } - Spacer(Modifier.height(12.dp)) - } - } - - if (savedHosts.isEmpty() && discoveredUnsaved.isEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - EmptyHostsState() - } - } - - if (savedHosts.isNotEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - SectionLabel("Saved hosts") - } - items(savedCards, key = { it.key }) { entry -> - val kh = entry.host - val pin = entry.pin - val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } } - HostCard( - name = kh.name, - address = "${kh.address}:${kh.port}", - status = if (kh.paired) HostStatus.PAIRED else HostStatus.TOFU, - online = kh.isOnline(discovered, reachable), - // Live advert preferred (the store lags a discovery tick), else stored. - os = discovered.firstOrNull { kh.matches(it) && it.os.isNotEmpty() }?.os - ?: kh.os, - enabled = !connecting, - // A pinned card connects with ITS profile; the host's own card follows the - // binding, which is exactly what its chip says it will do. - onConnect = { - if (pin != null) { - connect(kh.address, kh.port, oneOffProfile = pin.id) - } else { - connect(kh.address, kh.port) - } - }, - // Edit / Forget / Wake live on the host's own card only: a pinned card is a - // shortcut, not a second host, and offering destructive host actions on it - // would blur exactly that. - onForget = if (pin != null) { - null - } else { - { - knownHostStore.remove(kh) - savedHosts = knownHostStore.all() - } - }, - onEdit = if (pin != null) null else ({ editTarget = kh }), - // Explicit wake-only: offered when the host is offline and we have a MAC. Runs - // through the WakeController so it shows the "Waking…" overlay and waits for - // the host to come online (matched by fingerprint, so a new DHCP address on a - // cold boot still counts as "up") rather than firing a single silent packet. - onWake = if (pin == null && kh.mac.isNotEmpty() && !kh.isOnline(discovered, reachable)) { - { - // The magic packet is UDP broadcast — LNP-blocked like everything else. - if (!lnpGranted) { - lnpPrompt = true - } else { - waker.start( - hostName = kh.name, - connectsAfter = false, - macs = kh.mac, - lastIp = kh.address, - isOnline = { discovered.any { kh.matches(it) } }, - onOnline = {}, - ) - } - } - } else { - null - }, - profileLabel = pin?.name ?: bound?.name, - profileProminent = pin != null, - accent = accentColor(pin?.accent ?: bound?.accent), - menuItems = hostMenu(kh, pin), - reserveProfileSlot = anyProfileChip, - ) - } - } - - if (discoveredUnsaved.isNotEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - Spacer(Modifier.height(12.dp)) - SectionLabel("Discovered on the network") - } - items(discoveredUnsaved, key = { "disc-${it.host}-${it.port}" }) { dh -> - HostCard( - name = dh.name, - address = "${dh.host}:${dh.port}", - status = if (dh.pairingRequired) HostStatus.PAIRING else HostStatus.TOFU, - online = true, // in the discovered list ⇒ live on mDNS right now - os = dh.os, - enabled = !connecting, - onConnect = { connect(dh.host, dh.port, dh) }, - onForget = null, - ) - } - } - - // Active-discovery hint: discovery runs whenever this screen is up, so while it's - // scanning but nothing's turned up yet (and we're not mid-connect), show it's working - // rather than looking idle/empty. Suppressed while local network access is denied — - // a spinner would be a lie there (the browse can't receive anything); the banner above - // owns that state. - // Scan again is offered whether or not anything turned up: the case that sends people - // here is ONE expected host missing, not an empty list, and a browse that quietly went - // deaf (blocked when it started, or backed off to its hour-long re-query) looks - // exactly like a network without that host on it. - if (lnpGranted && !connecting) { - item(span = { GridItemSpan(maxLineSpan) }) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - if (discovered.isEmpty()) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(8.dp)) - Text( - "Searching the local network…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(8.dp)) - } - TextButton(onClick = { discovery.restart() }) { Text("Scan again") } - } - } - } - - item(span = { GridItemSpan(maxLineSpan) }) { - Spacer(Modifier.height(96.dp)) - } - } - - 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), + ConnectGrid( + savedHosts = savedHosts, + discovered = discovered, + discoveredUnsaved = discoveredUnsaved, + reachable = reachable, + profiles = profiles, + pinsFor = profileStore::pinsFor, + connecting = connecting, + notice = notice, + status = status, + lnpGranted = lnpGranted, + onAskLocalNetwork = { lnpPrompt = true }, + onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) }, + onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) }, + onForget = { kh -> forgetHost(kh) }, + onEdit = { kh -> editTarget = kh }, + onWake = { kh -> wakeHost(kh) }, + onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) }, + onCopyLink = { kh, pin -> copyLink(kh, pin) }, + onTogglePin = { kh, p -> togglePin(kh, p) }, + onRescan = { discovery.restart() }, + onAddHost = { showManualSheet = true }, ) - } } + // Add Host stayed behind while the other modals moved into ConnectPrompts: its form fields are + // remembered HERE, on purpose, so a half-typed address survives the sheet being dismissed and + // reopened. Moving the block without moving that state would quietly change what a dismiss + // costs; moving both is a separate decision from this one. if (showManualSheet) { if (gamepadUi) { // Console add-host: field list + on-screen controller keyboard. "Add" connects (which @@ -1132,157 +837,81 @@ fun ConnectScreen( } } - pendingTrust?.let { pt -> - // Same trust/pairing logic, console-styled + controller-navigable in gamepad mode. - val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) } - val onSavePaired = { fp: String -> + // Which layer a measurement would land in. Resolved here, not in the prompt: it is a question + // for the profile store, and the Apply button and the caption above it must agree on the answer. + val speedTestTarget = speedTest?.let { SpeedTestTarget.resolve(it.host, it.pin?.id, profileStore) } + // Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's + // `discovery.hosts.first { host.matches($0) }?.macAddresses`. + val editSuggestedMacs = + editTarget?.let { kh -> discovered.firstOrNull { kh.matches(it) }?.mac } ?: emptyList() + + // Everything that floats above whichever home was drawn, in one place and in one order — see + // ConnectPrompts.kt. It decides nothing: each action below lands right back in the engine above. + ConnectPrompts( + gamepadUi = gamepadUi, + identity = identity, + profiles = profiles, + isOnline = { it.isOnline(discovered, reachable) }, + pendingTrust = pendingTrust, + onPendingTrustChange = { pendingTrust = it }, + onTrustNew = { pt -> + pendingTrust = null + doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) + }, + onPaired = { pt, fp -> knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true) savedHosts = knownHostStore.all() 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 -> 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 -> - 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 }) - } - } - - awaiting?.let { req -> - val onCancel = { - req.cancelled.set(true) + }, + onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) }, + awaitingHostName = awaiting?.target?.name, + onCancelApproval = { + awaiting?.cancelled?.set(true) awaiting = null connecting = false discovery.start() // the request may still be pending on the host; keep scanning - } - AwaitingApprovalPrompt(gamepadUi, hostLabel = req.target.name, onCancel = onCancel) - } - - // Console host options (Up on a saved carousel tile): Wake / Edit / Forget. - optionsTarget?.let { entry -> - val kh = entry.host - val pin = entry.pin - val offline = !kh.isOnline(discovered, reachable) - GamepadHostOptionsDialog( - hostName = kh.name, - canWake = kh.mac.isNotEmpty() && offline, - onWake = { - optionsTarget = null - // The magic packet is UDP broadcast — LNP-blocked like everything else. - if (!lnpGranted) { - lnpPrompt = true - } else { - waker.start( - hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address, - isOnline = { discovered.any { kh.matches(it) } }, - onOnline = {}, - ) - } - }, - // A saved host always has a library (it's a knownHost) → offer it when the setting's on, - // so a TV remote reaches the library here instead of via the Y face button. - onLibrary = if (settings.libraryEnabled && pin == null) { - { optionsTarget = null; onOpenLibrary(kh) } - } else { - null - }, - onSpeedTest = if (pin == null) { - { optionsTarget = null; startSpeedTest(HostCardEntry(kh, null)) } - } else { - null - }, - onCopyLink = { optionsTarget = null; copyLink(kh, pin) }, - onEdit = { optionsTarget = null; editTarget = kh }, - onForget = { - knownHostStore.remove(kh) - savedHosts = knownHostStore.all() - optionsTarget = null - }, - onDismiss = { optionsTarget = null }, - // A pin's only action: unpinning touches neither the host nor the profile. - onUnpin = pin?.let { p -> { togglePin(kh, p); optionsTarget = null } }, - profileName = pin?.name, - ) - } - - speedTest?.let { entry -> - val target = SpeedTestTarget.resolve(entry.host, entry.pin?.id, profileStore) - val dismiss = { speedTest = null } - val apply: (Boolean) -> Unit = { toProfile -> + }, + optionsTarget = optionsTarget, + onDismissOptions = { optionsTarget = null }, + libraryEnabled = settings.libraryEnabled, + onOpenLibrary = onOpenLibrary, + onWake = { kh -> wakeHost(kh) }, + onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) }, + onCopyLink = { kh, pin -> copyLink(kh, pin) }, + onEditHost = { kh -> editTarget = kh }, + onForgetHost = { kh -> forgetHost(kh) }, + onTogglePin = { kh, p -> togglePin(kh, p) }, + speedTest = speedTest, + speedTestTarget = speedTestTarget, + speedTestPhase = speedTestPhase, + onApplySpeedTest = { toProfile -> val done = speedTestPhase as? SpeedTestPhase.Done - if (done != null) { + if (done != null && speedTestTarget != null) { val where = applySpeedTestResult( - done.recommendedKbps, target, toProfile, profileStore, settings, onSettingsChange, + done.recommendedKbps, speedTestTarget, toProfile, profileStore, settings, + onSettingsChange, ) profiles = profileStore.all() notice = "%.0f Mbit/s set in %s".format(done.recommendedMbps, where) } speedTest = null - } - SpeedTestPrompt(gamepadUi, entry.host.name, target, speedTestPhase, apply, dismiss) - } - - editTarget?.let { kh -> - // Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's - // `discovery.hosts.first { host.matches($0) }?.macAddresses`. - val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList() - val onSaveHost: (KnownHost) -> Unit = { updated -> + }, + onDismissSpeedTest = { speedTest = null }, + editTarget = editTarget, + editSuggestedMacs = editSuggestedMacs, + onSaveHost = { updated -> knownHostStore.save(updated) savedHosts = knownHostStore.all() editTarget = null - } - if (gamepadUi) { - // Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the - // host with an extra MAC row; the action SAVES instead of connecting. - GamepadAddHostScreen( - onAdd = { _, _, _ -> }, - onDismiss = { editTarget = null }, - editHost = kh, - suggestedMacs = suggested, - onSave = onSaveHost, - // Shared clipboard and the profile binding — the two host decisions that used to - // exist only in the touch edit sheet, which a TV box has no way to reach. - profiles = profiles, - ) - } else { - EditHostDialog( - target = kh, - suggestedMacs = suggested, - profiles = profiles, - onSave = onSaveHost, - onDismiss = { editTarget = null }, - ) - } - } - - if (lnpPrompt) { - // Android 17+ local-network-permission rationale: re-request (a permanently-denied request - // returns instantly without a system prompt — hence the settings deep link alongside). - val onAllow = { + }, + onDismissEdit = { editTarget = null }, + lnpPrompt = lnpPrompt, + onAllowLocalNetwork = { lnpPrompt = false localNetLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) - } - val onSettings = { + }, + onOpenSystemSettings = { lnpPrompt = false context.startActivity( Intent( @@ -1290,22 +919,10 @@ fun ConnectScreen( Uri.fromParts("package", context.packageName, null), ), ) - } - LocalNetworkPrompt( - gamepadUi, - onAllow = onAllow, - onSettings = onSettings, - onDismiss = { lnpPrompt = false }, - ) - } - - // Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing - // seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch - // grid and the console home. - ConnectOverlay( + }, + onDismissLnpPrompt = { lnpPrompt = false }, connectingHostName = attempt?.hostName, waker = waker, - gamepadUi = gamepadUi, onCancelConnect = { cancelConnect() }, ) } @@ -1314,8 +931,11 @@ fun ConnectScreen( * One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned * host+profile cards. Pins are additive presentation state on the host record — never duplicated * host entries, which would fork pairing, trust and renames (design §5.2a). + * + * The console reuses it deliberately: its options dialog acts on a host-or-pin exactly as the touch + * card's overflow menu does, and one currency for "which card is this" keeps the two from drifting. */ -private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) { +internal data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) { val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}" } @@ -1324,7 +944,7 @@ private data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) { * as a multicast-reception hedge on OEMs that filter multicast without it, but discovery (raw mDNS via * the native core + MulticastLock) does not depend on it. */ -fun hasNearbyPermission(context: Context): Boolean = +internal fun hasNearbyPermission(context: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED @@ -1336,7 +956,7 @@ fun hasNearbyPermission(context: Context): Boolean = * QUIC dial surfaces as a silent handshake timeout and the mDNS browse receives nothing. Unlike * [hasNearbyPermission] this is load-bearing — nothing on the connect screen works without it. */ -fun hasLocalNetworkPermission(context: Context): Boolean = +internal fun hasLocalNetworkPermission(context: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED @@ -1346,7 +966,7 @@ fun hasLocalNetworkPermission(context: Context): Boolean = * fingerprint when both carry it (so it survives a DHCP address change), else by address:port. * Mirrors the Apple client's `StoredHost.matches`; de-dupes "Discovered" against "Saved hosts". */ -private fun KnownHost.matches(dh: DiscoveredHost): Boolean { +internal fun KnownHost.matches(dh: DiscoveredHost): Boolean { val advFp = dh.fingerprint?.lowercase() if (!advFp.isNullOrEmpty() && fpHex.isNotEmpty() && fpHex.lowercase() == advFp) return true return address == dh.host && port == dh.port @@ -1356,6 +976,9 @@ private fun KnownHost.matches(dh: DiscoveredHost): Boolean { * True when a saved host is reachable RIGHT NOW: advertising on mDNS OR answering the QUIC probe * (a host reached over a routed network — Tailscale/VPN — never advertises but is reachable). The * display-side companion to dial-first: presence no longer means "on this LAN". + * + * `internal`, not private: the touch grid draws the same pip in its own file now, and the console's + * tile builder is handed this as a lambda so it never has to know what "reachable" is made of. */ -private fun KnownHost.isOnline(discovered: List, reachable: Set): Boolean = +internal fun KnownHost.isOnline(discovered: List, reachable: Set): Boolean = discovered.any { matches(it) } || reachable.contains("$address:$port") diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index c95a6e79..df960c1c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -1,6 +1,7 @@ package io.unom.punktfunk import android.content.Context +import android.content.res.Configuration import android.hardware.input.InputManager import android.os.Build import android.os.CombinedVibration @@ -11,12 +12,15 @@ import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -39,11 +43,15 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource import io.unom.punktfunk.kit.DsDevice import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.Sc2Capture @@ -55,10 +63,145 @@ import kotlinx.coroutines.delay * case where a pad "doesn't work" — adapters and BT-to-USB dongles often enumerate with a different * identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices * Android classifies as gamepad/joystick. This screen makes that visible on the device itself. + * + * This is the TOUCH entry point; [ConsoleControllersScreen] shows the same body on the console's + * field. Both drive [ControllersBody] — the screen exists once, and the support answer it gives has + * to be the same one whichever interface asked. */ @Composable fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { BackHandler(onBack = onBack) + var testing by remember { mutableStateOf(false) } + ControllersBody( + gamepadSetting = gamepadSetting, + scroll = rememberScrollState(), + testing = testing, + onTestingChange = { testing = it }, + // The touch screen holds the probes for its whole life: events are OBSERVED (not consumed) + // while the test is off, which is what keeps the "Last input" line live while browsing. + // Nothing else here wants the pad, so there is no one to hand them to. + observeInput = true, + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp), + ) { + Text("Controllers", style = MaterialTheme.typography.headlineMedium) + } +} + +/** + * The same screen on the console's field — the couch route to it, which a TV box has no other way to + * reach (there is no touch interface to fall back to there, which is exactly why this matters). + * + * Navigation, and how the pad is shared with the test: + * * up/down scrolls, the shoulders page — the body is cards and prose with no focusable rows, and + * Compose only scrolls to keep a FOCUSED child visible (see [rememberConsoleScroller]); + * * A starts the input test, which is the one thing on this screen a controller can act on; + * * while the test runs it OWNS the pad — that is the whole point of it — so this screen's nav + * drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends. + */ +@Composable +fun ConsoleControllersScreen(gamepadSetting: Int, onBack: () -> Unit, navActive: Boolean = true) { + BackHandler(onBack = onBack) + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val hazeState = remember { HazeState() } + val scroll = rememberScrollState() + val scrollBy = rememberConsoleScroller(scroll) + var testing by remember { mutableStateOf(false) } + val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true + + GamepadNavEffect2D( + // Off while the test runs: both want the same single probe slot, and the test is the one + // the user just asked for. The identity check in each teardown (here and in the body) is + // what makes the handover safe in either direction. + active = navActive && !testing, + onDirection = { dir -> + when (dir) { + NavDir.UP -> scrollBy(-1, false) + NavDir.DOWN -> scrollBy(1, false) + // Nothing on this screen steps sideways; paging is the shoulders' job. + NavDir.LEFT, NavDir.RIGHT -> {} + } + }, + onActivate = { testing = true }, + onShoulder = { delta -> scrollBy(delta, true) }, + ) + + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + // The calm backdrop, full-bleed under the bars and the cutout: this is a screen to READ, + // and the aurora is ambience. Only the content takes the safe area. + GamepadFormBackground(Modifier.fillMaxSize()) + // The body is written against the touch theme; on the console field it has to be inked + // from the palette or it is grey-on-pastel over the six pale palettes. + ConsoleInkedTheme { + Column(Modifier.fillMaxSize().consoleSafeArea()) { + ControllersBody( + gamepadSetting = gamepadSetting, + scroll = scroll, + testing = testing, + onTestingChange = { testing = it }, + // Only while testing: the rest of the time the screen's own nav holds the + // probes, so the "Last input" line is a test-time readout here rather than + // an always-on one. A pad that reaches this screen at all has already + // proved it is seen — by moving the cursor here. + observeInput = testing, + contentPadding = PaddingValues( + start = ConsoleEdgeInset, + end = ConsoleEdgeInset, + // Clears the floating legend zone, like every other console list. + bottom = ConsoleLegendClearance, + ), + ) { + ConsoleHeader("Connected controllers", horizontalInset = false) + } + } + } + } + Box( + Modifier + .align(Alignment.BottomStart) + .consoleLegendInsets(landscape) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar( + if (testing) { + // The rule, stated at the moment it applies: while the test runs, B is a BUTTON + // UNDER TEST like any other — it lights its own chip — so only a hold ends the + // test, after which B is the universal Back again. Tappable as the touch hatch. + listOf(PadGlyph.hint('B', "Hold to finish") { testing = false }) + } else { + listOfNotNull( + GamepadHint('↕', PadGlyph.Arrow, "Scroll"), + // Advertised only where they exist — a TV remote has no shoulders, and + // claiming otherwise is both a lie and the reason a narrow legend overflows. + GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad }, + PadGlyph.hint('A', "Test inputs") { testing = true }, + PadGlyph.hint('B', "Done", onClick = onBack), + ) + }, + hazeState = hazeState, + ) + } + } +} + +/** + * The screen itself, shared by both interfaces. [contentPadding] and [heading] are where they + * differ: the touch screen pads for a thumb and titles with the Material headline, the console pads + * to the shared edge inset, clears its floating legend, and titles with [ConsoleHeader]. + * + * [observeInput] decides whether this body installs the shared MainActivity probes at all — see the + * two call sites, and [ConsoleControllersScreen] for why they cannot both be on at once. + */ +@Composable +private fun ControllersBody( + gamepadSetting: Int, + scroll: ScrollState, + testing: Boolean, + onTestingChange: (Boolean) -> Unit, + observeInput: Boolean, + contentPadding: PaddingValues, + heading: @Composable () -> Unit, +) { val context = LocalContext.current val activity = context as? MainActivity @@ -84,17 +227,31 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { // Live input test. While `testing`, the MainActivity probes consume pad events (so they show up // here instead of driving focus navigation); holding B releases, since the pad can no longer - // reach the Switch. Events are observed (not consumed) even when the test is off, so the - // "last input" line works while browsing. - var testing by remember { mutableStateOf(false) } + // reach the Switch. val held = remember { mutableStateMapOf() } val axes = remember { mutableStateMapOf() } var lastInput by remember { mutableStateOf(null) } var bHeld by remember { mutableStateOf(false) } + // The hold has lasted long enough; the test ends when B is let go (see the probe). + var holdSatisfied by remember { mutableStateOf(false) } + // The probes below are built ONCE per `observeInput` and then read these for the life of that + // installation. `testing` and the callback arrive as parameters now, so capturing them plainly + // would freeze the values they had when the probe was made — the test would consume nothing. + val consuming by rememberUpdatedState(testing) + // The console's refusal thud, on whatever actuator the driving pad or this device has. + val haptics by rememberUpdatedState(rememberConsoleHaptics()) - DisposableEffect(Unit) { - activity?.padKeyProbe = probe@{ event -> + DisposableEffect(observeInput) { + // Stable probe refs, and a teardown that releases the slot only if WE still hold it — the + // rule GamepadNavEffect2D follows. Without it this screen's dispose nulls whatever is in the + // slot: during the console shell's push/pop BOTH screens are briefly composed, so leaving + // here would kill the pad navigation the arriving screen had just installed. The same + // teardown also runs when this screen hands the pad to its own input test and back. + val keyProbe: (KeyEvent) -> Boolean = probe@{ event -> if (!Gamepad.isPad(event.device)) return@probe false + // Read ONCE, up front: the test can end inside this very event, and the release that + // ended it still has to be swallowed here — see the B branch below. + val consume = consuming when (event.action) { KeyEvent.ACTION_DOWN -> { held[event.keyCode] = true @@ -102,13 +259,34 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { } KeyEvent.ACTION_UP -> { held[event.keyCode] = false - if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = false + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) { + bHeld = false + if (consume) { + if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) { + // The hold ends the test HERE, on the release, and NOT the moment + // the 1.2 s elapsed: end it a moment earlier and this release falls + // through unconsumed to the activity's B→BACK remap, which takes the + // whole screen with it. Finishing the test and leaving the screen on + // one press is not what "hold B to finish" says. + onTestingChange(false) + held.clear() + } else { + // A short B is not swallowed either. While the test owns the pad, B + // is a BUTTON UNDER TEST — it lights its chip like every other — so + // a tap can't also mean "leave", and in the console B is otherwise + // the universal back. The press gets the boundary thud instead, the + // same answer a refused step gets on the settings screen: heard, and + // it means something else here. + haptics.boundary() + } + } + } } } lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}" - testing + consume } - activity?.padMotionProbe = probe@{ event -> + val motionProbe: (MotionEvent) -> Boolean = probe@{ event -> if (!Gamepad.isPad(event.device)) return@probe false axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X) axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y) @@ -124,31 +302,43 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { ) axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X) axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y) - testing + consuming + } + if (observeInput) { + activity?.padKeyProbe = keyProbe + activity?.padMotionProbe = motionProbe } onDispose { - activity?.padKeyProbe = null - activity?.padMotionProbe = null + activity?.let { a -> + if (a.padKeyProbe === keyProbe) a.padKeyProbe = null + if (a.padMotionProbe === motionProbe) a.padMotionProbe = null + } } } // Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the - // test instead (touch still works). A short tap cancels the effect before the delay fires. - LaunchedEffect(bHeld) { + // test instead (touch still works). This half only ANSWERS the hold once it is long enough; the + // release is what ends the test (see the probe). Letting go early cancels the effect before the + // delay fires, so nothing is announced. + LaunchedEffect(bHeld, testing) { if (bHeld && testing) { - delay(1_200) - testing = false - held.clear() + delay(HOLD_TO_FINISH_MS) + holdSatisfied = true + // A hold with no answer at the moment it lands is a hold you keep holding. Say it in + // both channels a couch user has: a pulse in the hands, a changed line on the screen. + haptics.confirm() + } else { + holdSatisfied = false } } Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 24.dp), + .verticalScroll(scroll) + .padding(contentPadding), verticalArrangement = Arrangement.spacedBy(24.dp), ) { - Text("Controllers", style = MaterialTheme.typography.headlineMedium) + heading() // Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice // (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from @@ -212,13 +402,19 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { Column(Modifier.weight(1f)) { Text("Test inputs", style = MaterialTheme.typography.bodyLarge) Text( - if (testing) "Controller input stays on this screen — hold B to finish" - else "Show button presses and stick motion live", + when { + holdSatisfied -> "Release B to finish" + testing -> "Controller input stays on this screen — hold B to finish" + else -> "Show button presses and stick motion live" + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Switch(checked = testing, onCheckedChange = { testing = it; if (!it) held.clear() }) + Switch( + checked = testing, + onCheckedChange = { on -> onTestingChange(on); if (!on) held.clear() }, + ) } if (testing) { ButtonGrid(held) @@ -680,3 +876,11 @@ private val TEST_BUTTONS = listOf( /** Axis bars shown in the test view, in display order. */ private val AXIS_LABELS = listOf("LX", "LY", "RX", "RY", "LT", "RT", "HX", "HY") + +/** + * How long B must be held to end the input test — and, below that, how long a press still counts as + * a tap that gets answered rather than ignored. One constant, because a hold that ends at 1.2 s + * while the "you tapped" answer stops at some other number leaves a window where a press does + * nothing at all. + */ +private const val HOLD_TO_FINISH_MS = 1_200L diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 984f6b0b..48bb5fb7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -110,6 +110,12 @@ internal class GpRow( val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text) val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail) + /** + * What A does on a non-adjustable row, for the legend. It was the literal "Pin to hosts" in the + * hint bar back when a profile row was the only kind of row that acted rather than stepped; a + * row that opens the Controllers view was then advertised as pinning something. + */ + val actionHint: String = "Open", ) /** @@ -121,12 +127,28 @@ internal class GpRow( internal fun liveRow(rows: List, index: Int): GpRow? = rows.getOrNull(index)?.takeIf { it.enabled } +/** + * Where the cursor was when a row opened a SUB-SCREEN. The shell holds it across the trip (this + * screen's own state does not outlive it) and hands it back, so Back from the Controllers view lands + * on the row that opened it rather than on the first row of the first section. + * + * The row is remembered by ID, not by index: a tab's length follows the hardware and the profile + * catalog, and a remembered index is the stale-pointer bug the tab-switch clamp already exists for. + */ +data class GpSettingsPlace(val tab: GpTab, val rowId: String) + @Composable fun GamepadSettingsScreen( initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit, navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad + /** Open the connected-controllers view / the open-source notices — the shell pushes them. */ + onOpenControllers: () -> Unit = {}, + onOpenLicenses: () -> Unit = {}, + /** Where a return from one of those lands; null = a fresh entry, which starts at the top. */ + resume: GpSettingsPlace? = null, + onPlace: (GpSettingsPlace) -> Unit = {}, ) { var s by remember { mutableStateOf(initial) } fun update(next: Settings) { s = next; onChange(next) } @@ -169,18 +191,35 @@ fun GamepadSettingsScreen( // path there is this screen's own Controller-optimized UI toggle, which swaps in the standard // interface remote-navigably. The strings branch on it. val tv = remember { isTvDevice(context) } - val allRows = buildSettingsRows(s, hasBodyVibrator, hasGyroscope, av1Capable, ::update) + - buildProfileRows(profiles, savedHosts, tv) { pinProfile = it } + // The installed version, for the About row — the console is the ONLY interface on a TV box, so + // the identity the touch About page states has to be reachable from here too. + val appVersion = remember { + runCatching { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull().orEmpty() + } + val allRows = buildSettingsRows( + s, hasBodyVibrator, hasGyroscope, av1Capable, + appVersion = appVersion, + openControllers = onOpenControllers, + openLicenses = onOpenLicenses, + update = ::update, + ) + buildProfileRows(profiles, savedHosts, tv) { pinProfile = it } // Which section is showing, and where each one's focus was when it was last left — a detour // into another tab shouldn't lose your place. - var tab by remember { mutableStateOf(GpTab.STREAM) } + var tab by remember { mutableStateOf(resume?.tab ?: GpTab.STREAM) } // True while the STRIP holds the cursor rather than the list. Up from the first row moves // here and Down goes back — the only route to the sections on a D-pad remote, which has no // shoulder buttons at all (and is exactly what a TV box ships with). var tabFocused by remember { mutableStateOf(false) } val tabFocus = remember { mutableStateMapOf() } val rows = allRows.filter { it.tab == tab } - var focus by remember { mutableIntStateOf(0) } + // Entry focus: the row a sub-screen was opened from, if we are coming back from one. Resolved + // ONCE, against the first row list — after that the cursor belongs to this screen. + var focus by remember { + mutableIntStateOf(rows.indexOfFirst { it.id == resume?.rowId }.coerceAtLeast(0)) + } if (focus > rows.lastIndex) focus = rows.lastIndex.coerceAtLeast(0) // Which way the section last moved (+1 forward / -1 back) — the row list slides in from that @@ -218,6 +257,16 @@ fun GamepadSettingsScreen( val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + // Act on a row, publishing where the cursor was FIRST. A row that opens a sub-screen unmounts + // this one on the spot, so the place has to be out of here before its `activate` runs; every + // activation route (pad, tap, the legend's own A cell) goes through this one door so none of + // them can be the one that forgets. + fun activate(row: GpRow) { + onPlace(GpSettingsPlace(tab, row.id)) + adjustDir = 1 + row.activate() + } + // Step the focused row's value, answering a refusal rather than swallowing it. fun step(delta: Int) { adjustDir = delta @@ -248,7 +297,7 @@ fun GamepadSettingsScreen( }, // A on the strip drops into the section you picked, which is what "confirm" means there. onActivate = { - if (tabFocused) tabFocused = false else { adjustDir = 1; liveRow(rows, focus)?.activate() } + if (tabFocused) tabFocused = false else liveRow(rows, focus)?.let { activate(it) } }, // The shoulders work from either place — a real pad never has to visit the strip. onShoulder = { delta -> stepTab(delta) }, @@ -341,7 +390,7 @@ fun GamepadSettingsScreen( // (so its detail explains itself) but never flips it. tabFocused = false if (focus != index) focus = index - else if (row.enabled) { adjustDir = 1; row.activate() } + else if (row.enabled) activate(row) }, ) } @@ -392,14 +441,19 @@ fun GamepadSettingsScreen( focused != null && !focused.enabled -> listOf( PadGlyph.hint('B', "Done", onClick = onBack), ) + // What A does here follows the ROW: it opens the pin picker on a profile, + // the connected-controllers view on that one, the notices on the About row. + // It was the literal "Pin to hosts" while profiles were the only such rows. focused != null && !focused.adjustable -> listOf( - PadGlyph.hint('A', "Pin to hosts") { focused.activate() }, + PadGlyph.hint('A', focused.actionHint) { activate(focused) }, PadGlyph.hint('B', "Done", onClick = onBack), ) else -> listOf( GamepadHint('↔', PadGlyph.Arrow, "Adjust"), // Tappable too (touch hatch): Change cycles the focused row, Done leaves. - PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() }, + PadGlyph.hint('A', "Change") { + rows.getOrNull(focus)?.let { activate(it) } + }, PadGlyph.hint('B', "Done", onClick = onBack), ) }, @@ -610,12 +664,17 @@ private fun SettingRowView( /** Build the console settings rows from the current [Settings], writing through [update]. * [hasBodyVibrator] gates the "Rumble on this phone" row and [hasGyroscope] the "Gyro from this * phone" row (both absent on TVs); [av1Capable] gates the AV1 codec entry (see - * `codecOptionsFor`). Every row declares its [GpTab]; the screen shows one tab at a time. */ + * `codecOptionsFor`). [appVersion] is the installed version the About row states, and + * [openControllers] / [openLicenses] are the two rows that navigate rather than set anything. + * Every row declares its [GpTab]; the screen shows one tab at a time. */ internal fun buildSettingsRows( s: Settings, hasBodyVibrator: Boolean, hasGyroscope: Boolean, av1Capable: Boolean, + appVersion: String = "", + openControllers: () -> Unit = {}, + openLicenses: () -> Unit = {}, update: (Settings) -> Unit, ): List { fun choice( @@ -793,6 +852,28 @@ internal fun buildSettingsRows( "triggers, lightbar and gyro.", s.dsCapture, enabled = s.gamepadForwarding, ) { update(s.copy(dsCapture = it)) }, + // The diagnostics view — same screen the touch settings reach, same words for it. It was + // reachable from touch ONLY, which on a TV box means not at all: there is no touch interface + // to fall back to there, and "my controller does nothing" is the support case it answers. + // + // Deliberately NOT gated on the master forwarding switch its neighbours all follow: this is + // the row you reach for when forwarding looks broken, and a diagnostic that dims itself when + // the thing it diagnoses is off is worse than none. + // + // No value: this row navigates, it doesn't hold a setting (a count read here would be a + // snapshot, and a stale "none detected" is worse than no number at all — the screen it opens + // watches hot-plug live). + GpRow( + id = "controllers", + tab = GpTab.CONTROLLER, + header = "Diagnostics", + label = "Connected controllers", + value = "", + detail = "What the app detects, with a live input test.", + adjust = { false }, + activate = openControllers, + adjustable = false, + ), // The palette leads Interface: it is the one row whose effect you can see while you step // it (the backdrop behind this very list recolours), so it wants to be the first thing @@ -840,6 +921,22 @@ internal fun buildSettingsRows( } else { null }, + ) + listOf( + // About closes the Interface section, the way the touch settings' last category does. The + // notices are a licence obligation and were reachable from touch only — on a TV box that is + // nowhere. The version rides in the VALUE slot rather than as a second, inert row: it is the + // identity half of an About page, and the screen this opens states it again at the top. + GpRow( + id = "licenses", + tab = GpTab.INTERFACE, + header = "About", + label = "Open-source licenses", + value = appVersion, + detail = "Third-party notices and credits.", + adjust = { false }, + activate = openLicenses, + adjustable = false, + ), ) } @@ -854,7 +951,7 @@ internal fun buildSettingsRows( * Controller-optimized UI toggle a few rows up, which swaps the standard interface in * (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists). */ -private fun buildProfileRows( +internal fun buildProfileRows( profiles: List, savedHosts: List, tv: Boolean, @@ -901,6 +998,7 @@ private fun buildProfileRows( adjust = { false }, activate = { openPinPicker(p) }, adjustable = false, + actionHint = "Pin to hosts", ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HomeTiles.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HomeTiles.kt new file mode 100644 index 00000000..89aacf53 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HomeTiles.kt @@ -0,0 +1,100 @@ +package io.unom.punktfunk + +import io.unom.punktfunk.kit.discovery.DiscoveredHost +import io.unom.punktfunk.kit.security.KnownHost + +/** + * The console home's tiles, in carousel order: every saved host with its pinned host+profile cards + * immediately behind it, then the hosts seen on the network but not yet saved, then Add Host. + * + * Pure, and deliberately not a composable. The half of this that can be WRONG is the ordering and + * what a tile claims — a pin drifting away from the host it belongs to, a discovered host offered a + * second time next to the saved record it already is, a chip naming a profile the press won't + * actually use. None of that needs a display to be checked, and `HomeTilesTest` checks it without + * one; the console home itself needs the live JNI core to compose at all. + * + * [isOnline] and [pinsFor] arrive as lambdas rather than as the discovery lists and the profile + * store behind them: "online" means advertising on mDNS OR answering a QUIC probe (the routed + * Tailscale/VPN case), which is a rule belonging to the screen that does the probing, not to a list + * builder. + */ +internal fun buildHomeTiles( + savedHosts: List, + /** The live catalog — resolves each host's binding into the name and colour its chip wears. */ + profiles: List, + pinsFor: (KnownHost) -> List, + /** Already de-duped against [savedHosts] by the caller: a saved host is not also "discovered". */ + discoveredUnsaved: List, + isOnline: (KnownHost) -> Boolean, + /** + * Dial a saved host. The second argument is `connect`'s one-off profile reference: null on a + * host's own tile (follow whatever the host is bound to), the pinned profile's id on a pin tile. + */ + onConnect: (KnownHost, String?) -> Unit, + onConnectDiscovered: (DiscoveredHost) -> Unit, + onAddHost: () -> Unit, +): List = buildList { + savedHosts.forEach { kh -> + val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } } + add( + HomeTile( + id = "saved-${kh.id}", + title = kh.name, + subtitle = "${kh.address}:${kh.port}", + filled = true, + online = isOnline(kh), + paired = kh.paired, + knownHost = kh, + // The binding is what a press will actually do, so the tile says so — the console + // can't edit profiles, but it must never lie about which one it uses. It rides in + // the card's own chip now rather than as a "· Name" tail on the address, which is + // where it read as an afterthought. + profileName = bound?.name, + profileAccent = accentColor(bound?.accent), + activate = { onConnect(kh, null) }, + ), + ) + // Pinned host+profile combinations, right after their host: one focus-and-press each, + // which is the affordance a controller surface does well (menus are not). + pinsFor(kh).forEach { p -> + add( + HomeTile( + id = "pin-${kh.id}-${p.id}", + title = kh.name, + // The address, like every other card — the PROFILE is what makes this card + // different, and it now says so in the chip instead of standing in for the + // subtitle, which left a pin card unable to say where it pointed. + subtitle = "${kh.address}:${kh.port}", + filled = true, + online = isOnline(kh), + paired = kh.paired, + knownHost = kh, + pinnedProfileId = p.id, + profileName = p.name, + profileAccent = accentColor(p.accent), + activate = { onConnect(kh, p.id) }, + ), + ) + } + } + discoveredUnsaved.forEach { dh -> + add( + HomeTile( + id = "disc-${dh.host}:${dh.port}", + title = dh.name, + subtitle = "${dh.host}:${dh.port}", + online = true, + activate = { onConnectDiscovered(dh) }, + ), + ) + } + add( + HomeTile( + id = "add", + title = "Add Host", + subtitle = "Register a host by address", + isAdd = true, + activate = onAddHost, + ), + ) +} diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LicensesScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LicensesScreen.kt index e6f3ee33..29e5b90f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LicensesScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LicensesScreen.kt @@ -1,8 +1,12 @@ package io.unom.punktfunk +import android.content.res.Configuration import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -19,20 +23,130 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource /** * Open-source licenses: punktfunk's own license (MIT OR Apache-2.0) plus the third-party software * notices, read from the bundled `THIRD-PARTY-NOTICES.txt` asset (generated by * scripts/gen-third-party-notices.sh). Reached from [SettingsScreen]; Back returns there. + * + * This is the TOUCH entry point; [ConsoleLicensesScreen] shows the same notices on the console's + * field, where they need a scroll route a controller can actually drive. */ @Composable fun LicensesScreen(onBack: () -> Unit) { - val context = LocalContext.current BackHandler(onBack = onBack) + Column(Modifier.fillMaxSize()) { + // Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler). + Row( + modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall) + } + LicensesBody( + scroll = rememberScrollState(), + contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 24.dp), + ) + } +} + +/** + * The notices on the console's field. The reason this exists as its own screen rather than the touch + * one dropped into the shell is the SCROLL: the body is a wall of text with exactly one focusable + * node (the touch screen's back arrow), and Compose scrolls a container only to keep a FOCUSED child + * visible — so a controller could reach the first screenful of `THIRD-PARTY-NOTICES.txt` and not one + * line further. Here up/down steps and the shoulders page, driving the scroll state directly. + * + * B closes, as everywhere else; there is nothing on this screen to confirm, so A is not advertised. + */ +@Composable +fun ConsoleLicensesScreen(onBack: () -> Unit, navActive: Boolean = true) { + BackHandler(onBack = onBack) + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val hazeState = remember { HazeState() } + val scroll = rememberScrollState() + val scrollBy = rememberConsoleScroller(scroll) + val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true + + GamepadNavEffect2D( + active = navActive, + onDirection = { dir -> + when (dir) { + NavDir.UP -> scrollBy(-1, false) + NavDir.DOWN -> scrollBy(1, false) + // Left/right are deliberately inert: there is nothing beside this text, and paging + // sideways off a D-pad would be a second, undocumented way to do the shoulders' job. + NavDir.LEFT, NavDir.RIGHT -> {} + } + }, + onActivate = {}, + onShoulder = { delta -> scrollBy(delta, true) }, + ) + + Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + // Calm: this is a screen to read, and a drifting field behind small monospace text is + // the one place the aurora would be actively unhelpful. Full-bleed under the cutout — + // only the content takes the safe area. + GamepadFormBackground(Modifier.fillMaxSize()) + // Inked from the palette: the notices carry no colour of their own, so outside a Surface + // they would render in Material's default BLACK content colour over the aurora. + ConsoleInkedTheme { + Column(Modifier.fillMaxSize().consoleSafeArea()) { + LicensesBody( + scroll = scroll, + contentPadding = PaddingValues( + start = ConsoleEdgeInset, + end = ConsoleEdgeInset, + bottom = ConsoleLegendClearance, + ), + ) { + ConsoleHeader("Open-source licenses", horizontalInset = false) + } + } + } + } + Box( + Modifier + .align(Alignment.BottomStart) + .consoleLegendInsets(landscape) + .padding(ConsoleLegendInset), + ) { + GamepadHintBar( + listOfNotNull( + GamepadHint('↕', PadGlyph.Arrow, "Scroll"), + // A TV remote has no shoulders — its route is the D-pad, one step at a time. + GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad }, + PadGlyph.hint('B', "Close", onClick = onBack), + ), + hazeState = hazeState, + ) + } + } +} + +/** + * The notices themselves, shared by both interfaces — the licenses are a legal obligation, so the + * two routes must show the same text rather than two copies that can drift. [heading] is empty for + * the touch screen, which pins its own title row above the scroll. + */ +@Composable +private fun LicensesBody( + scroll: ScrollState, + contentPadding: PaddingValues, + heading: @Composable () -> Unit = {}, +) { + val context = LocalContext.current val notices = remember { runCatching { context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() } @@ -52,52 +166,40 @@ fun LicensesScreen(onBack: () -> Unit) { }.getOrNull() } - Column(Modifier.fillMaxSize()) { - // Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler). - Row( - modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall) - } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (version != null) { - Text( - "Punktfunk $version", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + heading() + if (version != null) { Text( - "Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " + - "components below, each under its own license.", + "Punktfunk $version", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + "Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " + + "components below, each under its own license.", + style = MaterialTheme.typography.bodyMedium, + ) + Text( + notices, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + if (fontLicense != null) { + Text("Bundled font", style = MaterialTheme.typography.titleMedium) + Text( + "The Geist typeface is licensed under the SIL Open Font License 1.1.", style = MaterialTheme.typography.bodyMedium, ) Text( - notices, + fontLicense, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), ) - if (fontLicense != null) { - Text("Bundled font", style = MaterialTheme.typography.titleMedium) - Text( - "The Geist typeface is licensed under the SIL Open Font License 1.1.", - style = MaterialTheme.typography.bodyMedium, - ) - Text( - fontLicense, - style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - ) - } } } } diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRoutesTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRoutesTest.kt new file mode 100644 index 00000000..b35fe887 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRoutesTest.kt @@ -0,0 +1,107 @@ +package io.unom.punktfunk + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The console route to the two sub-screens, driven through the REAL settings screen — the rows + * themselves are pinned by `ConsoleSubScreenRowsTest`; what needs the Compose runtime is the trip: + * that a press on the row reaches the shell, and that coming back lands where you left rather than + * at the top of the first section (the shell's `AnimatedContent` discards a screen's state the + * moment it stops being the target, so the place has to travel out and back). + * + * Rows are activated by TAP for the same reason `GamepadSettingsLayoutTest` does it: the pad path + * needs a `MainActivity` for its probes, and both routes end in the same `activate`. + * + * `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36 while + * the app compiles against 37. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi") +class ConsoleSubScreenRoutesTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun openingTheControllersRowNavigatesAndReportsWhereItWas() { + var opened = 0 + var place: GpSettingsPlace? = null + compose.setContent { + GamepadSettingsScreen( + initial = Settings(), + onChange = {}, + onBack = {}, + onOpenControllers = { opened++ }, + // Entering as if we had just come back from it, which is also what puts the cursor + // on the row — so a single tap ACTIVATES rather than merely focusing. + resume = GpSettingsPlace(GpTab.CONTROLLER, "controllers"), + onPlace = { place = it }, + ) + } + compose.waitForIdle() + + compose.onNodeWithText("Connected controllers").performClick() + compose.waitForIdle() + + assertEquals("the console never reached the diagnostics screen", 1, opened) + assertEquals( + "the place has to leave before the row does — this screen is gone the next frame", + GpSettingsPlace(GpTab.CONTROLLER, "controllers"), + place, + ) + } + + /** + * Back from a sub-screen lands on the section it was opened from, with the row on screen. The + * cursor is restored by row ID rather than index, so it survives a section whose length follows + * the hardware. + */ + @Test + fun comingBackFromTheNoticesLandsOnTheRowThatOpenedThem() { + compose.setContent { + GamepadSettingsScreen( + initial = Settings(), + onChange = {}, + onBack = {}, + resume = GpSettingsPlace(GpTab.INTERFACE, "licenses"), + ) + } + compose.waitForIdle() + + compose.onNodeWithText("Open-source licenses").assertIsDisplayed() + // Not back at the top of the first section — "Resolution" leads the Stream tab, which is + // where a screen that forgot its place would be. + compose.onNodeWithText("Resolution").assertDoesNotExist() + // And the legend describes THIS row's A. It said the literal "Pin to hosts" on every + // non-adjustable row back when profiles were the only ones. + compose.onNodeWithText("Open").assertIsDisplayed() + compose.onNodeWithText("Pin to hosts").assertDoesNotExist() + } + + /** + * The notices screen stands on its own on the console's field: no Scaffold or Surface above it + * (the shell has neither), its own backdrop, and a legend that says how to leave. Composing it + * is most of the assertion — a screen that only ever ran inside the touch Scaffold takes its + * content colour from one. + */ + @Test + fun theConsoleNoticesScreenStandsOnItsOwn() { + compose.setContent { ConsoleLicensesScreen(onBack = {}) } + compose.waitForIdle() + + compose.onNodeWithText("Open-source licenses").assertIsDisplayed() + compose.onNodeWithText("Scroll").assertIsDisplayed() + compose.onNodeWithText("Close").assertIsDisplayed() + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRowsTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRowsTest.kt new file mode 100644 index 00000000..9744e5ea --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRowsTest.kt @@ -0,0 +1,117 @@ +package io.unom.punktfunk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The console's route to the two screens that were reachable from touch only — the + * connected-controllers diagnostics and the open-source notices. + * + * "Touch only" reads as a minor gap on a phone and is a dead end on a TV box, where the console IS + * the interface: there is no touch UI to fall back to, so a screen with no console row could not be + * opened at all. These pin the rows themselves; `ConsoleSubScreenRoutesTest` drives the real screen. + */ +class ConsoleSubScreenRowsTest { + + private fun rows( + forwarding: Boolean = true, + version: String = "1.2.3", + controllers: () -> Unit = {}, + licenses: () -> Unit = {}, + ): List = buildSettingsRows( + Settings(gamepadForwarding = forwarding), + hasBodyVibrator = true, + hasGyroscope = true, + av1Capable = true, + appVersion = version, + openControllers = controllers, + openLicenses = licenses, + ) {} + + private fun row(rows: List, id: String): GpRow = rows.first { it.id == id } + + @Test + fun `the controllers row opens the diagnostics view from the controller section`() { + var opened = 0 + val r = row(rows(controllers = { opened++ }), "controllers") + assertEquals(GpTab.CONTROLLER, r.tab) + assertEquals("Connected controllers", r.label) + r.activate() + assertEquals(1, opened) + } + + /** + * It must NOT follow the master forwarding switch, unlike every other row in its section: the + * screen it opens is what you reach for precisely when forwarding looks broken, and a diagnostic + * that dims itself when the thing it diagnoses is off is worse than no diagnostic. + */ + @Test + fun `the controllers row stays live with forwarding off`() { + val off = rows(forwarding = false) + assertTrue(row(off, "controllers").enabled) + assertNotNull(liveRow(off, off.indexOfFirst { it.id == "controllers" })) + // Its neighbours in the section still dim, so this is a deliberate exemption and not a + // forgotten `enabled =`. + assertFalse(row(off, "sc2").enabled) + } + + @Test + fun `the about row opens the notices and states the installed version`() { + var opened = 0 + val r = row(rows(version = "0.27.0", licenses = { opened++ }), "licenses") + assertEquals(GpTab.INTERFACE, r.tab) + assertEquals("About", r.header) + // The version rides in the value slot — on a TV this row is the whole About page. + assertEquals("0.27.0", r.value) + r.activate() + assertEquals(1, opened) + } + + /** Both navigate; neither holds a value, so left/right must be refused rather than silently eaten. */ + @Test + fun `neither row steps a value`() { + val all = rows() + for (id in listOf("controllers", "licenses")) { + val r = row(all, id) + assertFalse("$id should draw no chevrons", r.adjustable) + assertFalse("$id must refuse a step", r.adjust(1)) + assertFalse("$id must refuse a step", r.adjust(-1)) + } + } + + /** + * The legend follows the ROW. It used to say the literal "Pin to hosts" on every non-adjustable + * row, because a profile row was the only kind there was — so the moment another one existed, + * A on it was advertised as pinning something. + */ + @Test + fun `an action row advertises what A actually does`() { + val all = rows() + assertEquals("Open", row(all, "controllers").actionHint) + assertEquals("Open", row(all, "licenses").actionHint) + val profiles = buildProfileRows(listOf(newProfile("Work")), emptyList(), tv = false) {} + assertEquals("Pin to hosts", profiles.first().actionHint) + } + + /** + * The scroll geometry both console sub-screens share. A wall of text has no focusable rows for + * Compose to keep visible, so these screens move the scroll state themselves — and how far one + * press travels is the whole of their feel. + */ + @Test + fun `a page overlaps what you were reading and a step is shorter still`() { + val viewport = 1000f + val page = consoleScrollDelta(viewport, page = true, dir = 1) + val step = consoleScrollDelta(viewport, page = false, dir = 1) + assertTrue("a page that skips a whole screenful loses your place", page < viewport) + assertTrue("a page has to be worth pressing", page > viewport / 2f) + assertTrue("a D-pad step must be shorter than a shoulder page", step > 0f && step < page) + assertEquals("the other direction is the other way", -page, consoleScrollDelta(viewport, true, -1), 0.001f) + // Before the first layout there is no viewport: a press then moves nothing, rather than + // scrolling by a fraction of zero and reading as a dead button on the way in. + assertEquals(0f, consoleScrollDelta(0f, page = true, dir = 1), 0f) + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/HomeTilesTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/HomeTilesTest.kt new file mode 100644 index 00000000..4a75ea4e --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/HomeTilesTest.kt @@ -0,0 +1,210 @@ +package io.unom.punktfunk + +import androidx.compose.ui.graphics.Color +import io.unom.punktfunk.kit.discovery.DiscoveredHost +import io.unom.punktfunk.kit.security.KnownHost +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The console home's tile list ([buildHomeTiles]). Pure JVM — the carousel itself needs the live + * JNI core to compose, so its ORDER and what each tile claims had no cover at all until now, and + * both are exactly the kind of thing that survives a refactor looking fine and behaving wrong. + * + * Run: `./gradlew :app:testDebugUnitTest --tests 'io.unom.punktfunk.HomeTilesTest'`. + */ +class HomeTilesTest { + private fun host( + name: String, + address: String, + fp: String = "", + profileId: String? = null, + pins: List = emptyList(), + ) = KnownHost( + address = address, + port = 9777, + name = name, + fpHex = fp, + paired = true, + id = "id-$name", + profileId = profileId, + pinnedProfileIds = pins, + ) + + private fun advert(name: String, address: String, fp: String? = null) = DiscoveredHost( + key = "$address:9777", + name = name, + host = address, + port = 9777, + fingerprint = fp, + ) + + private val work = StreamProfile(id = "p-work", name = "Work", accent = "#3B82F6") + private val travel = StreamProfile(id = "p-travel", name = "Travel") + + /** The builder with nothing plugged in — every list empty, every callback a no-op. */ + private fun tiles( + savedHosts: List = emptyList(), + profiles: List = emptyList(), + pins: Map> = emptyMap(), + discoveredUnsaved: List = emptyList(), + online: Set = emptySet(), + onConnect: (KnownHost, String?) -> Unit = { _, _ -> }, + onConnectDiscovered: (DiscoveredHost) -> Unit = {}, + onAddHost: () -> Unit = {}, + ) = buildHomeTiles( + savedHosts = savedHosts, + profiles = profiles, + pinsFor = { kh -> pins[kh.id].orEmpty() }, + discoveredUnsaved = discoveredUnsaved, + isOnline = { it.name in online }, + onConnect = onConnect, + onConnectDiscovered = onConnectDiscovered, + onAddHost = onAddHost, + ) + + /** + * A pin belongs to the host above it. Ordering is the whole affordance: on a controller a pin is + * reached by walking one tile past its host, and a builder that grouped all the pins at the end + * would still LOOK right in a screenshot of any single tile. + */ + @Test + fun pinnedCardsFollowTheirOwnHost() { + val living = host("living", "192.168.1.42", pins = listOf(work.id, travel.id)) + val studio = host("studio", "192.168.1.61", pins = listOf(work.id)) + val ids = tiles( + savedHosts = listOf(living, studio), + profiles = listOf(work, travel), + pins = mapOf(living.id to listOf(work, travel), studio.id to listOf(work)), + ).map { it.id } + assertEquals( + listOf( + "saved-id-living", + "pin-id-living-p-work", + "pin-id-living-p-travel", + "saved-id-studio", + "pin-id-studio-p-work", + "add", + ), + ids, + ) + } + + /** Add Host is the last tile, always — including on a device with nothing saved or seen. */ + @Test + fun theAddTileIsAlwaysLast() { + val empty = tiles() + assertEquals(listOf("add"), empty.map { it.id }) + assertTrue(empty.single().isAdd) + + val populated = tiles( + savedHosts = listOf(host("living", "192.168.1.42")), + discoveredUnsaved = listOf(advert("studio", "192.168.1.61")), + ) + assertEquals(listOf("saved-id-living", "disc-192.168.1.61:9777", "add"), populated.map { it.id }) + assertTrue(populated.last().isAdd) + // The Add tile is not a host: no library, no options menu, nothing to wake. + assertNull(populated.last().knownHost) + } + + /** + * A host that is both saved and advertising appears ONCE. The de-dupe is the caller's + * ([KnownHost.matches], which the screen applies before handing the list over) — checked here + * because the rule that matters is the fingerprint one: a host that came back on a new DHCP + * address is the same machine, and matching on address alone would offer it a second time as a + * stranger, next to the record that already holds its trust. + */ + @Test + fun aSavedHostSeenOnTheNetworkIsNotListedTwice() { + val fp = "ab12cd34" + val living = host("living", "192.168.1.42", fp = fp) + // Same host, new address after a cold boot, plus a genuine stranger. + val adverts = listOf(advert("living", "192.168.1.77", fp = fp), advert("stranger", "192.168.1.99")) + val unsaved = adverts.filter { dh -> listOf(living).none { it.matches(dh) } } + val ids = tiles(savedHosts = listOf(living), discoveredUnsaved = unsaved).map { it.id } + assertEquals(listOf("saved-id-living", "disc-192.168.1.99:9777", "add"), ids) + } + + /** + * The chip says which profile a press will connect with — the host's binding on its own tile, + * the pinned profile on a pin tile. The console cannot EDIT profiles, so this claim is the only + * thing standing between a user and a stream with settings they didn't choose. + */ + @Test + fun theChipNamesTheProfileThePressWillUse() { + val living = host("living", "192.168.1.42", profileId = work.id, pins = listOf(travel.id)) + val result = tiles( + savedHosts = listOf(living), + profiles = listOf(work, travel), + pins = mapOf(living.id to listOf(travel)), + ) + val own = result[0] + assertEquals("Work", own.profileName) + assertEquals(Color(0xFF3B82F6), own.profileAccent) + assertNull(own.pinnedProfileId) + + val pin = result[1] + assertEquals("Travel", pin.profileName) + assertEquals(travel.id, pin.pinnedProfileId) + // Travel set no accent: a chip with no colour, not a crash and not a stray default. + assertNull(pin.profileAccent) + + // A binding whose profile was deleted resolves to nothing — the tile stays silent rather + // than naming an id that resolves to nobody. + val dangling = tiles(savedHosts = listOf(host("ghost", "10.0.0.5", profileId = "p-gone"))) + assertNull(dangling[0].profileName) + } + + /** Both address and the subtitle: a pin card says where it points, like every other card. */ + @Test + fun everySavedTileSaysWhereItPoints() { + val living = host("living", "192.168.1.42", pins = listOf(work.id)) + val result = tiles( + savedHosts = listOf(living), + profiles = listOf(work), + pins = mapOf(living.id to listOf(work)), + online = setOf("living"), + ) + result.take(2).forEach { + assertEquals("192.168.1.42:9777", it.subtitle) + assertEquals("living", it.title) + assertTrue(it.filled) + assertTrue(it.online) + assertTrue(it.paired) + assertNotNull(it.knownHost) + } + // Host tile → library (Y); pin tile → none, because a pin is a shortcut, not a second host. + assertTrue(result[0].hasLibrary) + assertFalse(result[1].hasLibrary) + } + + /** + * What a press DOES. A host's own tile dials with no one-off reference so the host's binding is + * followed; a pin tile forces its own profile. Passing the pin's id as the binding (or the + * other way round) is invisible until someone streams at the wrong bitrate. + */ + @Test + fun activationCarriesTheRightProfileReference() { + val living = host("living", "192.168.1.42", pins = listOf(work.id)) + val dialled = mutableListOf>() + val discovered = mutableListOf() + var addOpened = false + val result = tiles( + savedHosts = listOf(living), + profiles = listOf(work), + pins = mapOf(living.id to listOf(work)), + discoveredUnsaved = listOf(advert("stranger", "192.168.1.99")), + onConnect = { kh, oneOff -> dialled += kh.name to oneOff }, + onConnectDiscovered = { dh -> discovered += dh.host }, + onAddHost = { addOpened = true }, + ) + result.forEach { it.activate() } + assertEquals(listOf("living" to null, "living" to work.id), dialled) + assertEquals(listOf("192.168.1.99"), discovered) + assertTrue(addOpened) + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 70fdd1ff..e6497327 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -150,6 +150,23 @@ class ScreenshotTest { @Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi") fun consoleHomeBlobFallback() = shootRoot("console-home-blobs") { ConsoleHomeScene() } + // The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a + // pale palette, because the console draws them through a ColorScheme derived from the palette's + // ink — and the pale one is the only place a grey-on-pastel slip can show up. + @Test + fun consoleLicenses() = shootRoot("console-licenses") { ConsoleLicensesScene() } + + @Test + fun consoleLicensesLight() = + shootRoot("console-licenses-light") { ConsoleLicensesScene(paletteId = "holo") } + + @Test + fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() } + + @Test + fun consoleControllersLight() = + shootRoot("console-controllers-light") { ConsoleControllersScene(paletteId = "holo") } + @Test fun trust() = shootScreen("trust") { HostsScene() diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 8e2c7dab..c6cf105f 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 @@ -35,6 +35,8 @@ import androidx.compose.runtime.CompositionLocalProvider import io.unom.punktfunk.GamepadHome import io.unom.punktfunk.GamepadInk import io.unom.punktfunk.GamepadPalette +import io.unom.punktfunk.ConsoleControllersScreen +import io.unom.punktfunk.ConsoleLicensesScreen import io.unom.punktfunk.GamepadSettingsScreen import io.unom.punktfunk.HomeTile import io.unom.punktfunk.LocalGamepadInk @@ -499,6 +501,43 @@ internal fun ConsoleHomeScene(paletteId: String = "violet") { } } +/** + * The two screens the console could not reach at all until WP8.3 — the open-source notices and the + * connected-controllers view — in their console presentation. + * + * Worth a shot each, and worth a PALE one: both are ordinary Material screens underneath, and the + * console shows them through a `ColorScheme` derived from the palette's ink. That derivation is the + * whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing + * before this could catch light-grey body text stranded on a pastel field. + * + * Robolectric enumerates no input devices, so the controllers scene renders its deterministic + * "nothing connected" state. + */ +@Composable +internal fun ConsoleLicensesScene(paletteId: String = "violet") = + ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) } + +@Composable +internal fun ConsoleControllersScene(paletteId: String = "violet") = + ConsolePalette(paletteId) { + ConsoleControllersScreen(gamepadSetting = 0, onBack = {}, navActive = false) + } + +/** + * Publish the palette locals `App` would normally provide. A scene that calls a console screen + * directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently + * prove nothing at all. + */ +@Composable +private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) { + val palette = GamepadPalette.named(paletteId) + CompositionLocalProvider( + LocalGamepadPalette provides palette, + LocalGamepadInk provides GamepadInk.of(palette), + content = content, + ) +} + @Composable internal fun ConsoleSettingsScene(paletteId: String = "violet") { // The scene calls the screen directly, so it has to publish the palette locals `App` would