diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0f9f745..c7510e4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -13,6 +13,8 @@
tools:ignore="ScopedStorage" />
+
+
+
diff --git a/app/src/main/java/com/lazy/emulate/MainActivity.kt b/app/src/main/java/com/lazy/emulate/MainActivity.kt
index 6af4a93..0cc392c 100644
--- a/app/src/main/java/com/lazy/emulate/MainActivity.kt
+++ b/app/src/main/java/com/lazy/emulate/MainActivity.kt
@@ -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)
}
}
diff --git a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt
index fa4fd03..0e1af6f 100644
--- a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt
+++ b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt
@@ -102,16 +102,25 @@ 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 {
- 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
+ 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}")
}
}
}
diff --git a/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt
index 3de692f..c51b0db 100644
--- a/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt
+++ b/app/src/main/java/com/lazy/emulate/input/ControllerManager.kt
@@ -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
+)
+
+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>(emptyList())
+ val usbDevices: StateFlow> = _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
+ )
+ 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> = 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()
+
+ // 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()
+ private val attachThrottleMs = 500L
+
+ private val targetVidPids: Set> = 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) {
+ // 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>(emptyList())
val connectedControllers: StateFlow> = _connectedControllers.asStateFlow()
@@ -22,22 +607,79 @@ class ControllerManager(
private val _activeController = MutableStateFlow(null)
val activeController: StateFlow = _activeController.asStateFlow()
+ // Raw-event ring buffer shown on ControllerTestScreen. Mirrors the logcat debug output.
+ private val _rawEventLog = MutableStateFlow>(emptyList())
+ val rawEventLog: StateFlow> = _rawEventLog.asStateFlow()
+
+ // Currently pressed mapped buttons — drives the live indicator on the test screen.
+ private val _pressedButtons = MutableStateFlow>(emptySet())
+ val pressedButtons: StateFlow> = _pressedButtons.asStateFlow()
+
+ // Most recent axis values, for analog readout on the test screen.
+ private val _analogSnapshot = MutableStateFlow(AnalogSnapshot())
+ val analogSnapshot: StateFlow = _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)
- 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)
+ 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)
+ }
+ if (rx != 0f || ry != 0f) {
+ onAnalogEvent?.invoke(rx, ry, 1)
+ }
+ 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)
diff --git a/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt b/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt
index e1e8b1e..4a89a3c 100644
--- a/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt
+++ b/app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt
@@ -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() }
)
}
diff --git a/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt b/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt
index 15fbc3f..2abf34d 100644
--- a/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt
+++ b/app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt
@@ -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"
}
diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerTestScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerTestScreen.kt
new file mode 100644
index 0000000..bffcaf8
--- /dev/null
+++ b/app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerTestScreen.kt
@@ -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) {
+ 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"
+}
diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt
index 5c5584b..3a81571 100644
--- a/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt
@@ -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
diff --git a/app/src/main/res/xml/usb_device_filter.xml b/app/src/main/res/xml/usb_device_filter.xml
new file mode 100644
index 0000000..d4231bd
--- /dev/null
+++ b/app/src/main/res/xml/usb_device_filter.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+