fix(gamepad/android): make the exit chord usable again — shorter hold + on-screen hint
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 1m7s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
ci / bench (push) Successful in 5m21s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
docker / deploy-docs (push) Successful in 24s
ci / rust (push) Failing after 8m21s
arch / build-publish (push) Successful in 11m13s
deb / build-publish (push) Successful in 12m19s
android / android (push) Successful in 15m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m54s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m56s
apple / swift (push) Successful in 4m57s
apple / screenshots (push) Successful in 20m12s

The Select+Start+L1+R1 close-stream chord read as broken after 48933dc4
changed it from an instant quit to a 1.5 s arm-and-hold: a normal quick
press did nothing and there was no on-screen cue that a hold was now
required. Keep the accident-prevention hold (an errant brush of the four
buttons still shouldn't kill a session), but make it usable:

- Shorten EXIT_HOLD_MS 1500 -> 1000 ms — still rejects a brush, feels
  responsive.
- GamepadRouter gains onExitArmed(Boolean): fires true when the chord
  completes and the countdown starts (once per cycle, past the
  pendingExit guard), false on an early release or when the timer
  elapses.
- StreamScreen shows a "Hold to quit…" pill (top-center) while armed, so
  the hold is discoverable; the callback is detached in onDispose before
  router.release() so its disarm can't poke Compose state during
  teardown.
- MainActivity: drop the now-stale "~1.5 s" dispatch comment.

Verified on this Mac: :kit + :app compileDebugKotlin clean; Android lint
clean for all three touched files (the kit lint baseline errors are
pre-existing, unrelated). On-glass on a real phone + pad still owed (the
1 s hold firing the exit, early-release cancelling, the hint showing /
hiding) — per the Android-input-regressions-only-show-on-hardware
history, and the original hold path was never exercised on a device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:24:49 +02:00
parent 499bf2dae8
commit a7d4a93ff2
3 changed files with 62 additions and 8 deletions
@@ -317,9 +317,9 @@ class MainActivity : ComponentActivity() {
if (bit != 0) { if (bit != 0) {
// The router forwards the bit on this device's own wire pad index and tracks held // The router forwards the bit on this device's own wire pad index and tracks held
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled // state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
// inside the router: holding it for ~1.5 s fires router.onExitChord (wired in // inside the router: holding it briefly (~1 s, with an on-screen hint) fires
// StreamScreen), so a couch user with no keyboard/Back can still leave — but an // router.onExitChord (wired in StreamScreen), so a couch user with no keyboard/Back
// accidental brush of the four buttons no longer quits instantly. // can still leave — but an accidental brush of the four buttons no longer quits.
gamepadRouter?.onButton(event, bit) gamepadRouter?.onButton(event, bit)
return true // consumed return true // consumed
} }
@@ -23,10 +23,13 @@ import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -36,9 +39,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@@ -163,6 +168,10 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
}.onEach { it.setReferenceCounted(false) } }.onEach { it.setReferenceCounted(false) }
} }
// True while the gamepad exit chord (Select+Start+L1+R1) is held and counting down — drives the
// "hold to quit" hint overlay. Set from the router's onExitArmed (main thread).
var exitArming by remember { mutableStateOf(false) }
DisposableEffect(handle) { DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
wifiLocks.forEach { lock -> wifiLocks.forEach { lock ->
@@ -205,6 +214,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// the same way the Back gesture does. // the same way the Back gesture does.
activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() }
router.onExitChord = { activity?.requestStreamExit?.invoke() } router.onExitChord = { activity?.requestStreamExit?.invoke() }
// Show a "hold to quit" hint the moment the chord completes (the router debounces the actual
// exit); it clears when the buttons release early or the hold elapses. Runs on the main thread.
router.onExitArmed = { armed -> exitArming = armed }
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad // Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
// index via the router; poll threads stopped + joined before the router is released and the // index via the router; poll threads stopped + joined before the router is released and the
@@ -274,6 +286,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } } sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down) sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null activity?.gamepadRouter = null
activity?.streamHandle = 0L activity?.streamHandle = 0L
@@ -356,6 +369,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp))
} }
} }
// "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s
// hold, so without this cue a couch user reads the (deliberately no-longer-instant) chord as
// broken. Purely visual; it sits above the video and below the gesture layer.
if (exitArming) {
ExitChordHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp))
}
// Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe // Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe
// up in the mouse modes) — it never draws or takes touches, it just owns IME focus. // up in the mouse modes) — it never draws or takes touches, it just owns IME focus.
AndroidView( AndroidView(
@@ -382,6 +401,24 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
} }
} }
/**
* The "hold to quit" cue shown while the gamepad exit chord (Select + Start + L1 + R1) is held. The
* chord no longer quits on a quick press — the router debounces it on a ~1 s hold — so this confirms
* the press registered and tells the user to keep holding. Purely visual; [GamepadRouter.onExitArmed]
* toggles its visibility.
*/
@Composable
private fun ExitChordHint(modifier: Modifier = Modifier) {
Text(
"Hold to quit…",
modifier = modifier
.background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp),
color = Color.White,
fontSize = 15.sp,
)
}
/** /**
* Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME * Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME
* onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s * onto this view. `TYPE_NULL` puts the IME in "dumb keyboard" mode — it delivers raw [KeyEvent]s
@@ -57,6 +57,14 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
*/ */
var onExitChord: (() -> Unit)? = null var onExitChord: (() -> Unit)? = null
/**
* Invoked (main thread) with `true` the moment the exit chord completes and the hold countdown
* starts, and `false` when it's cancelled (a button lifted early) or the timer elapses. `StreamScreen`
* wires this to a "hold to quit" hint so the hold is discoverable — the chord no longer quits on a
* quick press, and without an on-screen cue that reads as the shortcut being broken.
*/
var onExitArmed: ((armed: Boolean) -> Unit)? = null
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
/** The pending exit-chord hold timer, or null when the chord isn't currently armed. */ /** The pending exit-chord hold timer, or null when the chord isn't currently armed. */
private var pendingExit: Runnable? = null private var pendingExit: Runnable? = null
@@ -84,9 +92,10 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
* One gamepad button transition for the device that produced [event] (already resolved to BTN_* * One gamepad button transition for the device that produced [event] (already resolved to BTN_*
* bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the * bit [bit]). Opens the device's slot (declaring its type) if unseen, forwards the bit on the
* slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select + * slot's pad index, and tracks held state. Completing the emergency stream-exit chord (Select +
* Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly; * Start + L1 + R1) on any one pad ARMS a [EXIT_HOLD_MS] hold timer rather than leaving instantly
* [onExitChord] fires only if the chord is still held at expiry (a brief accidental brush is * ([onExitArmed] fires so the UI can show a "hold to quit" hint); [onExitChord] fires only if the
* ignored), matching `DISCONNECT_HOLD` on the SDL/Apple clients. Any controller can leave. * chord is still held at expiry (a brief accidental brush is ignored), matching `DISCONNECT_HOLD`
* on the SDL/Apple clients. Any controller can leave.
*/ */
fun onButton(event: KeyEvent, bit: Int) { fun onButton(event: KeyEvent, bit: Int) {
val slot = slotFor(event.device) ?: return val slot = slotFor(event.device) ?: return
@@ -123,6 +132,7 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
if (pendingExit != null) return // already counting down if (pendingExit != null) return // already counting down
val r = Runnable { val r = Runnable {
pendingExit = null pendingExit = null
onExitArmed?.invoke(false) // countdown over — drop the hint whether or not we leave
// Fire only if the chord survived the full hold on some pad. // Fire only if the chord survived the full hold on some pad.
val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD } val held = slots.values.filter { it.held and EXIT_CHORD == EXIT_CHORD }
if (held.isNotEmpty()) { if (held.isNotEmpty()) {
@@ -134,12 +144,15 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
} }
pendingExit = r pendingExit = r
mainHandler.postDelayed(r, EXIT_HOLD_MS) mainHandler.postDelayed(r, EXIT_HOLD_MS)
onExitArmed?.invoke(true) // chord complete → show the "hold to quit" hint
} }
/** Cancel a pending exit-chord hold timer. */ /** Cancel a pending exit-chord hold timer. */
private fun disarmExit() { private fun disarmExit() {
val wasArmed = pendingExit != null
pendingExit?.let { mainHandler.removeCallbacks(it) } pendingExit?.let { mainHandler.removeCallbacks(it) }
pendingExit = null pendingExit = null
if (wasArmed) onExitArmed?.invoke(false) // released early — drop the hint
} }
/** /**
@@ -303,8 +316,12 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */ /** Emergency stream-exit chord: Select + Start + L1 + R1 held together (matches the legacy single-pad chord). */
const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB const val EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
/** How long the exit chord must be held before the stream leaves — matches SDL/Apple `DISCONNECT_HOLD`. */ /**
const val EXIT_HOLD_MS = 1500L * How long the exit chord must be held before the stream leaves — long enough that an
* accidental brush of the four buttons doesn't quit, short enough to feel responsive (the
* on-screen hint covers the gap). Roughly matches SDL/Apple `DISCONNECT_HOLD`.
*/
const val EXIT_HOLD_MS = 1000L
/** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */ /** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */
const val EXTERNAL_ID_BASE = -1000 const val EXTERNAL_ID_BASE = -1000