# 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.
