Compare commits

...
Author SHA1 Message Date
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
7 changed files with 102 additions and 45 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,
)
@@ -255,11 +255,11 @@ private fun ControllersBody(
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.
// 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,15 +317,10 @@ private fun ControllersBody(
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
consuming
}
if (observeInput) {
activity?.padKeyProbe = keyProbe
activity?.padMotionProbe = motionProbe
}
val probes = if (observeInput) MainActivity.PadProbes(keyProbe, motionProbe) else null
probes?.let { activity?.pushPadProbes(it) }
onDispose {
activity?.let { a ->
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
}
probes?.let { activity?.removePadProbes(it) }
}
}
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
@@ -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
@@ -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 } }
@@ -149,6 +163,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)")
@@ -386,7 +401,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
}
}
}
@@ -228,13 +228,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)
}
+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));