From a40ae49cf857496c1d8da28fe073750182ef43e9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 15 Jul 2026 13:42:39 +0200 Subject: [PATCH] feat(android): SC2 drives the console UI + a real card in the Controllers view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/unom/punktfunk/ControllersScreen.kt | 107 +++++++++++++- .../kotlin/io/unom/punktfunk/GamepadUi.kt | 9 +- .../kotlin/io/unom/punktfunk/MainActivity.kt | 135 ++++++++++++++++++ .../io/unom/punktfunk/SettingsScreen.kt | 4 +- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 5 + .../io/unom/punktfunk/kit/Sc2Capture.kt | 114 ++++++++++++++- 6 files changed, 365 insertions(+), 9 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index 9aca433c..fd643933 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -1,5 +1,6 @@ package io.unom.punktfunk +import android.content.Context import android.hardware.input.InputManager import android.os.Build import android.os.CombinedVibration @@ -44,6 +45,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import io.unom.punktfunk.kit.Gamepad +import io.unom.punktfunk.kit.Sc2Capture import kotlinx.coroutines.delay /** @@ -147,8 +149,38 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { ) { Text("Controllers", style = MaterialTheme.typography.headlineMedium) + // Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the + // capture claims even those away), so it's enumerated on the capture side — USB device + // list + bonded BLE — and re-checked on USB hot-plug. + var sc2Generation by remember { mutableIntStateOf(0) } + DisposableEffect(Unit) { + val receiver = object : android.content.BroadcastReceiver() { + override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ } + } + val filter = android.content.IntentFilter().apply { + addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED) + addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_DETACHED) + } + if (Build.VERSION.SDK_INT >= 33) { + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + context.registerReceiver(receiver, filter) + } + onDispose { runCatching { context.unregisterReceiver(receiver) } } + } + val sc2Probe = remember { Sc2Capture(context) } + val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() } + val sc2Ble = remember(sc2Generation) { + if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) == + android.content.pm.PackageManager.PERMISSION_GRANTED + ) sc2Probe.pairedBleAddress() else null + } + val sc2Present = sc2Usb != null || sc2Ble != null + Group("Gamepads") { - if (pads.isEmpty()) { + if (sc2Present) Sc2Row(sc2Usb, activity) + if (pads.isEmpty() && !sc2Present) { Text( "No controller detected. punktfunk can only forward devices Android " + "classifies as a gamepad or joystick — a pad connected through an adapter " + @@ -214,6 +246,79 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { } } +/** + * The Steam Controller 2 card — capture-side state, since a (claimed or lizard-mode) SC2 never + * appears as a gamepad InputDevice. Shows the transport, whether the capture is live (driving + * these menus now; streamed as-is in a session), and a grant button when USB access is missing. + */ +@Composable +private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivity?) { + val context = LocalContext.current + val settingOn = remember { SettingsStore(context).load().sc2Capture } + val active = activity?.sc2MenuActive == true + val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager + val permitted = usbDev != null && usbManager.hasPermission(usbDev) + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + "Steam Controller 2", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + if (active) { + Text( + "navigating this UI", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + Text( + when { + usbDev == null -> "Paired via Bluetooth" + usbDev.productId == io.unom.punktfunk.kit.Sc2Device.PID_WIRED -> "Wired (USB)" + else -> "Puck dongle (USB)" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + when { + !settingOn -> Text( + "Passthrough is disabled in Settings — enable \"Steam Controller 2 " + + "passthrough\" to capture it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + active -> Text( + "Captured — streams as-is: the host presents a real Steam Controller 2 " + + "that its Steam drives directly (trackpads, gyro, haptics).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + usbDev != null && !permitted -> { + Text( + "Needs USB access to be captured.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedButton(onClick = { activity?.startSc2MenuNav(forceAsk = true) }) { + Text("Grant USB access") + } + } + else -> Text( + "Detected — capture engages automatically.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + /** One detected gamepad: identity, what it streams as, and a rumble test. */ @Composable private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadUi.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadUi.kt index 89e73ddb..7c2019f4 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadUi.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadUi.kt @@ -10,6 +10,7 @@ import android.os.Looper import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -46,6 +47,10 @@ fun isTvDevice(context: Context): Boolean { @Composable fun rememberControllerConnected(): State { val context = LocalContext.current + // A menu-captured Steam Controller 2 counts as connected: it drives the console UI through + // the capture link, but never surfaces as an Android InputDevice (lizard mode is kb/mouse, + // and the claim removes even those) — the InputManager path below can't see it. + val activity = context as? MainActivity val connected = remember { mutableStateOf(Gamepad.firstPad() != null) } DisposableEffect(Unit) { val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager @@ -59,5 +64,7 @@ fun rememberControllerConnected(): State { connected.value = Gamepad.firstPad() != null onDispose { im.unregisterInputDeviceListener(listener) } } - return connected + return remember { + derivedStateOf { connected.value || activity?.sc2MenuActive == true } + } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index 7b25a8fc..eddf5292 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -1,5 +1,12 @@ package io.unom.punktfunk +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.hardware.usb.UsbManager import android.os.Build import android.os.Bundle import android.view.InputDevice @@ -21,6 +28,9 @@ import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.Keymap import io.unom.punktfunk.kit.NativeBridge +/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */ +private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION" + class MainActivity : ComponentActivity() { /** * The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown. @@ -74,6 +84,20 @@ class MainActivity : ComponentActivity() { /** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */ private var highRefreshModeId = 0 + /** + * Menu-time Steam Controller 2 capture (UI mode — no router): a captured SC2 never produces + * ordinary gamepad events (lizard mode is kb/mouse; the claim removes even those), so this + * drives the console UI directly from the parsed reports via [sc2NavKey]. Runs while the app + * is foreground and NOT streaming; StreamScreen pauses it around its own stream-mode capture. + * [sc2MenuActive] is observed by the console-UI gate ([rememberControllerConnected]) and the + * Controllers screen. + */ + private var sc2Menu: io.unom.punktfunk.kit.Sc2Capture? = null + var sc2MenuActive by mutableStateOf(false) + private set + private var sc2Receiver: BroadcastReceiver? = null + private var sc2PermissionAsked = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lastPadIsGamepad = !isTvDevice(this) @@ -91,6 +115,33 @@ class MainActivity : ComponentActivity() { // UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez // pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV. val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false + // SC2 hot-plug + the menu-time USB-permission grant both (re)start the menu capture. + val receiver = object : BroadcastReceiver() { + override fun onReceive(c: Context?, intent: Intent?) { + when (intent?.action) { + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + sc2PermissionAsked = false // a fresh attach may ask once again + startSc2MenuNav() + } + SC2_MENU_PERMISSION -> { + if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { + startSc2MenuNav() + } + } + } + } + } + sc2Receiver = receiver + val filter = IntentFilter().apply { + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + addAction(SC2_MENU_PERMISSION) + } + if (Build.VERSION.SDK_INT >= 33) { + registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(receiver, filter) + } setContent { PunktfunkTheme { Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) } @@ -98,6 +149,90 @@ class MainActivity : ComponentActivity() { } } + override fun onResume() { + super.onResume() + startSc2MenuNav() + } + + override fun onPause() { + // Release the claim while backgrounded so the OS (and other apps) get the pad back. + stopSc2MenuNav() + super.onPause() + } + + override fun onDestroy() { + sc2Receiver?.let { runCatching { unregisterReceiver(it) } } + sc2Receiver = null + stopSc2MenuNav() + super.onDestroy() + } + + /** + * Engage the menu-time SC2 capture if possible: setting on, not streaming, and a wired/Puck + * pad attached (asking for USB permission at most once per attach — [forceAsk] re-arms the + * dialog, for the Controllers screen's explicit grant button) — else an already-paired BLE + * controller when BLUETOOTH_CONNECT is granted. Safe to call repeatedly. + */ + fun startSc2MenuNav(forceAsk: Boolean = false) { + if (forceAsk) sc2PermissionAsked = false + if (streamHandle != 0L) return // StreamScreen owns the pad while streaming + if (sc2Menu?.isActive == true) return + if (!SettingsStore(this).load().sc2Capture) return + val cap = sc2Menu ?: io.unom.punktfunk.kit.Sc2Capture(this).also { c -> + c.onUiKey = { key, down -> runOnUiThread { sc2NavKey(key, down) } } + c.onActiveChanged = { on -> runOnUiThread { sc2MenuActive = on } } + sc2Menu = c + } + val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager + val dev = cap.findUsbDevice() + when { + dev != null && usbManager.hasPermission(dev) -> cap.startUsb(dev) + dev != null && !sc2PermissionAsked -> { + sc2PermissionAsked = true + usbManager.requestPermission( + dev, + PendingIntent.getBroadcast( + this, 1, + Intent(SC2_MENU_PERMISSION).setPackage(packageName), + // MUTABLE: the USB stack appends the grant extras to this intent. + PendingIntent.FLAG_MUTABLE, + ), + ) + } + dev == null && checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) == + PackageManager.PERMISSION_GRANTED -> { + cap.pairedBleAddress()?.let { cap.startBle(it) } + } + } + } + + /** Release the menu-time SC2 capture (backgrounded / stream taking over). Idempotent. */ + fun stopSc2MenuNav() { + sc2Menu?.stop() + sc2MenuActive = false + } + + /** + * One SC2 navigation key transition from the menu-time capture (main thread) — routed the + * same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs, + * A activates the focused element, everything else (D-pad, shoulders, Start/Select) goes to + * the framework's focus navigation. Also claims the console-UI glyphs for the pad. + */ + private fun sc2NavKey(keyCode: Int, down: Boolean) { + if (streamHandle != 0L) return // raced a stream start — the wire path owns input now + lastPadIsGamepad = true + lastPadStyle = Gamepad.PadStyle.XBOX // Valve pads carry A/B/X/Y in Xbox positions + val action = if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP + when (keyCode) { + // B → back, on release (same edge the real-pad path uses). + KeyEvent.KEYCODE_BUTTON_B -> if (!down) onBackPressedDispatcher.onBackPressed() + // A → activate the focused element (the focus system understands DPAD_CENTER). + KeyEvent.KEYCODE_BUTTON_A -> + super.dispatchKeyEvent(KeyEvent(action, KeyEvent.KEYCODE_DPAD_CENTER)) + else -> super.dispatchKeyEvent(KeyEvent(action, keyCode)) + } + } + /** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */ private fun resolveHighRefreshMode() { @Suppress("DEPRECATION") diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index d66e5f94..5ff10bcd 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -429,8 +429,8 @@ private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenCont ToggleRow( title = "Steam Controller 2 passthrough", subtitle = "Capture a Steam Controller 2 (wired, Puck dongle, or paired " + - "Bluetooth) and pass it through as-is — Steam on the host drives it like " + - "the physical pad (trackpads, gyro, haptics)", + "Bluetooth): it navigates these menus and streams as-is — Steam on the " + + "host drives it like the physical pad (trackpads, gyro, haptics)", checked = s.sc2Capture, onCheckedChange = { on -> update(s.copy(sc2Capture = on)) }, ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 21fcd70c..ee3aba78 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -223,6 +223,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { // 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back // through feedback.onHidRaw onto the physical controller. Engages only when such a pad is // actually present; the wire slot is claimed lazily on its first state report. + // The menu-time capture (UI navigation) must let go before the stream-mode capture can + // claim the interfaces; it resumes in onDispose once the stream releases them. + activity?.stopSc2MenuNav() val sc2 = if (initialSettings.sc2Capture) Sc2Capture(context, router) else null var sc2UsbReceiver: BroadcastReceiver? = null if (sc2 != null) { @@ -275,6 +278,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { activity?.gamepadRouter = null activity?.streamHandle = 0L activity?.requestStreamExit = null + // Back in the menus: the SC2 (if present) resumes driving the console UI. + activity?.startSc2MenuNav() activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing window?.setSoftInputMode(priorSoftInput) diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt index c8ceb609..e666da6b 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Sc2Capture.kt @@ -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() + // UI-mode state (router == null): held navigation keys + the stick's current synth direction. + private var uiHeld = HashSet() + 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(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, + ) } }