# 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 `` for `USB_DEVICE_ATTACHED` + `` 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>` — the four target vid/pids - `lastAttachTimeByKey`, `attachThrottleMs = 500L` — per-vid/pid debounce - `permissionRequestedFor: MutableSet` — 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.