Add USB controller support with NES Joy-Con via 8BitDo adapter

Wires up ControllerManager to handle the 8BitDo USB Wireless Adapter 2 in
PS-Classic mode (Select+Down 3s), which is the only adapter mode the
Samsung Fold 6 kernel actually binds. Adds a Test Controller screen
(reachable from Settings) showing detected input devices, USB host
enumeration, live pressed-button chips, axis readout, and a scrolling raw
event log for debugging.

Per-device fixups for the adapter's PS-Classic identity (Sony 0x054C/0x0CDA):
treat ABS_X/Y as the dpad (no real analog stick), remap BTN_TL2/TR2 to
Select/Start so the Joy-Con's minus/plus buttons reach cores, and remap
BTN_C to FACE_BOTTOM so the NES Joy-Con's bottom face button works as
NES B / SNES B / PS Cross.

Also includes a manifest USB intent filter and a vendor-spec XInput
claim/reader path retained for future XInput-mode adapters, dpad routing
through dispatchButton so buttonMappingManager applies to dpad input, and
a concurrency fix in cover-art fetching (parallelism 8->2 + per-fetch
try/catch) to stop OOM crashes during launch.
This commit is contained in:
2026-04-11 17:13:17 -04:00
parent 7aed3ecbaf
commit da5f3a912b
9 changed files with 1244 additions and 42 deletions

View File

@@ -13,6 +13,8 @@
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -28,12 +30,14 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

View File

@@ -1,6 +1,11 @@
package com.lazy.emulate
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -26,6 +31,15 @@ class MainActivity : ComponentActivity() {
private lateinit var controllerManager: ControllerManager
private lateinit var gameRepository: GameRepository
private val usbAttachReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_DEVICE_ATTACHED) return
@Suppress("DEPRECATION")
val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (device != null) controllerManager.onUsbDeviceAttached(device)
}
}
lateinit var preferencesManager: PreferencesManager
private set
lateinit var buttonMappingManager: ButtonMappingManager
@@ -44,6 +58,9 @@ class MainActivity : ComponentActivity() {
gameRepository = GameRepository(this)
EmulationEngine.buttonMappingManager = buttonMappingManager
// Note: we deliberately do NOT call handleUsbAttachIntent(intent) here.
// start() hasn't registered the permission receiver yet, and onResume() will
// do a usbManager.deviceList sweep that picks up any device that's already attached.
setContent {
EmulateTheme {
val windowSizeClass = calculateWindowSizeClass(this)
@@ -64,6 +81,16 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
controllerManager.start()
// Silently receive USB attach events while the app is foregrounded — no dialogs.
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(usbAttachReceiver, filter, RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(usbAttachReceiver, filter)
}
// Also sweep for any matching device that's already connected when we resume.
controllerManager.tryClaimAlreadyConnectedUsb()
if (Environment.isExternalStorageManager()) {
gameRepository.scanGameFolders()
}
@@ -71,9 +98,29 @@ class MainActivity : ComponentActivity() {
override fun onPause() {
super.onPause()
try { unregisterReceiver(usbAttachReceiver) } catch (_: Throwable) {}
controllerManager.stop()
}
override fun onDestroy() {
super.onDestroy()
controllerManager.releaseClaim()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleUsbAttachIntent(intent)
}
private fun handleUsbAttachIntent(intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_DEVICE_ATTACHED) return
@Suppress("DEPRECATION")
val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (device != null) {
controllerManager.onUsbDeviceAttached(device)
}
}
private fun requestAllFilesPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) {
val intent = Intent(
@@ -84,18 +131,16 @@ class MainActivity : ComponentActivity() {
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (event != null && controllerManager.handleKeyEvent(event)) return true
return super.onKeyDown(keyCode, event)
// Compose's root view installs its own key-event handler and will consume events
// before Activity.onKeyDown/onKeyUp run. Intercept at dispatchKeyEvent instead so
// gamepad input always reaches ControllerManager.
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (controllerManager.handleKeyEvent(event)) return true
return super.dispatchKeyEvent(event)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
if (event != null && controllerManager.handleKeyEvent(event)) return true
return super.onKeyUp(keyCode, event)
}
override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
if (event != null && controllerManager.handleMotionEvent(event)) return true
return super.onGenericMotionEvent(event)
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (controllerManager.handleMotionEvent(event)) return true
return super.dispatchGenericMotionEvent(event)
}
}

View File

@@ -102,17 +102,26 @@ class GameRepository(private val context: Context) {
val gamesNeedingArt = _games.value.filter { it.coverArtPath == null }
if (gamesNeedingArt.isEmpty()) return
val semaphore = Semaphore(8)
// Concurrency was 8 — combined with parallel bitmap decodes that pinned the heap and
// caused OOM crashes on launch. 2 is plenty for a background fetch.
val semaphore = Semaphore(2)
scope.launch {
gamesNeedingArt.map { game ->
async {
semaphore.withPermit {
try {
val path = coverArtManager.fetchCoverArt(game.rawTitle, game.consoleType)
if (path != null) {
_games.value = _games.value.map {
if (it.id == game.id) it.copy(coverArtPath = path) else it
}
}
} catch (oom: OutOfMemoryError) {
// Don't bring the whole app down for one bad cover art fetch.
android.util.Log.w("GameRepository", "OOM fetching cover for ${game.title}")
} catch (t: Throwable) {
android.util.Log.w("GameRepository", "cover fetch failed for ${game.title}: ${t.message}")
}
}
}
}.awaitAll()

View File

@@ -1,20 +1,605 @@
package com.lazy.emulate.input
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.input.InputManager
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.os.Build
import android.util.Log
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private const val TAG = "ControllerManager"
// Nintendo USB/BT vendor id — Joy-Cons, Pro Controller, NES/SNES Online controllers
private const val VENDOR_NINTENDO = 0x057E
// 8BitDo USB vendor id — adapter exposes a variety of products under this VID
// depending on current adapter mode (XInput/DInput/macOS/PS Classic).
private const val VENDOR_8BITDO = 0x2DC8
// Max lines kept in the in-app raw event log (visible on ControllerTestScreen).
private const val MAX_EVENT_LOG_LINES = 300
data class AnalogSnapshot(
val lx: Float = 0f,
val ly: Float = 0f,
val rx: Float = 0f,
val ry: Float = 0f,
val hatX: Float = 0f,
val hatY: Float = 0f
)
data class UsbDeviceInfo(
val deviceName: String,
val vendorId: Int,
val productId: Int,
val deviceClass: Int,
val deviceSubclass: Int,
val productName: String?,
val manufacturerName: String?,
val interfaces: List<UsbInterfaceInfo>
)
data class UsbInterfaceInfo(
val number: Int,
val interfaceClass: Int,
val interfaceSubclass: Int,
val interfaceProtocol: Int,
val endpointCount: Int
)
class ControllerManager(
context: Context,
var buttonMappingManager: ButtonMappingManager? = null
) : InputManager.InputDeviceListener {
private val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager
private val appContext = context.applicationContext
private val inputManager = appContext.getSystemService(Context.INPUT_SERVICE) as InputManager
private val usbManager = appContext.getSystemService(Context.USB_SERVICE) as UsbManager
private val usbPermissionAction = "${appContext.packageName}.USB_PERMISSION"
private val usbPermissionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != usbPermissionAction) return
@Suppress("DEPRECATION")
val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
appendEventLog("USB permission result: granted=$granted device=${device?.deviceName}")
if (granted && device != null) {
onUsbDeviceAttached(device)
}
}
}
private var usbPermissionReceiverRegistered = false
private val _usbDevices = MutableStateFlow<List<UsbDeviceInfo>>(emptyList())
val usbDevices: StateFlow<List<UsbDeviceInfo>> = _usbDevices.asStateFlow()
// USB claim experiment: when a target device is attached via the USB_DEVICE_ATTACHED
// intent, we detach the kernel usbhid driver by calling claimInterface(force=true) and
// hold the claim until release(). Theory: the adapter's mode-cycling is triggered by the
// kernel driver's repeated probes. Holding the interface claim should stop the cycle.
private data class ClaimedDevice(
val device: UsbDevice,
val connection: UsbDeviceConnection,
val interfaces: List<UsbInterface>
)
private var claimedDevice: ClaimedDevice? = null
// Once we successfully claim any target device, refuse all subsequent attach events.
// The adapter cycles modes rapidly when Android can't complete an Xinput handshake;
// without this flag, each cycle would spawn a new reader thread and eventually OOM.
private var hasActiveClaim = false
// Xinput reader state — runs on a background thread reading 20-byte reports from the
// Xbox-360-style interrupt IN endpoint on interface #0, parsing them, and dispatching
// GamepadButton / analog events into the normal event pipeline.
private var xinputReaderThread: Thread? = null
private val xinputReaderStop = AtomicBoolean(false)
private var xinputPrevButtons = 0
private var xinputPrevL2 = false
private var xinputPrevR2 = false
// Xinput button bit layout
private val XINPUT_DPAD_UP = 0x0001
private val XINPUT_DPAD_DOWN = 0x0002
private val XINPUT_DPAD_LEFT = 0x0004
private val XINPUT_DPAD_RIGHT = 0x0008
private val XINPUT_START = 0x0010
private val XINPUT_BACK = 0x0020
private val XINPUT_LTHUMB = 0x0040
private val XINPUT_RTHUMB = 0x0080
private val XINPUT_LB = 0x0100
private val XINPUT_RB = 0x0200
private val XINPUT_GUIDE = 0x0400
private val XINPUT_A = 0x1000
private val XINPUT_B = 0x2000
private val XINPUT_X = 0x4000
private val XINPUT_Y = 0x8000
private val xinputBitToButton: List<Pair<Int, GamepadButton>> = listOf(
XINPUT_DPAD_UP to GamepadButton.DPAD_UP,
XINPUT_DPAD_DOWN to GamepadButton.DPAD_DOWN,
XINPUT_DPAD_LEFT to GamepadButton.DPAD_LEFT,
XINPUT_DPAD_RIGHT to GamepadButton.DPAD_RIGHT,
XINPUT_START to GamepadButton.START,
XINPUT_BACK to GamepadButton.SELECT,
XINPUT_LTHUMB to GamepadButton.L3,
XINPUT_RTHUMB to GamepadButton.R3,
XINPUT_LB to GamepadButton.L1,
XINPUT_RB to GamepadButton.R1,
XINPUT_A to GamepadButton.FACE_BOTTOM,
XINPUT_B to GamepadButton.FACE_RIGHT,
XINPUT_X to GamepadButton.FACE_LEFT,
XINPUT_Y to GamepadButton.FACE_TOP
)
// Debounce: only request USB permission once per vid/pid per session. The cycling adapter
// generates ATTACH broadcasts ~5/sec, which otherwise floods Android with permission
// dialogs and OOMs the app.
private val permissionRequestedFor = mutableSetOf<String>()
// Also rate-limit onUsbDeviceAttached itself — the broadcast can fire dozens of times per
// second. Drop calls that arrive within 500 ms of the previous one for the same vid/pid.
private val lastAttachTimeByKey = mutableMapOf<String, Long>()
private val attachThrottleMs = 500L
private val targetVidPids: Set<Pair<Int, Int>> = setOf(
0x2DC8 to 0x3106, // 8BitDo Pro 2 Wired — D-input
0x2DC8 to 0x3107, // 8BitDo IDLE state
0x2DC8 to 0x3105, // 8BitDo ...?
0x045E to 0x028E, // Xbox 360 controller — X-input
0x045E to 0x02e0, // Xbox 360 controller — also X-input
0x054C to 0x05C4 // Sony DualShock 4 — what the adapter latched to on macOS
)
private fun isTargetDevice(device: UsbDevice): Boolean =
(device.vendorId to device.productId) in targetVidPids
/** Sweep already-connected USB devices — called on resume. */
fun tryClaimAlreadyConnectedUsb() {
for (device in usbManager.deviceList.values) {
if (isTargetDevice(device) && claimedDevice?.device?.deviceName != device.deviceName) {
appendEventLog("USB sweep found target device ${device.deviceName}, attaching")
onUsbDeviceAttached(device)
}
}
}
fun onUsbDeviceAttached(device: UsbDevice) {
// Ignore USB events that aren't our target adapter.
if (!isTargetDevice(device)) {
Log.d(TAG, "ignoring non-target USB device v=${device.vendorId} p=${device.productId}")
return
}
// One claim per session. The cycling adapter otherwise thrashes threads and OOMs.
if (hasActiveClaim) {
Log.d(TAG, "hasActiveClaim=true, ignoring new attach for ${device.deviceName}")
return
}
// If we already hold a claim on this exact device, nothing to do.
if (claimedDevice?.device?.deviceName == device.deviceName) {
Log.d(TAG, "already claimed ${device.deviceName}, ignoring re-attach")
return
}
// Throttle: the cycling adapter fires ATTACH broadcasts every ~400 ms.
// Drop duplicate calls within the throttle window to avoid flooding the system.
val key = "${device.vendorId}:${device.productId}"
val now = System.currentTimeMillis()
val last = lastAttachTimeByKey[key] ?: 0L
if (now - last < attachThrottleMs) {
Log.d(TAG, "throttled re-attach for $key (${now - last}ms since last)")
return
}
lastAttachTimeByKey[key] = now
appendEventLog(
"USB ATTACH '${device.productName ?: device.deviceName}' " +
"v=0x${"%04x".format(device.vendorId)} p=0x${"%04x".format(device.productId)}"
)
Log.d(TAG, "onUsbDeviceAttached ${device.deviceName} v=${device.vendorId} p=${device.productId}")
// Enumerate interfaces once so we can decide whether to claim.
val allIfaces = (0 until device.interfaceCount).map { device.getInterface(it) }
for (i in allIfaces) {
appendEventLog(
" iface #${i.id} class=${i.interfaceClass}(${usbClassName(i.interfaceClass)}) " +
"sub=${i.interfaceSubclass} proto=${i.interfaceProtocol} eps=${i.endpointCount}"
)
}
// If any interface is standard HID (class=3), this is an 8BitDo in DInput / macOS /
// PS-Classic mode. On Samsung Fold 6, the kernel's usbhid driver ONLY binds the
// PS-Classic variant (vid=0x054c pid=0x0cda, Sony Interactive Entertainment Controller)
// — DInput and macOS modes are silently refused. In PS-Classic mode Android creates
// a proper GAMEPAD InputDevice and events flow through dispatchKeyEvent → handleKeyEvent.
//
// Switch the adapter to PS-Classic mode with Select + D-Pad Down (3 seconds).
//
// In any HID case we do NOT claim the interface: claiming detaches whatever kernel
// driver may have bound and turns a working controller into a dead one.
val hasHidInterface = allIfaces.any { it.interfaceClass == UsbConstants.USB_CLASS_HID }
if (hasHidInterface) {
appendEventLog(
"USB device has HID interface — letting Android's usbhid driver handle it " +
"(no claim). Use PS-Classic mode (Select+Down) if events don't appear."
)
hasActiveClaim = true
refreshUsbDevices()
return
}
// Release any prior claim (e.g. the adapter just re-enumerated in a new mode).
releaseClaim()
if (!usbManager.hasPermission(device)) {
// Only request permission once per vid/pid — flooding Android with dialogs
// causes OOM.
if (key in permissionRequestedFor) {
appendEventLog("USB permission still missing, already requested once — skipping")
return
}
permissionRequestedFor.add(key)
appendEventLog("USB permission missing — requesting (one-shot)")
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pi = PendingIntent.getBroadcast(
appContext,
0,
Intent(usbPermissionAction).apply { setPackage(appContext.packageName) },
flags
)
try {
usbManager.requestPermission(device, pi)
} catch (t: Throwable) {
appendEventLog("requestPermission threw: ${t.message}")
}
return
}
val connection = try {
usbManager.openDevice(device)
} catch (t: Throwable) {
appendEventLog("USB openDevice threw: ${t.message}")
return
}
if (connection == null) {
appendEventLog("USB openDevice returned null")
return
}
// Only claim the main controller input interface (vendor-spec, subclass 0x5D,
// protocol 0x01). Claiming the headset / security interfaces appears to trigger
// re-enumeration on the adapter.
val mainIface = allIfaces.firstOrNull {
it.interfaceClass == 0xFF && it.interfaceSubclass == 0x5D && it.interfaceProtocol == 0x01
} ?: allIfaces.firstOrNull()
if (mainIface == null) {
appendEventLog("USB no interfaces to claim, closing")
connection.close()
return
}
val ok = try {
connection.claimInterface(mainIface, true)
} catch (t: Throwable) {
appendEventLog("claimInterface #${mainIface.id} threw: ${t.message}")
false
}
appendEventLog(
"USB claimInterface #${mainIface.id} " +
"class=${mainIface.interfaceClass} sub=${mainIface.interfaceSubclass} " +
"proto=${mainIface.interfaceProtocol}${if (ok) "OK (kernel detached)" else "FAIL"}"
)
if (!ok) {
connection.close()
return
}
claimedDevice = ClaimedDevice(device, connection, listOf(mainIface))
hasActiveClaim = true
appendEventLog("USB holding claim on 1 interface.")
startXinputReader(connection, listOf(mainIface))
// Refresh the in-app USB list so the UI reflects the new state.
refreshUsbDevices()
}
fun releaseClaim() {
val c = claimedDevice ?: return
appendEventLog("USB releasing claim on '${c.device.productName ?: c.device.deviceName}'")
// Close the connection BEFORE joining the reader thread — this unblocks any
// in-flight bulkTransfer call so the thread can actually exit.
xinputReaderStop.set(true)
for (iface in c.interfaces) {
try {
c.connection.releaseInterface(iface)
} catch (t: Throwable) {
Log.w(TAG, "releaseInterface failed", t)
}
}
try {
c.connection.close()
} catch (t: Throwable) {
Log.w(TAG, "connection.close failed", t)
}
// Now that the connection is dead, the reader's bulkTransfer will fail fast.
stopXinputReader()
claimedDevice = null
hasActiveClaim = false
// Clear any buttons left pressed by the reader thread.
_pressedButtons.value = emptySet()
_analogSnapshot.value = AnalogSnapshot()
xinputPrevButtons = 0
xinputPrevL2 = false
xinputPrevR2 = false
}
/**
* Locate the interrupt IN endpoint on the Xbox-360-style main controller interface
* (class 0xFF / subclass 0x5D / protocol 0x01) and spin up a reader thread.
*/
private fun startXinputReader(connection: UsbDeviceConnection, interfaces: List<UsbInterface>) {
// Main controller interface: vendor-specific, subclass 0x5D, protocol 0x01.
val mainIface = interfaces.firstOrNull {
it.interfaceClass == 0xFF && it.interfaceSubclass == 0x5D && it.interfaceProtocol == 0x01
} ?: interfaces.firstOrNull()
if (mainIface == null) {
appendEventLog("Xinput reader: no suitable interface")
return
}
var inEndpoint: UsbEndpoint? = null
var outEndpoint: UsbEndpoint? = null
for (i in 0 until mainIface.endpointCount) {
val ep = mainIface.getEndpoint(i)
if (ep.direction == UsbConstants.USB_DIR_IN &&
(ep.type == UsbConstants.USB_ENDPOINT_XFER_INT || ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK)
) {
inEndpoint = ep
}
if (ep.direction == UsbConstants.USB_DIR_OUT &&
(ep.type == UsbConstants.USB_ENDPOINT_XFER_INT || ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK)
) {
outEndpoint = ep
}
}
if (inEndpoint == null) {
appendEventLog("Xinput reader: no IN endpoint on iface #${mainIface.id}")
return
}
appendEventLog(
"Xinput reader starting on iface #${mainIface.id} " +
"endpoint addr=0x${"%02x".format(inEndpoint.address)} " +
"maxPkt=${inEndpoint.maxPacketSize}"
)
// Xbox 360 wired controllers need an LED init before they emit reports.
// The real command is an interrupt OUT write (NOT a HID SET_REPORT), so we use
// bulkTransfer against the OUT endpoint — UsbDeviceConnection.bulkTransfer handles
// both bulk and interrupt endpoints.
if (outEndpoint != null) {
// LED pattern 0x06 = player 1 blinking → solid
val ledCommand = byteArrayOf(0x01, 0x03, 0x06)
val sent = connection.bulkTransfer(outEndpoint, ledCommand, ledCommand.size, 100)
appendEventLog("Xinput LED init bulkTransfer(OUT) → $sent bytes")
}
xinputPrevButtons = 0
xinputPrevL2 = false
xinputPrevR2 = false
xinputReaderStop.set(false)
val ep = inEndpoint
xinputReaderThread = thread(name = "XinputReader", isDaemon = true) {
val buf = ByteArray(32)
var consecutiveErrors = 0
var totalPackets = 0
try {
while (!xinputReaderStop.get()) {
val n = try {
connection.bulkTransfer(ep, buf, buf.size, 200)
} catch (t: Throwable) {
Log.w(TAG, "bulkTransfer threw", t)
-1
}
when {
n >= 20 && buf[0].toInt() == 0x00 && buf[1].toInt() == 0x14 -> {
consecutiveErrors = 0
totalPackets++
if (totalPackets == 1) {
Log.d(TAG, "XinputReader: first valid packet")
}
parseXinputReport(buf)
}
n >= 0 -> {
// Short / non-input packet (heartbeat, LED status, etc).
consecutiveErrors = 0
}
else -> {
consecutiveErrors++
// After sustained failures the device is gone. Exit the thread
// instead of spinning forever.
if (consecutiveErrors > 100) {
Log.w(TAG, "XinputReader: $consecutiveErrors consecutive errors, giving up")
break
}
}
}
}
} finally {
Log.d(TAG, "XinputReader thread exiting (packets=$totalPackets)")
}
}
}
private fun stopXinputReader() {
if (xinputReaderThread == null) return
xinputReaderStop.set(true)
try {
xinputReaderThread?.join(500)
} catch (_: InterruptedException) {}
xinputReaderThread = null
}
private fun parseXinputReport(buf: ByteArray) {
// buf[0] = 0x00 (type), buf[1] = 0x14 (len)
val buttons = (buf[2].toInt() and 0xFF) or ((buf[3].toInt() and 0xFF) shl 8)
val lTrigger = buf[4].toInt() and 0xFF
val rTrigger = buf[5].toInt() and 0xFF
val lx = readInt16LE(buf, 6)
val ly = readInt16LE(buf, 8)
val rx = readInt16LE(buf, 10)
val ry = readInt16LE(buf, 12)
// Button diff + dispatch
val changed = buttons xor xinputPrevButtons
if (changed != 0) {
for ((bit, gamepadBtn) in xinputBitToButton) {
if (changed and bit != 0) {
val nowDown = (buttons and bit) != 0
dispatchButton(gamepadBtn, nowDown)
}
}
xinputPrevButtons = buttons
}
// Triggers as discrete L2/R2 with a deadzone threshold
val l2Down = lTrigger > 30
val r2Down = rTrigger > 30
if (l2Down != xinputPrevL2) {
dispatchButton(GamepadButton.L2, l2Down)
xinputPrevL2 = l2Down
}
if (r2Down != xinputPrevR2) {
dispatchButton(GamepadButton.R2, r2Down)
xinputPrevR2 = r2Down
}
// Sticks — normalize int16 → float [-1, 1] and negate Y to match Android conventions
// (Xinput: Y positive = up; Android input: Y positive = down).
val nLx = lx.toFloat() / 32767f
val nLy = -ly.toFloat() / 32767f
val nRx = rx.toFloat() / 32767f
val nRy = -ry.toFloat() / 32767f
onAnalogEvent?.invoke(nLx, nLy, 0)
onAnalogEvent?.invoke(nRx, nRy, 1)
_analogSnapshot.value = AnalogSnapshot(nLx, nLy, nRx, nRy, 0f, 0f)
}
private fun readInt16LE(buf: ByteArray, offset: Int): Int {
val lo = buf[offset].toInt() and 0xFF
val hi = buf[offset + 1].toInt() // preserve sign
return (hi shl 8) or lo
}
private fun dispatchButton(button: GamepadButton, isDown: Boolean) {
if (isDown) {
_pressedButtons.value = _pressedButtons.value + button
} else {
_pressedButtons.value = _pressedButtons.value - button
}
onButtonEvent?.invoke(button, isDown)
}
fun refreshUsbDevices() {
val snapshot = try {
usbManager.deviceList.values.map { device -> device.toInfo() }
} catch (t: Throwable) {
Log.w(TAG, "usbManager.deviceList threw", t)
emptyList()
}
_usbDevices.value = snapshot
appendEventLog("--- USB enumeration (${snapshot.size} device${if (snapshot.size == 1) "" else "s"}) ---")
for (d in snapshot) {
val classStr = usbClassName(d.deviceClass)
appendEventLog(
"USB '${d.productName ?: d.deviceName}' " +
"v=0x${"%04x".format(d.vendorId)} p=0x${"%04x".format(d.productId)} " +
"class=${d.deviceClass}($classStr) sub=${d.deviceSubclass} " +
"ifaces=${d.interfaces.size}"
)
for (i in d.interfaces) {
val iclassStr = usbClassName(i.interfaceClass)
appendEventLog(
" iface #${i.number} class=${i.interfaceClass}($iclassStr) " +
"sub=${i.interfaceSubclass} proto=${i.interfaceProtocol} endpoints=${i.endpointCount}"
)
}
}
}
private fun UsbDevice.toInfo(): UsbDeviceInfo {
val interfaces = (0 until interfaceCount).map { idx ->
val iface = getInterface(idx)
UsbInterfaceInfo(
number = iface.id,
interfaceClass = iface.interfaceClass,
interfaceSubclass = iface.interfaceSubclass,
interfaceProtocol = iface.interfaceProtocol,
endpointCount = iface.endpointCount
)
}
return UsbDeviceInfo(
deviceName = deviceName,
vendorId = vendorId,
productId = productId,
deviceClass = deviceClass,
deviceSubclass = deviceSubclass,
productName = try { productName } catch (t: Throwable) { null },
manufacturerName = try { manufacturerName } catch (t: Throwable) { null },
interfaces = interfaces
)
}
private fun usbClassName(c: Int): String = when (c) {
UsbConstants.USB_CLASS_PER_INTERFACE -> "per-interface"
UsbConstants.USB_CLASS_AUDIO -> "audio"
UsbConstants.USB_CLASS_COMM -> "comm"
UsbConstants.USB_CLASS_HID -> "HID"
UsbConstants.USB_CLASS_PHYSICA -> "physical"
UsbConstants.USB_CLASS_STILL_IMAGE -> "still-image"
UsbConstants.USB_CLASS_PRINTER -> "printer"
UsbConstants.USB_CLASS_MASS_STORAGE -> "mass-storage"
UsbConstants.USB_CLASS_HUB -> "hub"
UsbConstants.USB_CLASS_CDC_DATA -> "cdc-data"
UsbConstants.USB_CLASS_CSCID -> "smartcard"
UsbConstants.USB_CLASS_CONTENT_SEC -> "content-sec"
UsbConstants.USB_CLASS_VIDEO -> "video"
UsbConstants.USB_CLASS_WIRELESS_CONTROLLER -> "wireless-ctl"
UsbConstants.USB_CLASS_MISC -> "misc"
UsbConstants.USB_CLASS_APP_SPEC -> "app-specific"
UsbConstants.USB_CLASS_VENDOR_SPEC -> "VENDOR-SPEC (xinput?)"
0 -> "use-iface-class"
else -> "class-$c"
}
private val _connectedControllers = MutableStateFlow<List<GameController>>(emptyList())
val connectedControllers: StateFlow<List<GameController>> = _connectedControllers.asStateFlow()
@@ -22,22 +607,79 @@ class ControllerManager(
private val _activeController = MutableStateFlow<GameController?>(null)
val activeController: StateFlow<GameController?> = _activeController.asStateFlow()
// Raw-event ring buffer shown on ControllerTestScreen. Mirrors the logcat debug output.
private val _rawEventLog = MutableStateFlow<List<String>>(emptyList())
val rawEventLog: StateFlow<List<String>> = _rawEventLog.asStateFlow()
// Currently pressed mapped buttons — drives the live indicator on the test screen.
private val _pressedButtons = MutableStateFlow<Set<GamepadButton>>(emptySet())
val pressedButtons: StateFlow<Set<GamepadButton>> = _pressedButtons.asStateFlow()
// Most recent axis values, for analog readout on the test screen.
private val _analogSnapshot = MutableStateFlow(AnalogSnapshot())
val analogSnapshot: StateFlow<AnalogSnapshot> = _analogSnapshot.asStateFlow()
private val timestampFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
var onButtonEvent: ((GamepadButton, Boolean) -> Unit)? = null
var onAnalogEvent: ((Float, Float, Int) -> Unit)? = null
fun clearEventLog() {
_rawEventLog.value = emptyList()
}
private fun appendEventLog(line: String) {
val stamped = "${timestampFormat.format(Date())} $line"
// Mirror in-app log to logcat so we can see the full claim flow from adb.
Log.d(TAG, "LOG: $line")
val current = _rawEventLog.value
val next = current + stamped
_rawEventLog.value = if (next.size > MAX_EVENT_LOG_LINES) {
next.takeLast(MAX_EVENT_LOG_LINES)
} else {
next
}
}
fun start() {
inputManager.registerInputDeviceListener(this, null)
refreshControllers()
if (!usbPermissionReceiverRegistered) {
val filter = IntentFilter(usbPermissionAction)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
appContext.registerReceiver(usbPermissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
appContext.registerReceiver(usbPermissionReceiver, filter)
}
usbPermissionReceiverRegistered = true
}
}
fun stop() {
inputManager.unregisterInputDeviceListener(this)
if (usbPermissionReceiverRegistered) {
try { appContext.unregisterReceiver(usbPermissionReceiver) } catch (_: Throwable) {}
usbPermissionReceiverRegistered = false
}
}
private fun refreshControllers() {
val controllers = InputDevice.getDeviceIds()
val allDevices = InputDevice.getDeviceIds()
.toList()
.mapNotNull { InputDevice.getDevice(it) }
// Log every device we see, so we can tell why the Joy-Con is or isn't being accepted.
appendEventLog("--- device enumeration ---")
for (d in allDevices) {
val line = "DEV id=${d.id} '${d.name}' " +
"v=0x${"%04x".format(d.vendorId)} p=0x${"%04x".format(d.productId)} " +
"src=0x${"%08x".format(d.sources)} ext=${d.isExternal} ok=${isGameController(d)}"
Log.d(TAG, line)
appendEventLog(line)
}
val controllers = allDevices
.filter { isGameController(it) }
.map { device ->
GameController(
@@ -55,47 +697,111 @@ class ControllerManager(
}
fun handleKeyEvent(event: KeyEvent): Boolean {
if (!isGameControllerDevice(event.device ?: return false)) return false
val device = event.device ?: return false
val rawButton = mapKeyToButton(event.keyCode) ?: return false
// Log every key event from every device (both to logcat and the in-app ring buffer)
// so we can see exactly what a controller emits even when it isn't yet mapped.
val actionStr = when (event.action) {
KeyEvent.ACTION_DOWN -> "DOWN"
KeyEvent.ACTION_UP -> "UP"
else -> "A${event.action}"
}
val keyLine = "KEY $actionStr " +
"dev=${device.id}('${device.name}') " +
"v=0x${"%04x".format(device.vendorId)} p=0x${"%04x".format(device.productId)} " +
"code=${event.keyCode}(${KeyEvent.keyCodeToString(event.keyCode)}) " +
"scan=${event.scanCode}"
Log.d(TAG, keyLine)
appendEventLog(keyLine)
if (!isGameControllerDevice(device)) return false
val mapped = mapKeyToButton(event.keyCode) ?: return false
// Per-device fixups for the 8BitDo USB Adapter 2 in PS-Classic mode (Sony Interactive
// Entertainment Controller). The adapter routes the NES Joy-Con's buttons through a
// 6-button-style layout that doesn't match what game cores expect:
// - Joy-Con `-` / `+` come through as BTN_TL2/TR2 → KEYCODE_BUTTON_L2/R2. There are
// no real triggers on this controller, so reinterpret those as Select/Start —
// otherwise the Joy-Con can't send Select/Start at all (Home/Capture get eaten by
// the OS via the Consumer Control sibling node).
// - Joy-Con B (the bottom-right face button) comes through as BTN_C → BUTTON_C →
// C_BUTTON. Most cores have no mapping for C_BUTTON, so remap it to FACE_BOTTOM
// so it acts as the "south" face button (NES B / SNES B / PS Cross).
val rawButton = if (isDpadOnPrimaryAxisDevice(device)) {
when (mapped) {
GamepadButton.L2 -> GamepadButton.SELECT
GamepadButton.R2 -> GamepadButton.START
GamepadButton.C_BUTTON -> GamepadButton.FACE_BOTTOM
else -> mapped
}
} else mapped
val button = buttonMappingManager?.remapControllerButton(rawButton) ?: rawButton
when (event.action) {
KeyEvent.ACTION_DOWN -> onButtonEvent?.invoke(button, true)
KeyEvent.ACTION_UP -> onButtonEvent?.invoke(button, false)
KeyEvent.ACTION_DOWN -> dispatchButton(button, true)
KeyEvent.ACTION_UP -> dispatchButton(button, false)
}
return true
}
fun handleMotionEvent(event: MotionEvent): Boolean {
if (!isGameControllerDevice(event.device ?: return false)) return false
val device = event.device ?: return false
// Left stick
val lx = event.getAxisValue(MotionEvent.AXIS_X)
val ly = event.getAxisValue(MotionEvent.AXIS_Y)
val rx = event.getAxisValue(MotionEvent.AXIS_Z)
val ry = event.getAxisValue(MotionEvent.AXIS_RZ)
val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X)
val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
val anyNonZero = lx != 0f || ly != 0f || rx != 0f || ry != 0f || hatX != 0f || hatY != 0f
if (anyNonZero) {
val motionLine = "MOT dev=${device.id}('${device.name}') " +
"L=(${"%.2f".format(lx)},${"%.2f".format(ly)}) " +
"R=(${"%.2f".format(rx)},${"%.2f".format(ry)}) " +
"HAT=(${"%.2f".format(hatX)},${"%.2f".format(hatY)})"
Log.d(TAG, motionLine)
appendEventLog(motionLine)
_analogSnapshot.value = AnalogSnapshot(lx, ly, rx, ry, hatX, hatY)
}
if (!isGameControllerDevice(device)) return false
if (isDpadOnPrimaryAxisDevice(device)) {
// Sony PS Classic / 8BitDo in PS-Classic mode reports its dpad as raw ABS_X/ABS_Y
// (range 0..2, normalized to -1..+1) and has no real analog sticks. Route the
// primary axis to the dpad handler instead of the left stick.
handleDpadAxis(lx, ly)
} else {
if (lx != 0f || ly != 0f) {
onAnalogEvent?.invoke(lx, ly, 0)
}
// Right stick
val rx = event.getAxisValue(MotionEvent.AXIS_Z)
val ry = event.getAxisValue(MotionEvent.AXIS_RZ)
if (rx != 0f || ry != 0f) {
onAnalogEvent?.invoke(rx, ry, 1)
}
// D-pad via axes (some controllers report dpad as axis)
val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X)
val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
handleDpadAxis(hatX, hatY)
}
return true
}
/**
* True for devices whose dpad is reported on AXIS_X/AXIS_Y rather than AXIS_HAT_X/Y,
* and which have no real analog sticks. Currently: 8BitDo USB Adapter 2 in PS-Classic
* mode (Select+Down 3s), which impersonates the Sony Interactive Entertainment
* Controller (vid=0x054C, pid=0x0CDA).
*/
private fun isDpadOnPrimaryAxisDevice(device: InputDevice): Boolean {
return device.vendorId == 0x054C && device.productId == 0x0CDA
}
private fun handleDpadAxis(hatX: Float, hatY: Float) {
onButtonEvent?.invoke(GamepadButton.DPAD_LEFT, hatX < -0.5f)
onButtonEvent?.invoke(GamepadButton.DPAD_RIGHT, hatX > 0.5f)
onButtonEvent?.invoke(GamepadButton.DPAD_UP, hatY < -0.5f)
onButtonEvent?.invoke(GamepadButton.DPAD_DOWN, hatY > 0.5f)
// Route through dispatchButton so the test screen's _pressedButtons reflects dpad
// state, the buttonMappingManager remap path applies, and the emulator core sees
// the events via onButtonEvent — same path as physical key events.
dispatchButton(GamepadButton.DPAD_LEFT, hatX < -0.5f)
dispatchButton(GamepadButton.DPAD_RIGHT, hatX > 0.5f)
dispatchButton(GamepadButton.DPAD_UP, hatY < -0.5f)
dispatchButton(GamepadButton.DPAD_DOWN, hatY > 0.5f)
}
fun setActiveController(controller: GameController) {
@@ -106,8 +812,10 @@ class ControllerManager(
return when (keyCode) {
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER -> GamepadButton.FACE_BOTTOM
KeyEvent.KEYCODE_BUTTON_B -> GamepadButton.FACE_RIGHT
KeyEvent.KEYCODE_BUTTON_C -> GamepadButton.C_BUTTON
KeyEvent.KEYCODE_BUTTON_X -> GamepadButton.FACE_LEFT
KeyEvent.KEYCODE_BUTTON_Y -> GamepadButton.FACE_TOP
KeyEvent.KEYCODE_BUTTON_Z -> GamepadButton.Z_BUTTON
KeyEvent.KEYCODE_BUTTON_L1 -> GamepadButton.L1
KeyEvent.KEYCODE_BUTTON_R1 -> GamepadButton.R1
KeyEvent.KEYCODE_BUTTON_L2 -> GamepadButton.L2
@@ -125,9 +833,21 @@ class ControllerManager(
}
private fun isGameController(device: InputDevice): Boolean {
if (device.isVirtual) return false
val sources = device.sources
return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD ||
(sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
val isGamepad = (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
val isJoystick = (sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
if (isGamepad || isJoystick) return true
// Nintendo Joy-Cons and the NES/SNES Online controllers frequently register as
// keyboard-only on Android — accept them by vendor id so their events reach us.
if (device.vendorId == VENDOR_NINTENDO) return true
// 8BitDo adapters in DInput mode (and bare 8BitDo controllers) sometimes don't
// get GAMEPAD/JOYSTICK bits set on Samsung OneUI — accept by vendor id.
if (device.vendorId == VENDOR_8BITDO) return true
return false
}
private fun isGameControllerDevice(device: InputDevice): Boolean = isGameController(device)

View File

@@ -13,6 +13,7 @@ import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.input.ButtonMappingManager
import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.ui.screens.controller.ControllerLayoutScreen
import com.lazy.emulate.ui.screens.controller.ControllerTestScreen
import com.lazy.emulate.ui.screens.game.GameScreen
import com.lazy.emulate.ui.screens.home.HomeScreen
import com.lazy.emulate.ui.screens.settings.SettingsScreen
@@ -51,6 +52,14 @@ fun EmulateNavGraph(
controllerManager = controllerManager,
preferencesManager = preferencesManager,
buttonMappingManager = buttonMappingManager,
onBack = { navController.popBackStack() },
onTestController = { navController.navigate(Screen.ControllerTest.route) }
)
}
composable(Screen.ControllerTest.route) {
ControllerTestScreen(
controllerManager = controllerManager,
onBack = { navController.popBackStack() }
)
}

View File

@@ -3,6 +3,7 @@ package com.lazy.emulate.ui.navigation
sealed class Screen(val route: String) {
data object Home : Screen("home")
data object Settings : Screen("settings")
data object ControllerTest : Screen("controller_test")
data object ControllerLayout : Screen("controller_layout/{consoleType}") {
fun createRoute(consoleType: String) = "controller_layout/$consoleType"
}

View File

@@ -0,0 +1,364 @@
package com.lazy.emulate.ui.screens.controller
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.input.GamepadButton
import com.lazy.emulate.input.UsbDeviceInfo
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ControllerTestScreen(
controllerManager: ControllerManager,
onBack: () -> Unit
) {
val controllers by controllerManager.connectedControllers.collectAsState()
val pressed by controllerManager.pressedButtons.collectAsState()
val analog by controllerManager.analogSnapshot.collectAsState()
val log by controllerManager.rawEventLog.collectAsState()
val usbDevices by controllerManager.usbDevices.collectAsState()
LaunchedEffect(Unit) { controllerManager.refreshUsbDevices() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Test Controller") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { controllerManager.clearEventLog() }) {
Icon(Icons.Default.Delete, contentDescription = "Clear log")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
// Detected devices
Text(
"Detected Controllers (${controllers.size})",
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(6.dp))
Card(modifier = Modifier.fillMaxWidth()) {
if (controllers.isEmpty()) {
Text(
"None — press a button or re-pair the controller.",
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
controllers.forEachIndexed { i, c ->
Column(modifier = Modifier.padding(12.dp)) {
Text(c.name, fontWeight = FontWeight.SemiBold)
Text(
"vendor=0x${"%04x".format(c.vendorId)} product=0x${"%04x".format(c.productId)} id=${c.deviceId} ${if (c.isExternal) "external" else "internal"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (i < controllers.lastIndex) HorizontalDivider()
}
}
}
Spacer(Modifier.height(16.dp))
// USB devices — helps diagnose whether a USB host-mode adapter is enumerating.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"USB Devices (${usbDevices.size})",
style = MaterialTheme.typography.titleMedium
)
IconButton(onClick = { controllerManager.refreshUsbDevices() }) {
Icon(Icons.Default.Refresh, contentDescription = "Rescan USB")
}
}
Spacer(Modifier.height(6.dp))
Card(modifier = Modifier.fillMaxWidth()) {
if (usbDevices.isEmpty()) {
Text(
"No USB host devices visible to the app. If you have an OTG adapter plugged in, tap rescan.",
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Column(modifier = Modifier.padding(12.dp)) {
usbDevices.forEachIndexed { i, d ->
UsbDeviceRow(d)
if (i < usbDevices.lastIndex) Spacer(Modifier.height(8.dp))
}
}
}
}
Spacer(Modifier.height(16.dp))
// Live button indicators
Text("Pressed Buttons", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
ButtonGrid(pressed = pressed)
Spacer(Modifier.height(16.dp))
// Analog readout
Text("Axes", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
AxisRow("Left stick", analog.lx, analog.ly)
AxisRow("Right stick", analog.rx, analog.ry)
AxisRow("D-pad (hat)", analog.hatX, analog.hatY)
}
}
Spacer(Modifier.height(16.dp))
// Raw event log
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Raw Events (${log.size})", style = MaterialTheme.typography.titleMedium)
}
Spacer(Modifier.height(6.dp))
val listState = rememberLazyListState()
LaunchedEffect(log.size) {
if (log.isNotEmpty()) {
listState.scrollToItem(log.size - 1)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 180.dp, max = 360.dp)
.padding(8.dp)
) {
if (log.isEmpty()) {
item {
Text(
"No events yet. Press any button or move any axis on the controller.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
items(log) { line ->
Text(
text = line,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
}
}
@Composable
private fun ButtonGrid(pressed: Set<GamepadButton>) {
val rows = listOf(
listOf(GamepadButton.DPAD_UP, GamepadButton.DPAD_DOWN, GamepadButton.DPAD_LEFT, GamepadButton.DPAD_RIGHT),
listOf(GamepadButton.FACE_TOP, GamepadButton.FACE_BOTTOM, GamepadButton.FACE_LEFT, GamepadButton.FACE_RIGHT),
listOf(GamepadButton.C_BUTTON, GamepadButton.Z_BUTTON, GamepadButton.MODE),
listOf(GamepadButton.L1, GamepadButton.R1, GamepadButton.L2, GamepadButton.R2),
listOf(GamepadButton.L3, GamepadButton.R3, GamepadButton.START, GamepadButton.SELECT)
)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(8.dp)) {
rows.forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
row.forEach { btn ->
ButtonChip(
label = shortLabel(btn),
active = btn in pressed,
modifier = Modifier.weight(1f)
)
}
}
Spacer(Modifier.height(6.dp))
}
}
}
}
@Composable
private fun ButtonChip(label: String, active: Boolean, modifier: Modifier = Modifier) {
val bg = if (active) MaterialTheme.colorScheme.primary else Color.Transparent
val fg = if (active) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
Box(
modifier = modifier
.height(36.dp)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(6.dp)
)
.background(bg, RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center
) {
Text(
text = label,
color = fg,
fontSize = 12.sp,
fontWeight = if (active) FontWeight.Bold else FontWeight.Normal
)
}
}
@Composable
private fun UsbDeviceRow(d: UsbDeviceInfo) {
val classLabel = when (d.deviceClass) {
3 -> "HID"
0 -> "use-iface"
0xFF -> "VENDOR (xinput?)"
9 -> "hub"
else -> "class-${d.deviceClass}"
}
val isHid = d.deviceClass == 3 || d.interfaces.any { it.interfaceClass == 3 }
Column {
Text(
d.productName ?: d.deviceName,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium
)
Text(
"v=0x${"%04x".format(d.vendorId)} p=0x${"%04x".format(d.productId)} " +
"devClass=${d.deviceClass} ($classLabel) " +
if (isHid) "HID ✓" else "NO HID",
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = if (isHid)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
)
d.manufacturerName?.let {
Text(
"mfr: $it",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
d.interfaces.forEach { i ->
val iLabel = when (i.interfaceClass) {
3 -> "HID"
0xFF -> "VENDOR"
9 -> "hub"
else -> "class-${i.interfaceClass}"
}
Text(
" iface#${i.number} class=${i.interfaceClass} ($iLabel) sub=${i.interfaceSubclass} proto=${i.interfaceProtocol} ep=${i.endpointCount}",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun AxisRow(label: String, x: Float, y: Float) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Text(label, modifier = Modifier.width(110.dp), style = MaterialTheme.typography.bodyMedium)
Text(
"x=${"%+.2f".format(x)} y=${"%+.2f".format(y)}",
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodyMedium
)
}
}
private fun shortLabel(button: GamepadButton): String = when (button) {
GamepadButton.DPAD_UP -> ""
GamepadButton.DPAD_DOWN -> ""
GamepadButton.DPAD_LEFT -> ""
GamepadButton.DPAD_RIGHT -> ""
GamepadButton.FACE_TOP -> "△/Y/X"
GamepadButton.FACE_BOTTOM -> "✕/B/A"
GamepadButton.FACE_LEFT -> "□/X/Y"
GamepadButton.FACE_RIGHT -> "○/A/B"
GamepadButton.L1 -> "L1"
GamepadButton.R1 -> "R1"
GamepadButton.L2 -> "L2"
GamepadButton.R2 -> "R2"
GamepadButton.L3 -> "L3"
GamepadButton.R3 -> "R3"
GamepadButton.START -> "START"
GamepadButton.SELECT -> "SELECT"
GamepadButton.MODE -> "MODE"
GamepadButton.Z_BUTTON -> "Z"
GamepadButton.C_BUTTON -> "C"
}

View File

@@ -54,7 +54,8 @@ fun SettingsScreen(
controllerManager: ControllerManager,
preferencesManager: PreferencesManager,
buttonMappingManager: ButtonMappingManager,
onBack: () -> Unit
onBack: () -> Unit,
onTestController: () -> Unit = {}
) {
val controllers by controllerManager.connectedControllers.collectAsState()
val activeController by controllerManager.activeController.collectAsState()
@@ -143,6 +144,38 @@ fun SettingsScreen(
}
}
Spacer(modifier = Modifier.height(12.dp))
// Test Controller — live tester with raw event log
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onTestController)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Gamepad,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
"Test Controller",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Live button tester and raw event log",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Touch overlay section

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
USB device filter for the controller test experiment.
Listing the vid/pids we observed the 8BitDo receiver cycling through,
so Android routes ACTION_USB_DEVICE_ATTACHED to us and we can claim
the device before kernel usbhid re-probes it.
-->
<resources>
<!-- 8BitDo "Pro 2 Wired Controller" — D-input / generic HID mode (0x2DC8/0x3106) -->
<usb-device vendor-id="11720" product-id="12550" />
<!-- 8BitDo IDLE state seen on macOS (0x2DC8/0x3107) -->
<usb-device vendor-id="11720" product-id="12551" />
<!-- Microsoft Xbox 360 Controller — X-input mode emulation (0x045E/0x028E) -->
<usb-device vendor-id="1118" product-id="654" />
<!-- Sony DualShock 4 — the mode the adapter latches into on macOS (0x054C/0x05C4) -->
<usb-device vendor-id="1356" product-id="1476" />
</resources>