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:
@@ -13,6 +13,8 @@
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -28,12 +30,14 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation|uiMode"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.lazy.emulate
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -26,6 +31,15 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private lateinit var controllerManager: ControllerManager
|
||||
private lateinit var gameRepository: GameRepository
|
||||
|
||||
private val usbAttachReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != UsbManager.ACTION_USB_DEVICE_ATTACHED) return
|
||||
@Suppress("DEPRECATION")
|
||||
val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (device != null) controllerManager.onUsbDeviceAttached(device)
|
||||
}
|
||||
}
|
||||
lateinit var preferencesManager: PreferencesManager
|
||||
private set
|
||||
lateinit var buttonMappingManager: ButtonMappingManager
|
||||
@@ -44,6 +58,9 @@ class MainActivity : ComponentActivity() {
|
||||
gameRepository = GameRepository(this)
|
||||
EmulationEngine.buttonMappingManager = buttonMappingManager
|
||||
|
||||
// Note: we deliberately do NOT call handleUsbAttachIntent(intent) here.
|
||||
// start() hasn't registered the permission receiver yet, and onResume() will
|
||||
// do a usbManager.deviceList sweep that picks up any device that's already attached.
|
||||
setContent {
|
||||
EmulateTheme {
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
@@ -64,6 +81,16 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
controllerManager.start()
|
||||
// Silently receive USB attach events while the app is foregrounded — no dialogs.
|
||||
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(usbAttachReceiver, filter, RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
registerReceiver(usbAttachReceiver, filter)
|
||||
}
|
||||
// Also sweep for any matching device that's already connected when we resume.
|
||||
controllerManager.tryClaimAlreadyConnectedUsb()
|
||||
if (Environment.isExternalStorageManager()) {
|
||||
gameRepository.scanGameFolders()
|
||||
}
|
||||
@@ -71,9 +98,29 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
try { unregisterReceiver(usbAttachReceiver) } catch (_: Throwable) {}
|
||||
controllerManager.stop()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
controllerManager.releaseClaim()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleUsbAttachIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleUsbAttachIntent(intent: Intent?) {
|
||||
if (intent?.action != UsbManager.ACTION_USB_DEVICE_ATTACHED) return
|
||||
@Suppress("DEPRECATION")
|
||||
val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
if (device != null) {
|
||||
controllerManager.onUsbDeviceAttached(device)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAllFilesPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager()) {
|
||||
val intent = Intent(
|
||||
@@ -84,18 +131,16 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (event != null && controllerManager.handleKeyEvent(event)) return true
|
||||
return super.onKeyDown(keyCode, event)
|
||||
// Compose's root view installs its own key-event handler and will consume events
|
||||
// before Activity.onKeyDown/onKeyUp run. Intercept at dispatchKeyEvent instead so
|
||||
// gamepad input always reaches ControllerManager.
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (controllerManager.handleKeyEvent(event)) return true
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (event != null && controllerManager.handleKeyEvent(event)) return true
|
||||
return super.onKeyUp(keyCode, event)
|
||||
}
|
||||
|
||||
override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
|
||||
if (event != null && controllerManager.handleMotionEvent(event)) return true
|
||||
return super.onGenericMotionEvent(event)
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (controllerManager.handleMotionEvent(event)) return true
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,25 @@ class CoverArtManager(private val context: Context) {
|
||||
|
||||
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 {
|
||||
add(gameTitle)
|
||||
val expanded = expandRegions(gameTitle)
|
||||
expanded.filter { it != gameTitle }.forEach { add(it) }
|
||||
val cleaned = cleanTitle(gameTitle)
|
||||
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) {
|
||||
@@ -45,10 +57,12 @@ class CoverArtManager(private val context: Context) {
|
||||
.replace("+", "%20")
|
||||
val url = "$BASE_URL/${consoleType.libretroThumbnailRepo}/master/Named_Boxarts/$encoded.png"
|
||||
if (downloadFile(url, dest)) {
|
||||
Log.d(TAG, "Cover art hit for '$gameTitle' via '$name'")
|
||||
return dest.absolutePath
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "No cover art match for '$gameTitle' after ${namesToTry.size} attempts")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -89,13 +103,14 @@ class CoverArtManager(private val context: Context) {
|
||||
private const val TAG = "CoverArtManager"
|
||||
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(
|
||||
"(U)" to listOf("(USA, Europe)", "(USA)"),
|
||||
"(E)" to listOf("(Europe)", "(Europe, Australia)"),
|
||||
"(J)" to listOf("(Japan)", "(Japan, USA)"),
|
||||
"(UE)" to listOf("(USA, Europe)"),
|
||||
"(JU)" to listOf("(Japan, USA)"),
|
||||
"(U)" to listOf("(USA)", "(USA, Europe)", "(World)"),
|
||||
"(E)" to listOf("(Europe)", "(Europe, Australia)", "(World)"),
|
||||
"(J)" to listOf("(Japan)", "(Japan, USA)", "(World)"),
|
||||
"(UE)" to listOf("(USA, Europe)", "(USA)", "(World)"),
|
||||
"(JU)" to listOf("(Japan, USA)", "(USA)", "(Japan)"),
|
||||
"(W)" to listOf("(World)"),
|
||||
"(F)" to listOf("(France)"),
|
||||
"(G)" to listOf("(Germany)"),
|
||||
@@ -104,6 +119,19 @@ class CoverArtManager(private val context: Context) {
|
||||
"(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> {
|
||||
val stripped = title
|
||||
.replace(Regex("\\s*\\[[^]]*]"), "") // strip flags like [!], [b], [p1]
|
||||
|
||||
@@ -102,17 +102,26 @@ class GameRepository(private val context: Context) {
|
||||
val gamesNeedingArt = _games.value.filter { it.coverArtPath == null }
|
||||
if (gamesNeedingArt.isEmpty()) return
|
||||
|
||||
val semaphore = Semaphore(8)
|
||||
// Concurrency was 8 — combined with parallel bitmap decodes that pinned the heap and
|
||||
// caused OOM crashes on launch. 2 is plenty for a background fetch.
|
||||
val semaphore = Semaphore(2)
|
||||
scope.launch {
|
||||
gamesNeedingArt.map { game ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
try {
|
||||
val path = coverArtManager.fetchCoverArt(game.rawTitle, game.consoleType)
|
||||
if (path != null) {
|
||||
_games.value = _games.value.map {
|
||||
if (it.id == game.id) it.copy(coverArtPath = path) else it
|
||||
}
|
||||
}
|
||||
} catch (oom: OutOfMemoryError) {
|
||||
// Don't bring the whole app down for one bad cover art fetch.
|
||||
android.util.Log.w("GameRepository", "OOM fetching cover for ${game.title}")
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w("GameRepository", "cover fetch failed for ${game.title}: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
@@ -1,20 +1,605 @@
|
||||
package com.lazy.emulate.input
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.input.InputManager
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private const val TAG = "ControllerManager"
|
||||
|
||||
// Nintendo USB/BT vendor id — Joy-Cons, Pro Controller, NES/SNES Online controllers
|
||||
private const val VENDOR_NINTENDO = 0x057E
|
||||
|
||||
// 8BitDo USB vendor id — adapter exposes a variety of products under this VID
|
||||
// depending on current adapter mode (XInput/DInput/macOS/PS Classic).
|
||||
private const val VENDOR_8BITDO = 0x2DC8
|
||||
|
||||
// Max lines kept in the in-app raw event log (visible on ControllerTestScreen).
|
||||
private const val MAX_EVENT_LOG_LINES = 300
|
||||
|
||||
data class AnalogSnapshot(
|
||||
val lx: Float = 0f,
|
||||
val ly: Float = 0f,
|
||||
val rx: Float = 0f,
|
||||
val ry: Float = 0f,
|
||||
val hatX: Float = 0f,
|
||||
val hatY: Float = 0f
|
||||
)
|
||||
|
||||
data class UsbDeviceInfo(
|
||||
val deviceName: String,
|
||||
val vendorId: Int,
|
||||
val productId: Int,
|
||||
val deviceClass: Int,
|
||||
val deviceSubclass: Int,
|
||||
val productName: String?,
|
||||
val manufacturerName: String?,
|
||||
val interfaces: List<UsbInterfaceInfo>
|
||||
)
|
||||
|
||||
data class UsbInterfaceInfo(
|
||||
val number: Int,
|
||||
val interfaceClass: Int,
|
||||
val interfaceSubclass: Int,
|
||||
val interfaceProtocol: Int,
|
||||
val endpointCount: Int
|
||||
)
|
||||
|
||||
class ControllerManager(
|
||||
context: Context,
|
||||
var buttonMappingManager: ButtonMappingManager? = null
|
||||
) : InputManager.InputDeviceListener {
|
||||
|
||||
private val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||
private val appContext = context.applicationContext
|
||||
private val inputManager = appContext.getSystemService(Context.INPUT_SERVICE) as InputManager
|
||||
private val usbManager = appContext.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
|
||||
private val usbPermissionAction = "${appContext.packageName}.USB_PERMISSION"
|
||||
|
||||
private val usbPermissionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != usbPermissionAction) return
|
||||
@Suppress("DEPRECATION")
|
||||
val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
appendEventLog("USB permission result: granted=$granted device=${device?.deviceName}")
|
||||
if (granted && device != null) {
|
||||
onUsbDeviceAttached(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
private var usbPermissionReceiverRegistered = false
|
||||
|
||||
private val _usbDevices = MutableStateFlow<List<UsbDeviceInfo>>(emptyList())
|
||||
val usbDevices: StateFlow<List<UsbDeviceInfo>> = _usbDevices.asStateFlow()
|
||||
|
||||
// USB claim experiment: when a target device is attached via the USB_DEVICE_ATTACHED
|
||||
// intent, we detach the kernel usbhid driver by calling claimInterface(force=true) and
|
||||
// hold the claim until release(). Theory: the adapter's mode-cycling is triggered by the
|
||||
// kernel driver's repeated probes. Holding the interface claim should stop the cycle.
|
||||
private data class ClaimedDevice(
|
||||
val device: UsbDevice,
|
||||
val connection: UsbDeviceConnection,
|
||||
val interfaces: List<UsbInterface>
|
||||
)
|
||||
private var claimedDevice: ClaimedDevice? = null
|
||||
|
||||
// Once we successfully claim any target device, refuse all subsequent attach events.
|
||||
// The adapter cycles modes rapidly when Android can't complete an Xinput handshake;
|
||||
// without this flag, each cycle would spawn a new reader thread and eventually OOM.
|
||||
private var hasActiveClaim = false
|
||||
|
||||
// Xinput reader state — runs on a background thread reading 20-byte reports from the
|
||||
// Xbox-360-style interrupt IN endpoint on interface #0, parsing them, and dispatching
|
||||
// GamepadButton / analog events into the normal event pipeline.
|
||||
private var xinputReaderThread: Thread? = null
|
||||
private val xinputReaderStop = AtomicBoolean(false)
|
||||
private var xinputPrevButtons = 0
|
||||
private var xinputPrevL2 = false
|
||||
private var xinputPrevR2 = false
|
||||
|
||||
// Xinput button bit layout
|
||||
private val XINPUT_DPAD_UP = 0x0001
|
||||
private val XINPUT_DPAD_DOWN = 0x0002
|
||||
private val XINPUT_DPAD_LEFT = 0x0004
|
||||
private val XINPUT_DPAD_RIGHT = 0x0008
|
||||
private val XINPUT_START = 0x0010
|
||||
private val XINPUT_BACK = 0x0020
|
||||
private val XINPUT_LTHUMB = 0x0040
|
||||
private val XINPUT_RTHUMB = 0x0080
|
||||
private val XINPUT_LB = 0x0100
|
||||
private val XINPUT_RB = 0x0200
|
||||
private val XINPUT_GUIDE = 0x0400
|
||||
private val XINPUT_A = 0x1000
|
||||
private val XINPUT_B = 0x2000
|
||||
private val XINPUT_X = 0x4000
|
||||
private val XINPUT_Y = 0x8000
|
||||
|
||||
private val xinputBitToButton: List<Pair<Int, GamepadButton>> = listOf(
|
||||
XINPUT_DPAD_UP to GamepadButton.DPAD_UP,
|
||||
XINPUT_DPAD_DOWN to GamepadButton.DPAD_DOWN,
|
||||
XINPUT_DPAD_LEFT to GamepadButton.DPAD_LEFT,
|
||||
XINPUT_DPAD_RIGHT to GamepadButton.DPAD_RIGHT,
|
||||
XINPUT_START to GamepadButton.START,
|
||||
XINPUT_BACK to GamepadButton.SELECT,
|
||||
XINPUT_LTHUMB to GamepadButton.L3,
|
||||
XINPUT_RTHUMB to GamepadButton.R3,
|
||||
XINPUT_LB to GamepadButton.L1,
|
||||
XINPUT_RB to GamepadButton.R1,
|
||||
XINPUT_A to GamepadButton.FACE_BOTTOM,
|
||||
XINPUT_B to GamepadButton.FACE_RIGHT,
|
||||
XINPUT_X to GamepadButton.FACE_LEFT,
|
||||
XINPUT_Y to GamepadButton.FACE_TOP
|
||||
)
|
||||
|
||||
// Debounce: only request USB permission once per vid/pid per session. The cycling adapter
|
||||
// generates ATTACH broadcasts ~5/sec, which otherwise floods Android with permission
|
||||
// dialogs and OOMs the app.
|
||||
private val permissionRequestedFor = mutableSetOf<String>()
|
||||
|
||||
// Also rate-limit onUsbDeviceAttached itself — the broadcast can fire dozens of times per
|
||||
// second. Drop calls that arrive within 500 ms of the previous one for the same vid/pid.
|
||||
private val lastAttachTimeByKey = mutableMapOf<String, Long>()
|
||||
private val attachThrottleMs = 500L
|
||||
|
||||
private val targetVidPids: Set<Pair<Int, Int>> = setOf(
|
||||
0x2DC8 to 0x3106, // 8BitDo Pro 2 Wired — D-input
|
||||
0x2DC8 to 0x3107, // 8BitDo IDLE state
|
||||
0x2DC8 to 0x3105, // 8BitDo ...?
|
||||
0x045E to 0x028E, // Xbox 360 controller — X-input
|
||||
0x045E to 0x02e0, // Xbox 360 controller — also X-input
|
||||
0x054C to 0x05C4 // Sony DualShock 4 — what the adapter latched to on macOS
|
||||
)
|
||||
|
||||
private fun isTargetDevice(device: UsbDevice): Boolean =
|
||||
(device.vendorId to device.productId) in targetVidPids
|
||||
|
||||
/** Sweep already-connected USB devices — called on resume. */
|
||||
fun tryClaimAlreadyConnectedUsb() {
|
||||
for (device in usbManager.deviceList.values) {
|
||||
if (isTargetDevice(device) && claimedDevice?.device?.deviceName != device.deviceName) {
|
||||
appendEventLog("USB sweep found target device ${device.deviceName}, attaching")
|
||||
onUsbDeviceAttached(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onUsbDeviceAttached(device: UsbDevice) {
|
||||
// Ignore USB events that aren't our target adapter.
|
||||
if (!isTargetDevice(device)) {
|
||||
Log.d(TAG, "ignoring non-target USB device v=${device.vendorId} p=${device.productId}")
|
||||
return
|
||||
}
|
||||
|
||||
// One claim per session. The cycling adapter otherwise thrashes threads and OOMs.
|
||||
if (hasActiveClaim) {
|
||||
Log.d(TAG, "hasActiveClaim=true, ignoring new attach for ${device.deviceName}")
|
||||
return
|
||||
}
|
||||
|
||||
// If we already hold a claim on this exact device, nothing to do.
|
||||
if (claimedDevice?.device?.deviceName == device.deviceName) {
|
||||
Log.d(TAG, "already claimed ${device.deviceName}, ignoring re-attach")
|
||||
return
|
||||
}
|
||||
|
||||
// Throttle: the cycling adapter fires ATTACH broadcasts every ~400 ms.
|
||||
// Drop duplicate calls within the throttle window to avoid flooding the system.
|
||||
val key = "${device.vendorId}:${device.productId}"
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastAttachTimeByKey[key] ?: 0L
|
||||
if (now - last < attachThrottleMs) {
|
||||
Log.d(TAG, "throttled re-attach for $key (${now - last}ms since last)")
|
||||
return
|
||||
}
|
||||
lastAttachTimeByKey[key] = now
|
||||
|
||||
appendEventLog(
|
||||
"USB ATTACH '${device.productName ?: device.deviceName}' " +
|
||||
"v=0x${"%04x".format(device.vendorId)} p=0x${"%04x".format(device.productId)}"
|
||||
)
|
||||
Log.d(TAG, "onUsbDeviceAttached ${device.deviceName} v=${device.vendorId} p=${device.productId}")
|
||||
|
||||
// Enumerate interfaces once so we can decide whether to claim.
|
||||
val allIfaces = (0 until device.interfaceCount).map { device.getInterface(it) }
|
||||
for (i in allIfaces) {
|
||||
appendEventLog(
|
||||
" iface #${i.id} class=${i.interfaceClass}(${usbClassName(i.interfaceClass)}) " +
|
||||
"sub=${i.interfaceSubclass} proto=${i.interfaceProtocol} eps=${i.endpointCount}"
|
||||
)
|
||||
}
|
||||
|
||||
// If any interface is standard HID (class=3), this is an 8BitDo in DInput / macOS /
|
||||
// PS-Classic mode. On Samsung Fold 6, the kernel's usbhid driver ONLY binds the
|
||||
// PS-Classic variant (vid=0x054c pid=0x0cda, Sony Interactive Entertainment Controller)
|
||||
// — DInput and macOS modes are silently refused. In PS-Classic mode Android creates
|
||||
// a proper GAMEPAD InputDevice and events flow through dispatchKeyEvent → handleKeyEvent.
|
||||
//
|
||||
// Switch the adapter to PS-Classic mode with Select + D-Pad Down (3 seconds).
|
||||
//
|
||||
// In any HID case we do NOT claim the interface: claiming detaches whatever kernel
|
||||
// driver may have bound and turns a working controller into a dead one.
|
||||
val hasHidInterface = allIfaces.any { it.interfaceClass == UsbConstants.USB_CLASS_HID }
|
||||
if (hasHidInterface) {
|
||||
appendEventLog(
|
||||
"USB device has HID interface — letting Android's usbhid driver handle it " +
|
||||
"(no claim). Use PS-Classic mode (Select+Down) if events don't appear."
|
||||
)
|
||||
hasActiveClaim = true
|
||||
refreshUsbDevices()
|
||||
return
|
||||
}
|
||||
|
||||
// Release any prior claim (e.g. the adapter just re-enumerated in a new mode).
|
||||
releaseClaim()
|
||||
|
||||
if (!usbManager.hasPermission(device)) {
|
||||
// Only request permission once per vid/pid — flooding Android with dialogs
|
||||
// causes OOM.
|
||||
if (key in permissionRequestedFor) {
|
||||
appendEventLog("USB permission still missing, already requested once — skipping")
|
||||
return
|
||||
}
|
||||
permissionRequestedFor.add(key)
|
||||
appendEventLog("USB permission missing — requesting (one-shot)")
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
val pi = PendingIntent.getBroadcast(
|
||||
appContext,
|
||||
0,
|
||||
Intent(usbPermissionAction).apply { setPackage(appContext.packageName) },
|
||||
flags
|
||||
)
|
||||
try {
|
||||
usbManager.requestPermission(device, pi)
|
||||
} catch (t: Throwable) {
|
||||
appendEventLog("requestPermission threw: ${t.message}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val connection = try {
|
||||
usbManager.openDevice(device)
|
||||
} catch (t: Throwable) {
|
||||
appendEventLog("USB openDevice threw: ${t.message}")
|
||||
return
|
||||
}
|
||||
if (connection == null) {
|
||||
appendEventLog("USB openDevice returned null")
|
||||
return
|
||||
}
|
||||
|
||||
// Only claim the main controller input interface (vendor-spec, subclass 0x5D,
|
||||
// protocol 0x01). Claiming the headset / security interfaces appears to trigger
|
||||
// re-enumeration on the adapter.
|
||||
val mainIface = allIfaces.firstOrNull {
|
||||
it.interfaceClass == 0xFF && it.interfaceSubclass == 0x5D && it.interfaceProtocol == 0x01
|
||||
} ?: allIfaces.firstOrNull()
|
||||
|
||||
if (mainIface == null) {
|
||||
appendEventLog("USB no interfaces to claim, closing")
|
||||
connection.close()
|
||||
return
|
||||
}
|
||||
|
||||
val ok = try {
|
||||
connection.claimInterface(mainIface, true)
|
||||
} catch (t: Throwable) {
|
||||
appendEventLog("claimInterface #${mainIface.id} threw: ${t.message}")
|
||||
false
|
||||
}
|
||||
appendEventLog(
|
||||
"USB claimInterface #${mainIface.id} " +
|
||||
"class=${mainIface.interfaceClass} sub=${mainIface.interfaceSubclass} " +
|
||||
"proto=${mainIface.interfaceProtocol} → ${if (ok) "OK (kernel detached)" else "FAIL"}"
|
||||
)
|
||||
if (!ok) {
|
||||
connection.close()
|
||||
return
|
||||
}
|
||||
|
||||
claimedDevice = ClaimedDevice(device, connection, listOf(mainIface))
|
||||
hasActiveClaim = true
|
||||
appendEventLog("USB holding claim on 1 interface.")
|
||||
startXinputReader(connection, listOf(mainIface))
|
||||
// Refresh the in-app USB list so the UI reflects the new state.
|
||||
refreshUsbDevices()
|
||||
}
|
||||
|
||||
fun releaseClaim() {
|
||||
val c = claimedDevice ?: return
|
||||
appendEventLog("USB releasing claim on '${c.device.productName ?: c.device.deviceName}'")
|
||||
|
||||
// Close the connection BEFORE joining the reader thread — this unblocks any
|
||||
// in-flight bulkTransfer call so the thread can actually exit.
|
||||
xinputReaderStop.set(true)
|
||||
for (iface in c.interfaces) {
|
||||
try {
|
||||
c.connection.releaseInterface(iface)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "releaseInterface failed", t)
|
||||
}
|
||||
}
|
||||
try {
|
||||
c.connection.close()
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "connection.close failed", t)
|
||||
}
|
||||
|
||||
// Now that the connection is dead, the reader's bulkTransfer will fail fast.
|
||||
stopXinputReader()
|
||||
|
||||
claimedDevice = null
|
||||
hasActiveClaim = false
|
||||
// Clear any buttons left pressed by the reader thread.
|
||||
_pressedButtons.value = emptySet()
|
||||
_analogSnapshot.value = AnalogSnapshot()
|
||||
xinputPrevButtons = 0
|
||||
xinputPrevL2 = false
|
||||
xinputPrevR2 = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the interrupt IN endpoint on the Xbox-360-style main controller interface
|
||||
* (class 0xFF / subclass 0x5D / protocol 0x01) and spin up a reader thread.
|
||||
*/
|
||||
private fun startXinputReader(connection: UsbDeviceConnection, interfaces: List<UsbInterface>) {
|
||||
// Main controller interface: vendor-specific, subclass 0x5D, protocol 0x01.
|
||||
val mainIface = interfaces.firstOrNull {
|
||||
it.interfaceClass == 0xFF && it.interfaceSubclass == 0x5D && it.interfaceProtocol == 0x01
|
||||
} ?: interfaces.firstOrNull()
|
||||
if (mainIface == null) {
|
||||
appendEventLog("Xinput reader: no suitable interface")
|
||||
return
|
||||
}
|
||||
var inEndpoint: UsbEndpoint? = null
|
||||
var outEndpoint: UsbEndpoint? = null
|
||||
for (i in 0 until mainIface.endpointCount) {
|
||||
val ep = mainIface.getEndpoint(i)
|
||||
if (ep.direction == UsbConstants.USB_DIR_IN &&
|
||||
(ep.type == UsbConstants.USB_ENDPOINT_XFER_INT || ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK)
|
||||
) {
|
||||
inEndpoint = ep
|
||||
}
|
||||
if (ep.direction == UsbConstants.USB_DIR_OUT &&
|
||||
(ep.type == UsbConstants.USB_ENDPOINT_XFER_INT || ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK)
|
||||
) {
|
||||
outEndpoint = ep
|
||||
}
|
||||
}
|
||||
if (inEndpoint == null) {
|
||||
appendEventLog("Xinput reader: no IN endpoint on iface #${mainIface.id}")
|
||||
return
|
||||
}
|
||||
appendEventLog(
|
||||
"Xinput reader starting on iface #${mainIface.id} " +
|
||||
"endpoint addr=0x${"%02x".format(inEndpoint.address)} " +
|
||||
"maxPkt=${inEndpoint.maxPacketSize}"
|
||||
)
|
||||
|
||||
// Xbox 360 wired controllers need an LED init before they emit reports.
|
||||
// The real command is an interrupt OUT write (NOT a HID SET_REPORT), so we use
|
||||
// bulkTransfer against the OUT endpoint — UsbDeviceConnection.bulkTransfer handles
|
||||
// both bulk and interrupt endpoints.
|
||||
if (outEndpoint != null) {
|
||||
// LED pattern 0x06 = player 1 blinking → solid
|
||||
val ledCommand = byteArrayOf(0x01, 0x03, 0x06)
|
||||
val sent = connection.bulkTransfer(outEndpoint, ledCommand, ledCommand.size, 100)
|
||||
appendEventLog("Xinput LED init bulkTransfer(OUT) → $sent bytes")
|
||||
}
|
||||
|
||||
xinputPrevButtons = 0
|
||||
xinputPrevL2 = false
|
||||
xinputPrevR2 = false
|
||||
xinputReaderStop.set(false)
|
||||
val ep = inEndpoint
|
||||
xinputReaderThread = thread(name = "XinputReader", isDaemon = true) {
|
||||
val buf = ByteArray(32)
|
||||
var consecutiveErrors = 0
|
||||
var totalPackets = 0
|
||||
try {
|
||||
while (!xinputReaderStop.get()) {
|
||||
val n = try {
|
||||
connection.bulkTransfer(ep, buf, buf.size, 200)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "bulkTransfer threw", t)
|
||||
-1
|
||||
}
|
||||
when {
|
||||
n >= 20 && buf[0].toInt() == 0x00 && buf[1].toInt() == 0x14 -> {
|
||||
consecutiveErrors = 0
|
||||
totalPackets++
|
||||
if (totalPackets == 1) {
|
||||
Log.d(TAG, "XinputReader: first valid packet")
|
||||
}
|
||||
parseXinputReport(buf)
|
||||
}
|
||||
n >= 0 -> {
|
||||
// Short / non-input packet (heartbeat, LED status, etc).
|
||||
consecutiveErrors = 0
|
||||
}
|
||||
else -> {
|
||||
consecutiveErrors++
|
||||
// After sustained failures the device is gone. Exit the thread
|
||||
// instead of spinning forever.
|
||||
if (consecutiveErrors > 100) {
|
||||
Log.w(TAG, "XinputReader: $consecutiveErrors consecutive errors, giving up")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Log.d(TAG, "XinputReader thread exiting (packets=$totalPackets)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopXinputReader() {
|
||||
if (xinputReaderThread == null) return
|
||||
xinputReaderStop.set(true)
|
||||
try {
|
||||
xinputReaderThread?.join(500)
|
||||
} catch (_: InterruptedException) {}
|
||||
xinputReaderThread = null
|
||||
}
|
||||
|
||||
private fun parseXinputReport(buf: ByteArray) {
|
||||
// buf[0] = 0x00 (type), buf[1] = 0x14 (len)
|
||||
val buttons = (buf[2].toInt() and 0xFF) or ((buf[3].toInt() and 0xFF) shl 8)
|
||||
val lTrigger = buf[4].toInt() and 0xFF
|
||||
val rTrigger = buf[5].toInt() and 0xFF
|
||||
val lx = readInt16LE(buf, 6)
|
||||
val ly = readInt16LE(buf, 8)
|
||||
val rx = readInt16LE(buf, 10)
|
||||
val ry = readInt16LE(buf, 12)
|
||||
|
||||
// Button diff + dispatch
|
||||
val changed = buttons xor xinputPrevButtons
|
||||
if (changed != 0) {
|
||||
for ((bit, gamepadBtn) in xinputBitToButton) {
|
||||
if (changed and bit != 0) {
|
||||
val nowDown = (buttons and bit) != 0
|
||||
dispatchButton(gamepadBtn, nowDown)
|
||||
}
|
||||
}
|
||||
xinputPrevButtons = buttons
|
||||
}
|
||||
|
||||
// Triggers as discrete L2/R2 with a deadzone threshold
|
||||
val l2Down = lTrigger > 30
|
||||
val r2Down = rTrigger > 30
|
||||
if (l2Down != xinputPrevL2) {
|
||||
dispatchButton(GamepadButton.L2, l2Down)
|
||||
xinputPrevL2 = l2Down
|
||||
}
|
||||
if (r2Down != xinputPrevR2) {
|
||||
dispatchButton(GamepadButton.R2, r2Down)
|
||||
xinputPrevR2 = r2Down
|
||||
}
|
||||
|
||||
// Sticks — normalize int16 → float [-1, 1] and negate Y to match Android conventions
|
||||
// (Xinput: Y positive = up; Android input: Y positive = down).
|
||||
val nLx = lx.toFloat() / 32767f
|
||||
val nLy = -ly.toFloat() / 32767f
|
||||
val nRx = rx.toFloat() / 32767f
|
||||
val nRy = -ry.toFloat() / 32767f
|
||||
|
||||
onAnalogEvent?.invoke(nLx, nLy, 0)
|
||||
onAnalogEvent?.invoke(nRx, nRy, 1)
|
||||
_analogSnapshot.value = AnalogSnapshot(nLx, nLy, nRx, nRy, 0f, 0f)
|
||||
}
|
||||
|
||||
private fun readInt16LE(buf: ByteArray, offset: Int): Int {
|
||||
val lo = buf[offset].toInt() and 0xFF
|
||||
val hi = buf[offset + 1].toInt() // preserve sign
|
||||
return (hi shl 8) or lo
|
||||
}
|
||||
|
||||
private fun dispatchButton(button: GamepadButton, isDown: Boolean) {
|
||||
if (isDown) {
|
||||
_pressedButtons.value = _pressedButtons.value + button
|
||||
} else {
|
||||
_pressedButtons.value = _pressedButtons.value - button
|
||||
}
|
||||
onButtonEvent?.invoke(button, isDown)
|
||||
}
|
||||
|
||||
fun refreshUsbDevices() {
|
||||
val snapshot = try {
|
||||
usbManager.deviceList.values.map { device -> device.toInfo() }
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "usbManager.deviceList threw", t)
|
||||
emptyList()
|
||||
}
|
||||
_usbDevices.value = snapshot
|
||||
appendEventLog("--- USB enumeration (${snapshot.size} device${if (snapshot.size == 1) "" else "s"}) ---")
|
||||
for (d in snapshot) {
|
||||
val classStr = usbClassName(d.deviceClass)
|
||||
appendEventLog(
|
||||
"USB '${d.productName ?: d.deviceName}' " +
|
||||
"v=0x${"%04x".format(d.vendorId)} p=0x${"%04x".format(d.productId)} " +
|
||||
"class=${d.deviceClass}($classStr) sub=${d.deviceSubclass} " +
|
||||
"ifaces=${d.interfaces.size}"
|
||||
)
|
||||
for (i in d.interfaces) {
|
||||
val iclassStr = usbClassName(i.interfaceClass)
|
||||
appendEventLog(
|
||||
" iface #${i.number} class=${i.interfaceClass}($iclassStr) " +
|
||||
"sub=${i.interfaceSubclass} proto=${i.interfaceProtocol} endpoints=${i.endpointCount}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun UsbDevice.toInfo(): UsbDeviceInfo {
|
||||
val interfaces = (0 until interfaceCount).map { idx ->
|
||||
val iface = getInterface(idx)
|
||||
UsbInterfaceInfo(
|
||||
number = iface.id,
|
||||
interfaceClass = iface.interfaceClass,
|
||||
interfaceSubclass = iface.interfaceSubclass,
|
||||
interfaceProtocol = iface.interfaceProtocol,
|
||||
endpointCount = iface.endpointCount
|
||||
)
|
||||
}
|
||||
return UsbDeviceInfo(
|
||||
deviceName = deviceName,
|
||||
vendorId = vendorId,
|
||||
productId = productId,
|
||||
deviceClass = deviceClass,
|
||||
deviceSubclass = deviceSubclass,
|
||||
productName = try { productName } catch (t: Throwable) { null },
|
||||
manufacturerName = try { manufacturerName } catch (t: Throwable) { null },
|
||||
interfaces = interfaces
|
||||
)
|
||||
}
|
||||
|
||||
private fun usbClassName(c: Int): String = when (c) {
|
||||
UsbConstants.USB_CLASS_PER_INTERFACE -> "per-interface"
|
||||
UsbConstants.USB_CLASS_AUDIO -> "audio"
|
||||
UsbConstants.USB_CLASS_COMM -> "comm"
|
||||
UsbConstants.USB_CLASS_HID -> "HID"
|
||||
UsbConstants.USB_CLASS_PHYSICA -> "physical"
|
||||
UsbConstants.USB_CLASS_STILL_IMAGE -> "still-image"
|
||||
UsbConstants.USB_CLASS_PRINTER -> "printer"
|
||||
UsbConstants.USB_CLASS_MASS_STORAGE -> "mass-storage"
|
||||
UsbConstants.USB_CLASS_HUB -> "hub"
|
||||
UsbConstants.USB_CLASS_CDC_DATA -> "cdc-data"
|
||||
UsbConstants.USB_CLASS_CSCID -> "smartcard"
|
||||
UsbConstants.USB_CLASS_CONTENT_SEC -> "content-sec"
|
||||
UsbConstants.USB_CLASS_VIDEO -> "video"
|
||||
UsbConstants.USB_CLASS_WIRELESS_CONTROLLER -> "wireless-ctl"
|
||||
UsbConstants.USB_CLASS_MISC -> "misc"
|
||||
UsbConstants.USB_CLASS_APP_SPEC -> "app-specific"
|
||||
UsbConstants.USB_CLASS_VENDOR_SPEC -> "VENDOR-SPEC (xinput?)"
|
||||
0 -> "use-iface-class"
|
||||
else -> "class-$c"
|
||||
}
|
||||
|
||||
private val _connectedControllers = MutableStateFlow<List<GameController>>(emptyList())
|
||||
val connectedControllers: StateFlow<List<GameController>> = _connectedControllers.asStateFlow()
|
||||
@@ -22,22 +607,79 @@ class ControllerManager(
|
||||
private val _activeController = MutableStateFlow<GameController?>(null)
|
||||
val activeController: StateFlow<GameController?> = _activeController.asStateFlow()
|
||||
|
||||
// Raw-event ring buffer shown on ControllerTestScreen. Mirrors the logcat debug output.
|
||||
private val _rawEventLog = MutableStateFlow<List<String>>(emptyList())
|
||||
val rawEventLog: StateFlow<List<String>> = _rawEventLog.asStateFlow()
|
||||
|
||||
// Currently pressed mapped buttons — drives the live indicator on the test screen.
|
||||
private val _pressedButtons = MutableStateFlow<Set<GamepadButton>>(emptySet())
|
||||
val pressedButtons: StateFlow<Set<GamepadButton>> = _pressedButtons.asStateFlow()
|
||||
|
||||
// Most recent axis values, for analog readout on the test screen.
|
||||
private val _analogSnapshot = MutableStateFlow(AnalogSnapshot())
|
||||
val analogSnapshot: StateFlow<AnalogSnapshot> = _analogSnapshot.asStateFlow()
|
||||
|
||||
private val timestampFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
|
||||
|
||||
var onButtonEvent: ((GamepadButton, Boolean) -> Unit)? = null
|
||||
var onAnalogEvent: ((Float, Float, Int) -> Unit)? = null
|
||||
|
||||
fun clearEventLog() {
|
||||
_rawEventLog.value = emptyList()
|
||||
}
|
||||
|
||||
private fun appendEventLog(line: String) {
|
||||
val stamped = "${timestampFormat.format(Date())} $line"
|
||||
// Mirror in-app log to logcat so we can see the full claim flow from adb.
|
||||
Log.d(TAG, "LOG: $line")
|
||||
val current = _rawEventLog.value
|
||||
val next = current + stamped
|
||||
_rawEventLog.value = if (next.size > MAX_EVENT_LOG_LINES) {
|
||||
next.takeLast(MAX_EVENT_LOG_LINES)
|
||||
} else {
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
inputManager.registerInputDeviceListener(this, null)
|
||||
refreshControllers()
|
||||
if (!usbPermissionReceiverRegistered) {
|
||||
val filter = IntentFilter(usbPermissionAction)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
appContext.registerReceiver(usbPermissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
appContext.registerReceiver(usbPermissionReceiver, filter)
|
||||
}
|
||||
usbPermissionReceiverRegistered = true
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
inputManager.unregisterInputDeviceListener(this)
|
||||
if (usbPermissionReceiverRegistered) {
|
||||
try { appContext.unregisterReceiver(usbPermissionReceiver) } catch (_: Throwable) {}
|
||||
usbPermissionReceiverRegistered = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshControllers() {
|
||||
val controllers = InputDevice.getDeviceIds()
|
||||
val allDevices = InputDevice.getDeviceIds()
|
||||
.toList()
|
||||
.mapNotNull { InputDevice.getDevice(it) }
|
||||
|
||||
// Log every device we see, so we can tell why the Joy-Con is or isn't being accepted.
|
||||
appendEventLog("--- device enumeration ---")
|
||||
for (d in allDevices) {
|
||||
val line = "DEV id=${d.id} '${d.name}' " +
|
||||
"v=0x${"%04x".format(d.vendorId)} p=0x${"%04x".format(d.productId)} " +
|
||||
"src=0x${"%08x".format(d.sources)} ext=${d.isExternal} ok=${isGameController(d)}"
|
||||
Log.d(TAG, line)
|
||||
appendEventLog(line)
|
||||
}
|
||||
|
||||
val controllers = allDevices
|
||||
.filter { isGameController(it) }
|
||||
.map { device ->
|
||||
GameController(
|
||||
@@ -55,47 +697,111 @@ class ControllerManager(
|
||||
}
|
||||
|
||||
fun handleKeyEvent(event: KeyEvent): Boolean {
|
||||
if (!isGameControllerDevice(event.device ?: return false)) return false
|
||||
val device = event.device ?: return false
|
||||
|
||||
val rawButton = mapKeyToButton(event.keyCode) ?: return false
|
||||
// Log every key event from every device (both to logcat and the in-app ring buffer)
|
||||
// so we can see exactly what a controller emits even when it isn't yet mapped.
|
||||
val actionStr = when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> "DOWN"
|
||||
KeyEvent.ACTION_UP -> "UP"
|
||||
else -> "A${event.action}"
|
||||
}
|
||||
val keyLine = "KEY $actionStr " +
|
||||
"dev=${device.id}('${device.name}') " +
|
||||
"v=0x${"%04x".format(device.vendorId)} p=0x${"%04x".format(device.productId)} " +
|
||||
"code=${event.keyCode}(${KeyEvent.keyCodeToString(event.keyCode)}) " +
|
||||
"scan=${event.scanCode}"
|
||||
Log.d(TAG, keyLine)
|
||||
appendEventLog(keyLine)
|
||||
|
||||
if (!isGameControllerDevice(device)) return false
|
||||
|
||||
val mapped = mapKeyToButton(event.keyCode) ?: return false
|
||||
// Per-device fixups for the 8BitDo USB Adapter 2 in PS-Classic mode (Sony Interactive
|
||||
// Entertainment Controller). The adapter routes the NES Joy-Con's buttons through a
|
||||
// 6-button-style layout that doesn't match what game cores expect:
|
||||
// - Joy-Con `-` / `+` come through as BTN_TL2/TR2 → KEYCODE_BUTTON_L2/R2. There are
|
||||
// no real triggers on this controller, so reinterpret those as Select/Start —
|
||||
// otherwise the Joy-Con can't send Select/Start at all (Home/Capture get eaten by
|
||||
// the OS via the Consumer Control sibling node).
|
||||
// - Joy-Con B (the bottom-right face button) comes through as BTN_C → BUTTON_C →
|
||||
// C_BUTTON. Most cores have no mapping for C_BUTTON, so remap it to FACE_BOTTOM
|
||||
// so it acts as the "south" face button (NES B / SNES B / PS Cross).
|
||||
val rawButton = if (isDpadOnPrimaryAxisDevice(device)) {
|
||||
when (mapped) {
|
||||
GamepadButton.L2 -> GamepadButton.SELECT
|
||||
GamepadButton.R2 -> GamepadButton.START
|
||||
GamepadButton.C_BUTTON -> GamepadButton.FACE_BOTTOM
|
||||
else -> mapped
|
||||
}
|
||||
} else mapped
|
||||
val button = buttonMappingManager?.remapControllerButton(rawButton) ?: rawButton
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> onButtonEvent?.invoke(button, true)
|
||||
KeyEvent.ACTION_UP -> onButtonEvent?.invoke(button, false)
|
||||
KeyEvent.ACTION_DOWN -> dispatchButton(button, true)
|
||||
KeyEvent.ACTION_UP -> dispatchButton(button, false)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun handleMotionEvent(event: MotionEvent): Boolean {
|
||||
if (!isGameControllerDevice(event.device ?: return false)) return false
|
||||
val device = event.device ?: return false
|
||||
|
||||
// Left stick
|
||||
val lx = event.getAxisValue(MotionEvent.AXIS_X)
|
||||
val ly = event.getAxisValue(MotionEvent.AXIS_Y)
|
||||
val rx = event.getAxisValue(MotionEvent.AXIS_Z)
|
||||
val ry = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||
val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
val anyNonZero = lx != 0f || ly != 0f || rx != 0f || ry != 0f || hatX != 0f || hatY != 0f
|
||||
|
||||
if (anyNonZero) {
|
||||
val motionLine = "MOT dev=${device.id}('${device.name}') " +
|
||||
"L=(${"%.2f".format(lx)},${"%.2f".format(ly)}) " +
|
||||
"R=(${"%.2f".format(rx)},${"%.2f".format(ry)}) " +
|
||||
"HAT=(${"%.2f".format(hatX)},${"%.2f".format(hatY)})"
|
||||
Log.d(TAG, motionLine)
|
||||
appendEventLog(motionLine)
|
||||
_analogSnapshot.value = AnalogSnapshot(lx, ly, rx, ry, hatX, hatY)
|
||||
}
|
||||
|
||||
if (!isGameControllerDevice(device)) return false
|
||||
|
||||
if (isDpadOnPrimaryAxisDevice(device)) {
|
||||
// Sony PS Classic / 8BitDo in PS-Classic mode reports its dpad as raw ABS_X/ABS_Y
|
||||
// (range 0..2, normalized to -1..+1) and has no real analog sticks. Route the
|
||||
// primary axis to the dpad handler instead of the left stick.
|
||||
handleDpadAxis(lx, ly)
|
||||
} else {
|
||||
if (lx != 0f || ly != 0f) {
|
||||
onAnalogEvent?.invoke(lx, ly, 0)
|
||||
}
|
||||
|
||||
// Right stick
|
||||
val rx = event.getAxisValue(MotionEvent.AXIS_Z)
|
||||
val ry = event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||
if (rx != 0f || ry != 0f) {
|
||||
onAnalogEvent?.invoke(rx, ry, 1)
|
||||
}
|
||||
|
||||
// D-pad via axes (some controllers report dpad as axis)
|
||||
val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X)
|
||||
val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
handleDpadAxis(hatX, hatY)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* True for devices whose dpad is reported on AXIS_X/AXIS_Y rather than AXIS_HAT_X/Y,
|
||||
* and which have no real analog sticks. Currently: 8BitDo USB Adapter 2 in PS-Classic
|
||||
* mode (Select+Down 3s), which impersonates the Sony Interactive Entertainment
|
||||
* Controller (vid=0x054C, pid=0x0CDA).
|
||||
*/
|
||||
private fun isDpadOnPrimaryAxisDevice(device: InputDevice): Boolean {
|
||||
return device.vendorId == 0x054C && device.productId == 0x0CDA
|
||||
}
|
||||
|
||||
private fun handleDpadAxis(hatX: Float, hatY: Float) {
|
||||
onButtonEvent?.invoke(GamepadButton.DPAD_LEFT, hatX < -0.5f)
|
||||
onButtonEvent?.invoke(GamepadButton.DPAD_RIGHT, hatX > 0.5f)
|
||||
onButtonEvent?.invoke(GamepadButton.DPAD_UP, hatY < -0.5f)
|
||||
onButtonEvent?.invoke(GamepadButton.DPAD_DOWN, hatY > 0.5f)
|
||||
// Route through dispatchButton so the test screen's _pressedButtons reflects dpad
|
||||
// state, the buttonMappingManager remap path applies, and the emulator core sees
|
||||
// the events via onButtonEvent — same path as physical key events.
|
||||
dispatchButton(GamepadButton.DPAD_LEFT, hatX < -0.5f)
|
||||
dispatchButton(GamepadButton.DPAD_RIGHT, hatX > 0.5f)
|
||||
dispatchButton(GamepadButton.DPAD_UP, hatY < -0.5f)
|
||||
dispatchButton(GamepadButton.DPAD_DOWN, hatY > 0.5f)
|
||||
}
|
||||
|
||||
fun setActiveController(controller: GameController) {
|
||||
@@ -106,8 +812,10 @@ class ControllerManager(
|
||||
return when (keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER -> GamepadButton.FACE_BOTTOM
|
||||
KeyEvent.KEYCODE_BUTTON_B -> GamepadButton.FACE_RIGHT
|
||||
KeyEvent.KEYCODE_BUTTON_C -> GamepadButton.C_BUTTON
|
||||
KeyEvent.KEYCODE_BUTTON_X -> GamepadButton.FACE_LEFT
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> GamepadButton.FACE_TOP
|
||||
KeyEvent.KEYCODE_BUTTON_Z -> GamepadButton.Z_BUTTON
|
||||
KeyEvent.KEYCODE_BUTTON_L1 -> GamepadButton.L1
|
||||
KeyEvent.KEYCODE_BUTTON_R1 -> GamepadButton.R1
|
||||
KeyEvent.KEYCODE_BUTTON_L2 -> GamepadButton.L2
|
||||
@@ -125,9 +833,21 @@ class ControllerManager(
|
||||
}
|
||||
|
||||
private fun isGameController(device: InputDevice): Boolean {
|
||||
if (device.isVirtual) return false
|
||||
val sources = device.sources
|
||||
return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD ||
|
||||
(sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|
||||
val isGamepad = (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
|
||||
val isJoystick = (sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|
||||
if (isGamepad || isJoystick) return true
|
||||
|
||||
// Nintendo Joy-Cons and the NES/SNES Online controllers frequently register as
|
||||
// keyboard-only on Android — accept them by vendor id so their events reach us.
|
||||
if (device.vendorId == VENDOR_NINTENDO) return true
|
||||
|
||||
// 8BitDo adapters in DInput mode (and bare 8BitDo controllers) sometimes don't
|
||||
// get GAMEPAD/JOYSTICK bits set on Samsung OneUI — accept by vendor id.
|
||||
if (device.vendorId == VENDOR_8BITDO) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isGameControllerDevice(device: InputDevice): Boolean = isGameController(device)
|
||||
|
||||
@@ -24,9 +24,12 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Scale
|
||||
import com.lazy.emulate.data.model.Game
|
||||
import java.io.File
|
||||
|
||||
@@ -55,8 +58,16 @@ fun GameCard(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
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(
|
||||
model = File(game.coverArtPath),
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(File(game.coverArtPath))
|
||||
.size(384)
|
||||
.scale(Scale.FILL)
|
||||
.build(),
|
||||
contentDescription = "${game.title} cover art",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.lazy.emulate.emulation.ConsoleType
|
||||
import com.lazy.emulate.input.ButtonMappingManager
|
||||
import com.lazy.emulate.input.ControllerManager
|
||||
import com.lazy.emulate.ui.screens.controller.ControllerLayoutScreen
|
||||
import com.lazy.emulate.ui.screens.controller.ControllerTestScreen
|
||||
import com.lazy.emulate.ui.screens.game.GameScreen
|
||||
import com.lazy.emulate.ui.screens.home.HomeScreen
|
||||
import com.lazy.emulate.ui.screens.settings.SettingsScreen
|
||||
@@ -51,6 +52,14 @@ fun EmulateNavGraph(
|
||||
controllerManager = controllerManager,
|
||||
preferencesManager = preferencesManager,
|
||||
buttonMappingManager = buttonMappingManager,
|
||||
onBack = { navController.popBackStack() },
|
||||
onTestController = { navController.navigate(Screen.ControllerTest.route) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Screen.ControllerTest.route) {
|
||||
ControllerTestScreen(
|
||||
controllerManager = controllerManager,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.lazy.emulate.ui.navigation
|
||||
sealed class Screen(val route: String) {
|
||||
data object Home : Screen("home")
|
||||
data object Settings : Screen("settings")
|
||||
data object ControllerTest : Screen("controller_test")
|
||||
data object ControllerLayout : Screen("controller_layout/{consoleType}") {
|
||||
fun createRoute(consoleType: String) = "controller_layout/$consoleType"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.lazy.emulate.ui.screens.game
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Handler
|
||||
@@ -7,6 +8,7 @@ import android.os.Looper
|
||||
import android.view.PixelCopy
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -109,6 +111,17 @@ fun GameScreen(
|
||||
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) {
|
||||
val loaded = EmulationEngine.startGame(context, game.consoleType, game.romPath)
|
||||
if (loaded != null) {
|
||||
|
||||
@@ -54,7 +54,8 @@ fun SettingsScreen(
|
||||
controllerManager: ControllerManager,
|
||||
preferencesManager: PreferencesManager,
|
||||
buttonMappingManager: ButtonMappingManager,
|
||||
onBack: () -> Unit
|
||||
onBack: () -> Unit,
|
||||
onTestController: () -> Unit = {}
|
||||
) {
|
||||
val controllers by controllerManager.connectedControllers.collectAsState()
|
||||
val activeController by controllerManager.activeController.collectAsState()
|
||||
@@ -143,6 +144,38 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Test Controller — live tester with raw event log
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onTestController)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Gamepad,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
"Test Controller",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
"Live button tester and raw event log",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Touch overlay section
|
||||
|
||||
17
app/src/main/res/xml/usb_device_filter.xml
Normal file
17
app/src/main/res/xml/usb_device_filter.xml
Normal 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
377
docs/joycon-esp32-bridge.md
Normal 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
|
||||
60–120 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. $8–12 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 ~10–20 hobby hours.
|
||||
- **Path A (BLE out)**: same Path B starting point plus 1–2 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 60–120 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.
|
||||
178
docs/joycon-investigation-history.md
Normal file
178
docs/joycon-investigation-history.md
Normal 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 60–120 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.
|
||||
Reference in New Issue
Block a user