diff --git a/README.md b/README.md index fe69e8df..154583fa 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ 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)). Motion on + these is accelerometer-only: they send no gyroscope data, so gyro aiming can't work. ## Setup guide @@ -228,6 +231,7 @@ shoulder buttons. | "Shizuku permission denied" | Shizuku → Apps → allow Joycon2Android | | Controller not found | Hold SYNC again and move closer | | Controller stops responding | Press SYNC and reconnect; wait a moment if it stays silent | +| 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 | | Gamepad doesn't show up in games | Check `adb shell getevent -p` lists "Joy-Con Virtual Gamepad" | | No DSUClient device in the emulator | Check the server address, restart the emulator and open a mapping screen; `adb logcat -s DsuServer` shows whether it's connecting | | Emulator won't detect DSU presses | Pick inputs from the list — detection never sees DSU — with the virtual gamepad off | diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryCharge.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryCharge.kt new file mode 100644 index 00000000..f714e685 --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryCharge.kt @@ -0,0 +1,13 @@ +package com.joegec.joycon2android.model + +/** Charge as a percentage: the common report gives volts, the console report a level. */ +@JvmInline +value class BatteryCharge(val percent: Int) { + + companion object { + fun fromVolts(volts: Float): BatteryCharge? = + if (volts <= 0f) null else BatteryCharge(BatteryGauge.percentFromVolts(volts)) + + fun fromLevel(level: Int, maxLevel: Int) = BatteryCharge(level * 100 / maxLevel) + } +} diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt index c163fda3..4d20036e 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt @@ -14,5 +14,6 @@ data class JoyconInput( val gyroX: Int = 0, val gyroY: Int = 0, val gyroZ: Int = 0, - val batteryVolts: Float = 0f, + val battery: BatteryCharge? = null, + val motionSupport: MotionSupport = MotionSupport.Full, ) diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/MotionSupport.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/MotionSupport.kt new file mode 100644 index 00000000..1ea117d4 --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/MotionSupport.kt @@ -0,0 +1,21 @@ +package com.joegec.joycon2android.model + +/** What a controller actually measures; console-protocol ones carry no gyroscope. */ +enum class MotionSupport { + Full, + AccelerometerOnly, + None, + ; + + val measuresAcceleration: Boolean + get() = when (this) { + Full, AccelerometerOnly -> true + None -> false + } + + val measuresRotation: Boolean + get() = when (this) { + Full -> true + AccelerometerOnly, None -> false + } +} diff --git a/core/model/src/test/kotlin/com/joegec/joycon2android/model/BatteryChargeTest.kt b/core/model/src/test/kotlin/com/joegec/joycon2android/model/BatteryChargeTest.kt new file mode 100644 index 00000000..c631121b --- /dev/null +++ b/core/model/src/test/kotlin/com/joegec/joycon2android/model/BatteryChargeTest.kt @@ -0,0 +1,25 @@ +package com.joegec.joycon2android.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class BatteryChargeTest { + + @Test + fun `a voltage becomes the gauge percentage`() { + assertEquals(75, BatteryCharge.fromVolts(3.30f)?.percent) + } + + @Test + fun `a packet without a voltage reports no charge`() { + assertNull(BatteryCharge.fromVolts(0f)) + } + + @Test + fun `a level becomes a percentage of the levels available`() { + assertEquals(100, BatteryCharge.fromLevel(9, 9).percent) + assertEquals(55, BatteryCharge.fromLevel(5, 9).percent) + assertEquals(0, BatteryCharge.fromLevel(0, 9).percent) + } +} diff --git a/docs/protocol.md b/docs/protocol.md index 65540c2a..f36805c0 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -183,7 +183,8 @@ requested at any alignment. The packet's voltage reads ~0.6 V below the cell's: ~3.30 V shows 75% on a Switch 2, ~3.60 V shows 100%. `BatteryGauge` interpolates Nintendo's Joy-Con thresholds (3.3 / 3.6 / 3.76 / 3.9 / 4.2 V, from dekuNukem's docs) shifted down 0.6 V. Below ~3.0 V is extrapolated; no low readings have been -captured yet. +captured yet. `JoyconInput` carries the result as a `BatteryCharge` percentage, since the console +report gives a level rather than a voltage. ## Stick range and centre @@ -207,12 +208,142 @@ 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`, 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. It does not pair (report `0x15`): pairing would store +this host on the controller and unpair it from its owner's console, and it buys nothing, because +Android connects from a resolvable private address the controller ignores. + +### 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 | +| `0x0E` (left) / `0x0F` (right) | 1 | motion block length — 4 during init, then 30 | +| `0x0F` (left) / `0x10` (right) | 30 | motion block, below | + +#### Motion block + +Seven 4-byte words and two spare bytes; the preceding byte gives the length. Offsets are from the +start of the block. ndeadly's reference calls this format unknown and allots it 0x28 bytes, listing +lengths {0, 30, 40}. A NYXI Hyperion 3 only ever sends 30: 8,830 consecutive reports (both sides, at +the 30 ms interval with no output running) are all length 30 and type `0x0C`, bar one 4-byte block +per side at init. + +| Word | Contents | +|---|---| +| `0x00` | timestamp, climbing steadily even at rest, then a block type at `0x03` — `0x0C` here | +| `0x04`, `0x08`, `0x0C` | a dead-reckoned estimate in world coordinates, not raw sensor data | +| `0x10`, `0x14`, `0x18` | accel x, y, z: int16 in each word's **high half**, low half always zero, 4096 = 1 g | +| `0x1C` | two spare bytes, always zero — the block ends here | + +Accelerometer, measured on a NYXI Hyperion 3 (both sides, 2026-10) against gravity in six +orientations, magnitude 1.00 g throughout: x is the controller's right and z leaves the button face, +as on a genuine Joy-Con 2, but **y runs the opposite way**, so `ConsolePacketParser` negates it. +Confirmed by Mario Kart Wii's tilt steering in Dolphin. + +No rotation data reaches the app at all, so these controllers report +`MotionSupport.AccelerometerOnly`: DSU advertises the slot as a pad without a gyroscope, the readout +names it, and nothing downstream reads zeros as a real measurement. The 12 bytes a 40-byte block +would add are exactly where a gyro triple would sit, but nothing moved them: no reply to feature +select (`0x0C`) changes the length, including configure (`0x06`) with the IMU flag and the +reference's own data bytes, dropping the mouse feature (mask `0x07`), or either one of enable and +set-mask. Get-feature-info (`0x0C/0x01`) answers with a bare header on this hardware, where the +reference documents 8 bytes of capability data, so the firmware stubs it. Nor does anything else +reach it: the two characteristics the reference lists as "Input Report (Unknown)" +(`ab7de9be…7fde` and `d3bd69d2…`) accept a subscription and then never notify, and the report-rate +descriptor takes ten different values, content byte included, without the length budging. + +The sensor itself is present. NYXI specifies 9-axis motion, and the three words at `0x04`–`0x0F` +hold a world-frame estimate the firmware could only resolve from a gyroscope: they ramp at rest as +an accelerometer bias integrates, and after a 90° yaw the two largest swap roles and rotate with it +while the third stays small. It is the raw rate that never reaches this report. + +A genuine Joy-Con 2 forced down this path does send more (measured on an AYN Thor, Android 13): its +length cycles 30 → 4 → 30 → 40, and the 40-byte block — type `0x0F` — arrives at a steady ~34/s per +side, bit-packed and probably several samples per report, which is most likely where the gyro sits. +Its 30-byte blocks carry type `0x0C` as these do but not this layout: their accel words have no zero +low half, so the guards report `MotionSupport.None` for a genuine controller that ends up here. +Asking for 7.5 ms suppresses the 40-byte stream, which drops from 34/s to between 2 and 30/s while +the smaller blocks flood the link, so that measurement needs faster updates off. + +Mario Kart Wii tricks and wheelies ride `Gyro Pitch`, so they cannot fire; bind **Shake** to a +button instead ([why](dsu-motion.md#tricks-and-wheelies)). Tilt steering is accelerometer-only and +works. + +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. Genuine Joy-Con 2s answer the console channel too, so that +silence is the only thing separating them: a controller moved over by the timeout goes back to the +common path as soon as a report arrives on `...fd2`, which only a controller speaking that protocol +sends. + +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. + +That pending channel has a side effect. When the controller drops, the stack turns the in-flight +`connect()` into a pending direct connection and keeps it armed; closing the socket does not remove +it. The tablet then connects to the controller the moment it is switched back on — `initiator:local`, +this app's own process — which consumes the advertisement, so no scan ever sees it, and brings the +link up with no GATT client and no pool entry behind it. Nothing aborts the pairing broadcast, the +dialog appears, and the failed pairing powers the controller off; restarting the app was the only +cure, because process death cancels the pending connect. So `JoyconConnection` keeps its pool entry +after a drop and arms `connectGatt(autoConnect = true)` for two minutes, which makes that connection +its own: the dialog is aborted and the start-up sequence runs. A dropped controller returns on its +next SYNC with no rescan — 5.9 s and 6.8 s on a RedMagic Astra, 2026-10-05 — and keeps its player +slot. The wait is not console-specific: any controller is held for those two minutes, then released +to the pool as before. + +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)` after connecting, wait for `onMtuChanged`, then discover services. 2. **One GATT operation at a time.** A second issued before the callback is silently dropped. - `GattOpQueue` advances on the matching callback, or after a timeout if none comes. + `GattOpQueue` carries every operation on a connection, whichever protocol issues it, and advances + on the callback of the operation in flight, or after a timeout if none comes. 3. **Write the CCCD.** `setCharacteristicNotification(true)` alone delivers nothing; descriptor `0x2902` must be written too. 4. **Pass `TRANSPORT_LE`** to `connectGatt`, or it may try classic Bluetooth. diff --git a/feature/connection/data/build.gradle.kts b/feature/connection/data/build.gradle.kts index 26a9d34d..d7a76926 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/CommonChannel.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonChannel.kt new file mode 100644 index 00000000..6b4311f8 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonChannel.kt @@ -0,0 +1,31 @@ +package com.joegec.joycon2android.connection + +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import java.util.UUID + +/** The channel a Joy-Con 2 speaks to any host: docs/protocol.md#ble-services */ +internal class CommonChannel( + val write: BluetoothGattCharacteristic, + val input: BluetoothGattCharacteristic, + val commandResponse: BluetoothGattCharacteristic?, +) { + companion object { + val INPUT: UUID = UUID.fromString("ab7de9be-89fe-49ad-828f-118f09df7fd2") + val COMMAND_RESPONSE: UUID = UUID.fromString("c765a961-d9d8-4d36-a20a-5315b111836a") + val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + private val SERVICE = UUID.fromString("ab7de9be-89fe-49ad-828f-118f09df7fd0") + private val WRITE = UUID.fromString("649d4ac9-8eb7-4e6c-af44-1ea54fe5f005") + + fun hasService(gatt: BluetoothGatt) = gatt.getService(SERVICE) != null + + /** Null when a controller advertises the service but not the characteristics to drive it. */ + fun find(gatt: BluetoothGatt): CommonChannel? { + val service = gatt.getService(SERVICE) ?: return null + val write = service.getCharacteristic(WRITE) ?: return null + val input = service.getCharacteristic(INPUT) ?: return null + return CommonChannel(write, input, service.getCharacteristic(COMMAND_RESPONSE)) + } + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt new file mode 100644 index 00000000..5ebf03e1 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt @@ -0,0 +1,164 @@ +package com.joegec.joycon2android.connection + +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.util.Log +import com.joegec.joycon2android.model.JoyconInput +import com.joegec.joycon2android.model.Side +import java.util.UUID + +/** Start-up sequence: docs/protocol.md#input-report */ +@SuppressLint("MissingPermission") +internal class CommonSession( + private val gatt: BluetoothGatt, + private val channel: CommonChannel, + private val side: Side, + private val ops: GattOpQueue, + initialLedBitmask: Byte, + private val onInput: (JoyconInput) -> Unit, + private val onAccentColor: (Int) -> Unit, + private val onReady: () -> Unit, +) : ControllerProtocol { + companion object { + private const val TAG = "Joycon2" + + // A controller misses an init command sent too soon after the one before. + private const val INIT_GAP_MS = 500L + + private val ALL_FEATURES: Byte = 0xFF.toByte() + private val FEATURES_INIT = ControllerCommands.selectFeatures(ALL_FEATURES) + private val FEATURES_ENABLE = ControllerCommands.enableFeatures(ALL_FEATURES) + + // 0x40 bytes of the DeviceInfo block: docs/protocol.md#spi-reads. This path has always + // sent transport 0x00 (USB) here, and a Joy-Con 2 answers it. + private val SPI_READ_COLOR = ControllerCommands.spiRead( + 0x40, ControllerCommands.DEVICE_INFO_ADDRESS, ControllerCommands.USB, + ) + } + + @Volatile private var ready = false + @Volatile private var ledBitmask = initialLedBitmask + private var ledSentAfterFirstPacket = false + + override fun start() { + channel.write.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + subscribeCommandResponse() + subscribeInput() + ops.enqueue(INIT_GAP_MS) { write(FEATURES_INIT) } + ops.enqueue(INIT_GAP_MS) { write(FEATURES_ENABLE) } + ops.enqueue(INIT_GAP_MS) { write(SPI_READ_COLOR) } + ops.enqueue { + ready = true + Log.i(TAG, "[$side] Init sequence complete") + onReady() + false // no GATT op — advance immediately + } + } + + override fun stop() { + ready = false + } + + /** The console detour ended, so this channel is driving the controller again. */ + fun resume() { + ready = true + } + + override fun onCharacteristicChanged(uuid: UUID, value: ByteArray) { + when (uuid) { + CommonChannel.INPUT -> onReport(value) + CommonChannel.COMMAND_RESPONSE -> onCommandResponse(value) + } + } + + override fun setPlayerLed(bitmask: Byte) { + ledBitmask = bitmask + if (ready) sendPlayerLed() + } + + override fun setHighPriority(enabled: Boolean) { + val priority = if (enabled) { + BluetoothGatt.CONNECTION_PRIORITY_HIGH + } else { + BluetoothGatt.CONNECTION_PRIORITY_BALANCED + } + Log.i(TAG, "[$side] Connection priority high=$enabled accepted=${gatt.requestConnectionPriority(priority)}") + } + + private fun onReport(report: ByteArray) { + PacketParser.parse(report, side)?.let(onInput) + if (!ledSentAfterFirstPacket && ready) { + ledSentAfterFirstPacket = true + sendPlayerLed() + } + } + + private fun onCommandResponse(value: ByteArray) { + Log.d(TAG, "[$side] Cmd response: ${value.joinToString(" ") { "%02X".format(it) }}") + SpiColorParser.parseAccentColor(value)?.let { color -> + Log.i(TAG, "[$side] Accent color: #${"%06X".format(color)}") + onAccentColor(color) + } + } + + private fun sendPlayerLed() { + ops.enqueue { + val cmd = ControllerCommands.playerLed(ledBitmask) + Log.i(TAG, "[$side] Sending LED cmd: ${cmd.joinToString(" ") { "%02X".format(it) }}") + write(cmd) + } + } + + private fun subscribeCommandResponse() { + val characteristic = channel.commandResponse ?: return + gatt.setCharacteristicNotification(characteristic, true) + val cccd = characteristic.getDescriptor(CommonChannel.CCCD) + if (cccd == null) { + Log.w(TAG, "[$side] CMD_RESPONSE char has no CCCD descriptor") + return + } + ops.enqueue { + Log.d(TAG, "[$side] Writing CMD_RESPONSE CCCD") + writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } + } + + private fun subscribeInput() { + gatt.setCharacteristicNotification(channel.input, true) + val cccd = channel.input.getDescriptor(CommonChannel.CCCD) + if (cccd == null) { + Log.e(TAG, "[$side] NOTIFY char has no CCCD descriptor — notifications won't work!") + return + } + ops.enqueue { + Log.d(TAG, "[$side] Writing NOTIFY CCCD") + writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + } + } + + private fun write(value: ByteArray): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic( + channel.write, value, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + ) == BluetoothStatusCodes.SUCCESS + } else { + @Suppress("DEPRECATION") + channel.write.value = value + @Suppress("DEPRECATION") + gatt.writeCharacteristic(channel.write) + } + + private fun writeDescriptor(descriptor: BluetoothGattDescriptor, value: ByteArray): Boolean = + 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) + } +} 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 53691df2..3c90f7ad 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,6 +3,7 @@ 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 @@ -11,6 +12,10 @@ import java.util.concurrent.ConcurrentHashMap class ConnectionPool(private val context: Context) { 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 +26,18 @@ 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, + 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 +48,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/ControllerCommands.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerCommands.kt new file mode 100644 index 00000000..0a3d6b9c --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerCommands.kt @@ -0,0 +1,41 @@ +package com.joegec.joycon2android.connection + +/** Command framing, shared by both protocols: docs/protocol.md#commands */ +internal object ControllerCommands { + + const val HEADER_LENGTH = 8 + + // Transport, at [2] of the header. + const val BLUETOOTH = 0x01 + const val USB = 0x00 + + // A reply echoes the id at [0], this at [1] and the sub-command at [3]. + const val REPLY_MARKER: Byte = 0x01 + + const val DEVICE_INFO_ADDRESS = 0x013000 + const val LED_ALL_ON: Byte = 0x0F + + private const val REQUEST = 0x91 + + /** Header: id, direction, transport, sub-command, unknown, data length, two reserved bytes. */ + fun command(id: Int, sub: Int, data: ByteArray = ByteArray(0), transport: Int = BLUETOOTH): ByteArray = + bytes(id, REQUEST, transport, sub, 0x00, data.size, 0x00, 0x00) + data + + fun selectFeatures(mask: Byte) = command(0x0C, 0x02, featureMask(mask)) + + fun enableFeatures(mask: Byte) = command(0x0C, 0x04, featureMask(mask)) + + fun playerLed(bitmask: Byte) = command(0x09, 0x07, byteArrayOf(bitmask) + ByteArray(7)) + + fun spiRead(length: Int, address: Int, transport: Int = BLUETOOTH) = command( + 0x02, 0x04, + byteArrayOf(length.toByte(), 0x7E, 0x00, 0x00) + littleEndian(address), + transport, + ) + + private fun featureMask(mask: Byte) = byteArrayOf(mask, 0x00, 0x00, 0x00) + + 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/ControllerProtocol.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerProtocol.kt new file mode 100644 index 00000000..ecfc04b0 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerProtocol.kt @@ -0,0 +1,13 @@ +package com.joegec.joycon2android.connection + +import java.util.UUID + +/** How one controller is driven: the common channel, or the console one. */ +internal interface ControllerProtocol { + fun start() + fun stop() + fun onCharacteristicChanged(uuid: UUID, value: ByteArray) + fun setPlayerLed(bitmask: Byte) + fun setHighPriority(enabled: Boolean) + fun onPairingFailed() {} +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt index 62da5675..124ead32 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/GattOpQueue.kt @@ -1,11 +1,17 @@ package com.joegec.joycon2android.connection +import android.bluetooth.BluetoothGatt import android.os.Handler import android.os.Looper import android.util.Log import java.util.ArrayDeque +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit -/** Android silently drops a second outstanding GATT op. Advances after [TIMEOUT_MS] if a callback never comes. */ +/** + * Android silently drops a second outstanding GATT op, so every op on a connection goes through + * here. A callback releases the op that is in flight, never whatever else is waiting. + */ class GattOpQueue { companion object { @@ -13,43 +19,84 @@ class GattOpQueue { private const val TIMEOUT_MS = 2000L } + private class Op(val run: () -> Boolean, val gapMs: Long, val finished: CountDownLatch?) { + @Volatile var status = BluetoothGatt.GATT_FAILURE + } + private val handler = Handler(Looper.getMainLooper()) - private val queue = ArrayDeque<() -> Boolean>() - private var inFlight = false + private val queue = ArrayDeque() + private var inFlight: Op? = null private val timeoutRunnable = Runnable { Log.w(TAG, "Op timed out — advancing queue") - inFlight = false - runNext() + complete(BluetoothGatt.GATT_FAILURE) } - fun enqueue(op: () -> Boolean) { - queue.add(op) - runNext() + /** Runs [op] when the queue reaches it, then waits [gapMs] before the next one. */ + fun enqueue(gapMs: Long = 0L, op: () -> Boolean) = add(Op(op, gapMs, null)) + + /** Runs [op] and returns its status. Blocks, so callers must stay off the main thread. */ + fun awaitOp(timeoutMs: Long, op: () -> Boolean): Int { + val entry = Op(op, 0L, CountDownLatch(1)) + add(entry) + return if (entry.finished!!.await(timeoutMs, TimeUnit.MILLISECONDS)) { + entry.status + } else { + BluetoothGatt.GATT_FAILURE + } } - fun complete() { + @Synchronized + fun complete(status: Int) { handler.removeCallbacks(timeoutRunnable) - inFlight = false - runNext() + val op = inFlight ?: return + inFlight = null + op.status = status + op.finished?.countDown() + if (op.gapMs > 0L) handler.postDelayed({ runNext() }, op.gapMs) else runNext() } + @Synchronized fun clear() { handler.removeCallbacks(timeoutRunnable) + val abandoned = queue.toList() + listOfNotNull(inFlight) queue.clear() - inFlight = false + inFlight = null + abandoned.forEach { it.finished?.countDown() } + } + + @Synchronized + private fun add(op: Op) { + queue.add(op) + runNext() } + @Synchronized private fun runNext() { - if (inFlight) return + if (inFlight != null) return val op = queue.poll() ?: return - inFlight = true - val success = op() - if (!success) { - Log.w(TAG, "Op returned false — advancing queue immediately") - inFlight = false - runNext() - } else { + inFlight = op + handler.post { start(op) } + } + + // Ops run on the main thread, and outside the lock: one of them reports the controller ready. + private fun start(op: Op) { + if (!isInFlight(op)) return + if (op.run()) { handler.postDelayed(timeoutRunnable, TIMEOUT_MS) + return } + Log.w(TAG, "Op returned false — advancing queue immediately") + abandon(op) + } + + @Synchronized + private fun isInFlight(op: Op) = inFlight === op + + @Synchronized + private fun abandon(op: Op) { + if (inFlight !== op) return + inFlight = null + op.finished?.countDown() + runNext() } } 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 cb75c71f..beafc089 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 @@ -7,12 +7,12 @@ import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothProfile -import android.bluetooth.BluetoothStatusCodes import android.content.Context -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.ConsoleSession import com.joegec.joycon2android.model.JoyconConnectionState import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.PlayerNumber @@ -27,48 +27,19 @@ class JoyconConnection( private val context: Context, val side: Side, val deviceName: String, + private val onReady: () -> Unit, private val onDisconnected: (() -> Unit)? = null, ) { companion object { private const val TAG = "Joycon2" + private const val DESIRED_MTU = 247 - private val INPUT_SERVICE = UUID.fromString("ab7de9be-89fe-49ad-828f-118f09df7fd0") - private val NOTIFY_CHAR = UUID.fromString("ab7de9be-89fe-49ad-828f-118f09df7fd2") - private val WRITE_CHAR = UUID.fromString("649d4ac9-8eb7-4e6c-af44-1ea54fe5f005") - private val CMD_RESPONSE_CHAR = UUID.fromString("c765a961-d9d8-4d36-a20a-5315b111836a") - private val CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") - - private val INIT_CMD_1 = byteArrayOf( - 0x0C, 0x91.toByte(), 0x01, 0x02, 0x00, 0x04, - 0x00, 0x00, 0xFF.toByte(), 0x00, 0x00, 0x00 - ) - private val INIT_CMD_2 = byteArrayOf( - 0x0C, 0x91.toByte(), 0x01, 0x04, 0x00, 0x04, - 0x00, 0x00, 0xFF.toByte(), 0x00, 0x00, 0x00 - ) - - // 0x40 bytes of the DeviceInfo block at 0x013000: docs/protocol.md#spi-reads - private val SPI_READ_COLOR_CMD = byteArrayOf( - 0x02, 0x91.toByte(), 0x00, 0x04, 0x00, 0x08, 0x00, 0x00, - 0x40, 0x7E, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00 - ) - - // Bitmask layout: docs/protocol.md#player-leds - private fun playerLedCmd(bitmask: Byte): ByteArray { - return byteArrayOf( - 0x09, 0x91.toByte(), 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, - bitmask, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 - ) - } + // A controller that speaks the common protocol answers the SPI read or starts streaming + // well within this window; one that answers later is moved back by its first report. + private const val CONSOLE_FALLBACK_MS = 1_500L - // All four solid. - private val LED_ALL_ON_CMD = byteArrayOf( - 0x09, 0x91.toByte(), 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, - 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 - ) - - private const val DESIRED_MTU = 247 - private const val INIT_GAP_MS = 500L + // How long a dropped controller is waited for before the player loses it. + private const val RECONNECT_WINDOW_MS = 120_000L } private val _connectionState = MutableStateFlow( @@ -80,28 +51,73 @@ class JoyconConnection( val input: StateFlow = _input.asStateFlow() private val mainHandler = Handler(Looper.getMainLooper()) - private val opQueue = GattOpQueue() + private val ops = GattOpQueue() private val stickCalibrator = StickCalibrator() private var gatt: BluetoothGatt? = null - private var writeChar: BluetoothGattCharacteristic? = null - private var notifyChar: BluetoothGattCharacteristic? = null - private var cmdResponseChar: BluetoothGattCharacteristic? = null - private var pendingPlayerLed: PlayerNumber? = null + private var device: BluetoothDevice? = null + private var common: CommonSession? = null + @Volatile private var console: ConsoleSession? = null + + // The console protocol takes over for as long as it runs; everything else goes through here. + private val protocol: ControllerProtocol? get() = console ?: common + @Volatile var initComplete = false 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 reconnecting = false + @Volatile private var released = false fun connect(device: BluetoothDevice) { + this.device = device gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) } + /** docs/protocol.md#android-workarounds */ + 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() { + protocol?.onPairingFailed() + } + + fun reassertPriority() { + if (initComplete) protocol?.setHighPriority(highPriority) + } + + fun setHighPriority(enabled: Boolean) { + highPriority = enabled + if (initComplete) protocol?.setHighPriority(enabled) + } + + fun setPlayerLed(player: PlayerNumber) { + assignedPlayer = player + protocol?.setPlayerLed(player.ledBitmask) + } + + fun clearPlayerLed() { + assignedPlayer = null + protocol?.setPlayerLed(ControllerCommands.LED_ALL_ON) + } + fun disconnect() { + released = true + reconnecting = false + stopProtocols() mainHandler.removeCallbacksAndMessages(null) gatt?.disconnect() gatt?.close() gatt = null - opQueue.clear() + ops.clear() _connectionState.value = JoyconConnectionState() _input.value = JoyconInput() } @@ -111,6 +127,7 @@ class JoyconConnection( when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.i(TAG, "[$side] Connected. Requesting MTU $DESIRED_MTU") + reconnecting = false _connectionState.value = JoyconConnectionState( connected = true, deviceName = deviceName ) @@ -118,18 +135,15 @@ class JoyconConnection( } BluetoothProfile.STATE_DISCONNECTED -> { Log.w(TAG, "[$side] Disconnected (status=$status)") - opQueue.clear() - g.close() - gatt = null - initComplete = false - ledSentAfterFirstPacket = false + endSession(g) + _input.value = JoyconInput() + if (!released && awaitReturn()) return _connectionState.value = JoyconConnectionState( deviceName = deviceName, error = if (status != BluetoothGatt.GATT_SUCCESS) { "Connection lost (status $status)" } else null ) - _input.value = JoyconInput() onDisconnected?.invoke() } } @@ -143,188 +157,189 @@ class JoyconConnection( override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { Log.i(TAG, "[$side] Services discovered (status=$status)") if (status != BluetoothGatt.GATT_SUCCESS) { - _connectionState.value = JoyconConnectionState( - error = "Service discovery failed", deviceName = deviceName - ) + fail("Service discovery failed") return } - val svc = g.getService(INPUT_SERVICE) - if (svc == null) { - _connectionState.value = JoyconConnectionState( - error = "Not a compatible Joy-Con 2", deviceName = deviceName - ) + servicesDiscovered = true + if (securityRequested) { + startConsoleSession(g) return } - - writeChar = svc.getCharacteristic(WRITE_CHAR) - notifyChar = svc.getCharacteristic(NOTIFY_CHAR) - cmdResponseChar = svc.getCharacteristic(CMD_RESPONSE_CHAR) - if (writeChar == null || notifyChar == null) { - _connectionState.value = JoyconConnectionState( - error = "Missing BLE characteristics", deviceName = deviceName - ) + if (!CommonChannel.hasService(g)) { + fail("Not a compatible Joy-Con 2") return } - writeChar!!.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - - // LED and SPI replies arrive here. - if (cmdResponseChar != null) { - g.setCharacteristicNotification(cmdResponseChar, true) - val cmdCccd = cmdResponseChar!!.getDescriptor(CCCD) - if (cmdCccd != null) { - opQueue.enqueue { - Log.d(TAG, "[$side] Writing CMD_RESPONSE CCCD") - writeDescriptor(g, cmdCccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) - } - } else { - Log.w(TAG, "[$side] CMD_RESPONSE char has no CCCD descriptor") - } - } - - g.setCharacteristicNotification(notifyChar, true) - val notifyCccd = notifyChar!!.getDescriptor(CCCD) - if (notifyCccd != null) { - opQueue.enqueue { - Log.d(TAG, "[$side] Writing NOTIFY CCCD") - writeDescriptor(g, notifyCccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) - } - } else { - Log.e(TAG, "[$side] NOTIFY char has no CCCD descriptor — notifications won't work!") - } - enqueueInitWrite(g, INIT_CMD_1) - enqueueInitWrite(g, INIT_CMD_2) - enqueueInitWrite(g, SPI_READ_COLOR_CMD) - - opQueue.enqueue { - initComplete = true - _connectionState.value = _connectionState.value.copy( - connected = true, ready = true, deviceName = deviceName - ) - Log.i(TAG, "[$side] Init sequence complete") - if (highPriority) requestPriority(g) - false // no GATT op — advance immediately + val channel = CommonChannel.find(g) + if (channel == null) { + fail("Missing BLE characteristics") + return } + common = CommonSession( + gatt = g, + channel = channel, + side = side, + ops = ops, + initialLedBitmask = assignedPlayer?.ledBitmask ?: ControllerCommands.LED_ALL_ON, + onInput = ::publishInput, + onAccentColor = ::publishAccentColor, + onReady = { + onProtocolReady() + mainHandler.postDelayed({ fallBackToConsoleIfSilent(g) }, CONSOLE_FALLBACK_MS) + }, + ).also { it.start() } } override fun onDescriptorWrite( g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int ) { Log.i(TAG, "[$side] CCCD write status=$status") - mainHandler.post { opQueue.complete() } + ops.complete(status) } override fun onCharacteristicWrite( g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int ) { Log.d(TAG, "[$side] Char write status=$status initComplete=$initComplete") - val delay = if (initComplete) 0L else INIT_GAP_MS - mainHandler.postDelayed({ opQueue.complete() }, delay) + ops.complete(status) } @Deprecated("Deprecated in Java - used for API < 33") override fun onCharacteristicChanged( g: BluetoothGatt, ch: BluetoothGattCharacteristic ) { - handleCharacteristicChanged(g, ch.uuid, ch.value ?: return) + handleCharacteristicChanged(ch.uuid, ch.value ?: return) } override fun onCharacteristicChanged( g: BluetoothGatt, ch: BluetoothGattCharacteristic, value: ByteArray ) { - handleCharacteristicChanged(g, ch.uuid, value) + handleCharacteristicChanged(ch.uuid, value) } } - fun setHighPriority(enabled: Boolean) { - highPriority = enabled - if (initComplete) gatt?.let(::requestPriority) + private fun handleCharacteristicChanged(uuid: UUID, data: ByteArray) { + if (uuid == CommonChannel.INPUT) { + commonReportSeen = true + // Only a controller that speaks the common protocol notifies here, so the fallback misfired. + if (console != null && common != null) returnToCommonChannel() + } else if (console == null && uuid == CommonChannel.COMMAND_RESPONSE) { + commandReplySeen = true + } + protocol?.onCharacteristicChanged(uuid, data) } - // The connection interval is the report rate: docs/protocol.md#android-ble-gotchas - private fun requestPriority(g: BluetoothGatt) { - val priority = if (highPriority) { - BluetoothGatt.CONNECTION_PRIORITY_HIGH - } else { - BluetoothGatt.CONNECTION_PRIORITY_BALANCED + 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 || commonReportSeen || gatt !== g) return + val channel = ConsoleChannel.find(g) + if (channel == null) { + fail("Not a compatible Joy-Con 2") + return } - Log.i(TAG, "[$side] Connection priority high=$highPriority accepted=${g.requestConnectionPriority(priority)}") + ops.clear() + initComplete = false + _connectionState.value = _connectionState.value.copy(ready = false) + console = ConsoleSession( + gatt = g, + channel = channel, + ops = ops, + initialLedBitmask = assignedPlayer?.ledBitmask ?: ControllerCommands.LED_ALL_ON, + onInput = ::publishInput, + onAccentColor = ::publishAccentColor, + onReady = ::onProtocolReady, + ).also { it.start() } } - fun setPlayerLed(player: PlayerNumber) { - pendingPlayerLed = player - if (!initComplete) return - val g = gatt ?: return - opQueue.enqueue { sendLedCommand(g) } + private fun returnToCommonChannel() { + Log.i(TAG, "[$side] Common-channel report arrived; leaving the console protocol") + stopConsoleSession() + common?.resume() + initComplete = true + _connectionState.value = _connectionState.value.copy( + connected = true, ready = true, deviceName = deviceName + ) + protocol?.setHighPriority(highPriority) } - fun clearPlayerLed() { - pendingPlayerLed = null - if (!initComplete) return - val g = gatt ?: return - opQueue.enqueue { sendLedCommand(g) } + private fun onProtocolReady() { + initComplete = true + _connectionState.value = _connectionState.value.copy( + connected = true, ready = true, deviceName = deviceName + ) + if (highPriority) protocol?.setHighPriority(true) + onReady() } - private fun sendLedCommand(g: BluetoothGatt): Boolean { - val pending = pendingPlayerLed - pendingPlayerLed = null - val cmd = if (pending != null) playerLedCmd(pending.ledBitmask) else LED_ALL_ON_CMD - Log.i(TAG, "[$side] Sending LED cmd: ${cmd.joinToString(" ") { "%02X".format(it) }}") - return writeCharacteristic(g, writeChar!!, cmd) + // Everything that belongs to one link, so the next one starts its own detection over. + private fun endSession(g: BluetoothGatt) { + stopProtocols() + ops.clear() + g.close() + gatt = null + initComplete = false + securityRequested = false + servicesDiscovered = false + commonReportSeen = false + commandReplySeen = false } - private fun enqueueInitWrite(g: BluetoothGatt, bytes: ByteArray) { - opQueue.enqueue { writeCharacteristic(g, writeChar!!, bytes) } + /** + * Android keeps this app's pending L2CAP connect armed after a drop, so the controller is + * reconnected to on its next SYNC whatever we do: docs/protocol.md#android-workarounds. Holding + * the pool entry and asking for that connection makes it ours, dialog suppression included. + */ + private fun awaitReturn(): Boolean { + val target = device ?: return false + val reopened = target.connectGatt(context, true, gattCallback, BluetoothDevice.TRANSPORT_LE) + ?: return false + Log.i(TAG, "[$side] Waiting for the controller to come back") + gatt = reopened + reconnecting = true + _connectionState.value = JoyconConnectionState(connecting = true, deviceName = deviceName) + mainHandler.postDelayed(::stopAwaitingReturn, RECONNECT_WINDOW_MS) + return true } - private fun handleCharacteristicChanged(g: BluetoothGatt, uuid: UUID, data: ByteArray) { - when (uuid) { - NOTIFY_CHAR -> { - PacketParser.parse(data, side)?.let { _input.value = stickCalibrator.calibrate(it) } - if (!ledSentAfterFirstPacket && initComplete) { - ledSentAfterFirstPacket = true - mainHandler.post { opQueue.enqueue { sendLedCommand(g) } } - } - } - CMD_RESPONSE_CHAR -> { - 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)}") - _connectionState.value = _connectionState.value.copy(accentColor = color) - } - } - } + private fun stopAwaitingReturn() { + if (!reconnecting) return + reconnecting = false + Log.i(TAG, "[$side] Gave up waiting for the controller") + gatt?.disconnect() + gatt?.close() + gatt = null + _connectionState.value = JoyconConnectionState(deviceName = deviceName) + onDisconnected?.invoke() } - private fun writeCharacteristic( - g: BluetoothGatt, - ch: BluetoothGattCharacteristic, - value: ByteArray, - ): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - g.writeCharacteristic(ch, value, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) == - BluetoothStatusCodes.SUCCESS - } else { - @Suppress("DEPRECATION") - ch.value = value - @Suppress("DEPRECATION") - g.writeCharacteristic(ch) - } + private fun stopConsoleSession() { + console?.stop() + console = null } - private fun writeDescriptor( - g: BluetoothGatt, - descriptor: BluetoothGattDescriptor, - value: ByteArray, - ): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - g.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS - } else { - @Suppress("DEPRECATION") - descriptor.value = value - @Suppress("DEPRECATION") - g.writeDescriptor(descriptor) - } + private fun stopProtocols() { + stopConsoleSession() + common?.stop() + common = null + } + + private fun publishInput(input: JoyconInput) { + _input.value = stickCalibrator.calibrate(input) + } + + private fun publishAccentColor(color: Int) { + _connectionState.value = _connectionState.value.copy(accentColor = color) } + private fun fail(message: String) { + _connectionState.value = JoyconConnectionState(error = message, deviceName = deviceName) + } } 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 ea9fdd32..2d5bf264 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 @@ -1,5 +1,6 @@ package com.joegec.joycon2android.connection +import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.Side @@ -46,7 +47,7 @@ object PacketParser { gyroX = bb.getShort(0x36).toInt(), gyroY = bb.getShort(0x38).toInt(), gyroZ = bb.getShort(0x3A).toInt(), - batteryVolts = (bb.getShort(0x1F).toInt() and 0xFFFF) / 1000f, + battery = BatteryCharge.fromVolts((bb.getShort(0x1F).toInt() and 0xFFFF) / 1000f), ) } @@ -85,6 +86,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 00000000..5578ea06 --- /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 00000000..f8c90e16 --- /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 00000000..26250b41 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleCommands.kt @@ -0,0 +1,40 @@ +package com.joegec.joycon2android.connection.console + +import com.joegec.joycon2android.connection.ControllerCommands.command +import com.joegec.joycon2android.connection.ControllerCommands.enableFeatures +import com.joegec.joycon2android.connection.ControllerCommands.selectFeatures + +/** Command frames, in the order a console sends them: docs/protocol.md#console-protocol-controllers */ +internal object ConsoleCommands { + + 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 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). + private const val FEATURES: Byte = 0x37 + val FEATURES_INIT = selectFeatures(FEATURES) + val FEATURES_ENABLE = enableFeatures(FEATURES) + + 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 + + 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 00000000..d4c1b59f --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParser.kt @@ -0,0 +1,98 @@ +package com.joegec.joycon2android.connection.console + +import com.joegec.joycon2android.connection.PacketParser +import com.joegec.joycon2android.model.BatteryCharge +import com.joegec.joycon2android.model.JoyconInput +import com.joegec.joycon2android.model.MotionSupport +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 + + // Motion block: docs/protocol.md#motion-block + private const val MOTION_LEFT = 0x0F + private const val MOTION_RIGHT = 0x10 + private const val ACCEL_IN_BLOCK = 0x12 + private const val BLOCK_WITH_ACCEL = 0x1C + private const val AXIS_STRIDE = 4 + private const val AXES = 3 + private val NO_AXES = IntArray(AXES) + + 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) + val accel = accel(report, side) + val axes = accel ?: NO_AXES + + return JoyconInput( + packetId = packetId, + buttons = buttons, + pressed = PacketParser.decodeButtons(buttons), + stickX = stick and 0xFFF, + stickY = stick shr 12 and 0xFFF, + battery = BatteryCharge.fromLevel(level, MAX_BATTERY_LEVEL), + accelX = axes[0], + accelY = -axes[1], + accelZ = axes[2], + motionSupport = if (accel == null) MotionSupport.None else MotionSupport.AccelerometerOnly, + ) + } + + // Each axis is an int16 in the high half of its word; y runs opposite a genuine Joy-Con 2. + // Null for a block too short to hold them, or one whose words are not shaped that way at all. + private fun accel(report: ByteArray, side: Side): IntArray? { + val block = if (side == Side.LEFT) MOTION_LEFT else MOTION_RIGHT + val first = block + ACCEL_IN_BLOCK + if (report.size < first + (AXES - 1) * AXIS_STRIDE + 2) return null + if (u8(report, block - 1) < BLOCK_WITH_ACCEL) return null + if ((0 until AXES).any { axis -> lowHalf(report, first + axis * AXIS_STRIDE) != 0 }) return null + return IntArray(AXES) { axis -> i16(report, first + axis * AXIS_STRIDE) } + } + + private fun lowHalf(report: ByteArray, axis: Int) = u8(report, axis - 2) or u8(report, axis - 1) + + private fun i16(data: ByteArray, index: Int): Int { + val value = u8(data, index) or (u8(data, index + 1) shl 8) + return if (value and 0x8000 != 0) value - 0x10000 else value + } + + 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/ConsoleSession.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleSession.kt new file mode 100644 index 00000000..54b56bb6 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleSession.kt @@ -0,0 +1,152 @@ +package com.joegec.joycon2android.connection.console + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.util.Log +import com.joegec.joycon2android.connection.ControllerCommands +import com.joegec.joycon2android.connection.ControllerProtocol +import com.joegec.joycon2android.connection.GattOpQueue +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 + +/** Start-up sequence: docs/protocol.md#console-protocol-controllers */ +@SuppressLint("MissingPermission") +internal class ConsoleSession( + private val gatt: BluetoothGatt, + private val channel: ConsoleChannel, + private val ops: GattOpQueue, + initialLedBitmask: Byte, + private val onInput: (JoyconInput) -> Unit, + private val onAccentColor: (Int) -> Unit, + private val onReady: () -> Unit, +) : ControllerProtocol { + 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, ops) + 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 + + override fun start() { + Log.i(TAG, "[$label] Using the console protocol") + linkHolder.start() + submit(::initialise) + } + + override fun stop() { + stopped = true + linkHolder.stop() + transport.close() + ops.clear() + worker.shutdownNow() + } + + override fun onPairingFailed() { + linkHolder.holdNow() + } + + // 7.5 ms is worth far more here than on the common channel: docs/protocol.md#android-ble-gotchas + override fun setHighPriority(enabled: Boolean) { + if (enabled) { + submit { ConnectionInterval.requestFastest(gatt) } + return + } + val accepted = gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_BALANCED) + Log.i(TAG, "[$label] Connection priority high=false accepted=$accepted") + } + + override fun setPlayerLed(bitmask: Byte) { + ledBitmask = bitmask + submit { transport.send(ControllerCommands.playerLed(bitmask)) } + } + + override 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() + 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(ControllerCommands.spiRead(0x40, ControllerCommands.DEVICE_INFO_ADDRESS)) + ?.let(SpiColorParser::parseAccentColor) + ?.let(onAccentColor) + transport.send(ConsoleCommands.FIRMWARE_INFO) + transport.send(ConsoleCommands.UNKNOWN_16_01) + } + + private fun configure() { + transport.send(ConsoleCommands.VIBRATION_SAMPLE) + transport.send(ControllerCommands.playerLed(ledBitmask)) + transport.send(ConsoleCommands.FEATURES_INIT) + ConsoleCommands.SPI_READS_AFTER_FEATURES_INIT.forEach { (length, address) -> + transport.send(ControllerCommands.spiRead(length, address)) + } + transport.send(ConsoleCommands.UNKNOWN_11_03) + transport.send( + ControllerCommands.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 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 00000000..01530e3c --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleTransport.kt @@ -0,0 +1,112 @@ +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 com.joegec.joycon2android.connection.ControllerCommands +import com.joegec.joycon2android.connection.GattOpQueue +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, + private val ops: GattOpQueue, +) { + companion object { + private const val TAG = "Joycon2" + private const val REPLY_TIMEOUT_MS = 700L + private const val OPERATION_TIMEOUT_MS = 3_000L + } + + private val replies = LinkedBlockingQueue() + @Volatile private var closed = false + + fun close() { + closed = true + } + + 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(ControllerCommands.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 - ControllerCommands.HEADER_LENGTH) { + if (value[i] == command[0] && value[i + 1] == ControllerCommands.REPLY_MARKER && value[i + 3] == command[3]) { + return i + } + } + return -1 + } + + private fun runOperation(start: () -> Boolean): Boolean { + if (closed) return false + return ops.awaitOp(OPERATION_TIMEOUT_MS, start) == 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 00000000..3778e21f --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/LinkHolder.kt @@ -0,0 +1,72 @@ +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 FIRST_RETRY_DELAY_MS = 200L + private const val MAX_RETRY_DELAY_MS = 5_000L + } + + @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({ + var retryDelay = FIRST_RETRY_DELAY_MS + while (running) { + if (attempt() >= QUICK_FAILURE_MS) { + retryDelay = FIRST_RETRY_DELAY_MS + continue + } + SystemClock.sleep(retryDelay) + retryDelay = (retryDelay * 2).coerceAtMost(MAX_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 00000000..2430c296 --- /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/ControllerCommandsTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ControllerCommandsTest.kt new file mode 100644 index 00000000..f66a88c9 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ControllerCommandsTest.kt @@ -0,0 +1,63 @@ +package com.joegec.joycon2android.connection + +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class ControllerCommandsTest { + + // The frames both protocols sent before one builder produced them. + @Test + fun `builds the feature commands`() { + assertArrayEquals( + bytes(0x0C, 0x91, 0x01, 0x02, 0x00, 0x04, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00), + ControllerCommands.selectFeatures(0xFF.toByte()), + ) + assertArrayEquals( + bytes(0x0C, 0x91, 0x01, 0x04, 0x00, 0x04, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00), + ControllerCommands.enableFeatures(0xFF.toByte()), + ) + assertArrayEquals( + bytes(0x0C, 0x91, 0x01, 0x02, 0x00, 0x04, 0x00, 0x00, 0x37, 0x00, 0x00, 0x00), + ControllerCommands.selectFeatures(0x37), + ) + } + + @Test + fun `builds the player LED command`() { + assertArrayEquals( + bytes(0x09, 0x91, 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, 0x0F, 0, 0, 0, 0, 0, 0, 0), + ControllerCommands.playerLed(ControllerCommands.LED_ALL_ON), + ) + assertArrayEquals( + bytes(0x09, 0x91, 0x01, 0x07, 0x00, 0x08, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0), + ControllerCommands.playerLed(0x01), + ) + } + + // The common path has always read the colour over transport 0x00, the console one over 0x01. + @Test + fun `builds the SPI read on either transport`() { + assertArrayEquals( + bytes(0x02, 0x91, 0x00, 0x04, 0x00, 0x08, 0x00, 0x00, 0x40, 0x7E, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00), + ControllerCommands.spiRead(0x40, ControllerCommands.DEVICE_INFO_ADDRESS, ControllerCommands.USB), + ) + assertArrayEquals( + bytes(0x02, 0x91, 0x01, 0x04, 0x00, 0x08, 0x00, 0x00, 0x40, 0x7E, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00), + ControllerCommands.spiRead(0x40, ControllerCommands.DEVICE_INFO_ADDRESS), + ) + assertArrayEquals( + bytes(0x02, 0x91, 0x01, 0x04, 0x00, 0x08, 0x00, 0x00, 0x18, 0x7E, 0x00, 0x00, 0x00, 0x31, 0x01, 0x00), + ControllerCommands.spiRead(0x18, 0x013100), + ) + } + + @Test + fun `a command with no data still carries a full header`() { + assertArrayEquals( + bytes(0x07, 0x91, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00), + ControllerCommands.command(0x07, 0x01), + ) + } + + private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() } +} 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 00000000..ed7f642f --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParserTest.kt @@ -0,0 +1,117 @@ +package com.joegec.joycon2android.connection.console + +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.MotionSupport +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") + + // Captured flat, button face up, so gravity sits on z at 4096 = 1 g. + private val flatRight = hex( + "46 18 00 00 07 00 B8 7F 00 00 00 00 00 00 00 1E FF 86 01 0C 02 00 33 56 01 2F 0F 2C 00 " + + "3A AB 89 00 00 02 01 00 00 84 FF 00 00 F6 0F 00 00 00 00 00 00 00 00 00 00 00 00 00 " + + "00 00 00 00 00 00", + ) + private val flatLeft = hex( + "46 18 00 00 07 F1 17 82 00 00 00 00 00 00 1E FC 8E 01 0C 00 2E 88 BA 01 8E 52 08 01 A3 " + + "ED 77 00 00 99 FF 00 00 1C 00 00 00 EF 0F 00 00 00 00 00 00 00 00 00 00 00 00 00 00 " + + "00 00 00 00 00 00", + ) + + 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 percentage of the nine levels`() { + assertEquals(100, ConsolePacketParser.parse(report(0, power = 9 shl 2), Side.RIGHT, 1)!!.battery?.percent) + assertEquals(55, ConsolePacketParser.parse(report(0, power = 5 shl 2), Side.RIGHT, 1)!!.battery?.percent) + } + + @Test + fun `decodes the accelerometer, gravity on z, with y against the raw axis`() { + val input = ConsolePacketParser.parse(flatRight, Side.RIGHT, 1)!! + assertEquals(258, input.accelX) + assertEquals(124, input.accelY) + assertEquals(4086, input.accelZ) + } + + @Test + fun `the left controller's motion block starts a byte earlier`() { + val input = ConsolePacketParser.parse(flatLeft, Side.LEFT, 1)!! + assertEquals(-103, input.accelX) + assertEquals(-28, input.accelY) + assertEquals(4079, input.accelZ) + } + + @Test + fun `reports an accelerometer without a gyroscope`() { + assertEquals(MotionSupport.AccelerometerOnly, ConsolePacketParser.parse(flatRight, Side.RIGHT, 1)!!.motionSupport) + } + + @Test + fun `a block too short to hold motion reports none rather than a reading of zero`() { + val starting = flatRight.copyOf().also { it[0x0F] = 0x04 } + + val input = ConsolePacketParser.parse(starting, Side.RIGHT, 1)!! + + assertEquals(MotionSupport.None, input.motionSupport) + assertEquals(0, input.accelZ) + } + + @Test + fun `a motion block shaped some other way reports none`() { + val foreign = flatRight.copyOf().also { it[0x20] = 0x01 } + + assertEquals(MotionSupport.None, ConsolePacketParser.parse(foreign, Side.RIGHT, 1)!!.motionSupport) + } + + @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/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/BatteryPill.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/BatteryPill.kt index 3b993b9f..64b00ffb 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/BatteryPill.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/BatteryPill.kt @@ -18,16 +18,16 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import com.joegec.joycon2android.connection.presentation.R -import com.joegec.joycon2android.model.BatteryGauge +import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.ui.theme.AccentDim import com.joegec.joycon2android.ui.theme.AppType import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.batteryColor @Composable -internal fun BatteryPill(volts: Float, modifier: Modifier = Modifier) { +internal fun BatteryPill(charge: BatteryCharge, modifier: Modifier = Modifier) { BatteryReadout( - volts = volts, + charge = charge, modifier = modifier .background(AccentDim, RoundedCornerShape(Dimens.pillCorner)) .padding(horizontal = Dimens.pillPaddingHorizontal, vertical = Dimens.pillPaddingVertical), @@ -35,8 +35,8 @@ internal fun BatteryPill(volts: Float, modifier: Modifier = Modifier) { } @Composable -internal fun BatteryReadout(volts: Float, modifier: Modifier = Modifier) { - val percent = BatteryGauge.percentFromVolts(volts) +internal fun BatteryReadout(charge: BatteryCharge, modifier: Modifier = Modifier) { + val percent = charge.percent val color = batteryColor(percent) Row(modifier, verticalAlignment = Alignment.CenterVertically) { BatteryIcon(percent, color) @@ -52,9 +52,8 @@ internal fun BatteryReadout(volts: Float, modifier: Modifier = Modifier) { } @Composable -internal fun BatteryGlyph(volts: Float, modifier: Modifier = Modifier) { - val percent = BatteryGauge.percentFromVolts(volts) - BatteryIcon(percent, batteryColor(percent), modifier) +internal fun BatteryGlyph(charge: BatteryCharge, modifier: Modifier = Modifier) { + BatteryIcon(charge.percent, batteryColor(charge.percent), modifier) } @Composable diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt index 0dce09eb..99a5a117 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt @@ -98,7 +98,7 @@ private fun ControllerChip( horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), ) { val battery = @Composable { - if (joycon.input.batteryVolts > 0f) BatteryGlyph(joycon.input.batteryVolts) + joycon.input.battery?.let { BatteryGlyph(it) } } if (batteryFirst) battery() diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerButtons.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerButtons.kt index 6f2a6dcc..5bac11bc 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerButtons.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerButtons.kt @@ -29,11 +29,11 @@ internal fun MinusBatteryRow(input: JoyconInput, pressed: Set, modifier: verticalAlignment = Alignment.CenterVertically, ) { AnimatedVisibility( - visible = input.batteryVolts > 0f, + visible = input.battery != null, enter = fadeIn(), exit = fadeOut(), ) { - BatteryPill(input.batteryVolts) + input.battery?.let { BatteryPill(it) } } SmallButton(JoyconButton.Minus.label, JoyconButton.Minus.id in pressed) } @@ -48,11 +48,11 @@ internal fun PlusBatteryRow(input: JoyconInput, pressed: Set, modifier: ) { SmallButton(JoyconButton.Plus.label, JoyconButton.Plus.id in pressed) AnimatedVisibility( - visible = input.batteryVolts > 0f, + visible = input.battery != null, enter = fadeIn(), exit = fadeOut(), ) { - BatteryPill(input.batteryVolts) + input.battery?.let { BatteryPill(it) } } } } diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt index d986f24b..055e4b76 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ImuDisplay.kt @@ -20,8 +20,17 @@ import com.joegec.joycon2android.ui.theme.TextDim @Composable internal fun ImuDisplay(input: JoyconInput, modifier: Modifier = Modifier) { Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(Dimens.imuSectionSpacing)) { - SensorRow(stringResource(R.string.imu_accel), "X" to input.accelX, "Y" to input.accelY, "Z" to input.accelZ) - SensorRow(stringResource(R.string.imu_gyro), "X" to input.gyroX, "Y" to input.gyroY, "Z" to input.gyroZ) + if (input.motionSupport.measuresAcceleration) { + SensorRow(stringResource(R.string.imu_accel), "X" to input.accelX, "Y" to input.accelY, "Z" to input.accelZ) + } else { + AbsentRow(stringResource(R.string.imu_accel)) + } + if (input.motionSupport.measuresRotation) { + SensorRow(stringResource(R.string.imu_gyro), "X" to input.gyroX, "Y" to input.gyroY, "Z" to input.gyroZ) + } else { + AbsentRow(stringResource(R.string.imu_gyro)) + NoGyroNote() + } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { ImuLabel(stringResource(R.string.imu_packet)) ImuValue(input.packetId.toString()) @@ -29,6 +38,14 @@ internal fun ImuDisplay(input: JoyconInput, modifier: Modifier = Modifier) { } } +@Composable +private fun AbsentRow(title: String) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + ImuLabel(title) + ImuValue(stringResource(R.string.imu_sensor_absent)) + } +} + @Composable private fun SensorRow(title: String, vararg axes: Pair) { Column(verticalArrangement = Arrangement.spacedBy(Dimens.imuTitleGap)) { diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/NoGyroNote.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/NoGyroNote.kt new file mode 100644 index 00000000..ba77cc9b --- /dev/null +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/NoGyroNote.kt @@ -0,0 +1,26 @@ +package com.joegec.joycon2android.connection.presentation + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.connection.presentation.R +import com.joegec.joycon2android.ui.theme.AppType +import com.joegec.joycon2android.ui.theme.Dimens +import com.joegec.joycon2android.ui.theme.WarningText + +// So that motion doing nothing in an emulator reads as the controller's limit, not a fault. +@Composable +internal fun NoGyroNote(modifier: Modifier = Modifier) { + Text( + stringResource(R.string.imu_no_gyro), + modifier, + color = WarningText, + fontSize = Dimens.fontSizeLabel, + style = noteTextStyle, + ) +} + +private val noteTextStyle = AppType.telemetry.copy( + lineHeight = Dimens.fontSizeLabel * 1.3f, +) diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt index 48b2e2df..bb44ebdc 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt @@ -67,7 +67,7 @@ private fun ButtonRow(state: PlayerState, pressed: Set) { verticalAlignment = Alignment.CenterVertically, ) { SmallButton(JoyconButton.Minus.label, JoyconButton.Minus.id in pressed) - BatteryPill(state.leftInput.batteryVolts) + state.leftInput.battery?.let { BatteryPill(it) } SmallButton(JoyconButton.Plus.label, JoyconButton.Plus.id in pressed) } } diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt index 7da3366d..54aed113 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysImuDisplay.kt @@ -23,6 +23,7 @@ import com.joegec.joycon2android.ui.theme.TextDim internal fun SidewaysImuDisplay(input: JoyconInput, modifier: Modifier = Modifier) { Column(modifier, verticalArrangement = Arrangement.spacedBy(Dimens.imuRowSpacing)) { SensorRow(input) + if (!input.motionSupport.measuresRotation) NoGyroNote() PacketRow(input) } } @@ -33,8 +34,16 @@ private fun SensorRow(input: JoyconInput) { Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.cardPadding), ) { - SensorColumn(stringResource(R.string.imu_accel), input.accelX, input.accelY, input.accelZ, Modifier.weight(1f)) - SensorColumn(stringResource(R.string.imu_gyro), input.gyroX, input.gyroY, input.gyroZ, Modifier.weight(1f)) + if (input.motionSupport.measuresAcceleration) { + SensorColumn(stringResource(R.string.imu_accel), input.accelX, input.accelY, input.accelZ, Modifier.weight(1f)) + } else { + AbsentColumn(stringResource(R.string.imu_accel), Modifier.weight(1f)) + } + if (input.motionSupport.measuresRotation) { + SensorColumn(stringResource(R.string.imu_gyro), input.gyroX, input.gyroY, input.gyroZ, Modifier.weight(1f)) + } else { + AbsentColumn(stringResource(R.string.imu_gyro), Modifier.weight(1f)) + } } } @@ -52,6 +61,14 @@ private fun PacketRow(input: JoyconInput) { } } +@Composable +private fun AbsentColumn(title: String, modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(Dimens.imuTitleGap)) { + ImuText(title, bold = true) + ImuText(stringResource(R.string.imu_sensor_absent)) + } +} + @Composable private fun SensorColumn(title: String, x: Int, y: Int, z: Int, modifier: Modifier = Modifier) { Column(modifier, verticalArrangement = Arrangement.spacedBy(Dimens.imuTitleGap)) { diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysLeftLayout.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysLeftLayout.kt index b3a89694..22ceb8a5 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysLeftLayout.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysLeftLayout.kt @@ -79,11 +79,11 @@ private fun TopRow(state: PlayerState) { ) { SmallButton(JoyconButton.Minus.label, JoyconButton.Minus.id in pressed) AnimatedVisibility( - visible = state.leftInput.batteryVolts > 0f, + visible = state.leftInput.battery != null, enter = fadeIn(), exit = fadeOut(), ) { - BatteryPill(state.leftInput.batteryVolts) + state.leftInput.battery?.let { BatteryPill(it) } } CaptureButton(pressed) } diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysRightLayout.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysRightLayout.kt index d290b666..d5b6539f 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysRightLayout.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/SidewaysRightLayout.kt @@ -85,11 +85,11 @@ private fun TopRow(state: PlayerState) { HomeButton(pressed) } AnimatedVisibility( - visible = state.rightInput.batteryVolts > 0f, + visible = state.rightInput.battery != null, enter = fadeIn(), exit = fadeOut(), ) { - BatteryPill(state.rightInput.batteryVolts) + state.rightInput.battery?.let { BatteryPill(it) } } SmallButton(JoyconButton.Plus.label, JoyconButton.Plus.id in pressed) } diff --git a/feature/connection/presentation/src/main/res/values/strings.xml b/feature/connection/presentation/src/main/res/values/strings.xml index 2ae80421..926a9fa9 100644 --- a/feature/connection/presentation/src/main/res/values/strings.xml +++ b/feature/connection/presentation/src/main/res/values/strings.xml @@ -1,6 +1,8 @@ Accel Gyro + none + This controller has no gyroscope, so motion aiming and tilt will not work. Packet %d%% P%1$d diff --git a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt index 0b0b5227..0b17ceda 100644 --- a/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt +++ b/feature/dsu/data/src/main/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoder.kt @@ -3,8 +3,8 @@ import com.joegec.joycon2android.dsu.motion.DsuMotion import com.joegec.joycon2android.dsu.motion.MotionConverter import com.joegec.joycon2android.dsu.motion.SidewaysMotion -import com.joegec.joycon2android.model.BatteryGauge import com.joegec.joycon2android.model.GamepadState +import com.joegec.joycon2android.model.MotionSupport import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState import java.nio.ByteBuffer @@ -76,7 +76,7 @@ class DsuPacketEncoder( val connected = player?.hasController == true packet.put(slot.toByte()) packet.put(if (connected) SLOT_STATE_CONNECTED else 0) - packet.put(if (connected) MODEL_FULL_GYRO else 0) + packet.put(if (connected) model(player) else 0) packet.put(if (connected) CONNECTION_BLUETOOTH else 0) packet.put(macBytes(player)) packet.put(batteryByte(player)) @@ -88,10 +88,14 @@ class DsuPacketEncoder( return ByteArray(MAC_SIZE) { (parts[it].toIntOrNull(16) ?: 0).toByte() } } + private fun model(player: PlayerState?): Byte = + when (player?.motionSource?.input?.motionSupport) { + MotionSupport.AccelerometerOnly, MotionSupport.None -> MODEL_NO_GYRO + else -> MODEL_FULL_GYRO + } + private fun batteryByte(player: PlayerState?): Byte { - val volts = player?.motionSource?.input?.batteryVolts ?: 0f - if (volts <= 0f) return BATTERY_NA - val percent = BatteryGauge.percentFromVolts(volts) + val percent = player?.motionSource?.input?.battery?.percent ?: return BATTERY_NA return when { percent >= 90 -> BATTERY_FULL percent >= 70 -> BATTERY_HIGH @@ -160,6 +164,7 @@ class DsuPacketEncoder( private const val TYPE_PAD_DATA = 0x100002 private const val SLOT_STATE_CONNECTED: Byte = 2 + private const val MODEL_NO_GYRO: Byte = 1 private const val MODEL_FULL_GYRO: Byte = 2 private const val CONNECTION_BLUETOOTH: Byte = 2 diff --git a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoderTest.kt b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoderTest.kt index a8462f59..3e1fda8b 100644 --- a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoderTest.kt +++ b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuPacketEncoderTest.kt @@ -1,6 +1,7 @@ package com.joegec.joycon2android.dsu import com.joegec.joycon2android.dsu.motion.MotionConverter +import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput @@ -69,7 +70,7 @@ class DsuPacketEncoderTest { assertEquals(2, packet[23].toInt()) // bluetooth val expectedMac = byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte(), 0xEE.toByte(), 0x02) assertArrayEquals(expectedMac, packet.copyOfRange(24, 30)) - assertEquals(0x05, packet[30].toInt()) // 3.6 V → 100% → full + assertEquals(0x05, packet[30].toInt()) // 100% → full assertEquals(1, packet[31].toInt()) // is-connected flag } @@ -152,7 +153,7 @@ class DsuPacketEncoderTest { pressed = setOf(JoyconButton.A.id, JoyconButton.ZR.id), accelX = 4096, gyroZ = 1000, - batteryVolts = 3.6f, + battery = BatteryCharge(100), ), ), ) diff --git a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt index f5597280..89e26c29 100644 --- a/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt +++ b/feature/dsu/data/src/test/kotlin/com/joegec/joycon2android/dsu/DsuServerTest.kt @@ -1,5 +1,6 @@ package com.joegec.joycon2android.dsu +import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.PlayerNumber @@ -159,7 +160,7 @@ class DsuServerTest { address = "AA:BB:CC:DD:EE:02", side = Side.RIGHT, deviceName = "Joy-Con (R)", - input = JoyconInput(accelX = 4096, batteryVolts = 3.6f), + input = JoyconInput(accelX = 4096, battery = BatteryCharge(100)), ), ) @@ -169,7 +170,7 @@ class DsuServerTest { address = "AA:BB:CC:DD:EE:01", side = Side.LEFT, deviceName = "Joy-Con (L)", - input = JoyconInput(accelZ = 4096, batteryVolts = 3.5f), + input = JoyconInput(accelZ = 4096, battery = BatteryCharge(92)), ), ) diff --git a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt index 4967c458..8a218ab9 100644 --- a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt +++ b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt @@ -1,5 +1,6 @@ package com.joegec.joycon2android.gamepad +import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput @@ -23,7 +24,7 @@ class ReportMapperTest { rightStickY = 2048, accelX = 0, accelY = 0, accelZ = 0, gyroX = 0, gyroY = 0, gyroZ = 0, - batteryVolts = 4f, + battery = BatteryCharge(100), ) val pro = ConnectedJoycon(address = "pro", side = Side.PRO, deviceName = "Pro", input = input) return ReportMapper.buildReport(PlayerState(PlayerNumber.P1, left = pro)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 37c29200..5b4aab98 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" }