Merge feature/joycon: USB gamepad support + stability fixes

Adds USB host-mode game controller support, focused on the NES Joy-Con
via the 8BitDo USB Wireless Adapter 2 in PS-Classic mode, the only
adapter mode the Samsung Fold 6 kernel will bind as a real gamepad.
Includes a Test Controller screen under Settings with live button/axis
indicators, a USB device enumeration view, and a scrolling raw event
log for debugging.

Also ships a batch of stability fixes discovered during controller
bring-up: white-screen-on-USB-detach (missing configChanges for
keyboard/navigation tore down MainActivity when the gamepad
disappeared), OOM during in-game input delivery (cover-art bitmaps
decoding at full resolution), and a FLAG_KEEP_SCREEN_ON on GameScreen
to keep the system out of the deeper sleep states that aggravate OTG
selective-suspend.

Captures a design direction for replacing the 8BitDo adapter
entirely with a DIY ESP32-WROOM-32 Joy-Con bridge that re-advertises
as a BLE HID gamepad. Not implemented yet — docs only.
This commit is contained in:
2026-04-11 19:49:21 -04:00
14 changed files with 1860 additions and 51 deletions

View File

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

View File

@@ -1,6 +1,11 @@
package com.lazy.emulate package com.lazy.emulate
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -26,6 +31,15 @@ class MainActivity : ComponentActivity() {
private lateinit var controllerManager: ControllerManager private lateinit var controllerManager: ControllerManager
private lateinit var gameRepository: GameRepository 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 lateinit var preferencesManager: PreferencesManager
private set private set
lateinit var buttonMappingManager: ButtonMappingManager lateinit var buttonMappingManager: ButtonMappingManager
@@ -44,6 +58,9 @@ class MainActivity : ComponentActivity() {
gameRepository = GameRepository(this) gameRepository = GameRepository(this)
EmulationEngine.buttonMappingManager = buttonMappingManager 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 { setContent {
EmulateTheme { EmulateTheme {
val windowSizeClass = calculateWindowSizeClass(this) val windowSizeClass = calculateWindowSizeClass(this)
@@ -64,6 +81,16 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
controllerManager.start() 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()) { if (Environment.isExternalStorageManager()) {
gameRepository.scanGameFolders() gameRepository.scanGameFolders()
} }
@@ -71,9 +98,29 @@ class MainActivity : ComponentActivity() {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
try { unregisterReceiver(usbAttachReceiver) } catch (_: Throwable) {}
controllerManager.stop() 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() { private fun requestAllFilesPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) {
val intent = Intent( val intent = Intent(
@@ -84,18 +131,16 @@ class MainActivity : ComponentActivity() {
} }
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { // Compose's root view installs its own key-event handler and will consume events
if (event != null && controllerManager.handleKeyEvent(event)) return true // before Activity.onKeyDown/onKeyUp run. Intercept at dispatchKeyEvent instead so
return super.onKeyDown(keyCode, event) // 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 { override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (event != null && controllerManager.handleKeyEvent(event)) return true if (controllerManager.handleMotionEvent(event)) return true
return super.onKeyUp(keyCode, event) return super.dispatchGenericMotionEvent(event)
}
override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
if (event != null && controllerManager.handleMotionEvent(event)) return true
return super.onGenericMotionEvent(event)
} }
} }

View File

@@ -31,13 +31,25 @@ class CoverArtManager(private val context: Context) {
val dest = cacheFile(gameTitle, consoleType) val dest = cacheFile(gameTitle, consoleType)
// Try the title as-is, with expanded regions, then fully cleaned // Build the list of candidate filenames to try, in priority order:
// 1. Title as-is
// 2. Title with GoodTools-style region tags expanded into LibRetro forms
// (e.g. "(U)" -> "(USA)", "(USA, Europe)", "(World)")
// 3. Fully cleaned title (no parens at all) — rare hit
// 4. Cleaned title with each common LibRetro region tag appended — this catches
// cases like "Super Mario Bros. (U)" where LibRetro stores "(World)" but our
// region table didn't know that this specific game used the World tag.
val namesToTry = buildList { val namesToTry = buildList {
add(gameTitle) add(gameTitle)
val expanded = expandRegions(gameTitle) val expanded = expandRegions(gameTitle)
expanded.filter { it != gameTitle }.forEach { add(it) } expanded.filter { it != gameTitle }.forEach { add(it) }
val cleaned = cleanTitle(gameTitle) val cleaned = cleanTitle(gameTitle)
if (cleaned != gameTitle && cleaned !in expanded) add(cleaned) if (cleaned != gameTitle && cleaned !in expanded) add(cleaned)
// Append fallback region tags to the bare cleaned title
for (region in COMMON_LIBRETRO_REGIONS) {
val candidate = "$cleaned $region"
if (candidate !in this) add(candidate)
}
} }
for (name in namesToTry) { for (name in namesToTry) {
@@ -45,10 +57,12 @@ class CoverArtManager(private val context: Context) {
.replace("+", "%20") .replace("+", "%20")
val url = "$BASE_URL/${consoleType.libretroThumbnailRepo}/master/Named_Boxarts/$encoded.png" val url = "$BASE_URL/${consoleType.libretroThumbnailRepo}/master/Named_Boxarts/$encoded.png"
if (downloadFile(url, dest)) { if (downloadFile(url, dest)) {
Log.d(TAG, "Cover art hit for '$gameTitle' via '$name'")
return dest.absolutePath return dest.absolutePath
} }
} }
Log.d(TAG, "No cover art match for '$gameTitle' after ${namesToTry.size} attempts")
return null return null
} }
@@ -89,13 +103,14 @@ class CoverArtManager(private val context: Context) {
private const val TAG = "CoverArtManager" private const val TAG = "CoverArtManager"
private const val BASE_URL = "https://raw.githubusercontent.com/libretro-thumbnails" private const val BASE_URL = "https://raw.githubusercontent.com/libretro-thumbnails"
// GoodNES (U) can mean (USA) or (USA, Europe) — try both // GoodTools-style short region tags can map to several LibRetro long forms.
// Order matters — earlier entries are tried first.
private val regionExpansions = mapOf( private val regionExpansions = mapOf(
"(U)" to listOf("(USA, Europe)", "(USA)"), "(U)" to listOf("(USA)", "(USA, Europe)", "(World)"),
"(E)" to listOf("(Europe)", "(Europe, Australia)"), "(E)" to listOf("(Europe)", "(Europe, Australia)", "(World)"),
"(J)" to listOf("(Japan)", "(Japan, USA)"), "(J)" to listOf("(Japan)", "(Japan, USA)", "(World)"),
"(UE)" to listOf("(USA, Europe)"), "(UE)" to listOf("(USA, Europe)", "(USA)", "(World)"),
"(JU)" to listOf("(Japan, USA)"), "(JU)" to listOf("(Japan, USA)", "(USA)", "(Japan)"),
"(W)" to listOf("(World)"), "(W)" to listOf("(World)"),
"(F)" to listOf("(France)"), "(F)" to listOf("(France)"),
"(G)" to listOf("(Germany)"), "(G)" to listOf("(Germany)"),
@@ -104,6 +119,19 @@ class CoverArtManager(private val context: Context) {
"(Unl)" to listOf("(USA) (Unl)", "(Unl)"), "(Unl)" to listOf("(USA) (Unl)", "(Unl)"),
) )
// Region tags to append to a cleaned (paren-stripped) title as a fallback for ROMs
// whose original filename had no region tag, an unrecognized region tag, or a tag
// that doesn't match what LibRetro stores for that specific game (e.g. SMB1 is
// "(World)" but most US dumps tag it "(U)").
private val COMMON_LIBRETRO_REGIONS = listOf(
"(USA)",
"(World)",
"(USA, Europe)",
"(Europe)",
"(Japan)",
"(Japan, USA)",
)
fun expandRegions(title: String): List<String> { fun expandRegions(title: String): List<String> {
val stripped = title val stripped = title
.replace(Regex("\\s*\\[[^]]*]"), "") // strip flags like [!], [b], [p1] .replace(Regex("\\s*\\[[^]]*]"), "") // strip flags like [!], [b], [p1]

View File

@@ -102,17 +102,26 @@ class GameRepository(private val context: Context) {
val gamesNeedingArt = _games.value.filter { it.coverArtPath == null } val gamesNeedingArt = _games.value.filter { it.coverArtPath == null }
if (gamesNeedingArt.isEmpty()) return 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 { scope.launch {
gamesNeedingArt.map { game -> gamesNeedingArt.map { game ->
async { async {
semaphore.withPermit { semaphore.withPermit {
try {
val path = coverArtManager.fetchCoverArt(game.rawTitle, game.consoleType) val path = coverArtManager.fetchCoverArt(game.rawTitle, game.consoleType)
if (path != null) { if (path != null) {
_games.value = _games.value.map { _games.value = _games.value.map {
if (it.id == game.id) it.copy(coverArtPath = path) else it 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() }.awaitAll()

View File

@@ -1,20 +1,605 @@
package com.lazy.emulate.input package com.lazy.emulate.input
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.input.InputManager 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.InputDevice
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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( class ControllerManager(
context: Context, context: Context,
var buttonMappingManager: ButtonMappingManager? = null var buttonMappingManager: ButtonMappingManager? = null
) : InputManager.InputDeviceListener { ) : 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()) private val _connectedControllers = MutableStateFlow<List<GameController>>(emptyList())
val connectedControllers: StateFlow<List<GameController>> = _connectedControllers.asStateFlow() val connectedControllers: StateFlow<List<GameController>> = _connectedControllers.asStateFlow()
@@ -22,22 +607,79 @@ class ControllerManager(
private val _activeController = MutableStateFlow<GameController?>(null) private val _activeController = MutableStateFlow<GameController?>(null)
val activeController: StateFlow<GameController?> = _activeController.asStateFlow() 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 onButtonEvent: ((GamepadButton, Boolean) -> Unit)? = null
var onAnalogEvent: ((Float, Float, Int) -> 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() { fun start() {
inputManager.registerInputDeviceListener(this, null) inputManager.registerInputDeviceListener(this, null)
refreshControllers() 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() { fun stop() {
inputManager.unregisterInputDeviceListener(this) inputManager.unregisterInputDeviceListener(this)
if (usbPermissionReceiverRegistered) {
try { appContext.unregisterReceiver(usbPermissionReceiver) } catch (_: Throwable) {}
usbPermissionReceiverRegistered = false
}
} }
private fun refreshControllers() { private fun refreshControllers() {
val controllers = InputDevice.getDeviceIds() val allDevices = InputDevice.getDeviceIds()
.toList() .toList()
.mapNotNull { InputDevice.getDevice(it) } .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) } .filter { isGameController(it) }
.map { device -> .map { device ->
GameController( GameController(
@@ -55,47 +697,111 @@ class ControllerManager(
} }
fun handleKeyEvent(event: KeyEvent): Boolean { 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 val button = buttonMappingManager?.remapControllerButton(rawButton) ?: rawButton
when (event.action) { when (event.action) {
KeyEvent.ACTION_DOWN -> onButtonEvent?.invoke(button, true) KeyEvent.ACTION_DOWN -> dispatchButton(button, true)
KeyEvent.ACTION_UP -> onButtonEvent?.invoke(button, false) KeyEvent.ACTION_UP -> dispatchButton(button, false)
} }
return true return true
} }
fun handleMotionEvent(event: MotionEvent): Boolean { 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 lx = event.getAxisValue(MotionEvent.AXIS_X)
val ly = event.getAxisValue(MotionEvent.AXIS_Y) 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) { if (lx != 0f || ly != 0f) {
onAnalogEvent?.invoke(lx, ly, 0) 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) { if (rx != 0f || ry != 0f) {
onAnalogEvent?.invoke(rx, ry, 1) 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) handleDpadAxis(hatX, hatY)
}
return true 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) { private fun handleDpadAxis(hatX: Float, hatY: Float) {
onButtonEvent?.invoke(GamepadButton.DPAD_LEFT, hatX < -0.5f) // Route through dispatchButton so the test screen's _pressedButtons reflects dpad
onButtonEvent?.invoke(GamepadButton.DPAD_RIGHT, hatX > 0.5f) // state, the buttonMappingManager remap path applies, and the emulator core sees
onButtonEvent?.invoke(GamepadButton.DPAD_UP, hatY < -0.5f) // the events via onButtonEvent — same path as physical key events.
onButtonEvent?.invoke(GamepadButton.DPAD_DOWN, hatY > 0.5f) 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) { fun setActiveController(controller: GameController) {
@@ -106,8 +812,10 @@ class ControllerManager(
return when (keyCode) { return when (keyCode) {
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER -> GamepadButton.FACE_BOTTOM KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER -> GamepadButton.FACE_BOTTOM
KeyEvent.KEYCODE_BUTTON_B -> GamepadButton.FACE_RIGHT KeyEvent.KEYCODE_BUTTON_B -> GamepadButton.FACE_RIGHT
KeyEvent.KEYCODE_BUTTON_C -> GamepadButton.C_BUTTON
KeyEvent.KEYCODE_BUTTON_X -> GamepadButton.FACE_LEFT KeyEvent.KEYCODE_BUTTON_X -> GamepadButton.FACE_LEFT
KeyEvent.KEYCODE_BUTTON_Y -> GamepadButton.FACE_TOP KeyEvent.KEYCODE_BUTTON_Y -> GamepadButton.FACE_TOP
KeyEvent.KEYCODE_BUTTON_Z -> GamepadButton.Z_BUTTON
KeyEvent.KEYCODE_BUTTON_L1 -> GamepadButton.L1 KeyEvent.KEYCODE_BUTTON_L1 -> GamepadButton.L1
KeyEvent.KEYCODE_BUTTON_R1 -> GamepadButton.R1 KeyEvent.KEYCODE_BUTTON_R1 -> GamepadButton.R1
KeyEvent.KEYCODE_BUTTON_L2 -> GamepadButton.L2 KeyEvent.KEYCODE_BUTTON_L2 -> GamepadButton.L2
@@ -125,9 +833,21 @@ class ControllerManager(
} }
private fun isGameController(device: InputDevice): Boolean { private fun isGameController(device: InputDevice): Boolean {
if (device.isVirtual) return false
val sources = device.sources val sources = device.sources
return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || val isGamepad = (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
(sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK 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) private fun isGameControllerDevice(device: InputDevice): Boolean = isGameController(device)

View File

@@ -24,9 +24,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil.size.Scale
import com.lazy.emulate.data.model.Game import com.lazy.emulate.data.model.Game
import java.io.File import java.io.File
@@ -55,8 +58,16 @@ fun GameCard(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (game.coverArtPath != null) { if (game.coverArtPath != null) {
// Cap decode size — LibRetro thumbnail PNGs can be 1500x2000+, which
// burns ~12 MB of bitmap memory each. With 30 cards visible that's
// hundreds of MB and was OOM-ing the app during in-game input delivery.
// 384px is more than enough for a tile in a grid.
AsyncImage( AsyncImage(
model = File(game.coverArtPath), model = ImageRequest.Builder(LocalContext.current)
.data(File(game.coverArtPath))
.size(384)
.scale(Scale.FILL)
.build(),
contentDescription = "${game.title} cover art", contentDescription = "${game.title} cover art",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()

View File

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

View File

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

@@ -1,5 +1,6 @@
package com.lazy.emulate.ui.screens.game package com.lazy.emulate.ui.screens.game
import android.app.Activity
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Handler import android.os.Handler
@@ -7,6 +8,7 @@ import android.os.Looper
import android.view.PixelCopy import android.view.PixelCopy
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import android.view.WindowManager
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -109,6 +111,17 @@ fun GameScreen(
ControllerLayout.defaultForConsole(game.consoleType) ControllerLayout.defaultForConsole(game.consoleType)
} }
// Keep the screen on while a game is on-screen. This also keeps the system out of the
// deeper sleep / power-saving states that aggravate USB OTG selective-suspend on
// Samsung devices, where the adapter drops every ~60-120 seconds otherwise.
DisposableEffect(Unit) {
val window = (context as? Activity)?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onDispose {
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
DisposableEffect(game.id) { DisposableEffect(game.id) {
val loaded = EmulationEngine.startGame(context, game.consoleType, game.romPath) val loaded = EmulationEngine.startGame(context, game.consoleType, game.romPath)
if (loaded != null) { if (loaded != null) {

View File

@@ -54,7 +54,8 @@ fun SettingsScreen(
controllerManager: ControllerManager, controllerManager: ControllerManager,
preferencesManager: PreferencesManager, preferencesManager: PreferencesManager,
buttonMappingManager: ButtonMappingManager, buttonMappingManager: ButtonMappingManager,
onBack: () -> Unit onBack: () -> Unit,
onTestController: () -> Unit = {}
) { ) {
val controllers by controllerManager.connectedControllers.collectAsState() val controllers by controllerManager.connectedControllers.collectAsState()
val activeController by controllerManager.activeController.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)) Spacer(modifier = Modifier.height(24.dp))
// Touch overlay section // 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>

377
docs/joycon-esp32-bridge.md Normal file
View File

@@ -0,0 +1,377 @@
# Joy-Con ESP32 Bridge — Design Notes
Design for replacing the 8BitDo USB Wireless Adapter 2 with a DIY
ESP32-based Joy-Con bridge. Not yet implemented — this doc captures the
plan so it's ready to pick up when the hardware arrives.
## Decisions
- **Chosen architecture: Path A (fully wireless).** The bridge pairs with
the Joy-Con over BT Classic, then re-advertises itself to the phone as
a BLE HID gamepad. No cable between the phone and the bridge. The
bridge runs off its own battery (LiPo + charging circuit, or a small
USB power bank).
- **Chosen hardware: ESP32-WROOM-32 USB-C dev board**
([Amazon.ca link](https://www.amazon.ca/ESP-WROOM-32-NodeMCU-Bluetooth-Development-Microcontroller/dp/B0CHBMFJBQ)).
Original ESP32 silicon (BT Classic + BLE, dual-mode radio), CP2102
USB-serial for programming, USB-C for power + flashing.
- **Form factor plan:** 3D-printed case housing the ESP32 + a LiPo
battery + a small TP4056-style USB-C charge/protection board. Sits on
the couch next to the phone, pairs with the Joy-Con, connects to the
phone wirelessly.
- **Path B (USB serial) is the fallback.** If the dual-mode BT
coexistence on the ESP32 turns out to be too painful, we can fall back
to sending button state over the on-board CP2102's USB serial —
everything on the Joy-Con side is identical, and the app-side changes
for a USB-CDC reader are documented below.
## Why
The current "working" path uses an 8BitDo USB Wireless Adapter 2 in PS-Classic
mode, plugged into the Samsung Fold 6 via an OTG cable. It works, but has three
annoying problems:
1. **It wedges periodically.** The adapter stops sending HID reports every
60120 seconds on this phone. Unplug + replug clears it. Likely root cause
is Samsung's aggressive USB selective-suspend / OTG power management
kicking the idle device off the bus. See the "dongle rabbit hole" notes
below for everything we tried.
2. **PS-Classic mode is fragile.** We picked it because it's the only mode
where Samsung's kernel `usbhid` actually binds the device as a real
gamepad. Direct Joy-Con Bluetooth, XInput mode, DInput mode, and macOS/DS4
mode all fail for various reasons specific to Samsung's kernel + this
adapter's firmware.
3. **It's a cable dangling off a foldable phone.** Not great ergonomically.
Doing our own Joy-Con-to-gamepad translation on an ESP32 lets us control both
ends of the conversation: we pair the Joy-Con to hardware we own, we do the
init handshake correctly, and we present the result to the phone as either a
standard BLE HID gamepad (no cable) or a USB-CDC serial stream (cable, but we
control the firmware so the selective-suspend problem goes away).
## Why the Joy-Con is non-trivial
Joy-Cons are Bluetooth **Classic** HID devices. They are NOT BLE. This means
any bridge MCU must have BT Classic on its radio.
Once paired, they default to input report mode `0x3F`, which only emits a
small subset of buttons as a dumb HID "joystick". To get the full
button-mask + analog sticks + IMU + battery, you have to send a sequence of
subcommands:
1. Read SPI flash calibration at `0x6020` (stick factory calibration) and
`0x8010` (stick user calibration)
2. `set_player_lights` (subcommand `0x30`)
3. `enable_imu` (subcommand `0x40`) if you want gyro/accel
4. **`set_input_report_mode` to `0x30`** (subcommand `0x03`) — this is the
important one; switches to the 12-byte input report that contains
everything you actually want
Once the Joy-Con is in `0x30` mode, every input report is a fixed layout:
```
byte purpose
0 0x30 (report id)
1 timer (rolling counter)
2 battery + connection info
3 buttons right (Y, X, B, A, SR, SL, R, ZR)
4 buttons shared (-, +, R-stick, L-stick, home, capture)
5 buttons left (down, up, right, left, SR, SL, L, ZL)
6-8 left stick (12-bit X + 12-bit Y, packed)
9-11 right stick (same)
12 vibrator ack
13+ IMU samples (3 frames x 12 bytes) if IMU enabled
```
The full protocol is documented here:
- https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering
- https://github.com/DanielOgorchock/joycond (Linux userspace init daemon)
- Linux mainline `drivers/hid/hid-nintendo.c` (kernel driver that does the
same init sequence in C)
Either joycond or hid-nintendo is a fine starting point for porting to C on
ESP32.
## Hardware
Joy-Cons use **Bluetooth Classic (BR/EDR)**, not BLE. This is the single
most important constraint on the hardware. Most modern MCU boards that
have "Bluetooth" actually only have BLE, which won't work.
Only these chips have BT Classic:
| Chip | BT Classic | BLE | Native USB | Fits? |
|----------------------------|:----------:|:---:|:----------:|:-----:|
| **ESP32** (D0WDQ6 / WROOM-32 / WROVER-32) | ✅ | ✅ | ❌ | ✅ **best default** |
| **Raspberry Pi Pico W** (CYW43439) | ✅ | ✅ | ✅ | ✅ also works |
| ESP32-S3 | ❌ | ✅ | ✅ | ❌ BLE only (common mistake) |
| ESP32-S2 | ❌ | ❌ | ✅ | ❌ no BT at all |
| ESP32-C3 | ❌ | ✅ | ❌ | ❌ BLE only |
| ESP32-C6 | ❌ | ✅ | ❌ | ❌ BLE + 802.15.4 only |
| ESP32-H2 | ❌ | ✅ | ❌ | ❌ BLE + 802.15.4 only |
**⚠️ Do not buy ESP32-S3 for this project** even though it looks like the
obvious upgrade over the original ESP32. Espressif confirmed in their own
datasheet that ESP32-S3 only supports Bluetooth 5 LE — no BR/EDR — and
you cannot connect a Joy-Con to it over Bluetooth. The Bluepad32 FAQ
makes this explicit: *"controllers like Switch, Wii, DualSense, DualShock,
etc. only talk BR/EDR... you cannot use an ESP32-S3 as a Bluetooth HID
host to connect to Joy-Con controllers."*
**Recommended: original ESP32-WROOM-32 dev board** — any of the Amazon
"DOIT DEVKIT V1", "HiLetgo", "ELEGOO", "DIYmall" variants. They're all
the same chip, usually with a CP2102 or CH340 USB-to-serial chip on
board so you get programming + serial communication over a single USB
port. $812 Canadian. BT Classic is built into the ESP32-D0WDQ6 chip.
No native USB-OTG, so in Path B the chip talks to the phone over the
on-board USB-serial bridge.
**Also good: Raspberry Pi Pico W** — the CYW43439 WiFi/BT combo chip
on the Pico W supports BT Classic, and the RP2040 has native USB. If
you prefer C SDK + CMake to Arduino, Pico W is a nicer dev experience,
and the Bluepad32 library supports it as a first-class target alongside
the original ESP32.
**ESP32-WROVER-32** is the same silicon as WROOM-32 plus an extra PSRAM
chip. Works identically for this project but costs a bit more for PSRAM
we don't need. Fine if that's what you already have.
## Two architectures
### Path A — fully wireless (BT Classic host + BLE peripheral)
```
[Joy-Con] --BT Classic HID--> [ESP32-S3] --BLE HID--> [Phone]
```
The ESP32 runs two radio profiles at once on its dual-mode stack:
- **BT Classic HID host** (ESP-IDF `esp_hidh` API) — pairs with the Joy-Con,
runs the init subcommand sequence, subscribes to `0x30` input reports
- **BLE HID device** (ESP-IDF `esp_hids` API, or the `ESP32-BLE-Gamepad`
Arduino library) — advertises itself to the phone as a standard BLE
gamepad with a generic HID descriptor
The Android side needs **no code changes**. Samsung's kernel binds BLE HID
gamepads natively (BLE HID is a completely different code path from the
USB HID mess we've been fighting), the existing `InputDevice`/
`dispatchKeyEvent` path picks up button events, and our
`ControllerManager` routes them into the emulator the same way it does
for the 8BitDo today.
**Pros**
- No cable. Charge the ESP32 off a tiny LiPo or a power brick, use it as a
wireless dongle sitting next to the phone.
- Nothing to change in the app.
- Once it works, it works the same for every game on every emulator we
already support.
**Cons**
- Running BT Classic host + BLE peripheral simultaneously on the same radio
is non-trivial. ESP-IDF supports dual mode but the profiles have to share
a single controller — expect to spend time in `menuconfig` and the
Bluetooth controller's coexistence settings.
- Bigger firmware, more things that can go wrong during bring-up.
### Path B — wired hybrid (BT Classic host + USB-CDC serial)
```
[Joy-Con] --BT Classic HID--> [ESP32-S3] --USB CDC serial--> [Phone]
```
Same Joy-Con side. Different phone side: instead of re-broadcasting over
BLE, the ESP32 streams button state as short binary frames over USB CDC.
The phone reads them via `usb-serial-for-android` or directly via the
`UsbManager` + `bulkTransfer` APIs we already use.
**Pros**
- Simpler firmware — no dual-mode radio, no BLE HID descriptor, no BLE
pairing dance. Get it working in a weekend.
- We own the wire protocol, so it's trivially debuggable. Dump the serial
bytes in Serial Monitor and you see exactly what the Joy-Con is sending.
- **Selective-suspend goes away** because we control the firmware on
both sides. Send a 60-Hz heartbeat frame even when no buttons are held
and the USB bus stays active. The ESP32 can also be externally
powered, so the phone's bus-power policy becomes irrelevant.
- Direct fit for the existing `ControllerManager.dispatchButton` plumbing.
**Cons**
- Still a cable between the ESP32 and the phone (we're swapping the 8BitDo
OTG cable for an ESP32 OTG cable — ergonomically the same).
- Requires a small addition to the app to read USB serial and parse the
wire protocol.
Recommended order of attack: **build Path B first**. The hard part (Joy-Con
BT Classic host + init sequence + `0x30` report parsing) is identical in
both paths, so Path B gets you to a working gamepad fastest and validates
the Joy-Con side. Once Path B is rock-solid, swap the output stage for BLE
HID and you have Path A for free (modulo the dual-mode coexistence tuning).
## Wire protocol sketch (Path B)
Keep it tiny, keep it fixed-size, keep it self-synchronizing.
```
byte 0 0xA5 sync / frame header
byte 1 seq rolling sequence counter, wraps at 255
byte 2 btns_lo (A, B, X, Y, L1, R1, L2, R2)
byte 3 btns_hi (Start, Select, L3, R3, Home, Capture, reserved, reserved)
byte 4 dpad packed: 4 bits for hat direction (0-7 clockwise from N, 8=none),
4 bits for Joy-Con-specific buttons (SR/SL etc)
byte 5 lx signed int8, -127..127 (already deadzoned + calibrated on ESP32)
byte 6 ly signed int8
byte 7 rx signed int8
byte 8 ry signed int8
byte 9 flags bit 0: battery low, bit 1: charging, bit 2: imu_valid, bits 3-7: reserved
byte 10 crc8 CRC-8 over bytes 0..9 (or just XOR checksum if we're lazy)
```
11 bytes per frame, 60 Hz = 660 bytes/sec. USB CDC at 115200+ baud handles
this with orders of magnitude to spare. Even ESP32's default 921600 baud is
fine for 240 Hz polling.
If later we want motion / gyro for games that use it, we append another ~12
bytes and bump a version byte in the header. Forward-compatible.
## App-side changes (Path B)
All contained in `ControllerManager.kt` and a new helper file:
1. **New dependency** on [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android)
(tiny, ~50 KB; supports CH340, CP210x, FTDI, and CDC-ACM, which covers
every ESP32 dev board).
2. **Add ESP32-S3 native USB VID/PID** (`0x303A / 0x1001`) and CP2102
(`0x10C4 / 0xEA60`) to `targetVidPids` in `ControllerManager`.
3. **In `onUsbDeviceAttached`**, after the existing HID-class short-circuit,
check whether the device matches one of the ESP32 VID/PIDs. If so, open
it as a USB serial port instead of going through the HID path, start a
reader thread, and parse incoming `[0xA5, seq, btns_lo, btns_hi, dpad,
lx, ly, rx, ry, flags, crc]` frames.
4. **For each frame**, diff against the previous frame and call
`dispatchButton(...)` for any changed button bits, plus `onAnalogEvent(...)`
for stick updates. Existing plumbing carries it into the emulator core.
5. **Heartbeat / liveness**: track the frame sequence counter, and if we
don't see a frame for 500 ms, log a warning and mark the controller as
disconnected so the test screen reflects it.
Approximate LOC: ~150 lines in ControllerManager + 50 lines for the wire
protocol parser. No changes needed anywhere else in the app.
## Firmware-side notes
### Bluepad32 changes the math
There's a project called **[Bluepad32](https://bluepad32.readthedocs.io/)**
that already implements BT Classic HID host for original ESP32 and Pico W,
with **explicit first-class Joy-Con support** alongside DualShock 3/4/5,
Switch Pro Controller, Wii Remote, Xbox, and generic HID gamepads. It does
the init subcommand dance, SPI-flash calibration reads, and 0x30 input
report parsing for you. MIT licensed.
This means the Joy-Con side of this project essentially does not need to
be written — it's library-level `#include`. Our firmware job shrinks to:
1. Initialize Bluepad32 and register a gamepad callback.
2. Format the callback's button/stick state into our wire protocol.
3. Send it out the output stage (USB serial for Path B, BLE HID for Path A).
The Joy-Con init quirks, reconnect handling, stick calibration, and BT
pairing UX are already solved.
### Starting points
- **[Bluepad32](https://github.com/ricardoquesada/bluepad32)** — the main
library. Has ESP-IDF and Arduino examples. The `controllers/` demo example
pairs gamepads and prints button state to serial; adapting it to our
wire format is ~50 lines.
- For **Path A** (BLE output): glue the Bluepad32 input side to
[`ESP32-BLE-Gamepad`](https://github.com/lemmingDev/ESP32-BLE-Gamepad) —
working BLE HID gamepad profile in ~30 lines of Arduino. Caveat: BLE
peripheral coexisting with BT Classic host on one chip is tricky.
- For **Path B** (USB serial): just `Serial.write()` the frame from the
Bluepad32 callback. Trivial.
- If you'd rather not use Bluepad32 and write the protocol yourself (for
learning or licensing reasons), reference joycond (C++, Apache 2.0) or
the Linux kernel `drivers/hid/hid-nintendo.c` (C, GPL).
### Prior art
- https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering — full
protocol docs
- https://github.com/DanielOgorchock/joycond — Linux userspace init daemon
- https://github.com/pipe01/joycon-rs — Rust Joy-Con protocol library
- Various ESP32-based Nintendo Switch Pro Controller *emulators* also
exist — they go the other direction (pretend to be a Pro Controller
talking to a Switch console) but share the same protocol knowledge.
### Estimated effort (with Bluepad32)
- **Path B (serial out)**: a weekend to get working, another weekend to
harden against reconnect/pair-loss edge cases. Total ~1020 hobby hours.
- **Path A (BLE out)**: same Path B starting point plus 12 weeks of
wrestling with BT Classic + BLE dual-mode coexistence on one chip.
Bluepad32 itself only targets single-mode BT Classic, so you'd be
extending it or running the BLE side in parallel via raw ESP-IDF calls.
Harder to estimate.
## The dongle rabbit hole (what we already tried and why it didn't work)
For anyone picking this up later, here's the short history so you don't
re-run the same experiments:
- **Direct Joy-Con over Bluetooth to Samsung Fold 6.** Pairs fine, kernel
creates two evdev nodes (main + IMU), IMU streams MSC_TIMESTAMP so the
link is alive, but the main node has only `BTN_TL`/`BTN_TL2` in its key
capability set (no face buttons, no dpad, no stick) and even those fire
zero events. Samsung's `hid-nintendo` driver parks the node waiting for a
userspace init handshake that nothing on the system provides, and shell
can't `chmod` `/sys/bus/hid/devices/.../` to kick it.
- **8BitDo adapter, XInput mode.** Enumerates as an Xbox 360 wired
controller (vendor-spec class 0xFF / subclass 0x5D). Android has no
XInput driver in its kernel. Adapter endlessly cycles modes because it
can't complete the Xbox 360 handshake with the phone.
- **8BitDo adapter, DInput mode.** Enumerates as a clean HID-class device,
but Samsung's kernel `usbhid` refuses to bind it for unknown reasons
(verified by uninstalling the app entirely and replugging — no
`/dev/input/event*` is ever created). Not fixable from userspace.
- **8BitDo adapter, macOS mode.** Enumerates as a Sony DualShock 4
(`054C:05C4`). Same failure as DInput — Samsung's kernel doesn't bind
it. Despite Android having native DS4 support.
- **8BitDo adapter, PS-Classic mode.** Enumerates as a "Sony Interactive
Entertainment Controller" (`054C:0CDA`). Samsung's kernel actually binds
this one as a real gamepad. **This is what we ship today.** Downside: the
adapter wedges every 60120 seconds and needs an unplug+replug to
recover, presumably from OTG selective-suspend.
Things that partially work or haven't been fully explored:
- Plugging the adapter into a powered USB hub between the phone and the
adapter helps somewhat with the wedging but does not fully fix it.
- Disabling Samsung battery optimization for the app does not fix the
wedging.
- Holding `FLAG_KEEP_SCREEN_ON` on the game window helps (the system
stays out of the deep-sleep regime that aggravates selective-suspend)
but again doesn't fully fix it.
All three workarounds stack with the ESP32 bridge idea if we go Path B —
but Path A (BLE) makes them all irrelevant because there's no USB bus to
suspend in the first place.
## Open questions to resolve during bring-up
1. Does the Samsung Fold 6 kernel actually bind ESP32-S3 native USB-CDC
as a `/dev/bus/usb/` device visible to our app, or does it do something
weird to USB-CDC too? Should be fine — CDC-ACM is boring and
well-supported — but verify before writing firmware.
2. What's the latency floor from Joy-Con button press to emulator core?
Joy-Con → BT Classic (~4-8 ms) + ESP32 parse (<1 ms) + USB serial
(<1 ms) + app dispatch (<1 ms) should come in under 15 ms, comfortably
below the ~16 ms/frame budget at 60 Hz. Worth measuring once hardware
exists.
3. Left+Right Joy-Con combined as one "Pro Controller" style pad, or
single-Joy-Con sideways mode? Single-Joy-Con is simpler for NES/SNES
and matches the NES Joy-Con we have. Pairing two Joy-Cons to the same
ESP32 is a separate BT Classic multi-device problem that we can punt
on.
4. Rumble — do we care? Joy-Con rumble is HD Rumble, which is a nightmare
even by Joy-Con standards. Probably punt; the emulated consoles we
support didn't have rumble anyway.

View File

@@ -0,0 +1,178 @@
# Handoff: Joy-Con / 8BitDo Adapter on Samsung Z Fold 6
> **⚠️ Status: deprecated / historical.**
>
> The questions this document was handing off ("how do we get a Joy-Con
> working on the Samsung Fold 6 via the 8BitDo adapter?") were ultimately
> **solved** — the working path was to put the adapter into PS-Classic
> mode (Select + D-Pad Down for 3 seconds), which is the only mode where
> Samsung's kernel `usbhid` actually binds the device as a real gamepad.
> That solution ships today in `ControllerManager.kt` and works, but the
> adapter wedges every 60120 seconds due to Samsung's OTG selective
> suspend. See the commit log on `feature/joycon` for the full fix set.
>
> Going forward, the 8BitDo-on-OTG path is **being replaced** by a DIY
> ESP32-WROOM-32 bridge that pairs with the Joy-Con over Bluetooth
> Classic and re-advertises itself to the phone as a BLE HID gamepad —
> no cables, no selective-suspend quirks. That design lives in
> [`joycon-esp32-bridge.md`](./joycon-esp32-bridge.md) and is the active
> work going forward.
>
> This file is kept purely for archaeology — it documents everything we
> tried on the adapter path, in the order we tried it, so future-me
> doesn't re-run any of the same experiments. **Do not use it as a
> current how-to.**
## Goal
Enable gamepad input from a Nintendo NES Controller (R) Joy-Con for the emulator app in `/Users/matt/code/android/emulate` (package `com.lazy.emulate`, branch `feature/joycon`), running on a Samsung Galaxy Z Fold 6 (model `SM-F966W`) with Android 16.
## Hardware in play
- **Phone**: Samsung SM-F966W, Android 16, build `25D125`-era Samsung kernel, DWC3 USB host controller (`/sys/class/udc/a600000.dwc3`)
- **Controller**: Nintendo "NES Controller (R)" (Switch Joy-Con (R) variant). Bluetooth name "NES Controller (R)", USB vid/pid `0x057E/0x2007`
- **Adapter**: 8BitDo USB Wireless Adapter 2 (the Joy-Con-compatible model, officially documents Android support at `support.8bitdo.com/Manual/USB-Adapter-2/switch-joycon-others.html`). **Firmware updated to latest** via 8BitDo's dedicated firmware updater (not Ultimate Software V2) during this session
- **Hub**: Anker USB-C hub with PD-in, ethernet, HDMI, 1× USB-C, 2× USB-A. Powered via PD, adapter plugged into a USB-A port
- **Mac** (MacBook on Darwin 25.3.0) used for cross-platform comparison and firmware flashing
## Paths tried and what we learned, in order
### 1. Direct Joy-Con over Bluetooth
- Joy-Con pairs to phone fine; Samsung's kernel includes `hid-nintendo` driver
- `getevent -pl` shows TWO evdev nodes created: `event16 "Nintendo Switch Right Joy-Con"` (with only `BTN_TL`, `BTN_TL2` in its key capability set — no face buttons, no dpad, no stick) and `event17 "Nintendo Switch Right Joy-Con IMU"` (accelerometer + gyro)
- IMU node actively streams `MSC_TIMESTAMP` events — controller is alive and communicating with kernel
- Main event16 node: **zero events fire**, even for SL/SR which its capability set claims. Confirmed via `adb shell getevent -l /dev/input/event16` while user pressed every button
- Samsung OneUI has a per-device "Use as input device" toggle in BT settings which we confirmed was ON — didn't change behavior
- **Diagnosis**: Samsung kernel has `hid-nintendo` driver but no userspace equivalent of Linux `joycond` to finish the init handshake and enable single-controller sideways mode. Driver parks the main node with only the rail buttons exposed (and even those don't fire). `/sys/bus/hid/devices/0005:057E:2007.0007/` is Permission denied to `adb shell` (SELinux `u:r:shell:s0`) even though shell is in the `input` group. No way to force mode from userspace without root.
### 2. Joy-Con via 8BitDo Wireless Adapter 2 — native Android HID path
The adapter is documented as Android-compatible. Plug it in, pair Joy-Con, expect plain HID gamepad.
What actually happened (observed multiple times, multiple firmware versions):
- Adapter cycles between USB descriptors every ~400ms:
- `vid=0x2DC8 pid=0x3106 name="8BitDo Pro 2 Wired Controller"` (what 8BitDo calls D-input)
- `vid=0x045E pid=0x028E name="Controller" (Microsoft X-Box 360 pad)` (X-input)
- Occasionally also `vid=0x2DC8 pid=0x3107 name="IDLE"` (no paired controller state)
- Both modes have `class=255 (vendor-spec)` and `interfaceClass=255 subclass=0x5D (93) protocol=0x01`**this is the textbook Xbox 360 wired Xinput descriptor**, not a HID gamepad class. Android has no Xinput driver in its kernel/framework, so `usbhid` never binds and nothing ever reaches the `InputDevice` / `KeyEvent` layer despite both modes showing up in `InputDevice.getDeviceIds()` with `SOURCE_GAMEPAD | SOURCE_JOYSTICK` bits set (presumably Android created a "Microsoft X-Box 360 pad" generic profile from the vid/pid match, but no bind because no driver)
- The adapter auto-cycles because its firmware is probing for "which host am I plugged into" and retrying on timeout — it never gets the Xinput handshake it expects from Android
- The Joy-Con side *does* pair successfully (solid blue on adapter, solid player 1 LED on Joy-Con). Pairing doesn't stop the USB-side mode cycling
- The adapter also has a **"green LED state"** which we initially thought was "Switch mode" but confirmed via macOS ioreg is actually **bootloader mode**: `vid=0x2DC8 pid=0x3208 name="BOOT"`. Enterable by holding the pair button during plug-in. In this state the adapter stably enumerates but can't pair any controllers (it's waiting to be flashed)
- **Mac cross-check**: Same hardware on macOS enumerates stably as `vid=0x054C pid=0x05C4 name="8BitDo Receiver"` (i.e. impersonating a Sony DualShock 4). macOS IOHIDFamily binds natively. Joy-Con buttons presumably work. We never confirmed button data end-to-end on the Mac, only that USB enumeration was stable and single-device
- **Firmware update**: user's adapter shipped with 2023 firmware; we flashed to latest 2025/2026 firmware via the 8BitDo firmware updater. Cycling behavior on Android is identical before and after
- **Ultimate Software V2** (8BitDo's config tool at `app.8bitdo.com/Ultimate-Software-V2/`) does not detect this adapter on macOS — only the older dedicated firmware updater does
### 3. USB-claim experiment (taking over the device from userspace)
Theory: if we claim the USB interface via `UsbDeviceConnection.claimInterface(force=true)`, we detach kernel usbhid, and the adapter might stop cycling because it's finally talking to "a host".
Implementation:
- `res/xml/usb_device_filter.xml` lists the four vid/pids we've seen (`0x2DC8/0x3106`, `0x2DC8/0x3107`, `0x045E/0x028E`, `0x054C/0x05C4`)
- `AndroidManifest.xml` has `<intent-filter>` for `USB_DEVICE_ATTACHED` + `<meta-data>` pointing to the filter, with `launchMode="singleTop"` on `MainActivity`
- `MainActivity.usbAttachReceiver` is a dynamic `BroadcastReceiver` registered in `onResume` for `ACTION_USB_DEVICE_ATTACHED`; unregistered in `onPause`. Also `onNewIntent → handleUsbAttachIntent` for manifest-delivered intents
- `ControllerManager.onUsbDeviceAttached(device)` filters to target vid/pids, requests permission via `PendingIntent`/`usbPermissionReceiver`, calls `openDevice()` + `claimInterface(force=true)` on interface #0 only (class=0xFF sub=0x5D proto=0x01 — the main input interface)
Results (observed on multiple runs):
- **Claim succeeds**: log shows `USB claimInterface #0 class=255 sub=93 proto=1 → OK (kernel detached)` and `USB holding claim on 1 interface.`
- **On the first run without a reader thread, the cycling stopped**. We saw one successful X-input claim at `15:32:40.548` followed by no further attach events for the target vid/pids. Only a single `ignoring non-target USB device v=1406 p=8201` (which is Joy-Con Bluetooth HID reappearing via Samsung's UHID pipe). **This is the one promising data point.** Reproducibility unknown — every subsequent attempt thrashed
- **Adding the Xinput reader thread broke everything**: `bulkTransfer(endpoint=0x81, buf, 32, 200ms)` returns `-1` almost instantly (~10 ms per call, ~100 consecutive errors in 1 second). Reader exits via the "101 consecutive errors" bailout with `packets=0`. Never received a single report. Endpoint address 0x81 is correct (IN bit + endpoint 1, standard Xbox 360 layout). Interrupt vs bulk transfer type: Android `UsbDeviceConnection` has no `interruptTransfer``bulkTransfer` handles both
- **Hypothesis for zero reads**: Xbox 360 controllers (real and emulated) sometimes require an LED init command (`0x01 0x03 0xNN` on the OUT endpoint) before they emit input reports. The adapter may additionally be waiting for the Xbox 360 security challenge-response (interface #3 in X-input mode is proto=19, 0 endpoints — that's the "security" interface the real Xbox console handshakes). We did not implement either
- **Activity lifecycle interaction**: with the manifest intent filter + `singleTop`, every `USB_DEVICE_ATTACHED` delivered via the filter seems to tear down and recreate `MainActivity` on Samsung OneUI, calling `onDestroy → releaseClaim → hasActiveClaim=false`. Each recreate starts a new `ControllerManager`, a new claim, a new reader thread. Old reader threads cannot be joined cleanly because the old `UsbDeviceConnection` from the destroyed activity is stuck in `bulkTransfer`. **Thread leak → OOM** at `VmSize ~19.9 GB` (hundreds of 8 MB thread stacks). We hit this OOM multiple times
- **Samsung DWC3 host controller physically wedges**: after enough claim/release thrashing, `/sys/bus/usb/devices/` goes completely empty (not even root hubs visible), `IsHostConnected :false`, `mIsHostConnected :false`. Only a full phone reboot recovers. This happened twice and is reproducible with enough cycling
### 4. Other things tried along the way
- Switching the adapter through every button-combo mode the user could find (blue flashing normal, green bootloader, holding pair during plug-in, etc)
- Plugging directly into phone with USB-C OTG cable (no hub) — inconsistent host mode detection; Samsung `SettingBlockUsbLock :1` flag was noted in dumpsys output but seemed to only matter when the phone was locked
- Plugging into the Mac to verify adapter isn't broken — it's fine on Mac
- Ultimate Software V2 / firmware updater on Mac — firmware updated successfully; Ultimate Software V2 never detected the adapter in any state
- Moving to wireless ADB once OTG occupied the phone's USB-C port
## Current state of the code (branch `feature/joycon`, uncommitted)
### Modified files
| File | What's in it |
|---|---|
| `app/src/main/AndroidManifest.xml` | `launchMode="singleTop"`, `USB_DEVICE_ATTACHED` intent filter + meta-data, `uses-feature android.hardware.usb.host` |
| `app/src/main/res/xml/usb_device_filter.xml` | **NEW** — four target vid/pids |
| `app/src/main/java/com/lazy/emulate/MainActivity.kt` | Dynamic `usbAttachReceiver`, `handleUsbAttachIntent`, `onNewIntent`, `releaseClaim()` call in `onDestroy`, `dispatchKeyEvent`/`dispatchGenericMotionEvent` override (replaces `onKeyDown`/`onKeyUp`) |
| `app/src/main/java/com/lazy/emulate/input/ControllerManager.kt` | Huge changes — see below |
| `app/src/main/java/com/lazy/emulate/input/GamepadButton.kt` | Unchanged |
| `app/src/main/java/com/lazy/emulate/ui/navigation/Screen.kt` | New `ControllerTest` route |
| `app/src/main/java/com/lazy/emulate/ui/navigation/NavGraph.kt` | Wires `ControllerTest` route |
| `app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerTestScreen.kt` | **NEW** — live button grid, axis readout, USB device list, scrolling raw event log |
| `app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt` | New "Test Controller" card that navigates to the test screen |
### `ControllerManager.kt` inventory
- `VENDOR_NINTENDO = 0x057E` — unused now
- `MAX_EVENT_LOG_LINES = 300`
- `AnalogSnapshot` data class
- `UsbDeviceInfo`, `UsbInterfaceInfo` data classes
- StateFlows: `connectedControllers`, `activeController`, `rawEventLog`, `pressedButtons`, `analogSnapshot`, `usbDevices`
- `usbPermissionAction`, `usbPermissionReceiver`, registered/unregistered in `start`/`stop`
- `ClaimedDevice` data class + `claimedDevice` field
- `hasActiveClaim: Boolean` — intended to prevent thread spam, effectively defeated by Activity recreation
- `targetVidPids: Set<Pair<Int, Int>>` — the four target vid/pids
- `lastAttachTimeByKey`, `attachThrottleMs = 500L` — per-vid/pid debounce
- `permissionRequestedFor: MutableSet<String>` — one-shot permission request per key
- `xinputReaderThread: Thread?`, `xinputReaderStop: AtomicBoolean`
- `xinputPrevButtons`, `xinputPrevL2`, `xinputPrevR2`
- Xinput bit constants + `xinputBitToButton` mapping list
- `onUsbDeviceAttached(device)` — target filter → `hasActiveClaim` check → already-claimed check → throttle → permission request → `openDevice``claimInterface(iface, force=true)` on first iface matching `0xFF/0x5D/0x01``startXinputReader`
- `tryClaimAlreadyConnectedUsb()` — called from `onResume` sweep
- `releaseClaim()` — sets stop flag, releases interface, closes connection, joins reader thread, clears state
- `startXinputReader(connection, interfaces)` — finds IN endpoint, spawns daemon thread, calls `bulkTransfer` in a loop, parses Xinput reports, exits after 100 consecutive errors
- `parseXinputReport(buf)` — 20-byte Xinput packet parser, dispatches buttons + analog sticks with Y-negated to match Android axis convention
- `dispatchButton(button, isDown)` — updates `_pressedButtons` + invokes `onButtonEvent`. Used by both the Android key path and the Xinput reader path
- `refreshUsbDevices()` — iterates `usbManager.deviceList`, builds `UsbDeviceInfo` snapshots
- `appendEventLog(line)` — ring buffer + mirrors to `Log.d(TAG, "LOG: $line")` so everything visible in `adb logcat -s ControllerManager:D`
- `isGameController(device)` — now also accepts any device with `VENDOR_NINTENDO` vendor id as a fallback (unused in practice; can be reverted)
- `handleKeyEvent`/`handleMotionEvent` — have extensive debug `Log.d` calls that dump every event including non-gamepad devices
### Compose UI
`ControllerTestScreen.kt` shows, top to bottom: **Detected Controllers** (from `InputDevice`), **USB Devices** (from `UsbManager`, with rescan button), **Pressed Buttons** (live chip grid colored per `_pressedButtons`), **Axes** (live analog values), **Raw Events** (scrolling `LazyColumn` with auto-scroll and a clear-log action in the top bar). The USB section highlights `class=3 (HID)` entries in green and `class=0xFF (VENDOR-SPEC)` in red (labelled "NO HID").
## Evidence for the promising path
On one run (`15:32:39 15:32:46`, logcat available by running `adb logcat -d -s ControllerManager:D` soon after reproducing), with a fresh boot and the adapter in blue mode + Joy-Con paired:
```
15:32:39.706 USB ATTACH '8BitDo Receiver' v=0x2dc8 p=0x3106
15:32:39.708 USB claimInterface #0 class=255 sub=93 proto=1 → OK (kernel detached)
15:32:39.709 USB holding claim on 1 interface(s).
15:32:40.548 USB ATTACH 'Controller' v=0x045e p=0x028e
15:32:40.551 USB claimInterface #0 sub=93 proto=1 → OK
15:32:40.551 USB claimInterface #1 sub=93 proto=3 → OK
15:32:40.551 USB claimInterface #2 sub=93 proto=2 → OK
15:32:40.551 USB claimInterface #3 sub=253 proto=19 → OK
15:32:40.551 USB holding claim on 4 interface(s).
15:32:42.724 ignoring non-target USB device v=1406 p=8201
(silence — no more attach events for target vid/pids)
```
This was the run **without the Xinput reader thread**. Cycling stopped after we claimed, for the remaining duration of the run. Adding the reader thread in subsequent runs reintroduced instability.
## Open questions / things another agent might investigate
1. **Does the adapter need a host-side init command before it emits Xinput reports?** The standard xpad driver on Linux sends a 3-byte LED init `0x01 0x03 0xNN` on the interrupt OUT endpoint. Newer 8BitDo adapter firmware may need different init. If yes, what bytes?
2. **Is Xbox 360 security handshake required?** Interface #3 (proto=19, 0 endpoints) is the security/challenge interface. If the adapter demands `0xC1/0x01` and `0x41/0xA9` control transfers with valid responses, we'd need a reverse-engineered response generator
3. **Is `bulkTransfer` returning -1 immediately because the endpoint is halted, or because of a driver-state bug from the force-detach?** Could try `UsbRequest` async API instead. Could try issuing `connection.controlTransfer()` to send `CLEAR_FEATURE(ENDPOINT_HALT)` first
4. **Why does `MainActivity` appear to recreate on every `USB_DEVICE_ATTACHED` delivery despite `launchMode="singleTop"`?** Samsung OneUI might override. Could test: remove the manifest intent filter and rely purely on the dynamic receiver while the app is foregrounded; accept that the adapter must be plugged in AFTER the app is running. Or: move the USB handling out of the Activity entirely, into a `Service` so it survives recreation
5. **Is there a way to stop Samsung's kernel from ever binding to these vid/pids in the first place?** `UsbManager.requestPermission()` with "use by default" stores a persistent preference; if checked once, future attaches should route silently. Whether Samsung honors it is unclear
6. **Is the `hid-nintendo` kernel driver path actually fixable via some sysfs write that shell can reach via a different SELinux domain?** We confirmed `u:r:shell:s0` is denied on `/sys/bus/hid/devices/0005:057E:2007.0007/`. Maybe there's a different path
7. **Does the macOS-mode firmware (DS4 emulation, `0x054C/0x05C4`) ever appear on Android?** We only ever saw it on the Mac. If it were to appear on Android, Android has native DS4 support and it would Just Work — but the adapter's auto-detect never picks DS4 mode on this phone. Unclear why
## Reboot state
When the user hands off, the Samsung phone's USB host controller may still be wedged from the last session (`/sys/bus/usb/devices/` was empty when checked). A phone reboot is required to recover USB host mode before further testing. The adb connection is currently wireless (`adb connect adb-RFCY71JW54X-E9025V._adb-tls-connect._tcp`); after reboot that may need re-pairing via Developer Options → Wireless debugging.
## Files to read first for the next agent
1. `app/src/main/java/com/lazy/emulate/input/ControllerManager.kt` — the USB claim + Xinput reader code, ~600 lines
2. `app/src/main/java/com/lazy/emulate/MainActivity.kt` — the receiver + lifecycle
3. `app/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerTestScreen.kt` — the debug UI
4. `app/src/main/AndroidManifest.xml` + `res/xml/usb_device_filter.xml` — the filter setup
5. `app/src/main/java/com/lazy/emulate/input/GamepadButton.kt` — the app's canonical button enum
## To reproduce the test quickly
```bash
cd /Users/matt/code/android/emulate
./gradlew :app:installDebug
adb shell am start -n com.lazy.emulate/.MainActivity
# In app: Settings → Test Controller
# Plug in adapter (blue mode, Joy-Con paired to it)
adb logcat -s ControllerManager:D
```
Good luck to whoever picks this up.