Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ as an ordinary gamepad.
- **Custom button mapping** — choose which button drives which emulator button, per console.
- **Live readout** of buttons, sticks, motion and battery, with each card in the controller's real
shell colour.
- **Third-party clones** — controllers that speak the console's protocol instead, tested with the
NYXI Hyperion 3 (see [protocol.md](docs/protocol.md#console-protocol-controllers)).

## Setup guide

Expand Down Expand Up @@ -242,6 +244,7 @@ Start with [CONTRIBUTING.md](CONTRIBUTING.md), then the [docs](docs/README.md):
- [Virtual gamepad](docs/virtual-gamepad.md) — UHID, keycodes, sideways Joy-Cons
- [DSU motion](docs/dsu-motion.md) — slots, motion frames, emulator mapping details
- [Debug tools](tools/README.md) — a DSU client for inspecting the stream
| Controllers drop when a game starts | Some phones clear background apps when a game launches. Set this app's battery use to unrestricted, and exclude it from the game launcher's cleanup |

## Credits

Expand Down
5 changes: 5 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@
# wiping every saved mapping on upgrade. Pin the names so the on-disk contract is stable.
-keepnames enum com.joegec.joycon2android.buttonmapping.** { *; }
-keepnames enum com.joegec.joycon2android.model.JoyconButton { *; }

# BluetoothGattCallback.onConnectionUpdated is hidden, so the platform calls it by name.
-keepclassmembers class * extends android.bluetooth.BluetoothGattCallback {
public void onConnectionUpdated(android.bluetooth.BluetoothGatt, int, int, int, int);
}
10 changes: 7 additions & 3 deletions app/src/main/java/com/joegec/joycon2android/AppContainer.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.joegec.joycon2android

import android.content.Context
import com.joegec.joycon2android.ble.HostBluetoothAddress
import com.joegec.joycon2android.connection.ConnectionPriorityRepository
import com.joegec.joycon2android.connection.ControllerRepository
import com.joegec.joycon2android.connection.DisconnectControllerUseCase
Expand Down Expand Up @@ -95,8 +96,12 @@ class AppContainer(context: Context) {
private val appContext = context.applicationContext
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

// --- Privileged access (Shizuku), shared by the gamepad and console-protocol pairing ---
private val privilegedAccess = PrivilegedAccess()
private val hostBluetoothAddress = HostBluetoothAddress(privilegedAccess::acquire)

// --- Connection (BLE) ---
private val joycon2Manager = Joycon2Manager(appContext, scope)
private val joycon2Manager = Joycon2Manager(appContext, scope, hostBluetoothAddress::read)
val controllerRepository: ControllerRepository = joycon2Manager
private val connectionPriorityRepository: ConnectionPriorityRepository = joycon2Manager
private val setHighConnectionPriority = SetHighConnectionPriorityUseCase(connectionPriorityRepository)
Expand Down Expand Up @@ -151,8 +156,7 @@ class AppContainer(context: Context) {
// --- Assignment ---
val assignmentRepository: AssignmentRepository = PlayerAssignmentManager()

// --- Gamepad + privileged access ---
private val privilegedAccess = PrivilegedAccess()
// --- Gamepad ---
private val gamepadRepository: GamepadRepository =
GamepadOutput(scope, GamepadManager(scope, appContext), privilegedAccess::acquire)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.joegec.joycon2android.ble

import com.joegec.joycon2android.gamepad.privileged.PrivilegedShell
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

/** The host address console-protocol pairing stores; apps are handed 02:00:00:00:00:00. Blocks. */
class HostBluetoothAddress(private val acquireShell: ((PrivilegedShell?) -> Unit) -> Unit) {

private companion object {
const val ACQUIRE_TIMEOUT_SECONDS = 30L
val ADDRESS_PATTERN = Regex("^([0-9A-F]{2}:){5}[0-9A-F]{2}$")
}

@Volatile private var cached: String? = null

fun read(): String? {
cached?.let { return it }
val shell = acquire() ?: return null
val process = shell.shell("settings get secure bluetooth_address") ?: return null
return try {
process.outputStream.close()
process.inputStream.bufferedReader().readText().trim().uppercase()
.takeIf { ADDRESS_PATTERN.matches(it) }
?.also { cached = it }
} catch (_: Exception) {
null
} finally {
process.destroy()
}
}

private fun acquire(): PrivilegedShell? {
val latch = CountDownLatch(1)
var shell: PrivilegedShell? = null
acquireShell {
shell = it
latch.countDown()
}
return if (latch.await(ACQUIRE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) shell else null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,15 @@ object BatteryGauge {
val fraction = (volts - lowVolts) / (highVolts - lowVolts)
return (lowPercent + fraction * (highPercent - lowPercent)).roundToInt()
}

/** Inverse of [percentFromVolts], for controllers that report a charge level instead of a voltage. */
fun voltsFromPercent(percent: Int): Float {
if (percent <= voltsToPercent.first().second) return voltsToPercent.first().first
if (percent >= voltsToPercent.last().second) return voltsToPercent.last().first
val upperIndex = voltsToPercent.indexOfFirst { (_, anchorPercent) -> percent < anchorPercent }
val (lowVolts, lowPercent) = voltsToPercent[upperIndex - 1]
val (highVolts, highPercent) = voltsToPercent[upperIndex]
val fraction = (percent - lowPercent).toFloat() / (highPercent - lowPercent)
return lowVolts + fraction * (highVolts - lowVolts)
}
}
76 changes: 76 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,82 @@ corrected values:
stores. Spans are seeded just under the smallest travel measured (~1180 LSB), so full tilt works
from the first packet, and only ever widen.

## Console-protocol controllers

Some third-party Joy-Con 2 clones (measured on a NYXI Hyperion 3, left and right, 2026-09) copy the
GATT table above but ignore the write characteristic and never notify on `...fd2`. They implement
only the side-specific channel a Switch 2 console uses, driven by `connection/console/`.

| Thing | Left | Right |
|---|---|---|
| Command write (no response) | `ce49a830-dced-48ae-931e-c8cf88aadbea` | `65a724b3-f1e7-4a61-8078-a342376b27ff` |
| Input notify | `cc1bbbb5-7354-4d32-a716-a81cb241a32a` | `d5a9e01e-2ffc-4cca-b20c-8b67142bf442` |
| Extended responses | `63a3810f-aec7-474b-9010-3d52403cb996` | `640ca58e-0e88-410c-a7f3-426faf2b690b` |
| Responses | `c765a961-d9d8-4d36-a20a-5315b111836a` | same |
| Session start | `00c5af5d-1964-4e30-8f51-1956f96bd282`, write `01 00` | same |
| Report rate descriptor | `679d5510-5a24-4dee-9557-95df80486ecb`, write `85 00` | same |

Commands take the same 8-byte header as above, behind 17 zero bytes. `ConsoleSession` replays the
console's order: hello (`07/01`), the DeviceInfo SPI read, firmware info (`10/01`), `16/01`,
pairing, a rumble sample, the player LED, feature mask `0x37`, four more SPI reads, `11/03`,
`11/01`, then the report-rate descriptor and the input CCCD.

### Pairing

Report `0x15` stores the host on the controller, which is what a console does instead of SMP:

1. `15/01` — the host address, byte-reversed, then the same with its lowest byte minus one.
2. `15/04` + A1 → the controller answers B1 (`5CF6EE79 2CDF05E1 BA2B6325 C41A5F10` on every unit
seen). The long-term key is `A1 xor B1`.
3. `15/02` + A2 → the controller answers `AES-128-ECB(key = reversed LTK, block = reversed A2)`,
which proves the key.
4. `15/03`, then `03/07` with the second address and the reversed LTK, then `03/09` to store it.

A1 and A2 are arbitrary; the app sends the values the console was observed to send. The host address
comes from `settings get secure bluetooth_address` through Shizuku, since apps are handed
`02:00:00:00:00:00`. Without it the app skips pairing and the controller still streams input.

### Input report

63 bytes on the input characteristic, report `0x07` left / `0x08` right:

| Offset | Size | Field |
|---|---|---|
| `0` | 1 | counter, +1 per report |
| `1` | 1 | power — bit 0 external, bit 1 charging, bits 2..5 battery level 0–9 |
| `2..3` | 2 | buttons, little-endian |
| `4` | 1 | always `0x07` |
| `5..7` | 3 | stick, packed 12-bit as above |
| `0x0F` (left) / `0x10` (right) | 0x28 | motion, undocumented packed format — not decoded |

Buttons, by bit: right `[2]` B A Y X R ZR + RS, `[3]` Home `0x01`, C `0x10`, SR `0x40`, SL `0x80`;
left `[2]` Down Right Left Up L ZL − LS, `[3]` Capture `0x01`, SR `0x40`, SL `0x80`.
`ConsolePacketParser` translates them into the bitmask above, so everything downstream is unchanged.

### Android workarounds

These controllers send an SMP Security Request on every connection, which a genuine Joy-Con 2 never
does — that request is what identifies them. Android pairs one device at a time, so a second clone
connecting while the first one's pairing is pending sends none; silence on the common channel 1.5 s
after init switches it over instead.

The pairing itself can never succeed (Confirm Value Failed, or a 30 s timeout), so:

- `SecurityRequestReceiver` aborts the ordered `ACTION_PAIRING_REQUEST` broadcast, and no system
dialog appears.
- `l2cu_start_post_bond_timer` then drops the link 3 s later unless it carries a dynamic L2CAP
channel. The controller never answers LE credit-based connection requests, so `LinkHolder` keeps a
pending `createInsecureL2capChannel(0x80).connect()` on the link — each attempt pends ~20 s.

Both depend on AOSP Bluetooth internals and may break on a future release.

High priority settles at 15 ms for these controllers (~67 reports/s). `ConnectionInterval` instead
asks the hidden `BluetoothGatt.requestLeConnectionUpdate` for 7.5 ms, the LE minimum, reached
through HiddenApiBypass because the method is on the blocked list; at 7.5 ms both controllers
deliver ~200 reports/s with no lost reports (RedMagic Astra, Android 16, 2026-09). It falls back to
`CONNECTION_PRIORITY_HIGH`, and every console session asks again when another controller joins,
since Android can slow an existing connection down when one does.

## Android BLE gotchas

1. **MTU first.** The default ATT MTU of 23 truncates 63-byte notifications: `requestMtu(247)`
Expand Down
1 change: 1 addition & 0 deletions feature/connection/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ dependencies {
implementation(project(":feature:connection:domain"))
implementation(project(":core:model"))
implementation(libs.androidx.datastore.preferences)
implementation(libs.hiddenapibypass)
testImplementation(libs.junit)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@ package com.joegec.joycon2android.connection
import android.annotation.SuppressLint
import android.bluetooth.le.ScanResult
import android.content.Context
import com.joegec.joycon2android.connection.console.SecurityRequestReceiver
import com.joegec.joycon2android.model.Side
import java.util.concurrent.ConcurrentHashMap

/** Thread-safe: BLE callbacks arrive on binder threads. */
@SuppressLint("MissingPermission")
class ConnectionPool(private val context: Context) {
class ConnectionPool(
private val context: Context,
private val hostAddress: () -> String? = { null },
) {

private val connections = ConcurrentHashMap<String, JoyconConnection>()
private val securityRequests = SecurityRequestReceiver(
onSecurityRequested = { address -> connections[address]?.let { it.onSecurityRequested(); true } ?: false },
onPairingFailed = { address -> connections[address]?.onPairingFailed() },
)

var onPoolChanged: (() -> Unit)? = null

Expand All @@ -21,12 +29,19 @@ class ConnectionPool(private val context: Context) {
/** Null for an address already in the pool (a duplicate scan result). */
fun connect(result: ScanResult, side: Side, name: String, highPriority: Boolean): JoyconConnection? {
val address = result.device.address
val connection = JoyconConnection(context, side, name) {
val connection = JoyconConnection(
context,
side,
name,
hostAddress,
onReady = { reassertOtherPriorities(address) },
) {
connections.remove(address)
onPoolChanged?.invoke()
}
connection.setHighPriority(highPriority)
if (connections.putIfAbsent(address, connection) != null) return null
securityRequests.register(context)
connection.connect(result.device)
return connection
}
Expand All @@ -37,6 +52,13 @@ class ConnectionPool(private val context: Context) {
connections.values.forEach { it.setHighPriority(enabled) }
}

// Android can slow an existing connection when another controller connects.
private fun reassertOtherPriorities(joined: String) {
connections.forEach { (address, connection) ->
if (address != joined) connection.reassertPriority()
}
}

fun disconnect(address: String) {
connections.remove(address)?.disconnect()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ import kotlinx.coroutines.launch
class Joycon2Manager(
private val context: Context,
private val scope: CoroutineScope,
hostAddress: () -> String? = { null },
) : ControllerRepository, ConnectionPriorityRepository {

companion object {
private const val MAX_CONNECTIONS = 8
}

private val scanner = BleScanner(context)
private val pool = ConnectionPool(context)
private val pool = ConnectionPool(context, hostAddress)
private val connectionJobs = mutableMapOf<String, Job>()

@Volatile
Expand Down
Loading