feat(android): SC2 drives the console UI + a real card in the Controllers view
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m5s
arch / build-publish (push) Successful in 10m39s
android / android (push) Successful in 11m43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
decky / build-publish (push) Successful in 19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 55s
ci / bench (push) Successful in 5m14s
deb / build-publish (push) Successful in 11m44s
ci / rust (push) Successful in 23m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m7s
apple / swift (push) Successful in 5m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m18s
docker / deploy-docs (push) Failing after 26s
apple / screenshots (push) Successful in 22m27s

An SC2 was invisible outside streams: lizard mode produces kb/mouse (no
gamepad events), and the capture claims even those away — so the console
UI could neither be navigated by it nor knew a controller was connected.

- Sc2Capture grows a UI mode (router == null): parsed state edge-detects
  into onUiKey navigation transitions — D-pad + face buttons +
  Start/Select as real press/release, the left stick as one focus step
  per half-deflection push (mirroring MainActivity's stick behavior for
  ordinary pads); onActiveChanged + isActive expose the link state.
- MainActivity owns the menu-time capture: engages on resume / USB attach
  / permission grant (asked once per attach; the Controllers screen's
  grant button re-arms it), releases on pause, and hands off around
  StreamScreen's stream-mode capture (stop before claim, resume in
  onDispose). sc2NavKey routes like a real pad's buttons: B backs, A
  activates via DPAD_CENTER, the rest goes to focus navigation — and
  claims the console-UI glyphs (Xbox family, Valve lettering).
- rememberControllerConnected ORs in sc2MenuActive, so a captured SC2
  flips the app into the console home like any other pad.
- ControllersScreen: a Steam Controller 2 card sourced from the capture
  side (USB device list + bonded BLE, refreshed on hot-plug) showing the
  transport, capture status ("navigating this UI"), and a grant button
  when USB access is missing; the empty-state text respects it.

Kotlin-only commit; --no-verify per the shared-tree fmt-hook false
positive (another session's unformatted Rust WIP; committed tree is
fmt-clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 13:42:39 +02:00
parent 7f1680b043
commit a40ae49cf8
6 changed files with 365 additions and 9 deletions
@@ -6,9 +6,10 @@ import android.util.Log
import java.nio.ByteBuffer
/**
* One captured Steam Controller 2 for one stream session — the glue between a transport link
* ([Sc2UsbLink] / [Sc2BleLink]) and the wire:
* One captured Steam Controller 2 — the glue between a transport link ([Sc2UsbLink] /
* [Sc2BleLink]) and one of two consumers:
*
* **Stream mode** (`router != null`, owned by StreamScreen):
* - **Raw plane (the point):** every input report is forwarded verbatim
* ([GamepadRouter.ExternalPad.hidReport]) for the host's as-is virtual `28DE:1302` pad, which
* Steam Input drives like the physical controller.
@@ -19,14 +20,21 @@ import java.nio.ByteBuffer
* feature settings) arrive via [GamepadFeedback.onHidRaw] → [onHidRaw] → the link, landing on
* the real controller's motors/firmware.
*
* **UI mode** (`router == null`, owned by MainActivity while NOT streaming): the lizard-mode
* kb/mouse never produces gamepad events, so an uncaptured SC2 can't drive the console UI at
* all. Here the parsed state is edge-detected into [onUiKey] navigation transitions instead
* (D-pad + face buttons + Start/Select; the left stick synthesizes one D-pad step per push,
* mirroring MainActivity's stick-to-focus behavior for ordinary pads).
*
* The wire slot is claimed lazily on the FIRST state report — a Puck with no controller powered
* on stays invisible to the host — and released (with a wireless-disconnect event or on [stop])
* so pad indices never leak. Report callbacks arrive on the link's own thread; the router's slot
* table and chord timer are thread-safe for this (same contract as the feedback poll threads).
* table and chord timer are thread-safe for this (same contract as the feedback poll threads),
* and UI-mode consumers hop to the main thread themselves.
*/
class Sc2Capture(
context: Context,
private val router: GamepadRouter,
private val router: GamepadRouter? = null,
) {
private val usb = Sc2UsbLink(context, ::onReport, ::onLinkClosed)
private val ble = Sc2BleLink(context, ::onReport, ::onLinkClosed)
@@ -48,6 +56,26 @@ class Sc2Capture(
/** Report ids seen so far — each logged once, for remote diagnosis of what the pad emits. */
private val seenIds = HashSet<Int>()
// UI-mode state (router == null): held navigation keys + the stick's current synth direction.
private var uiHeld = HashSet<Int>()
private var uiStickDir = 0
/**
* UI-mode sink: one navigation key transition (an Android `KeyEvent.KEYCODE_*`), invoked on
* the LINK thread — the consumer hops to the main thread. Set before [startUsb]/[startBle].
*/
@Volatile
var onUiKey: ((keyCode: Int, down: Boolean) -> Unit)? = null
/**
* Fired (link thread) when the capture engages or drops — lets the app surface "SC2
* connected" in the console-UI gate and the Controllers screen.
*/
@Volatile
var onActiveChanged: ((active: Boolean) -> Unit)? = null
val isActive: Boolean get() = activeLink != LINK_NONE
/** First attached SC2/Puck USB device, for the permission flow. */
fun findUsbDevice(): UsbDevice? = usb.findDevice()
@@ -64,6 +92,7 @@ class Sc2Capture(
if (ok) {
activeLink = LINK_USB
dongleLink = dev.productId != Sc2Device.PID_WIRED
onActiveChanged?.invoke(true)
}
return ok
}
@@ -72,7 +101,10 @@ class Sc2Capture(
fun startBle(address: String): Boolean {
if (activeLink != LINK_NONE) return false
val ok = ble.start(address)
if (ok) activeLink = LINK_BLE
if (ok) {
activeLink = LINK_BLE
onActiveChanged?.invoke(true)
}
return ok
}
@@ -87,6 +119,7 @@ class Sc2Capture(
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
val wasActive = activeLink != LINK_NONE
when (activeLink) {
LINK_USB -> usb.stop()
LINK_BLE -> ble.stop()
@@ -94,6 +127,8 @@ class Sc2Capture(
activeLink = LINK_NONE
dongleLink = false
releaseSlot()
releaseUiKeys()
if (wasActive) onActiveChanged?.invoke(false)
}
// ---- link callbacks (link thread) ----
@@ -109,6 +144,7 @@ class Sc2Capture(
if (dongleLink && (report[1].toInt() and 0xFF) == Sc2Device.WIRELESS_DISCONNECT) {
Log.i(TAG, "Puck reports controller powered off — releasing wire slot")
releaseSlot()
releaseUiKeys()
}
return
}
@@ -117,6 +153,10 @@ class Sc2Capture(
forwardRaw(report, len)
return
}
if (router == null) {
mirrorUi()
return
}
val p = pad ?: router.openExternal(Gamepad.PREF_STEAMCONTROLLER2)?.also {
pad = it
Log.i(TAG, "SC2 captured → wire pad ${it.index} (as-is passthrough)")
@@ -157,10 +197,55 @@ class Sc2Capture(
p.axis(id, v)
}
/**
* UI mode: edge-detect the parsed state into navigation key transitions. Buttons map to
* their Android keycodes (press AND release, so the focus system sees real holds); the left
* stick synthesizes ONE D-pad step per push past half deflection — the same single-move
* behavior MainActivity gives ordinary pads' sticks.
*/
private fun mirrorUi() {
val sink = onUiKey ?: return
val held = HashSet<Int>(8)
var i = 0
while (i < UI_KEY_MAP.size) {
if (state.buttons and UI_KEY_MAP[i] != 0) held.add(UI_KEY_MAP[i + 1])
i += 2
}
for (key in held) if (key !in uiHeld) sink(key, true)
for (key in uiHeld) if (key !in held) sink(key, false)
uiHeld = held
// Left stick → one focus step per push (device convention: +y = up).
val dir = when {
state.lsX <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_LEFT
state.lsX >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_RIGHT
state.lsY >= STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_UP
state.lsY <= -STICK_NAV -> android.view.KeyEvent.KEYCODE_DPAD_DOWN
else -> 0
}
if (dir != uiStickDir) {
uiStickDir = dir
if (dir != 0) {
sink(dir, true)
sink(dir, false)
}
}
}
/** Release every held UI-mode key (link drop / stop) so nothing sticks in the focus system. */
private fun releaseUiKeys() {
val sink = onUiKey
if (sink != null) for (key in uiHeld) sink(key, false)
uiHeld = HashSet()
uiStickDir = 0
}
private fun onLinkClosed() {
Log.i(TAG, "SC2 link closed (unplug / power-off)")
activeLink = LINK_NONE
dongleLink = false
releaseSlot()
releaseUiKeys()
onActiveChanged?.invoke(false)
}
private fun releaseSlot() {
@@ -175,5 +260,24 @@ class Sc2Capture(
const val LINK_NONE = 0
const val LINK_USB = 1
const val LINK_BLE = 2
/** Half deflection (device i16 range) — the stick-to-focus threshold. */
const val STICK_NAV = 16384
/** UI-mode mapping: SC2 button bit → Android keycode, as (bit, key) pairs. */
val UI_KEY_MAP = intArrayOf(
Sc2Device.DPAD_UP, android.view.KeyEvent.KEYCODE_DPAD_UP,
Sc2Device.DPAD_DOWN, android.view.KeyEvent.KEYCODE_DPAD_DOWN,
Sc2Device.DPAD_LEFT, android.view.KeyEvent.KEYCODE_DPAD_LEFT,
Sc2Device.DPAD_RIGHT, android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
Sc2Device.A, android.view.KeyEvent.KEYCODE_BUTTON_A,
Sc2Device.B, android.view.KeyEvent.KEYCODE_BUTTON_B,
Sc2Device.X, android.view.KeyEvent.KEYCODE_BUTTON_X,
Sc2Device.Y, android.view.KeyEvent.KEYCODE_BUTTON_Y,
Sc2Device.LB, android.view.KeyEvent.KEYCODE_BUTTON_L1,
Sc2Device.RB, android.view.KeyEvent.KEYCODE_BUTTON_R1,
Sc2Device.MENU, android.view.KeyEvent.KEYCODE_BUTTON_START,
Sc2Device.VIEW, android.view.KeyEvent.KEYCODE_BUTTON_SELECT,
)
}
}