Compare commits

...
Author SHA1 Message Date
enricobuehler 9cefa0a3ea feat(console-ui): connected controllers is the console's own screen — input stays on the pad
The Controllers row used to raise the D7 platform-screen mechanism: Android drew
the Compose ConsoleControllersScreen over the surface and suspended the console's
input until it closed. Now the page is a shared Skia screen
(screens/controllers.rs) pushed like any other settings sub-screen, so the console
keeps its own navigation, transitions and haptics on it — and a future desktop
build gets it for free (the row itself stays Android-only in row_on).

What genuinely cannot move into Rust stays with the host, asked for by ONE
parameterised command, ConsoleCmd::PadAction { action, pad_key }: the USB and
Bluetooth grant dialogs (sc2_bluetooth / sc2_usb / ds_usb), the rumble pulse on
the real InputDevice, and the DualSense pad-audio self test. SkiaConsoleShell
handles them with the same helpers the touch Controllers screen uses (testRumble,
the grant intents, nativePadAudioSelfTest), reporting through the notice toast, so
the support answer cannot drift between interfaces. PadInfo carries the three
fields the screen needed and the aggregated list already lacked (detail line,
forwarded, rumble), filled by ConsoleJson.pads from the same padInfoOf reader the
touch screen renders from.

PlatformScreen::Controllers is gone; the mechanism itself stays for Licenses,
which still suspends the console's input correctly (the probes gate on platformUp
as before). The Compose console variant and its screenshot scenes are deleted;
the touch ControllersScreen keeps the full page INCLUDING the live input test,
which deliberately did not move — the console only receives the aggregated
MenuSample, nowhere near a per-device axis/trigger readout (ponytail note at the
top of controllers.rs records the upgrade path).
2026-08-18 23:14:23 +02:00
enricobuehler 454531030d fix(android): a console that cannot draw yields to the touch UI instead of a gray screen
Connecting a controller could swap in the console shell over a SurfaceView
nothing would ever paint: the native create failing, the render thread dying,
or a GL context Android reclaimed all left the app on a gray screen for the
rest of the process — Kotlin only logged the Dead event.

SkiaConsole now exposes an observable [healthy] flag (false on create failure
or a Dead event) and App folds it into the gamepad-UI gate, so the touch UI
takes over. On the native side, a run of consecutive GL setup failures
(window surface / Skia wrap) — previously logged and retried forever, a hot
spin with a live surface — now ends the render thread through the same
release order as Quit, which raises Dead and hands the screen back.
2026-08-18 22:34:25 +02:00
enricobuehler a64a22ccfc fix(android): the console's pad probes survive the Controllers/Licenses pages
The MainActivity pad probes were one last-writer-wins slot. The Skia shell
installs its probes once (its effect keys never change); a Compose screen the
console opens over itself (Controllers, Licenses) overwrote that slot, and on
its way out nulled it — the shell never re-installed, so every gamepad press
after closing the page was silently dropped until the process died.

The slot is now a stack: each holder pushes its claim on install and removes
it BY IDENTITY on dispose, and dispatch consults the top. Whatever ordering
Compose produces — cross-fades composing both screens at once, non-LIFO
disposal — a leaving screen takes only its own entry, and the one underneath
resurfaces the moment it pops.
2026-08-18 22:34:10 +02:00
enricobuehler cfbde6aec7 Merge pull request 'Tell the agents where the issues live: AGENTS.md and docs/agents/' (#310) from worktree-agents-md-setup into main
ci / bun-nix (push) Successful in 33s
ci / web (push) Successful in 1m20s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 17s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 17s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 1m32s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
ci / docs-site (push) Successful in 3m24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m23s
ci / rust-arm64 (push) Successful in 4m17s
docker / deploy-docs (push) Successful in 38s
ci / rust (push) Successful in 8m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Failing after 13m29s
docker / builders-arm64cross (push) Skipped
Reviewed-on: #310
2026-08-18 19:33:35 +00:00
20 changed files with 800 additions and 273 deletions
@@ -94,7 +94,10 @@ fun App(forceGamepadUi: Boolean = false) {
// `debug.punktfunk.console_backend=none` forces the touch UI for on-glass triage). Without a
// console to draw, a controller drives the touch UI through Compose's own focus.
val skiaConsole = remember { SkiaConsole.wanted() }
val gamepadUi = skiaConsole && gamepadUiActive(
// …AND it actually came up: a console whose native create failed or whose render thread died
// ([SkiaConsole.healthy], observable) would front a SurfaceView nothing ever paints — a gray
// screen with a working pad probe, which is worse than the touch UI it replaced.
val gamepadUi = skiaConsole && SkiaConsole.healthy && gamepadUiActive(
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
)
@@ -1,7 +1,6 @@
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
@@ -14,7 +13,6 @@ import android.view.MotionEvent
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -49,11 +47,8 @@ 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.Sc2BleLink
@@ -61,158 +56,34 @@ import io.unom.punktfunk.kit.Sc2Capture
import kotlinx.coroutines.delay
/**
* Connected-controllers debug view (Settings → Host → Connected controllers): everything the app
* can see about attached input devices, plus a live input test. This exists for exactly the support
* 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.
* Connected-controllers debug view (Settings -> Controller -> Connected controllers): everything
* the app can see about attached input devices, plus a live input test. This exists for exactly
* the support 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.
* The TOUCH presentation, and since 2026-08 the only one: the console reaches the same answer
* through its own Skia screen (`crates/pf-console-ui/src/screens/controllers.rs`), which keeps the
* console's input on the page instead of suspending it behind a Compose takeover. What this screen
* still owns alone is the live input test - the console receives only the aggregated navigation
* sample, which is nowhere near a per-device axis/trigger readout. Everything the console DOES
* need from here it asks for as a `ConsoleCmd::PadAction` (see [SkiaConsoleShell]), which is why
* [padInfoOf] and [testRumble] are internal rather than private.
*/
@Composable
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
BackHandler(onBack = onBack)
var testing by remember { mutableStateOf(false) }
ControllersBody(
gamepadSetting = gamepadSetting,
scroll = rememberScrollState(),
testing = testing,
onTestingChange = { testing = it },
padsOverride = padsOverride,
// 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
internal fun ConsoleControllersScreen(
internal fun ControllersScreen(
gamepadSetting: Int,
onBack: () -> Unit,
navActive: Boolean = true,
padsOverride: List<PadInfo>? = null,
) {
BackHandler(onBack = onBack)
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val hazeState = remember { HazeState() }
val scroll = rememberScrollState()
val scrollBy = rememberConsoleScroller(scroll)
// 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.
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 },
padsOverride = padsOverride,
// 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,
padsOverride: List<PadInfo>? = null,
heading: @Composable () -> Unit,
) {
val onTestingChange: (Boolean) -> Unit = { testing = it }
val contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp)
val context = LocalContext.current
val activity = context as? MainActivity
@@ -247,19 +118,19 @@ private fun ControllersBody(
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.
// The probes below are built ONCE and then read these for the life of the screen, so
// capturing `testing` plainly would freeze the value it 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(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.
DisposableEffect(Unit) {
// One entry on the MainActivity probe stack, removed by identity on the way out — the rule
// GamepadNavEffect2D follows. During the console shell's push/pop BOTH screens are briefly
// composed, and only the identity removal keeps this screen's teardown from taking the
// arriving screen's claim with it. 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
@@ -317,16 +188,9 @@ private fun ControllersBody(
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
consuming
}
if (observeInput) {
activity?.padKeyProbe = keyProbe
activity?.padMotionProbe = motionProbe
}
onDispose {
activity?.let { a ->
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
}
}
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
activity?.pushPadProbes(probes)
onDispose { activity?.removePadProbes(probes) }
}
// 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). This half only ANSWERS the hold once it is long enough; the
@@ -351,7 +215,7 @@ private fun ControllersBody(
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
heading()
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
// 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
@@ -942,7 +806,8 @@ private fun deviceHasVibrator(dev: InputDevice): Boolean =
dev.vibrator.hasVibrator()
}
private fun testRumble(dev: InputDevice) {
/** A short pulse on the pad's own motor. Also the console's `PadAction::Rumble`. */
internal fun testRumble(dev: InputDevice) {
runCatching {
if (Build.VERSION.SDK_INT >= 31) {
val vm = dev.vibratorManager
@@ -82,8 +82,9 @@ fun GamepadNavEffect(
val currentOnOptions by rememberUpdatedState(onOptions)
DisposableEffect(active) {
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
// One entry on the MainActivity probe stack (see GamepadNavEffect2D), removed by identity on
// dispose — a cross-fading-out screen must take only its OWN claim, never the incoming
// screen's, and never the console shell's underneath.
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
@@ -113,13 +114,10 @@ fun GamepadNavEffect(
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
}
}
if (active) {
activity.padMotionProbe = motionProbe
activity.padKeyProbe = keyProbe
}
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
probes?.let { activity.pushPadProbes(it) }
onDispose {
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
probes?.let { activity.removePadProbes(it) }
state.reset()
}
}
@@ -186,9 +184,11 @@ fun GamepadNavEffect2D(
val currentOnShoulder by rememberUpdatedState(onShoulder)
DisposableEffect(active) {
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
// One entry on the MainActivity probe stack, removed by identity on dispose — during a
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
// teardown must not null out the incoming screen's just-installed probes.
// teardown must take only its own claim. On the console this effect sits OVER the Skia
// shell's probes: pushing (not overwriting) is what lets the shell's pad input resurface
// the moment this screen pops, instead of dying with a nulled slot.
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
@@ -220,13 +220,10 @@ fun GamepadNavEffect2D(
else -> false // B → MainActivity (remapped to BACK → BackHandler)
}
}
if (active) {
activity.padMotionProbe = motionProbe
activity.padKeyProbe = keyProbe
}
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
probes?.let { activity.pushPadProbes(it) }
onDispose {
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
probes?.let { activity.removePadProbes(it) }
state.reset()
}
}
@@ -109,12 +109,29 @@ class MainActivity : ComponentActivity() {
var gamepadRouter: GamepadRouter? = null
/**
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
* Called for every key/motion event while not streaming; a `true` return consumes the event
* the screen's "test inputs" mode uses that to keep pad input from also driving focus navigation.
* One screen's claim on the pad while not streaming: its key/motion observers, consulted for
* every event before the focus-navigation fallbacks below; a `true` return consumes the event.
* Holders are the Skia console shell, [GamepadNavEffect2D] on the Compose screens the console
* opens over itself, and the Controllers screen's input test.
*/
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
class PadProbes(val key: (KeyEvent) -> Boolean, val motion: (MotionEvent) -> Boolean)
/**
* The pad-probe claims, a STACK — only the top entry sees events. A single last-writer-wins
* slot is how the console shell used to lose the pad for good: a screen composed over it
* (Controllers/Licenses) overwrote the slot, then nulled it on its way out, and the shell —
* whose install effect had no reason to re-run — never got it back. Pushing on install and
* removing BY IDENTITY on dispose survives every ordering Compose produces (cross-fades
* compose both screens at once, and dispose is not always LIFO): whatever leaves takes only
* its own entry, and whatever is left on top resumes seeing the pad.
*/
private val padProbes = mutableListOf<PadProbes>()
fun pushPadProbes(p: PadProbes) { padProbes += p }
fun removePadProbes(p: PadProbes) { padProbes.remove(p) }
private val padKeyProbe: ((KeyEvent) -> Boolean)? get() = padProbes.lastOrNull()?.key
private val padMotionProbe: ((MotionEvent) -> Boolean)? get() = padProbes.lastOrNull()?.motion
/**
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
@@ -11,6 +11,7 @@ import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
import io.unom.punktfunk.kit.library.GameEntry
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.padInfoOf
import org.json.JSONArray
import org.json.JSONObject
@@ -229,16 +230,25 @@ internal object ConsoleJson {
/**
* `{"label", "pref", "pads": [...]}` — the controller chip's text (the driving pad's name),
* the glyph style's pref byte, and one entry per connected pad for the settings rows.
* the glyph style's pref byte, and one entry per connected pad for the settings rows and the
* console's Connected-controllers screen.
*
* `detail`/`forwarded`/`rumble` come straight from [padInfoOf], the same reader the touch
* Controllers screen renders from: the support answer a user gets must not depend on which
* interface asked, and two readers of `InputDevice` would be two answers waiting to drift.
*/
fun pads(pads: List<InputDevice>, driving: InputDevice?): String {
val arr = JSONArray()
for (d in pads) {
val info = padInfoOf(d)
val entry = JSONObject()
.put("name", d.name)
.put("key", "${d.vendorId}:${d.productId}:${d.name}")
.put("pref", Gamepad.prefFor(d))
.put("steam_virtual", false)
.put("detail", info.detail)
.put("forwarded", info.forwarded)
.put("rumble", info.canRumble)
val battery = if (android.os.Build.VERSION.SDK_INT >= 31) {
val b = d.batteryState
if (b.isPresent && b.capacity >= 0f) {
@@ -8,6 +8,9 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.InputDevice
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.unom.punktfunk.CONNECT_TIMEOUT_MS
import io.unom.punktfunk.ConnectErrors
import io.unom.punktfunk.ProfileStore
@@ -67,6 +70,17 @@ object SkiaConsole {
private const val PREFS = "punktfunk_console_settings"
private var handle = 0L
/**
* False once the console has proven it cannot draw the native create failed, or the render
* thread died (a GL context that never came up, or one Android reclaimed and that would not
* come back). Compose observes it: `App` folds it into the gamepad-UI gate, so the answer to a
* dead console is the touch UI not the gray, never-painted `SurfaceView` the shell would
* otherwise sit on for the rest of the process.
*/
var healthy by mutableStateOf(true)
private set
private var appContext: Context? = null
private val main = Handler(Looper.getMainLooper())
private val ioPool = Executors.newCachedThreadPool { r -> Thread(r, "pf-console-io").apply { isDaemon = true } }
@@ -90,6 +104,7 @@ object SkiaConsole {
private var onSettingsChange: ((Settings) -> Unit)? = null
private var onQuit: (() -> Unit)? = null
private var onPlatformScreen: ((String) -> Unit)? = null
private var onPadAction: ((String, String) -> Unit)? = null
private var onPulse: ((String) -> Unit)? = null
/** The connect in flight, if any — cancelable through `OverlayAction::CancelConnect`. */
@@ -149,6 +164,7 @@ object SkiaConsole {
handle = runCatching { NativeBridge.nativeConsoleCreate(opts.toString()) }.getOrDefault(0L)
if (handle == 0L) {
Log.e(TAG, "console: native create failed")
healthy = false // see [healthy] — the touch UI fronts everything from here
return 0L
}
Log.i(TAG, "console: created (gpu cache ${gpuCacheBytes(app) shr 20} MB)")
@@ -236,12 +252,14 @@ object SkiaConsole {
onSettingsChange: (Settings) -> Unit,
onQuit: () -> Unit,
onPlatformScreen: (String) -> Unit,
onPadAction: (String, String) -> Unit,
onPulse: (String) -> Unit,
) {
this.onConnected = onConnected
this.onSettingsChange = onSettingsChange
this.onQuit = onQuit
this.onPlatformScreen = onPlatformScreen
this.onPadAction = onPadAction
this.onPulse = onPulse
discovery?.restart()
// The touch UI may have paired/forgotten/edited hosts or profiles while we were away.
@@ -255,6 +273,7 @@ object SkiaConsole {
onSettingsChange = null
onQuit = null
onPlatformScreen = null
onPadAction = null
onPulse = null
}
@@ -373,7 +392,7 @@ object SkiaConsole {
NativeBridge.nativeConsoleSetKnownHosts(handle, ConsoleJson.knownHosts(knownHostStore.all()))
}
private fun notice(text: String) {
internal fun notice(text: String) {
if (handle != 0L) NativeBridge.nativeConsoleNotice(handle, text)
}
@@ -386,7 +405,10 @@ object SkiaConsole {
ev.has("editing") -> {} // the shell draws its own keyboard; nothing to raise here
ev.has("settings") -> onSettingsSaved(ev.getJSONObject("settings"))
ev.has("gles") -> Log.i(TAG, "console: GLES ${ev.optInt("gles")}")
ev.has("dead") -> Log.e(TAG, "console: render thread died: ${ev.optString("dead")}")
ev.has("dead") -> {
Log.e(TAG, "console: render thread died: ${ev.optString("dead")}")
healthy = false // the touch UI takes over; only a process restart tries again
}
}
}
@@ -521,6 +543,7 @@ object SkiaConsole {
c.optJSONObject("Wake")?.let(::wake)
c.optJSONObject("SetPin")?.let(::setPin)
c.optJSONObject("OpenPlatformScreen")?.let { onPlatformScreen?.invoke(it.optString("id")) }
c.optJSONObject("PadAction")?.let { onPadAction?.invoke(it.optString("action"), it.optString("pad_key")) }
c.optString("OpenPlatformScreen").takeIf { c.has("OpenPlatformScreen") && c.opt("OpenPlatformScreen") is String }
?.let { onPlatformScreen?.invoke(it) }
}
@@ -1,5 +1,9 @@
package io.unom.punktfunk.console
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.hardware.usb.UsbManager
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
@@ -26,15 +30,20 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.viewinterop.AndroidView
import io.unom.punktfunk.ConsoleControllersScreen
import androidx.core.app.ActivityCompat
import io.unom.punktfunk.ConsoleLicensesScreen
import io.unom.punktfunk.DS_USB_PERMISSION_ACTION
import io.unom.punktfunk.MainActivity
import io.unom.punktfunk.Settings
import io.unom.punktfunk.SettingsStore
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.LibraryReturn
import io.unom.punktfunk.kit.Sc2BleLink
import io.unom.punktfunk.rememberConsoleHaptics
import io.unom.punktfunk.testRumble
import kotlin.math.roundToInt
/**
@@ -44,9 +53,9 @@ import kotlin.math.roundToInt
*
* What lives here is only what needs a composition: the surface lifecycle, the safe-area insets,
* the pad probes (raw pad the shared menu synthesizer, over JNI), the system Back, the
* platform-native sub-screens the console can open (Controllers, Licences Compose, drawn over the
* surface), and the two intents the app hands over on the way in (a deep link, "come back to this
* shelf").
* platform-native sub-screen the console can open (Licences Compose, drawn over the surface;
* Connected controllers is the console's own Skia screen now), and the two intents the app hands
* over on the way in (a deep link, "come back to this shelf").
*/
@Composable
fun SkiaConsoleShell(
@@ -74,6 +83,7 @@ fun SkiaConsoleShell(
onSettingsChange = { currentOnSettingsChange(it) },
onQuit = { activity?.moveTaskToBack(true) },
onPlatformScreen = { platformScreen = it },
onPadAction = { action, key -> padAction(activity, action, key) },
onPulse = { pulse ->
when (pulse) {
"move" -> haptics.tick()
@@ -228,13 +238,13 @@ fun SkiaConsoleShell(
padState.push(handle)
true
}
activity.padKeyProbe = keyProbe
activity.padMotionProbe = motionProbe
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
activity.pushPadProbes(probes)
SkiaConsole.padsChanged(Gamepad.firstPad())
onDispose {
// Only clear what is still ours: a screen composed after us must not lose its probes.
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
// Remove OUR claim only — a platform screen pushed over us keeps its own, and when it
// pops, this one resurfaces (the stack is what fixed the pad dying after Controllers).
activity.removePadProbes(probes)
padState.reset()
if (handle != 0L) padState.push(handle)
}
@@ -293,16 +303,101 @@ fun SkiaConsoleShell(
},
)
when (platformScreen) {
"controllers" -> ConsoleControllersScreen(
gamepadSetting = settings.gamepad,
onBack = { platformScreen = null },
navActive = true,
)
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
}
}
}
/**
* A `ConsoleCmd::PadAction` from the console's Connected-controllers screen the handful of
* things only the platform can do: a rumble pulse on the real [InputDevice], the USB/Bluetooth
* grant dialogs, the DualSense pad-audio self test. The touch Controllers screen keeps its own
* buttons for the same actions; both routes end in the same helpers ([testRumble], the grant
* intents, `nativePadAudioSelfTest`), so the support answer cannot drift between interfaces.
* Runs on the main thread (the command drain lives there); results ride [SkiaConsole.notice].
*/
private fun padAction(activity: MainActivity?, action: String, padKey: String) {
if (activity == null) return
val settings = SettingsStore(activity).load()
val usb = activity.getSystemService(Context.USB_SERVICE) as UsbManager
when (action) {
"rumble" ->
Gamepad.pads()
.firstOrNull { "${it.vendorId}:${it.productId}:${it.name}" == padKey }
?.let(::testRumble)
"sc2_bluetooth" -> when {
!settings.sc2Capture ->
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
Sc2BleLink.permissionGranted(activity) ->
SkiaConsole.notice("Bluetooth access is already granted.")
// The system dialog pauses the activity; onResume re-probes and engages the capture,
// the same way the menu-time auto-ask completes.
else -> Sc2BleLink.CONNECT_PERMISSION?.let {
ActivityCompat.requestPermissions(activity, arrayOf(it), 5)
}
}
"sc2_usb" ->
if (!settings.sc2Capture) {
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
} else {
// Asks for the USB grant when one is missing and engages the capture on it.
activity.startSc2MenuNav(forceAsk = true)
}
"ds_usb" -> {
val dev = usb.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
}
when {
!settings.dsCapture ->
SkiaConsole.notice(
"Enable \"DualSense / DualShock passthrough (USB)\" in Settings first.",
)
dev == null -> SkiaConsole.notice("No wired DualSense or DualShock 4 detected.")
usb.hasPermission(dev) -> SkiaConsole.notice("USB access is already granted.")
else -> usb.requestPermission(
dev,
PendingIntent.getBroadcast(
activity, 3, // requestCode 3 — shared with the touch card's button
Intent(DS_USB_PERMISSION_ACTION).setPackage(activity.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
}
"ds_haptics" -> {
val dev = usb.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
}
when {
dev == null -> SkiaConsole.notice("No wired DualSense detected.")
DsDevice.modelFor(dev.productId) == DsDevice.Model.DUALSHOCK4 ->
SkiaConsole.notice("The DualShock 4 has no haptics audio device.")
!usb.hasPermission(dev) -> SkiaConsole.notice("Grant USB access first.")
else -> Thread({
// Its OWN connection: the renderer's descriptor must never be shared with
// another transfer engine, and that applies to this test as much as to the
// real path (same rule as the touch card's test).
val conn = runCatching { usb.openDevice(dev) }.getOrNull()
val fd = conn?.fileDescriptor ?: -1
val r = if (fd >= 0) NativeBridge.nativePadAudioSelfTest(fd, 3, 60) else -1
conn?.close()
SkiaConsole.notice(
when {
r > 0 -> "Haptics test passed — $r frames to the pad."
r == -1 ->
"Could not open the pad's audio interface. Some kernels " +
"refuse it; the pad still works normally."
r == -2 -> "The audio stream stopped part-way."
else -> "The stream opened but no audio reached the pad."
},
)
}, "pf-pad-selftest-console").start()
}
}
}
}
/** The raw pad as one `MenuSample`, pushed whenever any part of it changes. */
private class PadState {
var deviceId = -1
@@ -127,9 +127,9 @@ class ScreenshotTest {
WakeTimedOutScene()
}
// 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.
// The licences view — the one screen the console still opens as a Compose takeover. Shot on a
// dark AND a pale palette, because the console draws it 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", statusBar = false) { ConsoleLicensesScene() }
@@ -137,9 +137,6 @@ class ScreenshotTest {
fun consoleLicensesLight() =
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
@Test
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
/**
* The touch presentation, pads connected landscape, like every store frame: the app is
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
@@ -148,12 +145,6 @@ class ScreenshotTest {
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun controllers() = shootRoot("controllers") { ControllersScene() }
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleControllersLandscape() =
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
/**
* The same shelf as the TOUCH grid the presentation a finger gets from a host card's
* "Browse library…". Portrait (the default qualifiers), because that is the orientation a
@@ -162,10 +153,6 @@ class ScreenshotTest {
@Test
fun libraryTouch() = shootRoot("library-touch") { TouchLibraryScene() }
@Test
fun consoleControllersLight() =
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
@Test
fun trust() = shootScreen("trust") {
HostsScene()
@@ -62,7 +62,6 @@ import coil.test.FakeImageLoaderEngine
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.AddHostSheet
import io.unom.punktfunk.ConsoleControllersScreen
import io.unom.punktfunk.ConsoleHeader
import io.unom.punktfunk.ConsoleLegendInset
import io.unom.punktfunk.ConsoleLicensesScreen
@@ -559,36 +558,24 @@ private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
}
/**
* 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.
* The one Compose screen the console still opens over itself the open-source notices in its
* console presentation. (Connected controllers used to be its sibling here; it is the console's
* own Skia screen now, covered by pf-console-ui's tests.)
*
* 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
* Worth a shot, and worth a PALE one: it is an ordinary Material screen underneath, and the
* console shows it through a `ColorScheme` derived from the palette's ink. That derivation is the
* whole risk. Its 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 scenes inject [shotPads] the
* deterministic connected-pads state the store listing needs.
*/
@Composable
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) }
@Composable
internal fun ConsoleControllersScene(paletteId: String = "violet") =
ConsolePalette(paletteId) {
// Robolectric enumerates no input devices, so the shot injects the two pads the store
// listing talks about — the empty "no controller detected" state proves the palette but
// sells nothing.
ConsoleControllersScreen(
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
)
}
/**
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
* [Surface]: the activity provides the dark ground in the app, and without one here the content
* color falls back to black-on-white while the cards stay dark.
* The controllers screen with [shotPads] injected Robolectric enumerates no input devices, and
* the connected-pad card is the point of the shot. Wrapped in a background [Surface]: the
* activity provides the dark ground in the app, and without one here the content color falls
* back to black-on-white while the cards stay dark.
*/
@Composable
internal fun ControllersScene() =
@@ -40,7 +40,4 @@ class TvScreenshotTest {
@Test
fun streamDetailed() =
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
@Test
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
}
+31 -4
View File
@@ -11,7 +11,7 @@
use super::egl::{EglContext, EglSurface, GlesVersion};
use super::gpu::Gpu;
use anyhow::Result;
use anyhow::{bail, Result};
use ndk::native_window::NativeWindow;
use pf_client_core::console::{OverlayAction, PointerInput, SessionPhase};
use pf_client_core::menu_nav::{MenuEvent, MenuNav, MenuPulse, MenuSample, PadInfo};
@@ -267,6 +267,13 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
let mut was_editing = console.editing();
let mut saved_gen = store.saved_gen();
let mut menu_out: Vec<MenuEvent> = Vec::new();
// Consecutive GL setup failures (window surface / Skia wrap). One is a transient (a window
// torn down mid-create); a run of them is a context that is not coming back — most likely
// reclaimed by Android while the app was backgrounded. Only exiting reports that: each
// failure alone is logged, the loop retries, and the screen stays a gray never-painted
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
let mut gl_failures = 0u32;
const GL_FAILURE_LIMIT: u32 = 3;
loop {
// Take everything queued. With no surface up, block until something arrives.
@@ -347,11 +354,15 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
}
surface = Some(s);
window = Some(w);
gl_failures = 0;
// A fresh surface is a fresh entry: snapshot the pad so a button
// still held from before does not fire into the first frame.
nav.reset();
}
Err(e) => log::error!("console: window surface: {e:#}"),
Err(e) => {
log::error!("console: window surface: {e:#}");
gl_failures += 1;
}
}
}
Cmd::SurfaceChanged => {
@@ -411,8 +422,14 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
if need_wrap {
skia = None;
match g.wrap_window(&egl, w, h) {
Ok(surf) => skia = Some((surf, w, h)),
Err(e) => log::error!("console: {e:#}"),
Ok(surf) => {
skia = Some((surf, w, h));
gl_failures = 0;
}
Err(e) => {
log::error!("console: {e:#}");
gl_failures += 1;
}
}
}
if let Some((surf, _, _)) = skia.as_mut() {
@@ -441,6 +458,16 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
}
}
if gl_failures >= GL_FAILURE_LIMIT {
// Same release order as `Cmd::Quit`: the Skia surface, the current binding, then (on
// return) the EGL surface + window + context drop.
drop(skia.take());
if surface.is_some() {
egl.release_current();
}
bail!("GL surface failed {gl_failures} times in a row — giving the screen back");
}
// Publish what the console raised.
while let Some(a) = console.take_action() {
shared.emit(HostEvent::Action(a));
+13 -2
View File
@@ -83,6 +83,13 @@ struct PadJson {
steam_virtual: bool,
#[serde(default)]
battery: Option<BatteryJson>,
/// `VID:PID · gamepad · dpad` — what the controllers screen prints under the name.
#[serde(default)]
detail: String,
#[serde(default)]
forwarded: bool,
#[serde(default)]
rumble: bool,
}
#[derive(serde::Deserialize)]
@@ -454,8 +461,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleNavi
}
/// `NativeBridge.nativeConsoleSetPads(handle, padsJson)` — the connected controllers for the
/// chip + settings rows: `{"label": "DualSense", "pref": 1, "pads": [{name, key, pref,
/// steam_virtual, battery: {percent, charging} | null}]}`.
/// chip, the settings rows and the controllers screen: `{"label": "DualSense", "pref": 1,
/// "pads": [{name, key, pref, steam_virtual, battery: {percent, charging} | null, detail,
/// forwarded, rumble}]}`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetPads(
mut env: EnvUnowned,
@@ -479,6 +487,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetP
percent: b.percent.min(100),
charging: b.charging,
}),
detail: j.detail,
forwarded: j.forwarded,
rumble: j.rumble,
})
.collect();
h.shared.send(Cmd::Pads {
+5 -2
View File
@@ -667,9 +667,12 @@ impl ServiceState {
r.request();
}
}
// A platform-native screen (Android's Controllers/Licences views) — the desktop
// shell has no such rows, so this never arrives here.
// A platform-native screen (Android's Licences view) — the desktop shell has no
// such row, so this never arrives here.
ConsoleCmd::OpenPlatformScreen { .. } => {}
// Grants and rumble tests from the controllers screen. Android-only for the same
// reason: the settings row that opens that screen is not on the desktop's list.
ConsoleCmd::PadAction { .. } => {}
ConsoleCmd::SetPin {
key,
profile_id,
+8
View File
@@ -1075,6 +1075,14 @@ impl Worker {
// Unknowable from an ID-based getter — SDL reports power only for an OPEN
// device. `publish` fills it in for the one pad this service holds open.
battery: None,
// The three below feed the console's controllers screen, which is Android-only
// (design android-skia-console-port.md D3) — nothing on the desktop reads them.
// SDL enumerates only gamepad-classified devices, so the joystick-only case
// `forwarded` exists to name cannot arise here; rumble, like battery, needs the
// device OPEN and so is not knowable from this getter.
detail: format!("{vid:04X}:{pid:04X}"),
forwarded: true,
rumble: false,
})
}
+14
View File
@@ -235,6 +235,20 @@ pub struct PadInfo {
/// virtual gamepad reports nothing about the physical device behind it. Anything reading
/// this must degrade to "no battery shown" rather than to "0 %".
pub battery: Option<PadBattery>,
/// The identity line the console's controllers screen shows under the name —
/// `VID:PID · gamepad · dpad`. Support's first question when a pad "doesn't work" is
/// whether the OS enumerated the pad or the adapter in front of it, and the name alone
/// never answers that. Written by whoever enumerated the device; empty is "nothing more
/// to say", never an error.
pub detail: String,
/// Actually forwarded to the host: a real, non-virtual controller the OS classifies as a
/// GAMEPAD. A joystick-only node — an adapter that enumerates as a bare joystick, a
/// DualSense's motion-sensor sibling — is listed and NOT forwarded, which is the single
/// most common cause of "my pad is connected and nothing happens".
pub forwarded: bool,
/// The device reports a rumble motor. `false` is what turns the controllers screen's
/// rumble test into the sentence explaining why host rumble will be silent on this pad.
pub rumble: bool,
}
/// A controller's power state, as SDL reports it.
+11
View File
@@ -226,6 +226,17 @@ pub enum ConsoleCmd {
/// while it is up, and the console never learns what it looked like. The desktop raises
/// none — its settings list has no such rows.
OpenPlatformScreen { id: String },
/// Something only the PLATFORM can do to a controller, raised by the controllers screen:
/// Android's USB / Bluetooth grant dialogs, a rumble pulse on the real `InputDevice`, the
/// DualSense pad-audio self test. `action` is a
/// [`crate::screens::controllers::PadAction::id`]; `pad_key` addresses one of
/// [`crate::screens::Ctx::pads`] and is empty for the actions that are about a device the
/// pad list cannot name (an SC2 in lizard mode is no input device at all).
///
/// ONE parameterised command rather than one per button: the host's answer to every one
/// of them is the same shape — do the platform thing, report back as a notice — and a
/// command per grant would make adding the next pad a change in three crates.
PadAction { action: String, pad_key: String },
}
/// The overlay→binary command queue. A plain deque under the same locking discipline as
+7 -8
View File
@@ -1,10 +1,9 @@
//! Which platform the shell fronts. One shell, two hosts (design
//! android-skia-console-port.md D3/D7): the screens are the same everywhere, but not every
//! settings row means something on every platform — a decoder picker is a desktop concept,
//! low-latency decode an Android one — and only Android has native sub-screens (its
//! Controllers and Licenses views) for the settings list to open. Everything platform-shaped
//! is decided by asking this enum, so the row tables stay one union and no screen carries a
//! `cfg`.
//! low-latency decode an Android one — and only Android has a native sub-screen (its
//! Licenses view) for the settings list to open. Everything platform-shaped is decided by
//! asking this enum, so the row tables stay one union and no screen carries a `cfg`.
/// The host platform.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -20,9 +19,10 @@ pub enum Platform {
/// its own input until the host says the screen closed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlatformScreen {
/// Android's connected-controllers view (USB grant, rumble/haptics tests, DS capture).
Controllers,
/// The open-source licences view.
/// The open-source licences view. The last one: Connected controllers used to be here
/// too, and is a shared Skia screen now ([`crate::screens::controllers`]) — the console
/// keeps its own input on that page, and only the grant dialogs it cannot draw go back
/// to the host, as a [`crate::model::ConsoleCmd::PadAction`].
Licenses,
}
@@ -30,7 +30,6 @@ impl PlatformScreen {
/// The stable id the host matches on (crosses JNI as a string).
pub fn id(self) -> &'static str {
match self {
PlatformScreen::Controllers => "controllers",
PlatformScreen::Licenses => "licenses",
}
}
+10
View File
@@ -5,6 +5,7 @@
pub(crate) mod add_host;
pub(crate) mod collections;
pub(crate) mod controllers;
pub(crate) mod home;
pub(crate) mod library;
pub(crate) mod options;
@@ -179,6 +180,10 @@ pub(crate) enum Screen {
AddHost(add_host::AddHostScreen),
Pair(pair::PairScreen),
PinHosts(pin_hosts::PinHostsScreen),
/// "Connected controllers": the attached pads and their identity lines, plus the grants
/// and tests only the platform can perform. Android-reachable only — the settings row
/// that opens it is in `settings::row_on`'s Android-only list.
Controllers(controllers::ControllersScreen),
/// The context menu: a subject and the actions that apply to it — a host's Wake / Copy
/// link / Edit / Forget, a title's Copy link — raised by [`Outbox::options`]. It still
/// carries the host menu's name because [`host_options`] does; both are one rename.
@@ -200,6 +205,7 @@ impl Screen {
Screen::AddHost(s) => s.menu(ev, ctx, fx),
Screen::Pair(s) => s.menu(ev, ctx, fx),
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
Screen::Controllers(s) => s.menu(ev, ctx, fx),
Screen::HostOptions(s) => s.menu(ev, ctx, fx),
}
}
@@ -218,6 +224,7 @@ impl Screen {
Screen::AddHost(s) => s.pointer(p, ctx, fx),
Screen::Pair(s) => s.pointer(p, ctx, fx),
Screen::PinHosts(s) => s.pointer(p, ctx, fx),
Screen::Controllers(s) => s.pointer(p, ctx, fx),
Screen::HostOptions(s) => s.pointer(p, ctx, fx),
}
}
@@ -267,6 +274,7 @@ impl Screen {
Screen::AddHost(s) => s.title(),
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
Screen::Controllers(_) => "Connected controllers".into(),
Screen::HostOptions(s) => s.title(),
}
}
@@ -280,6 +288,7 @@ impl Screen {
Screen::AddHost(s) => s.hints(ctx),
Screen::Pair(s) => s.hints(ctx),
Screen::PinHosts(s) => s.hints(ctx),
Screen::Controllers(s) => s.hints(ctx),
Screen::HostOptions(s) => s.hints(ctx),
}
}
@@ -304,6 +313,7 @@ impl Screen {
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::Controllers(s) => s.render(canvas, rect, k, dt, fonts, ctx),
Screen::HostOptions(s) => s.render(canvas, rect, k, dt, fonts, ctx),
}
}
@@ -0,0 +1,453 @@
//! "Connected controllers" — everything the client can see about the attached pads, and the
//! handful of actions only the platform can perform on them. Reached from the settings
//! list's Controller tab.
//!
//! This exists for exactly one support case: 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 only devices the OS classifies as a gamepad are forwarded — so the
//! screen's real content is the identity line under each name, not the name.
//!
//! It was a Compose screen the Android host drew OVER the console (the D7 platform-screen
//! mechanism) until 2026-08. Drawing it here instead is what lets the console keep its own
//! input on the page; what genuinely cannot move — the USB and Bluetooth grant dialogs, a
//! rumble pulse on a real `InputDevice` — stays with the host and is asked for by
//! [`ConsoleCmd::PadAction`].
//
// ponytail: the Compose screen's live input test (button grid + axis bars, entered with A,
// left by holding B) did NOT move here — the console only receives the aggregated
// `MenuSample` (6 buttons, lx/ly, dpad), nowhere near a per-device axis/trigger readout,
// and the hold-to-exit gesture has no home in the edge-triggered MenuEvent grammar. The
// touch Controllers screen keeps the full test, so the feature exists on-device; add it
// here by widening the pad-sample bridge with a per-device payload while the test is open.
use crate::glyphs::{Hint, HintKey};
use crate::model::ConsoleCmd;
use crate::platform::Platform;
use crate::pointer::Pointer;
use crate::screens::{Ctx, Outbox};
use crate::theme::{fg, Fonts, W};
use crate::widgets::{ListMsg, MenuList, RowSpec};
use pf_client_core::menu_nav::{MenuEvent, MenuPulse, PadInfo};
use skia_safe::{Canvas, Rect};
/// Work on a controller that only the HOST can do — every one of these needs a permission
/// dialog or a real device handle, neither of which exists on this side of the bridge.
/// Ordered as they are listed.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum PadAction {
/// Pulse the focused pad's motor (the "is rumble even wired up" test).
Rumble,
/// `BLUETOOTH_CONNECT`, without which a BLE-paired Steam Controller 2 is invisible —
/// not "detected and idle", absent, which is why the row is offered rather than hidden
/// behind a detection that cannot run.
Sc2Bluetooth,
/// USB access for a wired or Puck-dongle Steam Controller 2.
Sc2Usb,
/// USB access for a wired Sony pad (DualSense, Edge, DualShock 4).
DsUsb,
/// The DualSense pad-audio self test: can this phone drive the pad's audio endpoint at
/// all. Deliberately reachable with no stream running — it exists to rule the pad out
/// when a session misbehaves, and gating it behind a session would make it depend on
/// the very thing under suspicion.
DsHaptics,
}
impl PadAction {
/// The stable id the host matches on (crosses JNI inside [`ConsoleCmd::PadAction`]).
pub(crate) fn id(self) -> &'static str {
match self {
PadAction::Rumble => "rumble",
PadAction::Sc2Bluetooth => "sc2_bluetooth",
PadAction::Sc2Usb => "sc2_usb",
PadAction::DsUsb => "ds_usb",
PadAction::DsHaptics => "ds_haptics",
}
}
}
/// The passthrough rows, in list order. Platform-gated as one union exactly like the
/// settings row table (`settings::row_on`): the desktop captures nothing over raw USB and
/// asks for no grants, so it has no such rows — never a control that changes nothing.
const PASSTHROUGH: [(PadAction, &str, &str); 4] = [
(
PadAction::Sc2Bluetooth,
"Steam Controller 2 over Bluetooth",
"Grant",
),
(PadAction::Sc2Usb, "Steam Controller 2 over USB", "Grant"),
(
PadAction::DsUsb,
"DualSense / DualShock over USB",
"Grant",
),
(PadAction::DsHaptics, "DualSense haptics self-test", "Test"),
];
/// One line in the list. Pads first, then whatever the platform can be asked to do.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Row {
/// An index into [`Ctx::pads`].
Pad(usize),
/// No pads at all — an inert row, so the list is never empty and the cursor always has
/// something to sit on while the passthrough rows below it stay reachable.
NoPads,
/// An index into [`PASSTHROUGH`].
Passthrough(usize),
}
fn rows_for(ctx: &Ctx) -> Vec<Row> {
let mut rows: Vec<Row> = if ctx.pads.is_empty() {
vec![Row::NoPads]
} else {
(0..ctx.pads.len()).map(Row::Pad).collect()
};
if ctx.platform == Platform::Android {
rows.extend((0..PASSTHROUGH.len()).map(Row::Passthrough));
}
rows
}
pub(crate) struct ControllersScreen {
list: MenuList,
}
impl ControllersScreen {
pub(crate) fn new() -> ControllersScreen {
ControllersScreen {
list: MenuList::new(),
}
}
pub(crate) fn menu(
&mut self,
ev: MenuEvent,
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
if ev == MenuEvent::Back {
fx.pop();
return None;
}
let rows = rows_for(ctx);
let (msg, pulse) = self.list.menu(ev, rows.len());
self.activate(msg, pulse, &rows, ctx, fx)
}
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
let rows = rows_for(ctx);
let (msg, pulse) = self.list.pointer(p, rows.len());
if matches!(msg, ListMsg::None) && pulse.is_none() {
return false;
}
self.activate(msg, pulse, &rows, ctx, fx);
true
}
/// One list message against the focused row — shared by the pad path and the pointer's,
/// so a click and an A press can never drift apart.
fn activate(
&mut self,
msg: ListMsg,
pulse: Option<MenuPulse>,
rows: &[Row],
ctx: &mut Ctx,
fx: &mut Outbox,
) -> Option<MenuPulse> {
let Some(&focused) = rows.get(self.list.cursor) else {
return pulse;
};
// Nothing here steps: every row is a button or a statement.
if matches!(msg, ListMsg::Adjust(_)) {
return Some(MenuPulse::Boundary);
}
if !matches!(msg, ListMsg::Activate) {
return pulse;
}
let (action, pad_key) = match focused {
Row::NoPads => return Some(MenuPulse::Boundary),
Row::Pad(i) => {
// A pad with no motor has nothing to test; say so with the thud rather than
// sending a command the host would silently drop.
if !ctx.pads[i].rumble {
return Some(MenuPulse::Boundary);
}
(PadAction::Rumble, ctx.pads[i].key.clone())
}
// The grants are about a device the pad list cannot name (an SC2 in lizard mode
// is no input device at all), so they carry no key.
Row::Passthrough(i) => (PASSTHROUGH[i].0, String::new()),
};
fx.cmds.push(ConsoleCmd::PadAction {
action: action.id().to_string(),
pad_key,
});
pulse
}
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
let rows = rows_for(ctx);
let confirm = match rows.get(self.list.cursor) {
Some(Row::Pad(i)) if ctx.pads[*i].rumble => Some("Test rumble"),
Some(Row::Passthrough(i)) => Some(match PASSTHROUGH[*i].0 {
PadAction::DsHaptics => "Test haptics",
_ => "Grant access",
}),
_ => None,
};
let mut hints = Vec::new();
if let Some(label) = confirm {
hints.push(Hint::new(HintKey::Confirm, label));
}
hints.push(Hint::new(HintKey::Back, "Done"));
hints
}
pub(crate) fn render(
&mut self,
canvas: &Canvas,
rect: Rect,
k: f64,
dt: f64,
fonts: &Fonts,
ctx: &mut Ctx,
) {
// The focused row's explainer takes a reserved band under the list — the settings
// screen's shape, and here it is the whole point: the identity of the device is the
// support answer, and it is far too long to live on the row.
let detail_h = 34.0 * k;
let rows = rows_for(ctx);
let specs: Vec<RowSpec> = rows.iter().map(|r| spec(*r, ctx)).collect();
self.list.render(
canvas,
Rect::from_ltrb(
rect.left,
rect.top,
rect.right,
rect.bottom - detail_h as f32,
),
&specs,
fonts,
k,
dt,
true,
);
let detail = rows
.get(self.list.cursor)
.map_or_else(String::new, |r| detail(*r, ctx));
fonts.centered(
canvas,
&detail,
W::Regular,
13.0 * k,
fg(0.55),
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
f64::from(rect.bottom) - detail_h + 6.0 * k,
f64::from(rect.width()) * 0.8,
);
}
}
fn spec(row: Row, ctx: &Ctx) -> RowSpec {
match row {
Row::NoPads => RowSpec {
header: Some("Gamepads"),
..RowSpec::action("No controller detected", false)
},
Row::Pad(i) => {
let pad = &ctx.pads[i];
RowSpec {
header: (i == 0).then_some("Gamepads"),
label: pad.name.clone(),
value: Some(if pad.rumble { "Test rumble" } else { "No rumble" }.into()),
value_dim: !pad.rumble,
caret: false,
adjustable: false,
enabled: pad.rumble,
}
}
Row::Passthrough(i) => {
let (_, label, verb) = PASSTHROUGH[i];
RowSpec {
header: (i == 0).then_some("Passthrough"),
label: label.into(),
value: Some(verb.into()),
value_dim: false,
caret: false,
adjustable: false,
enabled: true,
}
}
}
}
/// The band under the list: what this row is, in one sentence.
fn detail(row: Row, ctx: &Ctx) -> String {
match row {
Row::NoPads => "Punktfunk only forwards devices the system classifies as a gamepad or \
joystick a pad behind an adapter or hub may enumerate with the \
adapter's identity, or not at all."
.into(),
Row::Pad(i) => pad_detail(&ctx.pads[i]),
Row::Passthrough(i) => match PASSTHROUGH[i].0 {
PadAction::Sc2Bluetooth =>
"A Steam Controller 2 paired over Bluetooth cannot be detected at all without \
Bluetooth access. Wired and Puck-dongle controllers need no permission."
.into(),
PadAction::Sc2Usb =>
"A wired or Puck-dongle Steam Controller 2 needs USB access to be captured; \
until then it stays in its built-in keyboard/mouse mode."
.into(),
PadAction::DsUsb =>
"A wired DualSense or DualShock 4 needs USB access to be captured — with it, \
streams drive rumble, adaptive triggers, lightbar and gyro directly."
.into(),
PadAction::DsHaptics =>
"Play a short tone through a wired DualSense's audio endpoint, to tell a pad \
that cannot do haptics from a stream that is not sending them."
.into(),
// Not offered as a passthrough row — the pads carry it.
PadAction::Rumble => String::new(),
},
}
}
/// A pad's identity line: what the OS enumerated, whether it is forwarded, what the host
/// will build for it, and its charge if it reports one.
fn pad_detail(pad: &PadInfo) -> String {
let mut parts: Vec<String> = Vec::new();
if !pad.detail.is_empty() {
parts.push(pad.detail.clone());
}
if !pad.forwarded {
parts.push("not forwarded — not classified as a gamepad".into());
}
let kind = pad.kind_label();
parts.push(format!(
"streams as {}",
if kind.is_empty() { "Xbox 360" } else { kind }
));
if let Some(b) = pad.battery {
parts.push(if b.charging {
format!("battery {} %, charging", b.percent)
} else {
format!("battery {} %", b.percent)
});
}
parts.join(" · ")
}
#[cfg(test)]
mod tests {
use super::*;
use pf_client_core::trust::Settings;
use punktfunk_core::config::GamepadPref;
fn pad(name: &str, rumble: bool) -> PadInfo {
PadInfo {
name: name.into(),
key: format!("054c:0ce6:{name}"),
pref: GamepadPref::DualSense,
steam_virtual: false,
battery: None,
detail: "054C:0CE6 · gamepad".into(),
forwarded: true,
rumble,
}
}
fn drive(
screen: &mut ControllersScreen,
platform: Platform,
pads: &[PadInfo],
ev: MenuEvent,
) -> (Outbox, Option<MenuPulse>) {
let mut settings = Settings::default();
let library = crate::library::LibraryShared::default();
let mut ctx = Ctx {
hosts: &[],
library: &library,
settings: &mut settings,
store: crate::store::file_store(),
platform,
pads,
deck: false,
device_name: "t",
t: 0.0,
};
let mut fx = Outbox::default();
let pulse = screen.menu(ev, &mut ctx, &mut fx);
(fx, pulse)
}
#[test]
fn a_on_a_pad_asks_the_host_for_a_rumble_pulse() {
let pads = [pad("DualSense", true)];
let mut s = ControllersScreen::new();
let (fx, _) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::PadAction {
action: "rumble".into(),
pad_key: "054c:0ce6:DualSense".into(),
}]
);
}
#[test]
fn a_pad_with_no_motor_thuds_instead_of_sending_a_pulse() {
let pads = [pad("Adapter", false)];
let mut s = ControllersScreen::new();
let (fx, pulse) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
assert!(fx.cmds.is_empty());
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
}
#[test]
fn the_grant_rows_are_androids_alone_and_carry_no_pad_key() {
// Desktop: pads and nothing else — it asks for no grants and captures nothing raw.
let pads = [pad("DualSense", true)];
let mut settings = Settings::default();
let library = crate::library::LibraryShared::default();
let ctx = |platform, settings: &mut Settings| Ctx {
hosts: &[],
library: &library,
settings,
store: crate::store::file_store(),
platform,
pads: &pads,
deck: false,
device_name: "t",
t: 0.0,
};
assert_eq!(rows_for(&ctx(Platform::Desktop, &mut settings)).len(), 1);
assert_eq!(
rows_for(&ctx(Platform::Android, &mut settings)).len(),
1 + PASSTHROUGH.len()
);
// Down onto the first grant row, then A.
let mut s = ControllersScreen::new();
drive(
&mut s,
Platform::Android,
&pads,
MenuEvent::Move(pf_client_core::menu_nav::MenuDir::Down),
);
let (fx, _) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
assert_eq!(
fx.cmds,
vec![ConsoleCmd::PadAction {
action: "sc2_bluetooth".into(),
pad_key: String::new(),
}]
);
}
#[test]
fn with_no_pads_the_list_still_has_the_grants_under_an_inert_row() {
let mut s = ControllersScreen::new();
let (fx, pulse) = drive(&mut s, Platform::Android, &[], MenuEvent::Confirm);
assert!(fx.cmds.is_empty(), "the empty-state row does nothing");
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
}
}
+18 -8
View File
@@ -488,17 +488,27 @@ impl SettingsScreen {
ListMsg::None => pulse,
};
}
// The platform's own screens: A asks the host to open one; nothing here edits.
RowId::Controllers | RowId::Licenses => {
// Connected controllers is one of ours now — a shared Skia screen, so the console
// keeps its own input on the page and only the grant dialogs go back to the host.
RowId::Controllers => {
return match msg {
ListMsg::Activate => {
fx.push(Screen::Controllers(
super::controllers::ControllersScreen::new(),
));
pulse
}
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
ListMsg::None => pulse,
};
}
// The one screen still the platform's: A asks the host to open it; nothing here
// edits.
RowId::Licenses => {
return match msg {
ListMsg::Activate => {
let screen = if focused == RowId::Controllers {
crate::platform::PlatformScreen::Controllers
} else {
crate::platform::PlatformScreen::Licenses
};
fx.cmds.push(crate::model::ConsoleCmd::OpenPlatformScreen {
id: screen.id().to_string(),
id: crate::platform::PlatformScreen::Licenses.id().to_string(),
});
pulse
}