diff --git a/README.md b/README.md index 6eedcad..244c0d3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 0710d77..70d128d 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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); +} diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index f5e5cbd..91c5000 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -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 @@ -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) @@ -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) diff --git a/app/src/main/java/com/joegec/joycon2android/ble/HostBluetoothAddress.kt b/app/src/main/java/com/joegec/joycon2android/ble/HostBluetoothAddress.kt new file mode 100644 index 0000000..8e2d8d2 --- /dev/null +++ b/app/src/main/java/com/joegec/joycon2android/ble/HostBluetoothAddress.kt @@ -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 + } +} diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt index 834e55d..3b86f23 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryGauge.kt @@ -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) + } } diff --git a/docs/protocol.md b/docs/protocol.md index 58b64f9..e13d24f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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)` diff --git a/feature/connection/data/build.gradle.kts b/feature/connection/data/build.gradle.kts index 26a9d34..d7a7692 100644 --- a/feature/connection/data/build.gradle.kts +++ b/feature/connection/data/build.gradle.kts @@ -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) } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt index 53691df..5d07f9b 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt @@ -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() + private val securityRequests = SecurityRequestReceiver( + onSecurityRequested = { address -> connections[address]?.let { it.onSecurityRequested(); true } ?: false }, + onPairingFailed = { address -> connections[address]?.onPairingFailed() }, + ) var onPoolChanged: (() -> Unit)? = null @@ -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 } @@ -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() } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt index d7df5d2..90b9bd9 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.launch class Joycon2Manager( private val context: Context, private val scope: CoroutineScope, + hostAddress: () -> String? = { null }, ) : ControllerRepository, ConnectionPriorityRepository { companion object { @@ -24,7 +25,7 @@ class Joycon2Manager( } private val scanner = BleScanner(context) - private val pool = ConnectionPool(context) + private val pool = ConnectionPool(context, hostAddress) private val connectionJobs = mutableMapOf() @Volatile diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt index cb75c71..12f01b4 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt @@ -13,6 +13,9 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log +import com.joegec.joycon2android.connection.console.ConsoleChannel +import com.joegec.joycon2android.connection.console.ConsoleCommands +import com.joegec.joycon2android.connection.console.ConsoleSession import com.joegec.joycon2android.model.JoyconConnectionState import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.PlayerNumber @@ -27,6 +30,8 @@ class JoyconConnection( private val context: Context, val side: Side, val deviceName: String, + private val hostAddress: () -> String? = { null }, + private val onReady: (() -> Unit)? = null, private val onDisconnected: (() -> Unit)? = null, ) { companion object { @@ -69,6 +74,10 @@ class JoyconConnection( private const val DESIRED_MTU = 247 private const val INIT_GAP_MS = 500L + + // A genuine Joy-Con 2 replies to the SPI read and starts streaming well within this + // window after init; console-protocol clones ignore the common channel entirely. + private const val CONSOLE_FALLBACK_MS = 1_500L } private val _connectionState = MutableStateFlow( @@ -91,12 +100,41 @@ class JoyconConnection( private set @Volatile private var highPriority = false private var ledSentAfterFirstPacket = false + @Volatile private var securityRequested = false + @Volatile private var servicesDiscovered = false + @Volatile private var commonReportSeen = false + @Volatile private var commandReplySeen = false + @Volatile private var assignedPlayer: PlayerNumber? = null + @Volatile private var console: ConsoleSession? = null fun connect(device: BluetoothDevice) { gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) } + /** + * The controller sent an SMP Security Request, which a genuine Joy-Con 2 never does: it is a + * clone that only speaks the console protocol. Usually arrives before service discovery, but + * Android pairs one device at a time, so a second clone connecting while the first one's + * pairing is still pending sends none; [fallBackToConsoleIfSilent] catches that case. + */ + fun onSecurityRequested() { + if (securityRequested) return + securityRequested = true + Log.i(TAG, "[$side] SMP security request received") + val g = gatt ?: return + if (servicesDiscovered && !commonChannelAnswered()) mainHandler.post { startConsoleSession(g) } + } + + fun onPairingFailed() { + console?.holdLinkNow() + } + + fun reassertPriority() { + if (console != null && initComplete) gatt?.let(::requestPriority) + } + fun disconnect() { + stopConsoleSession() mainHandler.removeCallbacksAndMessages(null) gatt?.disconnect() gatt?.close() @@ -118,6 +156,7 @@ class JoyconConnection( } BluetoothProfile.STATE_DISCONNECTED -> { Log.w(TAG, "[$side] Disconnected (status=$status)") + stopConsoleSession() opQueue.clear() g.close() gatt = null @@ -149,6 +188,12 @@ class JoyconConnection( return } + servicesDiscovered = true + if (securityRequested) { + startConsoleSession(g) + return + } + val svc = g.getService(INPUT_SERVICE) if (svc == null) { _connectionState.value = JoyconConnectionState( @@ -203,6 +248,7 @@ class JoyconConnection( ) Log.i(TAG, "[$side] Init sequence complete") if (highPriority) requestPriority(g) + mainHandler.postDelayed({ fallBackToConsoleIfSilent(g) }, CONSOLE_FALLBACK_MS) false // no GATT op — advance immediately } } @@ -210,6 +256,10 @@ class JoyconConnection( override fun onDescriptorWrite( g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int ) { + console?.let { + it.onOperationComplete(status) + return + } Log.i(TAG, "[$side] CCCD write status=$status") mainHandler.post { opQueue.complete() } } @@ -217,6 +267,10 @@ class JoyconConnection( override fun onCharacteristicWrite( g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int ) { + console?.let { + it.onOperationComplete(status) + return + } Log.d(TAG, "[$side] Char write status=$status initComplete=$initComplete") val delay = if (initComplete) 0L else INIT_GAP_MS mainHandler.postDelayed({ opQueue.complete() }, delay) @@ -234,6 +288,58 @@ class JoyconConnection( ) { handleCharacteristicChanged(g, ch.uuid, value) } + + // Hidden BluetoothGattCallback method; the platform invokes it by signature. + @Suppress("unused") + fun onConnectionUpdated(g: BluetoothGatt, interval: Int, latency: Int, timeout: Int, status: Int) { + Log.i(TAG, "[$side] Connection interval ${interval * 1.25} ms, latency $latency, status $status") + } + } + + private fun commonChannelAnswered() = commonReportSeen || commandReplySeen + + private fun fallBackToConsoleIfSilent(g: BluetoothGatt) { + if (commonChannelAnswered()) return + Log.i(TAG, "[$side] No reply on the common channel; switching to the console protocol") + startConsoleSession(g) + } + + @Synchronized + private fun startConsoleSession(g: BluetoothGatt) { + if (console != null || gatt !== g) return + val channel = ConsoleChannel.find(g) + if (channel == null) { + _connectionState.value = JoyconConnectionState( + error = "Not a compatible Joy-Con 2", deviceName = deviceName + ) + return + } + opQueue.clear() + initComplete = false + _connectionState.value = _connectionState.value.copy(ready = false) + console = ConsoleSession( + gatt = g, + channel = channel, + hostAddress = hostAddress, + initialLedBitmask = assignedPlayer?.ledBitmask ?: ConsoleCommands.LED_ALL_ON, + onInput = { _input.value = stickCalibrator.calibrate(it) }, + onAccentColor = { color -> _connectionState.value = _connectionState.value.copy(accentColor = color) }, + onReady = ::onConsoleReady, + ).also { it.start() } + } + + private fun onConsoleReady() { + initComplete = true + _connectionState.value = _connectionState.value.copy( + connected = true, ready = true, deviceName = deviceName + ) + if (highPriority) gatt?.let(::requestPriority) + onReady?.invoke() + } + + private fun stopConsoleSession() { + console?.stop() + console = null } fun setHighPriority(enabled: Boolean) { @@ -243,6 +349,11 @@ class JoyconConnection( // The connection interval is the report rate: docs/protocol.md#android-ble-gotchas private fun requestPriority(g: BluetoothGatt) { + val session = console + if (highPriority && session != null) { + session.requestFastestInterval() + return + } val priority = if (highPriority) { BluetoothGatt.CONNECTION_PRIORITY_HIGH } else { @@ -253,6 +364,11 @@ class JoyconConnection( fun setPlayerLed(player: PlayerNumber) { pendingPlayerLed = player + assignedPlayer = player + console?.let { + it.setPlayerLed(player.ledBitmask) + return + } if (!initComplete) return val g = gatt ?: return opQueue.enqueue { sendLedCommand(g) } @@ -260,6 +376,11 @@ class JoyconConnection( fun clearPlayerLed() { pendingPlayerLed = null + assignedPlayer = null + console?.let { + it.setPlayerLed(ConsoleCommands.LED_ALL_ON) + return + } if (!initComplete) return val g = gatt ?: return opQueue.enqueue { sendLedCommand(g) } @@ -278,8 +399,13 @@ class JoyconConnection( } private fun handleCharacteristicChanged(g: BluetoothGatt, uuid: UUID, data: ByteArray) { + console?.let { + it.onCharacteristicChanged(uuid, data) + return + } when (uuid) { NOTIFY_CHAR -> { + commonReportSeen = true PacketParser.parse(data, side)?.let { _input.value = stickCalibrator.calibrate(it) } if (!ledSentAfterFirstPacket && initComplete) { ledSentAfterFirstPacket = true @@ -287,6 +413,7 @@ class JoyconConnection( } } CMD_RESPONSE_CHAR -> { + commandReplySeen = true Log.d(TAG, "[$side] Cmd response: ${data.joinToString(" ") { "%02X".format(it) }}") SpiColorParser.parseAccentColor(data)?.let { color -> Log.i(TAG, "[$side] Accent color: #${"%06X".format(color)}") diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt index ea9fdd3..266e9ca 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt @@ -85,6 +85,6 @@ object PacketParser { ((data[offset + 1].toInt() and 0xFF) shl 8) or ((data[offset + 2].toInt() and 0xFF) shl 16) - private fun decodeButtons(buttons: Long): Set = + internal fun decodeButtons(buttons: Long): Set = buttonMasks.filter { (mask, _) -> buttons and mask != 0L }.map { it.second.id }.toSet() } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConnectionInterval.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConnectionInterval.kt new file mode 100644 index 0000000..5578ea0 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConnectionInterval.kt @@ -0,0 +1,32 @@ +package com.joegec.joycon2android.connection.console + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.os.Build +import android.util.Log +import org.lsposed.hiddenapibypass.HiddenApiBypass + +/** 7.5 ms, the LE minimum: docs/protocol.md#console-protocol-controllers */ +@SuppressLint("MissingPermission") +internal object ConnectionInterval { + + private const val TAG = "Joycon2" + + // Units of 1.25 ms and 10 ms: 6 * 1.25 = 7.5 ms, no peripheral latency, 5 s supervision timeout. + private val FASTEST = arrayOf(6, 6, 0, 500, 0, 0) + + fun requestFastest(gatt: BluetoothGatt): Boolean { + val accepted = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + HiddenApiBypass.invoke(BluetoothGatt::class.java, gatt, "requestLeConnectionUpdate", *FASTEST) + } else { + BluetoothGatt::class.java + .getMethod("requestLeConnectionUpdate", *Array(FASTEST.size) { Int::class.java }) + .invoke(gatt, *FASTEST) + } == true + }.onFailure { Log.w(TAG, "7.5 ms interval request unavailable: $it") }.getOrDefault(false) + + if (!accepted) gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) + return accepted + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleChannel.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleChannel.kt new file mode 100644 index 0000000..f8c90e1 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleChannel.kt @@ -0,0 +1,49 @@ +package com.joegec.joycon2android.connection.console + +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import com.joegec.joycon2android.model.Side +import java.util.UUID + +/** Characteristics and command framing: docs/protocol.md#console-protocol-controllers */ +internal class ConsoleChannel( + val side: Side, + val command: BluetoothGattCharacteristic, + val input: BluetoothGattCharacteristic, + val extendedResponse: BluetoothGattCharacteristic?, +) { + companion object { + const val COMMAND_PREFIX_LENGTH = 17 + + val RESPONSE: UUID = UUID.fromString("c765a961-d9d8-4d36-a20a-5315b111836a") + val SESSION_START: UUID = UUID.fromString("00c5af5d-1964-4e30-8f51-1956f96bd282") + val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + val REPORT_RATE: UUID = UUID.fromString("679d5510-5a24-4dee-9557-95df80486ecb") + + private val LEFT_COMMAND = UUID.fromString("ce49a830-dced-48ae-931e-c8cf88aadbea") + private val LEFT_INPUT = UUID.fromString("cc1bbbb5-7354-4d32-a716-a81cb241a32a") + private val LEFT_EXTENDED_RESPONSE = UUID.fromString("63a3810f-aec7-474b-9010-3d52403cb996") + private val RIGHT_COMMAND = UUID.fromString("65a724b3-f1e7-4a61-8078-a342376b27ff") + private val RIGHT_INPUT = UUID.fromString("d5a9e01e-2ffc-4cca-b20c-8b67142bf442") + private val RIGHT_EXTENDED_RESPONSE = UUID.fromString("640ca58e-0e88-410c-a7f3-426faf2b690b") + + fun find(gatt: BluetoothGatt): ConsoleChannel? = + resolve(gatt, Side.LEFT, LEFT_COMMAND, LEFT_INPUT, LEFT_EXTENDED_RESPONSE) + ?: resolve(gatt, Side.RIGHT, RIGHT_COMMAND, RIGHT_INPUT, RIGHT_EXTENDED_RESPONSE) + + fun characteristic(gatt: BluetoothGatt, uuid: UUID): BluetoothGattCharacteristic? = + gatt.services.firstNotNullOfOrNull { it.getCharacteristic(uuid) } + + private fun resolve( + gatt: BluetoothGatt, + side: Side, + command: UUID, + input: UUID, + extendedResponse: UUID, + ): ConsoleChannel? { + val commandChar = characteristic(gatt, command) ?: return null + val inputChar = characteristic(gatt, input) ?: return null + return ConsoleChannel(side, commandChar, inputChar, characteristic(gatt, extendedResponse)) + } + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleCommands.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleCommands.kt new file mode 100644 index 0000000..da9eb24 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleCommands.kt @@ -0,0 +1,67 @@ +package com.joegec.joycon2android.connection.console + +/** Command frames, in the order a console sends them: docs/protocol.md#console-protocol-controllers */ +internal object ConsoleCommands { + + const val HEADER_LENGTH = 8 + + val HELLO = command(0x07, 0x01) + val FIRMWARE_INFO = command(0x10, 0x01) + val UNKNOWN_16_01 = command(0x16, 0x01) + val UNKNOWN_11_03 = command(0x11, 0x03) + val UNKNOWN_11_01 = command(0x11, 0x01) + val PAIRING_FINALISE = command(0x15, 0x03, bytes(0x00)) + val PAIRING_STORE = command(0x03, 0x09) + + val VIBRATION_SAMPLE = command(0x0A, 0x02, bytes(0x03, 0x00, 0x00, 0x00)) + val VIBRATION_DATA = command( + 0x0A, 0x08, + bytes( + 0x01, 0x59, 0x09, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0x35, + 0x00, 0x46, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ), + ) + + // Feature mask 0x37, as the console sends it; includes motion (bit 2) and mouse (bit 4). + val FEATURES_INIT = command(0x0C, 0x02, bytes(0x37, 0x00, 0x00, 0x00)) + val FEATURES_ENABLE = command(0x0C, 0x04, bytes(0x37, 0x00, 0x00, 0x00)) + + const val DEVICE_INFO_ADDRESS = 0x013000 + val SPI_READS_AFTER_FEATURES_INIT = listOf( + 0x40 to 0x013080, + 0x40 to 0x1FC040, + 0x10 to 0x013040, + 0x18 to 0x013100, + ) + const val SPI_READ_BEFORE_VIBRATION = 0x013060 + const val SPI_READ_BEFORE_VIBRATION_LENGTH = 0x20 + + const val LED_ALL_ON: Byte = 0x0F + + // Reply header echoes: id at [0], 0x01 at [1], sub at [3]. + const val REPLY_MARKER: Byte = 0x01 + + fun spiRead(length: Int, address: Int) = command( + 0x02, 0x04, + byteArrayOf(length.toByte(), 0x7E, 0x00, 0x00) + littleEndian(address), + ) + + fun playerLed(bitmask: Byte) = command(0x09, 0x07, byteArrayOf(bitmask) + ByteArray(7)) + + fun pairingAddresses(hostAddress: ByteArray, secondAddress: ByteArray) = + command(0x15, 0x01, bytes(0x00, 0x02) + hostAddress + secondAddress) + + fun pairingExchangeKey(a1: ByteArray) = command(0x15, 0x04, bytes(0x00) + a1) + + fun pairingConfirm(a2: ByteArray) = command(0x15, 0x02, bytes(0x00) + a2) + + fun pairingInfo(secondAddress: ByteArray, reversedLtk: ByteArray) = + command(0x03, 0x07, secondAddress + reversedLtk) + + fun command(id: Int, sub: Int, data: ByteArray = ByteArray(0)): ByteArray = + bytes(id, 0x91, 0x01, sub, 0x00, data.size, 0x00, 0x00) + data + + private fun littleEndian(value: Int) = bytes(value, value shr 8, value shr 16, value shr 24) + + private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParser.kt new file mode 100644 index 0000000..1f8e776 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParser.kt @@ -0,0 +1,64 @@ +package com.joegec.joycon2android.connection.console + +import com.joegec.joycon2android.connection.PacketParser +import com.joegec.joycon2android.model.BatteryGauge +import com.joegec.joycon2android.model.JoyconInput +import com.joegec.joycon2android.model.Side + +/** Console input report, translated into the common bitmask: docs/protocol.md#input-report */ +internal object ConsolePacketParser { + + private const val MIN_REPORT_SIZE = 8 + private const val MAX_BATTERY_LEVEL = 9 + + private val rightButtons = listOf( + 0 to 0x00000400L, // B + 1 to 0x00000800L, // A + 2 to 0x00000100L, // Y + 3 to 0x00000200L, // X + 4 to 0x00004000L, // R + 5 to 0x00008000L, // ZR + 6 to 0x00020000L, // + + 7 to 0x00040000L, // RS + 8 to 0x00100000L, // Home + 12 to 0x00400000L, // C + 14 to 0x00001000L, // SR + 15 to 0x00002000L, // SL + ) + + private val leftButtons = listOf( + 0 to 0x01000000L, // Down + 1 to 0x04000000L, // Right + 2 to 0x08000000L, // Left + 3 to 0x02000000L, // Up + 4 to 0x40000000L, // L + 5 to 0x80000000L, // ZL + 6 to 0x00010000L, // - + 7 to 0x00080000L, // LS + 8 to 0x00200000L, // Capture + 14 to 0x10000000L, // SR + 15 to 0x20000000L, // SL + ) + + fun counter(report: ByteArray): Int = report[0].toInt() and 0xFF + + fun parse(report: ByteArray, side: Side, packetId: Int): JoyconInput? { + if (report.size < MIN_REPORT_SIZE) return null + val raw = u8(report, 2) or (u8(report, 3) shl 8) + val table = if (side == Side.LEFT) leftButtons else rightButtons + val buttons = table.fold(0L) { acc, (bit, mask) -> if (raw shr bit and 1 == 1) acc or mask else acc } + val stick = u8(report, 5) or (u8(report, 6) shl 8) or (u8(report, 7) shl 16) + val level = (u8(report, 1) shr 2 and 0x0F).coerceAtMost(MAX_BATTERY_LEVEL) + + return JoyconInput( + packetId = packetId, + buttons = buttons, + pressed = PacketParser.decodeButtons(buttons), + stickX = stick and 0xFFF, + stickY = stick shr 12 and 0xFFF, + batteryVolts = BatteryGauge.voltsFromPercent(level * 100 / MAX_BATTERY_LEVEL), + ) + } + + private fun u8(data: ByteArray, index: Int) = data[index].toInt() and 0xFF +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePairing.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePairing.kt new file mode 100644 index 0000000..db6b0f2 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePairing.kt @@ -0,0 +1,45 @@ +package com.joegec.joycon2android.connection.console + +import android.annotation.SuppressLint +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +/** Report 0x15, which stores this host on the controller: docs/protocol.md#pairing */ +internal object ConsolePairing { + + val A1: ByteArray = hex("3503e92982877124bea80c664615834b") + val A2: ByteArray = hex("6fc6df8ad8fedf15bb8c15e91f320544") + private val KNOWN_B1: ByteArray = hex("5cf6ee792cdf05e1ba2b6325c41a5f10") + + private const val KEY_LENGTH = 16 + private val ADDRESS_PATTERN = Regex("^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$") + + /** `AA:BB:CC:DD:EE:FF` as the controller expects it: byte-reversed. Null when malformed. */ + fun encodeAddress(address: String): ByteArray? { + if (!ADDRESS_PATTERN.matches(address)) return null + return address.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray() + } + + fun secondAddress(encoded: ByteArray): ByteArray = + encoded.copyOf().also { it[0] = (it[0] - 1).toByte() } + + /** B1 from the 0x15/0x04 reply data (status byte, then the key). */ + fun b1From(replyData: ByteArray?): ByteArray = + replyData?.takeIf { it.size > KEY_LENGTH }?.copyOfRange(1, KEY_LENGTH + 1) ?: KNOWN_B1 + + fun ltk(b1: ByteArray): ByteArray = ByteArray(KEY_LENGTH) { (A1[it].toInt() xor b1[it].toInt()).toByte() } + + // The controller computes B2 with single-block AES-ECB; the mode is fixed by the protocol. + @SuppressLint("GetInstance") + fun confirms(ltk: ByteArray, replyData: ByteArray?): Boolean { + if (replyData == null || replyData.size <= KEY_LENGTH) return false + val expected = Cipher.getInstance("AES/ECB/NoPadding").run { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(ltk.reversedArray(), "AES")) + doFinal(A2.reversedArray()) + } + return replyData.copyOfRange(1, KEY_LENGTH + 1).contentEquals(expected) + } + + private fun hex(digits: String): ByteArray = + ByteArray(digits.length / 2) { digits.substring(it * 2, it * 2 + 2).toInt(16).toByte() } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleSession.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleSession.kt new file mode 100644 index 0000000..b8b8e1b --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleSession.kt @@ -0,0 +1,160 @@ +package com.joegec.joycon2android.connection.console + +import android.bluetooth.BluetoothGatt +import android.util.Log +import com.joegec.joycon2android.connection.SpiColorParser +import com.joegec.joycon2android.model.JoyconInput +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException + +/** + * Drives a controller that only speaks the console protocol over an already-connected, + * service-discovered [gatt]; the owning connection forwards its GATT callbacks here. + * Sequence: docs/protocol.md#console-protocol-controllers + */ +internal class ConsoleSession( + private val gatt: BluetoothGatt, + private val channel: ConsoleChannel, + private val hostAddress: () -> String?, + initialLedBitmask: Byte, + private val onInput: (JoyconInput) -> Unit, + private val onAccentColor: (Int) -> Unit, + private val onReady: () -> Unit, +) { + companion object { + private const val TAG = "Joycon2" + private val SESSION_START_VALUE = byteArrayOf(0x01, 0x00) + + // Written to the input characteristic's 0x679d5510 descriptor before subscribing, as the console does. + private val REPORT_RATE_VALUE = byteArrayOf(0x85.toByte(), 0x00) + } + + private val label = channel.side.name + private val worker = Executors.newSingleThreadExecutor { Thread(it, "console-$label") } + private val transport = ConsoleTransport(gatt, channel, label) + private val linkHolder = LinkHolder(gatt.device, label) + + @Volatile private var stopped = false + @Volatile private var ledBitmask = initialLedBitmask + private var lastCounter = -1 + private var packetId = 0 + + fun start() { + Log.i(TAG, "[$label] Using the console protocol") + linkHolder.start() + submit(::initialise) + } + + fun stop() { + stopped = true + linkHolder.stop() + transport.close() + worker.shutdownNow() + } + + fun holdLinkNow() = linkHolder.holdNow() + + fun requestFastestInterval() = submit { ConnectionInterval.requestFastest(gatt) } + + fun setPlayerLed(bitmask: Byte) { + ledBitmask = bitmask + submit { transport.send(ConsoleCommands.playerLed(bitmask)) } + } + + fun onOperationComplete(status: Int) = transport.onOperationComplete(status) + + fun onCharacteristicChanged(uuid: UUID, value: ByteArray) { + if (uuid == channel.input.uuid) onReport(value) else transport.onReply(value) + } + + private fun onReport(report: ByteArray) { + if (report.isEmpty()) return + val counter = ConsolePacketParser.counter(report) + packetId += if (lastCounter < 0) 1 else (counter - lastCounter + 256) % 256 + lastCounter = counter + ConsolePacketParser.parse(report, channel.side, packetId)?.let(onInput) + } + + private fun initialise() { + openCommandChannel() + identify() + pair() + configure() + if (!enableInput()) { + Log.e(TAG, "[$label] Could not enable console input reports") + return + } + Log.i(TAG, "[$label] Console init complete") + if (!stopped) onReady() + } + + private fun openCommandChannel() { + ConsoleChannel.characteristic(gatt, ConsoleChannel.SESSION_START)?.let { transport.write(it, SESSION_START_VALUE) } + ConsoleChannel.characteristic(gatt, ConsoleChannel.RESPONSE)?.let(transport::subscribe) + channel.extendedResponse?.let(transport::subscribe) + } + + private fun identify() { + transport.send(ConsoleCommands.HELLO) + transport.send(ConsoleCommands.spiRead(0x40, ConsoleCommands.DEVICE_INFO_ADDRESS)) + ?.let(SpiColorParser::parseAccentColor) + ?.let(onAccentColor) + transport.send(ConsoleCommands.FIRMWARE_INFO) + transport.send(ConsoleCommands.UNKNOWN_16_01) + } + + private fun pair() { + val host = hostAddress()?.let(ConsolePairing::encodeAddress) + if (host == null) { + Log.w(TAG, "[$label] Host Bluetooth address unavailable; skipping controller pairing") + return + } + val second = ConsolePairing.secondAddress(host) + transport.send(ConsoleCommands.pairingAddresses(host, second)) ?: return + val b1 = ConsolePairing.b1From(dataOf(transport.send(ConsoleCommands.pairingExchangeKey(ConsolePairing.A1)))) + val ltk = ConsolePairing.ltk(b1) + val confirmReply = dataOf(transport.send(ConsoleCommands.pairingConfirm(ConsolePairing.A2))) + if (!ConsolePairing.confirms(ltk, confirmReply)) Log.w(TAG, "[$label] Pairing confirmation did not match") + transport.send(ConsoleCommands.PAIRING_FINALISE) + transport.send(ConsoleCommands.pairingInfo(second, ltk.reversedArray())) + transport.send(ConsoleCommands.PAIRING_STORE) + } + + private fun configure() { + transport.send(ConsoleCommands.VIBRATION_SAMPLE) + transport.send(ConsoleCommands.playerLed(ledBitmask)) + transport.send(ConsoleCommands.FEATURES_INIT) + ConsoleCommands.SPI_READS_AFTER_FEATURES_INIT.forEach { (length, address) -> + transport.send(ConsoleCommands.spiRead(length, address)) + } + transport.send(ConsoleCommands.UNKNOWN_11_03) + transport.send( + ConsoleCommands.spiRead(ConsoleCommands.SPI_READ_BEFORE_VIBRATION_LENGTH, ConsoleCommands.SPI_READ_BEFORE_VIBRATION), + ) + transport.send(ConsoleCommands.VIBRATION_DATA) + transport.send(ConsoleCommands.UNKNOWN_11_01) + transport.send(ConsoleCommands.FEATURES_ENABLE) + } + + private fun enableInput(): Boolean { + channel.input.getDescriptor(ConsoleChannel.REPORT_RATE)?.let { transport.writeDescriptor(it, REPORT_RATE_VALUE) } + return transport.subscribe(channel.input) && !stopped + } + + private fun dataOf(reply: ByteArray?): ByteArray? = + reply?.copyOfRange(ConsoleCommands.HEADER_LENGTH, reply.size) + + private fun submit(task: () -> Unit) { + if (stopped) return + try { + worker.execute { + try { + task() + } catch (_: InterruptedException) { + } + } + } catch (_: RejectedExecutionException) { + } + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleTransport.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleTransport.kt new file mode 100644 index 0000000..cb4c0a9 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleTransport.kt @@ -0,0 +1,132 @@ +package com.joegec.joycon2android.connection.console + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothStatusCodes +import android.os.Build +import android.os.SystemClock +import android.util.Log +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** Blocking request/reply on the console channel, so callers must stay off the main thread. */ +@SuppressLint("MissingPermission") +internal class ConsoleTransport( + private val gatt: BluetoothGatt, + private val channel: ConsoleChannel, + private val label: String, +) { + companion object { + private const val TAG = "Joycon2" + private const val REPLY_TIMEOUT_MS = 700L + private const val OPERATION_TIMEOUT_MS = 3_000L + private const val START_ATTEMPTS = 40 + private const val START_RETRY_DELAY_MS = 25L + } + + private val replies = LinkedBlockingQueue() + @Volatile private var closed = false + @Volatile private var operation: CountDownLatch? = null + @Volatile private var operationStatus = BluetoothGatt.GATT_FAILURE + + fun close() { + closed = true + operation?.countDown() + } + + fun onOperationComplete(status: Int) { + operationStatus = status + operation?.countDown() + } + + fun onReply(value: ByteArray) { + replies.offer(value) + } + + /** Sends [command] and returns the reply from its header onwards, or null if none arrived. */ + fun send(command: ByteArray): ByteArray? { + replies.clear() + val frame = ByteArray(ConsoleChannel.COMMAND_PREFIX_LENGTH) + command + if (!write(channel.command, frame)) return null + val deadline = SystemClock.elapsedRealtime() + REPLY_TIMEOUT_MS + while (!closed) { + val remaining = deadline - SystemClock.elapsedRealtime() + if (remaining <= 0) break + val value = replies.poll(remaining, TimeUnit.MILLISECONDS) ?: break + val start = replyStart(value, command) + if (start >= 0) return value.copyOfRange(start, value.size) + } + Log.w(TAG, "[$label] No reply to ${hex(command.copyOf(ConsoleCommands.HEADER_LENGTH))}") + return null + } + + fun subscribe(characteristic: BluetoothGattCharacteristic): Boolean { + gatt.setCharacteristicNotification(characteristic, true) + val cccd = characteristic.getDescriptor(ConsoleChannel.CCCD) ?: return false + return writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } + + fun write(characteristic: BluetoothGattCharacteristic, value: ByteArray): Boolean { + val type = if (characteristic.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) { + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + } else { + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + } + Log.d(TAG, "[$label] TX ${hex(value)}") + return runOperation { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic(characteristic, value, type) == BluetoothStatusCodes.SUCCESS + } else { + @Suppress("DEPRECATION") + characteristic.writeType = type + @Suppress("DEPRECATION") + characteristic.value = value + @Suppress("DEPRECATION") + gatt.writeCharacteristic(characteristic) + } + } + } + + fun writeDescriptor(descriptor: BluetoothGattDescriptor, value: ByteArray): Boolean = runOperation { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS + } else { + @Suppress("DEPRECATION") + descriptor.value = value + @Suppress("DEPRECATION") + gatt.writeDescriptor(descriptor) + } + } + + // A reply echoes the command id at [0] and sub-command at [3], with 0x01 at [1]. + private fun replyStart(value: ByteArray, command: ByteArray): Int { + for (i in 0..value.size - ConsoleCommands.HEADER_LENGTH) { + if (value[i] == command[0] && value[i + 1] == ConsoleCommands.REPLY_MARKER && value[i + 3] == command[3]) { + return i + } + } + return -1 + } + + // Android runs one GATT operation at a time; starting another while one is in flight fails. + private fun runOperation(start: () -> Boolean): Boolean { + if (closed) return false + val latch = CountDownLatch(1) + operation = latch + operationStatus = BluetoothGatt.GATT_FAILURE + var started = false + for (attempt in 1..START_ATTEMPTS) { + if (closed) return false + started = start() + if (started) break + SystemClock.sleep(START_RETRY_DELAY_MS) + } + if (!started || !latch.await(OPERATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) return false + return operationStatus == BluetoothGatt.GATT_SUCCESS + } + + private fun hex(bytes: ByteArray) = bytes.joinToString(" ") { "%02X".format(it) } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/LinkHolder.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/LinkHolder.kt new file mode 100644 index 0000000..8d03443 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/LinkHolder.kt @@ -0,0 +1,66 @@ +package com.joegec.joycon2android.connection.console + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothSocket +import android.os.Build +import android.os.SystemClock +import android.util.Log +import java.util.concurrent.ConcurrentHashMap + +/** Holds the link open after Android's pairing fails: docs/protocol.md#android-workarounds */ +@SuppressLint("MissingPermission") +internal class LinkHolder(private val device: BluetoothDevice, private val label: String) { + + companion object { + private const val TAG = "Joycon2" + private const val PSM = 0x0080 + private const val QUICK_FAILURE_MS = 1_000L + private const val RETRY_DELAY_MS = 200L + } + + @Volatile private var running = false + private val sockets = ConcurrentHashMap.newKeySet() + + fun start() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || running) return + running = true + Thread({ + while (running) { + val took = attempt() + if (took < QUICK_FAILURE_MS) SystemClock.sleep(RETRY_DELAY_MS) + } + }, "link-holder-$label").start() + } + + /** Adds an extra pending channel right away, for the moment Android's pairing gives up. */ + fun holdNow() { + if (!running) return + Thread({ attempt() }, "link-holder-now-$label").start() + } + + fun stop() { + running = false + sockets.forEach { runCatching { it.close() } } + sockets.clear() + } + + private fun attempt(): Long { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 0 + val started = SystemClock.elapsedRealtime() + var socket: BluetoothSocket? = null + try { + socket = device.createInsecureL2capChannel(PSM) + sockets.add(socket) + if (running) socket.connect() + } catch (e: Exception) { + Log.v(TAG, "[$label] link hold attempt ended: ${e.message}") + } finally { + socket?.let { + sockets.remove(it) + runCatching { it.close() } + } + } + return SystemClock.elapsedRealtime() - started + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/SecurityRequestReceiver.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/SecurityRequestReceiver.kt new file mode 100644 index 0000000..2430c29 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/SecurityRequestReceiver.kt @@ -0,0 +1,61 @@ +package com.joegec.joycon2android.connection.console + +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build + +/** Identifies console-protocol clones and hides their pairing dialog: docs/protocol.md#android-workarounds */ +internal class SecurityRequestReceiver( + private val onSecurityRequested: (String) -> Boolean, + private val onPairingFailed: (String) -> Unit, +) : BroadcastReceiver() { + + companion object { + private const val PRIORITY_AHEAD_OF_SETTINGS = 999 + } + + private var registered = false + + @Synchronized + fun register(context: Context) { + if (registered) return + val filter = IntentFilter().apply { + addAction(BluetoothDevice.ACTION_PAIRING_REQUEST) + addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + priority = PRIORITY_AHEAD_OF_SETTINGS + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(this, filter, Context.RECEIVER_EXPORTED) + } else { + context.registerReceiver(this, filter) + } + registered = true + } + + override fun onReceive(context: Context, intent: Intent) { + val address = deviceOf(intent)?.address ?: return + when (intent.action) { + BluetoothDevice.ACTION_PAIRING_REQUEST -> { + if (onSecurityRequested(address) && isOrderedBroadcast) abortBroadcast() + } + BluetoothDevice.ACTION_BOND_STATE_CHANGED -> { + val previous = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.ERROR) + val current = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR) + if (previous == BluetoothDevice.BOND_BONDING && current == BluetoothDevice.BOND_NONE) { + onPairingFailed(address) + } + } + } + } + + private fun deviceOf(intent: Intent): BluetoothDevice? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParserTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParserTest.kt new file mode 100644 index 0000000..ff1c026 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParserTest.kt @@ -0,0 +1,67 @@ +package com.joegec.joycon2android.connection.console + +import com.joegec.joycon2android.model.BatteryGauge +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.Side +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ConsolePacketParserTest { + + // Captured from a NYXI Hyperion 3 (right) at rest: counter 2, power 0x18, stick centred. + private val restingRight = hex("02 18 00 00 07 00 F8 7F 00 00 00 00 00 00 00 1E") + + private fun report(buttons: Int, power: Int = 0x18): ByteArray = + restingRight.copyOf(63).apply { + this[1] = power.toByte() + this[2] = buttons.toByte() + this[3] = (buttons shr 8).toByte() + } + + @Test + fun `rejects reports shorter than the stick field`() { + assertNull(ConsolePacketParser.parse(ByteArray(7), Side.RIGHT, 1)) + } + + @Test + fun `decodes the packed stick`() { + val input = ConsolePacketParser.parse(restingRight, Side.RIGHT, 1)!! + assertEquals(2048, input.stickX) + assertEquals(2047, input.stickY) + } + + @Test + fun `right face and system buttons map onto the common bitmask`() { + val input = ConsolePacketParser.parse(report(0b0101_0001_0000_0011), Side.RIGHT, 1)!! + assertEquals( + setOf(JoyconButton.B, JoyconButton.A, JoyconButton.Home, JoyconButton.Chat, JoyconButton.SrRight).map { it.id }.toSet(), + input.pressed, + ) + } + + @Test + fun `left buttons map onto the common bitmask`() { + val input = ConsolePacketParser.parse(report(0b1000_0001_1111_0001), Side.LEFT, 1)!! + assertEquals( + setOf( + JoyconButton.Down, JoyconButton.L, JoyconButton.ZL, JoyconButton.Minus, + JoyconButton.LS, JoyconButton.Capture, JoyconButton.SlLeft, + ).map { it.id }.toSet(), + input.pressed, + ) + } + + @Test + fun `battery level becomes a voltage the gauge reads back as the same charge`() { + val input = ConsolePacketParser.parse(report(0, power = 9 shl 2), Side.RIGHT, 1)!! + assertEquals(100, BatteryGauge.percentFromVolts(input.batteryVolts)) + } + + @Test + fun `counter is the first byte`() { + assertEquals(2, ConsolePacketParser.counter(restingRight)) + } + + private fun hex(value: String) = value.split(" ").map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePairingTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePairingTest.kt new file mode 100644 index 0000000..2b2bc94 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePairingTest.kt @@ -0,0 +1,41 @@ +package com.joegec.joycon2android.connection.console + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConsolePairingTest { + + // Reply data captured from a NYXI Hyperion 3: status byte, then the 16-byte key. + private val b1Reply = hex("01 5C F6 EE 79 2C DF 05 E1 BA 2B 63 25 C4 1A 5F 10") + private val b2Reply = hex("01 13 4C 97 F5 11 B9 B6 DD 4D 86 FD 40 F5 36 E9 ED") + + @Test + fun `address is byte-reversed and the second address drops the lowest byte by one`() { + val encoded = ConsolePairing.encodeAddress("12:34:56:78:9A:BC")!! + assertArrayEquals(hex("BC 9A 78 56 34 12"), encoded) + assertArrayEquals(hex("BB 9A 78 56 34 12"), ConsolePairing.secondAddress(encoded)) + } + + @Test + fun `rejects malformed addresses`() { + assertNull(ConsolePairing.encodeAddress("02:00:00:00:00")) + } + + @Test + fun `long-term key is A1 xor B1`() { + val ltk = ConsolePairing.ltk(ConsolePairing.b1From(b1Reply)) + assertArrayEquals(hex("69 F5 07 50 AE 58 74 C5 04 83 6F 43 82 0F DC 5B"), ltk) + } + + @Test + fun `controller confirmation matches the derived key`() { + val ltk = ConsolePairing.ltk(ConsolePairing.b1From(b1Reply)) + assertTrue(ConsolePairing.confirms(ltk, b2Reply)) + assertFalse(ConsolePairing.confirms(ltk, null)) + } + + private fun hex(value: String) = value.split(" ").map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 37c2920..5b4aab9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ coroutines = "1.10.2" konsist = "0.17.3" datastore = "1.1.1" json = "20240303" +hiddenapibypass = "6.1" [libraries] shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" } @@ -24,6 +25,7 @@ compose-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compile androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +hiddenapibypass = { group = "org.lsposed.hiddenapibypass", name = "hiddenapibypass", version.ref = "hiddenapibypass" } junit = { group = "junit", name = "junit", version.ref = "junit" } # Real org.json for unit tests; the android.jar stub returns defaults instead of parsing json = { group = "org.json", name = "json", version.ref = "json" }