From 7ced80c4e30d9196a9a2d89800dc7b90d50ad8dd Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 2 Jul 2026 16:33:04 +0000 Subject: [PATCH] =?UTF-8?q?feat(android):=20connected-controllers=20debug?= =?UTF-8?q?=20view=20(Settings=20=E2=86=92=20Host)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client end of the "host doesn't see my gamepad" triage chain: a new ControllersScreen lists every InputDevice Android classifies as a gamepad/joystick (name, VID:PID, source classes, the punktfunk pad type it resolves to, rumble test) plus an "Other input devices" section — a pad behind a BT→USB adapter (the Pico 2W tester case) often enumerates with the adapter's identity or not as a gamepad at all, and this makes that visible on the device instead of over a bug report. A live input test (button chips + axis bars + raw last-keycode line) consumes pad events via new MainActivity probe hooks ahead of the focus-nav remap; hold B 1.2s to exit since the pad can't reach the toggle while captured. Gamepad grows pads()/isPad() (firstPad generalized). Kotlin compiles green (kit + app); on-device validation pending. Co-Authored-By: Claude Fable 5 --- .../io/unom/punktfunk/ControllersScreen.kt | 382 ++++++++++++++++++ .../kotlin/io/unom/punktfunk/MainActivity.kt | 32 +- .../io/unom/punktfunk/SettingsScreen.kt | 11 + .../kotlin/io/unom/punktfunk/kit/Gamepad.kt | 24 +- 4 files changed, 428 insertions(+), 21 deletions(-) create mode 100644 clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt 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 new file mode 100644 index 0000000..fcbcb62 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -0,0 +1,382 @@ +package io.unom.punktfunk + +import android.hardware.input.InputManager +import android.os.CombinedVibration +import android.os.Handler +import android.os.Looper +import android.os.VibrationEffect +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import io.unom.punktfunk.kit.Gamepad +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. + */ +@Composable +fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) { + BackHandler(onBack = onBack) + val context = LocalContext.current + val activity = context as? MainActivity + + // Device list, re-read on every hot-plug event. + var generation by remember { mutableIntStateOf(0) } + val pads = remember(generation) { Gamepad.pads() } + val others = remember(generation) { + InputDevice.getDeviceIds() + .toList() + .mapNotNull { InputDevice.getDevice(it) } + .filter { !it.isVirtual && !Gamepad.isPad(it) } + } + DisposableEffect(Unit) { + val im = context.getSystemService(InputManager::class.java) + val listener = object : InputManager.InputDeviceListener { + override fun onInputDeviceAdded(deviceId: Int) { generation++ } + override fun onInputDeviceRemoved(deviceId: Int) { generation++ } + override fun onInputDeviceChanged(deviceId: Int) { generation++ } + } + im.registerInputDeviceListener(listener, Handler(Looper.getMainLooper())) + onDispose { im.unregisterInputDeviceListener(listener) } + } + + // Live input test. While `testing`, the MainActivity probes consume pad events (so they show up + // here instead of driving focus navigation); holding B releases, since the pad can no longer + // reach the Switch. Events are observed (not consumed) even when the test is off, so the + // "last input" line works while browsing. + var testing by remember { mutableStateOf(false) } + val held = remember { mutableStateMapOf() } + val axes = remember { mutableStateMapOf() } + var lastInput by remember { mutableStateOf(null) } + var bHeld by remember { mutableStateOf(false) } + + DisposableEffect(Unit) { + activity?.padKeyProbe = probe@{ event -> + if (!Gamepad.isPad(event.device)) return@probe false + when (event.action) { + KeyEvent.ACTION_DOWN -> { + held[event.keyCode] = true + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true + } + KeyEvent.ACTION_UP -> { + held[event.keyCode] = false + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = false + } + } + lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}" + testing + } + activity?.padMotionProbe = probe@{ event -> + if (!Gamepad.isPad(event.device)) return@probe false + axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X) + axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y) + axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z) + axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ) + axes["LT"] = maxOf( + event.getAxisValue(MotionEvent.AXIS_LTRIGGER), + event.getAxisValue(MotionEvent.AXIS_BRAKE), + ) + axes["RT"] = maxOf( + event.getAxisValue(MotionEvent.AXIS_RTRIGGER), + event.getAxisValue(MotionEvent.AXIS_GAS), + ) + axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X) + axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y) + testing + } + onDispose { + activity?.padKeyProbe = null + activity?.padMotionProbe = null + } + } + // 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). A short tap cancels the effect before the delay fires. + LaunchedEffect(bHeld) { + if (bHeld && testing) { + delay(1_200) + testing = false + held.clear() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Text("Controllers", style = MaterialTheme.typography.headlineMedium) + + Group("Gamepads") { + if (pads.isEmpty()) { + Text( + "No controller detected. punktfunk can only forward devices Android " + + "classifies as a gamepad or joystick — a pad connected through an adapter " + + "or hub may show up under \"Other input devices\" below with the adapter's " + + "identity, or not at all.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + pads.forEachIndexed { i, dev -> + PadRow(dev, forwarded = i == 0, gamepadSetting = gamepadSetting) + } + } + + Group("Input test") { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Test inputs", style = MaterialTheme.typography.bodyLarge) + Text( + if (testing) "Controller input stays on this screen — hold B to finish" + else "Show button presses and stick motion live", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = testing, onCheckedChange = { testing = it; if (!it) held.clear() }) + } + if (testing) { + ButtonGrid(held) + AXIS_LABELS.forEach { label -> AxisBar(label, axes[label] ?: 0f) } + } + lastInput?.let { + Text( + "Last input — $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Group("Other input devices") { + if (others.isEmpty()) { + Text( + "None", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + others.forEach { dev -> + Column { + Text(dev.name, style = MaterialTheme.typography.bodyMedium) + Text( + deviceDetail(dev), + 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) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(dev.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + if (forwarded) { + Text( + "forwarded to host", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + Text( + deviceDetail(dev), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val resolved = Gamepad.prefFor(dev) + Text( + if (gamepadSetting == Gamepad.PREF_AUTO) { + "Streams as: ${prefLabel(resolved)} (automatic)" + } else { + "Streams as: ${prefLabel(gamepadSetting)} (set in Settings; " + + "automatic would pick ${prefLabel(resolved)})" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val canRumble = dev.vibratorManager.vibratorIds.isNotEmpty() + if (canRumble) { + OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") } + } else { + Text( + "No rumble motors reported — host rumble will be silent", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** The forwarded buttons as chips that light up while held. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ButtonGrid(held: Map) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + TEST_BUTTONS.forEach { (label, keyCode) -> + val active = held[keyCode] == true + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = if (active) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .background( + if (active) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(6.dp), + ) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } +} + +/** A labelled live axis bar; sticks/HAT are −1..1 (centre = half), triggers 0..1. */ +@Composable +private fun AxisBar(label: String, value: Float) { + val progress = if (label == "LT" || label == "RT") value else (value + 1f) / 2f + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(32.dp)) + LinearProgressIndicator( + progress = { progress.coerceIn(0f, 1f) }, + modifier = Modifier.weight(1f), + ) + Text( + "%+.2f".format(value), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp), + ) + } +} + +/** A titled section — same look as the Settings groups. */ +@Composable +private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 4.dp), + ) + Column(verticalArrangement = Arrangement.spacedBy(12.dp), content = content) + } +} + +private fun testRumble(dev: InputDevice) { + val vm = dev.vibratorManager + if (vm.vibratorIds.isEmpty()) return + runCatching { + vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200))) + } +} + +/** Identity line: VID:PID + the source classes Android assigned. */ +private fun deviceDetail(dev: InputDevice): String = + "%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources)) + +private fun sourcesLabel(sources: Int): String { + fun has(flag: Int) = sources and flag == flag + val names = buildList { + if (has(InputDevice.SOURCE_GAMEPAD)) add("gamepad") + if (has(InputDevice.SOURCE_JOYSTICK)) add("joystick") + if (has(InputDevice.SOURCE_DPAD)) add("dpad") + if (has(InputDevice.SOURCE_KEYBOARD)) add("keyboard") + if (has(InputDevice.SOURCE_MOUSE)) add("mouse") + if (has(InputDevice.SOURCE_TOUCHSCREEN)) add("touchscreen") + if (has(InputDevice.SOURCE_TOUCHPAD)) add("touchpad") + if (has(InputDevice.SOURCE_STYLUS)) add("stylus") + if (has(InputDevice.SOURCE_ROTARY_ENCODER)) add("rotary") + } + return if (names.isEmpty()) "sources 0x%08X".format(sources) else names.joinToString(" · ") +} + +/** [Gamepad] PREF_* wire byte → user-facing label (mirrors GAMEPAD_OPTIONS, plus the Steam types). */ +private fun prefLabel(pref: Int): String = when (pref) { + Gamepad.PREF_XBOX360 -> "Xbox 360" + Gamepad.PREF_DUALSENSE -> "DualSense" + Gamepad.PREF_XBOXONE -> "Xbox One" + Gamepad.PREF_DUALSHOCK4 -> "DualShock 4" + Gamepad.PREF_STEAMCONTROLLER -> "Steam Controller" + Gamepad.PREF_STEAMDECK -> "Steam Deck" + else -> "Automatic" +} + +/** Buttons shown in the test grid (label → Android keycode). */ +private val TEST_BUTTONS = listOf( + "A" to KeyEvent.KEYCODE_BUTTON_A, + "B" to KeyEvent.KEYCODE_BUTTON_B, + "X" to KeyEvent.KEYCODE_BUTTON_X, + "Y" to KeyEvent.KEYCODE_BUTTON_Y, + "LB" to KeyEvent.KEYCODE_BUTTON_L1, + "RB" to KeyEvent.KEYCODE_BUTTON_R1, + "L2" to KeyEvent.KEYCODE_BUTTON_L2, + "R2" to KeyEvent.KEYCODE_BUTTON_R2, + "LS" to KeyEvent.KEYCODE_BUTTON_THUMBL, + "RS" to KeyEvent.KEYCODE_BUTTON_THUMBR, + "Select" to KeyEvent.KEYCODE_BUTTON_SELECT, + "Start" to KeyEvent.KEYCODE_BUTTON_START, + "Guide" to KeyEvent.KEYCODE_BUTTON_MODE, + "↑" to KeyEvent.KEYCODE_DPAD_UP, + "↓" to KeyEvent.KEYCODE_DPAD_DOWN, + "←" to KeyEvent.KEYCODE_DPAD_LEFT, + "→" to KeyEvent.KEYCODE_DPAD_RIGHT, +) + +/** Axis bars shown in the test view, in display order. */ +private val AXIS_LABELS = listOf("LX", "LY", "RX", "RY", "LT", "RT", "HX", "HY") 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 a311b1f..3cd7c4e 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 @@ -26,6 +26,14 @@ class MainActivity : ComponentActivity() { /** Joystick-axis state mapper for the active session (built/reset by StreamScreen). */ var axisMapper: Gamepad.AxisMapper? = 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. + */ + var padKeyProbe: ((KeyEvent) -> Boolean)? = null + var padMotionProbe: ((MotionEvent) -> Boolean)? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Dark, transparent system bars regardless of the system theme — our UI is always dark, so @@ -81,16 +89,20 @@ class MainActivity : ComponentActivity() { } } } - } else if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { - // Not streaming: a game controller drives the Compose UI (TV + phone). Map the face - // buttons to the navigation keys the focus system understands; D-pad *keys* already move - // focus on their own, so they fall through to super untouched. - val mapped = when (event.keyCode) { - KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element - KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss - else -> 0 + } else { + // The Controllers debug screen sees pad events before the navigation remap below. + padKeyProbe?.let { if (it(event)) return true } + if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { + // Not streaming: a game controller drives the Compose UI (TV + phone). Map the face + // buttons to the navigation keys the focus system understands; D-pad *keys* already + // move focus on their own, so they fall through to super untouched. + val mapped = when (event.keyCode) { + KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element + KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss + else -> 0 + } + if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped)) } - if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped)) } return super.dispatchKeyEvent(event) } @@ -103,6 +115,8 @@ class MainActivity : ComponentActivity() { if (axisMapper?.onMotion(event) == true) return true return super.dispatchGenericMotionEvent(event) } + // The Controllers debug screen sees pad motion before the stick→D-pad synthesis below. + padMotionProbe?.let { if (it(event)) return true } // Not streaming: turn the gamepad HAT / left stick into discrete D-pad focus moves, so a // controller navigates the menus even when its D-pad reports as axes (not key events) and // for stick-based navigation. Edge-detected so a held direction moves focus exactly once. 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 f2bc874..6fbd079 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 @@ -46,6 +46,7 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () - var s by remember { mutableStateOf(initial) } val context = LocalContext.current var showLicenses by remember { mutableStateOf(false) } + var showControllers by remember { mutableStateOf(false) } fun update(next: Settings) { s = next onChange(next) @@ -62,6 +63,10 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () - LicensesScreen(onBack = { showLicenses = false }) return } + if (showControllers) { + ControllersScreen(gamepadSetting = s.gamepad, onBack = { showControllers = false }) + return + } Column( modifier = Modifier @@ -130,6 +135,12 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () - options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, selected = s.gamepad, ) { g -> update(s.copy(gamepad = g)) } + + ClickableRow( + title = "Connected controllers", + subtitle = "What the app detects, with a live input test", + onClick = { showControllers = true }, + ) } SettingsGroup("Audio") { diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index 978acc1..9712b96 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -98,20 +98,20 @@ object Gamepad { } } - /** First connected gamepad/joystick [InputDevice], or null when none is attached. */ - fun firstPad(): InputDevice? { - for (id in InputDevice.getDeviceIds()) { - val d = InputDevice.getDevice(id) ?: continue - val s = d.sources - if (s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || - s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK - ) { - return d - } - } - return null + /** True when [dev]'s source classes include gamepad or joystick. */ + fun isPad(dev: InputDevice?): Boolean { + val s = dev?.sources ?: return false + return s and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK } + /** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */ + fun pads(): List = + InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) } + + /** First connected gamepad/joystick [InputDevice], or null when none is attached. */ + fun firstPad(): InputDevice? = pads().firstOrNull() + /** * The [GamepadPref] wire byte to send for the user's [setting] (the persisted gamepad index). A * non-Auto setting is passed through unchanged; "Automatic" ([PREF_AUTO]) resolves to a concrete