The handoff doc was the starting point for this session's controller work — a list of open questions about getting a Joy-Con working on the Samsung Fold 6 via the 8BitDo adapter. Those questions have been answered (the working path is PS-Classic mode) and the adapter approach is being replaced entirely by a DIY ESP32 bridge. Move the doc into docs/ so it's not sitting at the repo root, rename it to reflect that it's archaeology rather than an active handoff, and add a banner at the top pointing at joycon-esp32-bridge.md for the current plan.
19 KiB
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
usbhidactually binds the device as a real gamepad. That solution ships today inControllerManager.ktand works, but the adapter wedges every 60–120 seconds due to Samsung's OTG selective suspend. See the commit log onfeature/joyconfor 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.mdand 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-nintendodriver getevent -plshows TWO evdev nodes created:event16 "Nintendo Switch Right Joy-Con"(with onlyBTN_TL,BTN_TL2in its key capability set — no face buttons, no dpad, no stick) andevent17 "Nintendo Switch Right Joy-Con IMU"(accelerometer + gyro)- IMU node actively streams
MSC_TIMESTAMPevents — 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/event16while 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-nintendodriver but no userspace equivalent of Linuxjoycondto 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 toadb shell(SELinuxu:r:shell:s0) even though shell is in theinputgroup. 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)andinterfaceClass=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, sousbhidnever binds and nothing ever reaches theInputDevice/KeyEventlayer despite both modes showing up inInputDevice.getDeviceIds()withSOURCE_GAMEPAD | SOURCE_JOYSTICKbits 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.xmllists the four vid/pids we've seen (0x2DC8/0x3106,0x2DC8/0x3107,0x045E/0x028E,0x054C/0x05C4)AndroidManifest.xmlhas<intent-filter>forUSB_DEVICE_ATTACHED+<meta-data>pointing to the filter, withlaunchMode="singleTop"onMainActivityMainActivity.usbAttachReceiveris a dynamicBroadcastReceiverregistered inonResumeforACTION_USB_DEVICE_ATTACHED; unregistered inonPause. AlsoonNewIntent → handleUsbAttachIntentfor manifest-delivered intentsControllerManager.onUsbDeviceAttached(device)filters to target vid/pids, requests permission viaPendingIntent/usbPermissionReceiver, callsopenDevice()+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)andUSB 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.548followed by no further attach events for the target vid/pids. Only a singleignoring 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-1almost instantly (~10 ms per call, ~100 consecutive errors in 1 second). Reader exits via the "101 consecutive errors" bailout withpackets=0. Never received a single report. Endpoint address 0x81 is correct (IN bit + endpoint 1, standard Xbox 360 layout). Interrupt vs bulk transfer type: AndroidUsbDeviceConnectionhas nointerruptTransfer—bulkTransferhandles both - Hypothesis for zero reads: Xbox 360 controllers (real and emulated) sometimes require an LED init command (
0x01 0x03 0xNNon 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, everyUSB_DEVICE_ATTACHEDdelivered via the filter seems to tear down and recreateMainActivityon Samsung OneUI, callingonDestroy → releaseClaim → hasActiveClaim=false. Each recreate starts a newControllerManager, a new claim, a new reader thread. Old reader threads cannot be joined cleanly because the oldUsbDeviceConnectionfrom the destroyed activity is stuck inbulkTransfer. Thread leak → OOM atVmSize ~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 :1flag 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 nowMAX_EVENT_LOG_LINES = 300AnalogSnapshotdata classUsbDeviceInfo,UsbInterfaceInfodata classes- StateFlows:
connectedControllers,activeController,rawEventLog,pressedButtons,analogSnapshot,usbDevices usbPermissionAction,usbPermissionReceiver, registered/unregistered instart/stopClaimedDevicedata class +claimedDevicefieldhasActiveClaim: Boolean— intended to prevent thread spam, effectively defeated by Activity recreationtargetVidPids: Set<Pair<Int, Int>>— the four target vid/pidslastAttachTimeByKey,attachThrottleMs = 500L— per-vid/pid debouncepermissionRequestedFor: MutableSet<String>— one-shot permission request per keyxinputReaderThread: Thread?,xinputReaderStop: AtomicBooleanxinputPrevButtons,xinputPrevL2,xinputPrevR2- Xinput bit constants +
xinputBitToButtonmapping list onUsbDeviceAttached(device)— target filter →hasActiveClaimcheck → already-claimed check → throttle → permission request →openDevice→claimInterface(iface, force=true)on first iface matching0xFF/0x5D/0x01→startXinputReadertryClaimAlreadyConnectedUsb()— called fromonResumesweepreleaseClaim()— sets stop flag, releases interface, closes connection, joins reader thread, clears statestartXinputReader(connection, interfaces)— finds IN endpoint, spawns daemon thread, callsbulkTransferin a loop, parses Xinput reports, exits after 100 consecutive errorsparseXinputReport(buf)— 20-byte Xinput packet parser, dispatches buttons + analog sticks with Y-negated to match Android axis conventiondispatchButton(button, isDown)— updates_pressedButtons+ invokesonButtonEvent. Used by both the Android key path and the Xinput reader pathrefreshUsbDevices()— iteratesusbManager.deviceList, buildsUsbDeviceInfosnapshotsappendEventLog(line)— ring buffer + mirrors toLog.d(TAG, "LOG: $line")so everything visible inadb logcat -s ControllerManager:DisGameController(device)— now also accepts any device withVENDOR_NINTENDOvendor id as a fallback (unused in practice; can be reverted)handleKeyEvent/handleMotionEvent— have extensive debugLog.dcalls 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
- 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 0xNNon the interrupt OUT endpoint. Newer 8BitDo adapter firmware may need different init. If yes, what bytes? - Is Xbox 360 security handshake required? Interface #3 (proto=19, 0 endpoints) is the security/challenge interface. If the adapter demands
0xC1/0x01and0x41/0xA9control transfers with valid responses, we'd need a reverse-engineered response generator - Is
bulkTransferreturning -1 immediately because the endpoint is halted, or because of a driver-state bug from the force-detach? Could tryUsbRequestasync API instead. Could try issuingconnection.controlTransfer()to sendCLEAR_FEATURE(ENDPOINT_HALT)first - Why does
MainActivityappear to recreate on everyUSB_DEVICE_ATTACHEDdelivery despitelaunchMode="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 aServiceso it survives recreation - 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 - Is the
hid-nintendokernel driver path actually fixable via some sysfs write that shell can reach via a different SELinux domain? We confirmedu:r:shell:s0is denied on/sys/bus/hid/devices/0005:057E:2007.0007/. Maybe there's a different path - 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
app/src/main/java/com/lazy/emulate/input/ControllerManager.kt— the USB claim + Xinput reader code, ~600 linesapp/src/main/java/com/lazy/emulate/MainActivity.kt— the receiver + lifecycleapp/src/main/java/com/lazy/emulate/ui/screens/controller/ControllerTestScreen.kt— the debug UIapp/src/main/AndroidManifest.xml+res/xml/usb_device_filter.xml— the filter setupapp/src/main/java/com/lazy/emulate/input/GamepadButton.kt— the app's canonical button enum
To reproduce the test quickly
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.