From f90a4e5f9757869dd7a97cf68f959b73382890eb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 20:18:20 +0200 Subject: [PATCH] refactor(android): ConnectScreen splits, and the console can finally open Controllers and Licenses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP9.2 and WP8.3 of the console visual-refresh plan. **WP9.2 β€” the 1361-line ConnectScreen becomes 979 plus three files.** `HomeTiles.kt` holds a pure `buildHomeTiles` (non-composable, so it is unit-testable β€” `HomeTilesTest` pins six behaviours the console carousel had no cover for at all, including that a saved host also advertising on a NEW DHCP address is listed once, which exercises the fingerprint match rather than just "the builder lists what it is given"). `ConnectGrid.kt` holds the touch home. `ConnectPrompts.kt` holds everything modal. `ConnectScreen`'s signature is untouched, so `App.kt` compiles unchanged. What stayed, deliberately: the state and the engine β€” discovery, the permission dance, identity, the trust decision, the dial and its wake fallback, the deep-link router. Those close over ~20 locals that a dozen callbacks read AND write, so lifting them means inventing a state holder, which is a second refactor and a second thing to get wrong. A comment above `ConnectScreen` now says so. πŸ›‘ **A real bug the split surfaced, fixed here:** the console carousel was live under a FINISHED speed test. It looked covered by `!connecting`, and was β€” until the measurement ended, because `startSpeedTest` clears `connecting` before the Done card is dismissed. From that moment the card and the carousel underneath both consumed the pad, so one A dismissed the card *and* started a connect. `speedTest` now sits in the `navActive` gate with every other modal. **WP8.3 β€” two screens the console could not reach.** On a TV box there is no touch interface to fall back to, so the notices and the controllers view were simply unreachable. Both are now console screens at nav depth 2 (reached FROM settings, which is what makes the trip a push and the way back a pop), opened by a Controller-tab row and an About row. `GpSettingsPlace` carries the cursor across the trip, keyed by row ID rather than index, so Back lands where you left rather than on the first row of the first tab β€” and because a tab's length follows the hardware, an index would have been the stale-pointer bug the tab-switch clamp already exists for. Four blockers, all real: * πŸ›‘ `ControllersScreen` installed the shared input probes unconditionally and NULLED them unconditionally on dispose β€” no identity check, unlike `GamepadNavEffect2D`. During the shell's push/pop both screens are briefly composed, so its teardown would have killed the incoming screen's pad navigation. Now it releases only the slot it still owns. * πŸ›‘ `LicensesScreen` had exactly ONE focusable node, and Compose only scrolls to keep a FOCUSED child visible β€” so a D-pad could not read past the first screenful of a many-screen file. Both screens now drive their scroll state directly: up/down steps 0.28 of the viewport, shoulders page 0.88 β€” under a screenful on purpose, so the line you were reading survives the press. * ⚠ Both were inked from the TOUCH theme (28 `colorScheme` sites plus implicit pulls from `OutlinedCard`, `Switch`, `OutlinedButton`, `LinearProgressIndicator`), which is always dark β€” invisible over the six PALE palettes. They are now shown through one `ColorScheme` derived from `LocalGamepadInk` rather than 27 call-site branches, because call-site edits cannot reach the implicit pulls at all. Screenshot scenes shoot both on a dark and a pale palette; the pale pair is the point. * ⚠ B was already taken β€” the input test's exit is a 1.2 s hold. The rule is now stated on screen: while the test runs the pad is the test's, a short B answers with the boundary thud instead of doing nothing, and the legend collapses to one "Hold to finish" cell. πŸ›‘ **Second bug fixed in passing:** that hold ended the test AT the 1.2 s mark, so the B *release* then fell through to MainActivity's Bβ†’BACK remap and closed the whole screen. It bit the touch screen too. The test now ends on the release, which is therefore consumed. Residual TV gap, flagged not fixed: the Controllers screen's inner buttons ("Grant USB access", "Test rumble", "Test haptics") have no console focus list, so they stay touch-only β€” a denied Sony USB grant still has no console recovery path. --- .gitea/workflows/android.yml | 11 +- .../src/main/kotlin/io/unom/punktfunk/App.kt | 164 ++++- .../kotlin/io/unom/punktfunk/ConnectGrid.kt | 339 +++++++++ .../io/unom/punktfunk/ConnectPrompts.kt | 195 +++++ .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 681 ++++-------------- .../io/unom/punktfunk/ControllersScreen.kt | 248 ++++++- .../unom/punktfunk/GamepadSettingsScreen.kt | 118 ++- .../kotlin/io/unom/punktfunk/HomeTiles.kt | 100 +++ .../io/unom/punktfunk/LicensesScreen.kt | 184 +++-- .../punktfunk/ConsoleSubScreenRoutesTest.kt | 107 +++ .../punktfunk/ConsoleSubScreenRowsTest.kt | 117 +++ .../kotlin/io/unom/punktfunk/HomeTilesTest.kt | 210 ++++++ .../punktfunk/screenshots/ScreenshotTest.kt | 17 + .../unom/punktfunk/screenshots/ShotScenes.kt | 39 + 14 files changed, 1925 insertions(+), 605 deletions(-) create mode 100644 clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectGrid.kt create mode 100644 clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectPrompts.kt create mode 100644 clients/android/app/src/main/kotlin/io/unom/punktfunk/HomeTiles.kt create mode 100644 clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRoutesTest.kt create mode 100644 clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleSubScreenRowsTest.kt create mode 100644 clients/android/app/src/test/kotlin/io/unom/punktfunk/HomeTilesTest.kt 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