From d5f9ffa6542bc1455fc9bec6bf10cd2741600e4e Mon Sep 17 00:00:00 2001 From: mygamecache <258696518+mygamecache@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:18:41 -0700 Subject: [PATCH 1/2] Support Joy-Con 2 clones that only speak the console protocol NYXI Hyperion 3 Joy-Cons copy the Joy-Con 2 GATT table but ignore the common command and input characteristics, answering only on the side-specific channel a Switch 2 console uses. They are recognised by the SMP security request they send on connect, or by silence on the common channel after init, and are then driven by the console start-up sequence and input report. Genuine Joy-Con 2s answer the console channel too, so a controller moved over by the timeout returns to the common path as soon as a report arrives there. Android's pairing with these controllers always fails, so the pairing dialog is suppressed and a pending L2CAP channel keeps the link from being dropped afterwards, retried with a growing delay while it fails instantly. A 7.5 ms connection interval is requested, with CONNECTION_PRIORITY_HIGH as the fallback, and every controller asks for its interval again when another one joins the pool. That pending channel outlives the link: the stack keeps it armed as a pending connect and reconnects to the controller on its next SYNC, swallowing the advertisement and leaving a link no GATT client owns, so the pairing dialog returns and powers the controller off. A dropped controller therefore keeps its pool entry and an armed connectGatt for two minutes, which makes that connection its own and brings it back on SYNC without a rescan. Both protocols now run over one operation mechanism and one command builder behind a ControllerProtocol interface, so a GATT callback releases the operation in flight rather than whatever else is waiting, and the common path's frames are built rather than hardcoded. The motion block holds an accelerometer on the same 4096 = 1 g scale as the common report, with y against the genuine axis, and no rotation at all: these send only the 30-byte block type, where a genuine Joy-Con 2 also sends a 40-byte one. Such a controller reports MotionSupport.AccelerometerOnly: DSU advertises the slot as a pad without a gyroscope, and the readout names it and warns that motion aiming cannot work, so nothing downstream reads zeros as a measurement. The console report carries a battery level rather than a voltage, so JoyconInput now holds the charge as a BatteryCharge percentage the UI shows directly. --- README.md | 4 + .../joycon2android/model/BatteryCharge.kt | 13 + .../joycon2android/model/JoyconInput.kt | 3 +- .../joycon2android/model/MotionSupport.kt | 21 + .../joycon2android/model/BatteryChargeTest.kt | 25 ++ docs/protocol.md | 135 ++++++- feature/connection/data/build.gradle.kts | 1 + .../connection/CommonChannel.kt | 31 ++ .../connection/CommonSession.kt | 164 ++++++++ .../connection/ConnectionPool.kt | 20 +- .../connection/ControllerCommands.kt | 41 ++ .../connection/ControllerProtocol.kt | 13 + .../joycon2android/connection/GattOpQueue.kt | 87 +++- .../connection/JoyconConnection.kt | 379 +++++++++--------- .../joycon2android/connection/PacketParser.kt | 5 +- .../connection/console/ConnectionInterval.kt | 32 ++ .../connection/console/ConsoleChannel.kt | 49 +++ .../connection/console/ConsoleCommands.kt | 40 ++ .../connection/console/ConsolePacketParser.kt | 98 +++++ .../connection/console/ConsoleSession.kt | 152 +++++++ .../connection/console/ConsoleTransport.kt | 112 ++++++ .../connection/console/LinkHolder.kt | 72 ++++ .../console/SecurityRequestReceiver.kt | 61 +++ .../connection/ControllerCommandsTest.kt | 63 +++ .../console/ConsolePacketParserTest.kt | 117 ++++++ .../connection/presentation/BatteryPill.kt | 15 +- .../presentation/CompactPlayerRow.kt | 2 +- .../presentation/ControllerButtons.kt | 8 +- .../connection/presentation/ImuDisplay.kt | 21 +- .../connection/presentation/NoGyroNote.kt | 26 ++ .../presentation/ProControllerLayout.kt | 2 +- .../presentation/SidewaysImuDisplay.kt | 21 +- .../presentation/SidewaysLeftLayout.kt | 4 +- .../presentation/SidewaysRightLayout.kt | 4 +- .../src/main/res/values/strings.xml | 2 + .../joycon2android/dsu/DsuPacketEncoder.kt | 15 +- .../dsu/DsuPacketEncoderTest.kt | 5 +- .../joycon2android/dsu/DsuServerTest.kt | 5 +- .../gamepad/ReportMapperTest.kt | 3 +- gradle/libs.versions.toml | 2 + 40 files changed, 1633 insertions(+), 240 deletions(-) create mode 100644 core/model/src/main/kotlin/com/joegec/joycon2android/model/BatteryCharge.kt create mode 100644 core/model/src/main/kotlin/com/joegec/joycon2android/model/MotionSupport.kt create mode 100644 core/model/src/test/kotlin/com/joegec/joycon2android/model/BatteryChargeTest.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonChannel.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerCommands.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerProtocol.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConnectionInterval.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleChannel.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleCommands.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParser.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleSession.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/ConsoleTransport.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/LinkHolder.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/console/SecurityRequestReceiver.kt create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ControllerCommandsTest.kt create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/console/ConsolePacketParserTest.kt create mode 100644 feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/NoGyroNote.kt 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" } From 508c8995f0986b2d7114dcc1736c98e403e0f2ac Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Mon, 12 Oct 2026 15:24:54 +0100 Subject: [PATCH 2/2] Hold the GATT queue for a sent op, and show a dropped controller as lost A GATT callback can't say which op it answers, so a sent op now holds the queue until its own callback or timeout, even after a protocol switch clears it. Each op arms its own timeout before it runs, so a stale one can't release a later op, and an op's pacing gap can't be skipped. Timers go through OpScheduler so GattOpQueue is unit tested. A dropped controller is marked Connection lost on its player and unassigned cards instead of showing as connecting, and a scan that finds it reconnects it rather than skipping a known address. ReturnWindow owns the two-minute hold, so a second drop gets a window of its own. Only a controller that reached ready is held. Docs: Android workarounds as facts, LinkHolder's Android 10 floor, and a section on coming back after a drop. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- .../joycon2android/model/ConnectedJoycon.kt | 1 + .../model/JoyconConnectionState.kt | 2 + docs/DESIGN.md | 3 + docs/protocol.md | 88 +++++++------ .../presentation/AssignmentPanel.kt | 14 +- .../src/main/res/values/strings.xml | 1 + .../joycon2android/connection/BleScanner.kt | 8 +- .../connection/ConnectionPool.kt | 8 +- .../joycon2android/connection/GattOpQueue.kt | 97 +++++++++----- .../connection/Joycon2Manager.kt | 2 +- .../connection/JoyconConnection.kt | 46 ++++--- .../connection/MainThreadScheduler.kt | 21 +++ .../joycon2android/connection/OpScheduler.kt | 7 + .../joycon2android/connection/ReturnWindow.kt | 29 +++++ .../connection/GattOpQueueTest.kt | 123 ++++++++++++++++++ .../connection/ManualScheduler.kt | 34 +++++ .../connection/ReturnWindowTest.kt | 53 ++++++++ .../presentation/CompactPlayerRow.kt | 13 +- .../presentation/DualJoyconLayout.kt | 4 +- .../connection/presentation/JoyconCard.kt | 15 ++- .../presentation/ProControllerLayout.kt | 2 +- .../presentation/SidewaysLeftLayout.kt | 2 +- .../presentation/SidewaysRightLayout.kt | 2 +- .../src/main/res/values/strings.xml | 2 + 25 files changed, 471 insertions(+), 110 deletions(-) create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/MainThreadScheduler.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/OpScheduler.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ReturnWindow.kt create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/GattOpQueueTest.kt create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ManualScheduler.kt create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ReturnWindowTest.kt diff --git a/README.md b/README.md index 154583fa..ba2493ac 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ as an ordinary gamepad. 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. + these is accelerometer-only: they send no gyroscope data, so gyro aiming can't work. They need + Android 10 or later to stay connected; on older versions they drop about 30 seconds in. ## Setup guide @@ -231,6 +232,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 | +| Controller shows "Connection lost" | Press SYNC on it and it reconnects to the same player by itself, or tap Scan. After two minutes it's removed | | 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 | diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt index 839e9f2c..a9d6d957 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt @@ -10,4 +10,5 @@ data class ConnectedJoycon( val ready: Boolean = false, ) { val accentColor: Int? get() = connectionState.accentColor + val connectionLost: Boolean get() = connectionState.connectionLost } diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt index 92d5e245..dc32c4ab 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconConnectionState.kt @@ -4,6 +4,8 @@ data class JoyconConnectionState( val connected: Boolean = false, val connecting: Boolean = false, val ready: Boolean = false, + /** Dropped, and held for a while in case it comes back. */ + val connectionLost: Boolean = false, val deviceName: String? = null, val error: String? = null, /** 0xRRGGBB from SPI flash; null until read, or unset on the controller. */ diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8f376a96..ff4a1aa8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -27,6 +27,9 @@ top-level `Color` vals used directly. One teal accent, plus each controller's ow **Status** - `ErrorText` — `#FF6B6B` / `ErrorBg` — `#2D1B1B` +- `WarningText` — `#FFB454` / `WarningBg` — `#2D2113`: a limit or a recoverable state, not a fault — + the no-gyroscope note, and a dropped controller's **Connection lost** (a `WarningBox` at the top + of its player card, a link-off glyph in the compact row, a label in the unassigned card) - Battery ramp (`batteryColor()`): high `Accent` · medium `#FBBF24` · low `BatteryLow #FF8A8A` (a lighter red than `ErrorText` so the low % clears AA on the `AccentDim` pill). Shown with a battery icon whose fill tracks the level, so it isn't colour-only. diff --git a/docs/protocol.md b/docs/protocol.md index f36805c0..ab060ca1 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -299,51 +299,61 @@ left `[2]` Down Right Left Up L ZL − LS, `[3]` Capture `0x01`, SR `0x40`, SL ` ### 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. +**Detection.** These controllers send an SMP Security Request on every connection; a genuine +Joy-Con 2 never does. Android pairs one device at a time, so a second clone connecting while the +first one's pairing is pending sends none, and silence on the common channel 1.5 s after init +switches it over instead. Genuine Joy-Con 2s answer the console channel too, so a controller moved +over by that timeout returns to the common path on its first report on `...fd2`, which only a +controller speaking that protocol sends. + +**Holding the link.** The pairing never succeeds (Confirm Value Failed, or a 30 s timeout): + +| Problem | Workaround | +|---|---| +| A system pairing dialog appears | `SecurityRequestReceiver` aborts the ordered `ACTION_PAIRING_REQUEST` broadcast | +| `l2cu_start_post_bond_timer` drops the link 3 s after the pairing fails, unless it carries a dynamic L2CAP channel | `LinkHolder` keeps a pending `createInsecureL2capChannel(0x80).connect()` on the link. The controller never answers LE credit-based connection requests, so each attempt pends ~20 s and is replaced when it ends | + +- `createInsecureL2capChannel` is API 29+. Below Android 10 nothing holds the link, and a clone + drops ~33 s after connecting. +- An attempt that fails at once (a stack that rejects the request) backs off from 200 ms to 5 s. +- Both workarounds depend on AOSP Bluetooth internals and may break on a future release. +- The pending channel outlives a drop: see [Coming back after a drop](#coming-back-after-a-drop). + +**Connection interval.** High priority settles at 15 ms for these controllers (~67 reports/s). +`ConnectionInterval` asks the hidden `BluetoothGatt.requestLeConnectionUpdate` for 7.5 ms, the LE +minimum, 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 controller asks again when another one joins, +since Android can slow an existing connection when one does. + +## Coming back after a drop + +Every controller, genuine or clone, is held for two minutes after it drops (`ReturnWindow`): + +- `JoyconConnection` keeps its pool entry, player slot and LEDs, and arms + `connectGatt(autoConnect = true)`. +- The UI marks it **Connection lost** and asks for SYNC, on the player card and the unassigned card. +- A scan that finds it hands it to the waiting connection (`ConnectionPool.connect` → + `reconnect`), which connects directly instead of skipping a known address. +- After two minutes, or a tap on Disconnect, it leaves the pool as any dropped controller did. +- Only a controller that reached ready is held. One that never finished connecting leaves at once. + +The hold exists for clones. When a clone drops, the stack turns `LinkHolder`'s in-flight `connect()` +into a pending direct connection, and closing the socket does not remove it; only process death +does. The phone then connects to the controller as soon as it advertises (`initiator:local` in the +HCI log), which consumes the advertisement, so a scan never sees it. Without a GATT client of ours on +that link, nothing aborts the pairing broadcast, and the failed pairing powers the controller off. +Holding the pool entry and arming `autoConnect` makes that link ours. A clone returns on its next +SYNC in ~6 s (5.9 s and 6.8 s, RedMagic Astra, 2026-10-05). ## 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` 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. + `GattOpQueue` carries every operation on a connection, whichever protocol issues it. A callback + doesn't say which operation it answers, so a sent operation holds the queue until its callback or + its own timeout, even after the queue is cleared for a protocol switch. 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/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt index 3d4a36d6..8eaa8412 100644 --- a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt @@ -42,6 +42,7 @@ import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.JoyconDefaultColor import com.joegec.joycon2android.ui.theme.TextDim import com.joegec.joycon2android.ui.theme.TextOnAccent +import com.joegec.joycon2android.ui.theme.WarningText import com.joegec.joycon2android.ui.theme.joyconBorderColor @Composable @@ -113,7 +114,18 @@ private fun JoyconAssignmentRow( style = MaterialTheme.typography.titleSmall, ) AnimatedVisibility( - visible = !joycon.ready, + visible = joycon.connectionLost, + enter = fadeIn() + expandHorizontally(), + exit = fadeOut() + shrinkHorizontally(), + ) { + Text( + stringResource(R.string.status_connection_lost), + color = WarningText, + style = MaterialTheme.typography.bodySmall, + ) + } + AnimatedVisibility( + visible = !joycon.ready && !joycon.connectionLost, enter = fadeIn() + expandHorizontally(), exit = fadeOut() + shrinkHorizontally(), ) { diff --git a/feature/assignment/presentation/src/main/res/values/strings.xml b/feature/assignment/presentation/src/main/res/values/strings.xml index ebd74d75..8bb8164e 100644 --- a/feature/assignment/presentation/src/main/res/values/strings.xml +++ b/feature/assignment/presentation/src/main/res/values/strings.xml @@ -4,6 +4,7 @@ Right Pro CONNECTING… + CONNECTION LOST · PRESS SYNC Unassigned Controllers Disconnect Tip: press L + R or SL + SR to assign players! diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt index 909e879b..18230547 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt @@ -36,12 +36,12 @@ class BleScanner(context: Context) { val isAvailable: Boolean get() = adapter?.bluetoothLeScanner != null - fun start(isKnownAddress: (String) -> Boolean) { + fun start(isConnected: (String) -> Boolean) { if (isScanning) return val scanner = adapter?.bluetoothLeScanner ?: return isScanning = true - scanner.startScan(null, lowLatencySettings(), createCallback(isKnownAddress)) + scanner.startScan(null, lowLatencySettings(), createCallback(isConnected)) Log.i(TAG, "Scanning for Joy-Con 2 controllers...") scheduleTimeout() } @@ -56,7 +56,7 @@ class BleScanner(context: Context) { private var activeCallback: ScanCallback? = null - private fun createCallback(isKnownAddress: (String) -> Boolean): ScanCallback { + private fun createCallback(isConnected: (String) -> Boolean): ScanCallback { val callback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { if (!isScanning) return @@ -64,7 +64,7 @@ class BleScanner(context: Context) { logAdvertisement(result, manufacturerData) // Only the bonded host can connect to a wake advert: docs/protocol.md#advertising if (!JoyconAdvertisement.isPairing(manufacturerData)) return - if (isKnownAddress(result.device.address)) return + if (isConnected(result.device.address)) return val name = result.device.name ?: result.scanRecord?.deviceName 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 3c90f7ad..d843f8a3 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 @@ -23,9 +23,13 @@ class ConnectionPool(private val context: Context) { val addresses: Set get() = connections.keys.toSet() val size: Int get() = connections.size - /** Null for an address already in the pool (a duplicate scan result). */ + /** Null for an address already in the pool; one awaiting its controller's return reconnects instead. */ fun connect(result: ScanResult, side: Side, name: String, highPriority: Boolean): JoyconConnection? { val address = result.device.address + connections[address]?.let { + it.reconnect(result.device) + return null + } val connection = JoyconConnection( context, side, @@ -44,6 +48,8 @@ class ConnectionPool(private val context: Context) { fun get(address: String): JoyconConnection? = connections[address] + fun isLive(address: String): Boolean = connections[address]?.awaitingReturn == false + fun setHighPriority(enabled: Boolean) { connections.values.forEach { it.setHighPriority(enabled) } } 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 124ead32..9931f61c 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,8 +1,6 @@ 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 @@ -10,58 +8,67 @@ import java.util.concurrent.TimeUnit /** * 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. + * here. A callback can't say which op it answers, so a sent op holds the queue until its own + * callback or timeout, even after [clear]. */ -class GattOpQueue { +internal class GattOpQueue(private val scheduler: OpScheduler = MainThreadScheduler()) { companion object { private const val TAG = "GattOpQueue" private const val TIMEOUT_MS = 2000L } - private class Op(val run: () -> Boolean, val gapMs: Long, val finished: CountDownLatch?) { + private inner class Op(val run: () -> Boolean, val gapMs: Long) { + val finished = CountDownLatch(1) @Volatile var status = BluetoothGatt.GATT_FAILURE + var abandoned = false + var answered = false + val timeout = Runnable { + Log.w(TAG, "Op timed out — advancing queue") + answer(this, BluetoothGatt.GATT_FAILURE) + } } - private val handler = Handler(Looper.getMainLooper()) private val queue = ArrayDeque() private var inFlight: Op? = null - private val timeoutRunnable = Runnable { - Log.w(TAG, "Op timed out — advancing queue") - complete(BluetoothGatt.GATT_FAILURE) - } /** 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)) + fun enqueue(gapMs: Long = 0L, op: () -> Boolean) = add(Op(op, gapMs)) /** 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)) + val entry = Op(op, 0L) add(entry) - return if (entry.finished!!.await(timeoutMs, TimeUnit.MILLISECONDS)) { + return if (entry.finished.await(timeoutMs, TimeUnit.MILLISECONDS)) { entry.status } else { BluetoothGatt.GATT_FAILURE } } + /** The GATT callback for the op in flight. */ @Synchronized fun complete(status: Int) { - handler.removeCallbacks(timeoutRunnable) - val op = inFlight ?: return - inFlight = null - op.status = status - op.finished?.countDown() - if (op.gapMs > 0L) handler.postDelayed({ runNext() }, op.gapMs) else runNext() + inFlight?.let { answer(it, status) } } + /** Drops every op not yet sent; one already sent still holds the queue until it is answered. */ @Synchronized fun clear() { - handler.removeCallbacks(timeoutRunnable) - val abandoned = queue.toList() + listOfNotNull(inFlight) + queue.forEach { it.finished.countDown() } queue.clear() + inFlight?.let { + it.abandoned = true + it.finished.countDown() + } + } + + /** For a closed [BluetoothGatt], whose callbacks never come. */ + @Synchronized + fun reset() { + clear() + inFlight?.let { scheduler.cancel(it.timeout) } inFlight = null - abandoned.forEach { it.finished?.countDown() } } @Synchronized @@ -75,28 +82,52 @@ class GattOpQueue { if (inFlight != null) return val op = queue.poll() ?: return inFlight = op - handler.post { start(op) } + scheduler.post { start(op) } } - // Ops run on the main thread, and outside the lock: one of them reports the controller ready. + // Ops run 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 - } + if (!claimForSending(op)) return + if (op.run()) return Log.w(TAG, "Op returned false — advancing queue immediately") - abandon(op) + skip(op) } + // Arms the timeout before the op runs, so a callback that beats it can't leave it armed. @Synchronized - private fun isInFlight(op: Op) = inFlight === op + private fun claimForSending(op: Op): Boolean { + if (inFlight !== op) return false + if (op.abandoned) { + skip(op) + return false + } + scheduler.postDelayed(op.timeout, TIMEOUT_MS) + return true + } + + @Synchronized + private fun answer(op: Op, status: Int) { + if (inFlight !== op || op.answered) return + op.answered = true + scheduler.cancel(op.timeout) + op.status = status + op.finished.countDown() + if (op.gapMs > 0L) scheduler.postDelayed({ release(op) }, op.gapMs) else release(op) + } + + @Synchronized + private fun skip(op: Op) { + if (inFlight !== op || op.answered) return + op.answered = true + scheduler.cancel(op.timeout) + op.finished.countDown() + release(op) + } @Synchronized - private fun abandon(op: Op) { + private fun release(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/Joycon2Manager.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt index d7df5d2f..1b6e4c20 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt @@ -56,7 +56,7 @@ class Joycon2Manager( } _error.value = null - scanner.start { address -> address in pool.addresses } + scanner.start(pool::isLive) _scanning.value = true } 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 beafc089..fba51189 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 @@ -38,8 +38,7 @@ class JoyconConnection( // well within this window; one that answers later is moved back by its first report. private const val CONSOLE_FALLBACK_MS = 1_500L - // How long a dropped controller is waited for before the player loses it. - private const val RECONNECT_WINDOW_MS = 120_000L + private const val RETURN_WINDOW_MS = 120_000L } private val _connectionState = MutableStateFlow( @@ -52,6 +51,7 @@ class JoyconConnection( private val mainHandler = Handler(Looper.getMainLooper()) private val ops = GattOpQueue() + private val returnWindow = ReturnWindow(RETURN_WINDOW_MS, MainThreadScheduler(), ::giveUpOnReturn) private val stickCalibrator = StickCalibrator() private var gatt: BluetoothGatt? = null private var device: BluetoothDevice? = null @@ -69,9 +69,11 @@ class JoyconConnection( @Volatile private var commonReportSeen = false @Volatile private var commandReplySeen = false @Volatile private var assignedPlayer: PlayerNumber? = null - @Volatile private var reconnecting = false + @Volatile private var everReady = false @Volatile private var released = false + val awaitingReturn: Boolean get() = returnWindow.isOpen + fun connect(device: BluetoothDevice) { this.device = device gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) @@ -109,15 +111,23 @@ class JoyconConnection( protocol?.setPlayerLed(ControllerCommands.LED_ALL_ON) } + /** A scan found this controller while it was awaited, so connect now rather than on its own time. */ + fun reconnect(device: BluetoothDevice) { + if (!awaitingReturn) return + Log.i(TAG, "[$side] Found by a scan while awaited; connecting directly") + gatt?.close() + gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + } + fun disconnect() { released = true - reconnecting = false + returnWindow.close() stopProtocols() mainHandler.removeCallbacksAndMessages(null) gatt?.disconnect() gatt?.close() gatt = null - ops.clear() + ops.reset() _connectionState.value = JoyconConnectionState() _input.value = JoyconInput() } @@ -127,7 +137,7 @@ class JoyconConnection( when (newState) { BluetoothProfile.STATE_CONNECTED -> { Log.i(TAG, "[$side] Connected. Requesting MTU $DESIRED_MTU") - reconnecting = false + returnWindow.close() _connectionState.value = JoyconConnectionState( connected = true, deviceName = deviceName ) @@ -137,7 +147,7 @@ class JoyconConnection( Log.w(TAG, "[$side] Disconnected (status=$status)") endSession(g) _input.value = JoyconInput() - if (!released && awaitReturn()) return + if (!released && (everReady || awaitingReturn) && awaitReturn(status)) return _connectionState.value = JoyconConnectionState( deviceName = deviceName, error = if (status != BluetoothGatt.GATT_SUCCESS) { @@ -272,6 +282,7 @@ class JoyconConnection( private fun onProtocolReady() { initComplete = true + everReady = true _connectionState.value = _connectionState.value.copy( connected = true, ready = true, deviceName = deviceName ) @@ -282,7 +293,7 @@ class JoyconConnection( // Everything that belongs to one link, so the next one starts its own detection over. private fun endSession(g: BluetoothGatt) { stopProtocols() - ops.clear() + ops.reset() g.close() gatt = null initComplete = false @@ -292,26 +303,19 @@ class JoyconConnection( commandReplySeen = false } - /** - * 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 { + // Android keeps a pending connect armed after a drop: docs/protocol.md#coming-back-after-a-drop + private fun awaitReturn(status: Int): 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") + Log.i(TAG, "[$side] Connection lost (status $status); waiting for the controller to come back") gatt = reopened - reconnecting = true - _connectionState.value = JoyconConnectionState(connecting = true, deviceName = deviceName) - mainHandler.postDelayed(::stopAwaitingReturn, RECONNECT_WINDOW_MS) + returnWindow.open() + _connectionState.value = JoyconConnectionState(deviceName = deviceName, connectionLost = true) return true } - private fun stopAwaitingReturn() { - if (!reconnecting) return - reconnecting = false + private fun giveUpOnReturn() { Log.i(TAG, "[$side] Gave up waiting for the controller") gatt?.disconnect() gatt?.close() diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/MainThreadScheduler.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/MainThreadScheduler.kt new file mode 100644 index 00000000..6d593a35 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/MainThreadScheduler.kt @@ -0,0 +1,21 @@ +package com.joegec.joycon2android.connection + +import android.os.Handler +import android.os.Looper + +internal class MainThreadScheduler : OpScheduler { + + private val handler = Handler(Looper.getMainLooper()) + + override fun post(task: Runnable) { + handler.post(task) + } + + override fun postDelayed(task: Runnable, delayMs: Long) { + handler.postDelayed(task, delayMs) + } + + override fun cancel(task: Runnable) { + handler.removeCallbacks(task) + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/OpScheduler.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/OpScheduler.kt new file mode 100644 index 00000000..71303c3c --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/OpScheduler.kt @@ -0,0 +1,7 @@ +package com.joegec.joycon2android.connection + +internal interface OpScheduler { + fun post(task: Runnable) + fun postDelayed(task: Runnable, delayMs: Long) + fun cancel(task: Runnable) +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ReturnWindow.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ReturnWindow.kt new file mode 100644 index 00000000..01697fd2 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ReturnWindow.kt @@ -0,0 +1,29 @@ +package com.joegec.joycon2android.connection + +/** How long a dropped controller keeps its place: docs/protocol.md#coming-back-after-a-drop */ +internal class ReturnWindow( + private val windowMs: Long, + private val scheduler: OpScheduler, + private val onExpired: () -> Unit, +) { + @Volatile var isOpen = false + private set + + private val expiry = Runnable { + if (!isOpen) return@Runnable + isOpen = false + onExpired() + } + + /** A drop while already open keeps the original deadline. */ + fun open() { + if (isOpen) return + isOpen = true + scheduler.postDelayed(expiry, windowMs) + } + + fun close() { + isOpen = false + scheduler.cancel(expiry) + } +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/GattOpQueueTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/GattOpQueueTest.kt new file mode 100644 index 00000000..c773808d --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/GattOpQueueTest.kt @@ -0,0 +1,123 @@ +package com.joegec.joycon2android.connection + +import android.bluetooth.BluetoothGatt +import org.junit.Assert.assertEquals +import org.junit.Test + +class GattOpQueueTest { + + private val scheduler = ManualScheduler() + private val queue = GattOpQueue(scheduler) + private val sent = mutableListOf() + + private fun send(name: String): () -> Boolean = { + sent += name + true + } + + @Test + fun `sends the next op only after the callback for the one in flight`() { + queue.enqueue(op = send("first")) + queue.enqueue(op = send("second")) + scheduler.runDue() + assertEquals(listOf("first"), sent) + + queue.complete(BluetoothGatt.GATT_SUCCESS) + scheduler.runDue() + assertEquals(listOf("first", "second"), sent) + } + + @Test + fun `a cleared op still holds the queue until its own callback arrives`() { + queue.enqueue(op = send("stale")) + scheduler.runDue() + queue.clear() + queue.enqueue(op = send("fresh")) + scheduler.runDue() + assertEquals(listOf("stale"), sent) + + queue.complete(BluetoothGatt.GATT_SUCCESS) // the stale op's callback + scheduler.runDue() + assertEquals(listOf("stale", "fresh"), sent) + } + + @Test + fun `the stale callback does not answer the op sent after a clear`() { + queue.enqueue(op = send("stale")) + scheduler.runDue() + queue.clear() + queue.enqueue(op = send("fresh")) + queue.enqueue(op = send("third")) + queue.complete(BluetoothGatt.GATT_SUCCESS) // the stale op's callback + scheduler.runDue() + + assertEquals(listOf("stale", "fresh"), sent) + } + + @Test + fun `a cleared op that was never sent is skipped`() { + queue.enqueue(op = send("unsent")) + queue.clear() + queue.enqueue(op = send("fresh")) + scheduler.runDue() + + assertEquals(listOf("fresh"), sent) + } + + @Test + fun `an op that times out releases the queue`() { + queue.enqueue(op = send("silent")) + queue.enqueue(op = send("next")) + scheduler.runDue() + scheduler.advanceBy(2_000) + + assertEquals(listOf("silent", "next"), sent) + } + + @Test + fun `an answered op's timeout never fires on a later op`() { + queue.enqueue(op = send("quick")) + scheduler.runDue() + queue.complete(BluetoothGatt.GATT_SUCCESS) + scheduler.advanceBy(1_500) + queue.enqueue(op = send("slow")) + queue.enqueue(op = send("after")) + scheduler.runDue() + scheduler.advanceBy(600) // past the quick op's 2 s + + assertEquals(listOf("quick", "slow"), sent) + } + + @Test + fun `waits the op's gap before the next one, even when another is added`() { + queue.enqueue(gapMs = 500, op = send("paced")) + scheduler.runDue() + queue.complete(BluetoothGatt.GATT_SUCCESS) + queue.enqueue(op = send("next")) + scheduler.advanceBy(499) + assertEquals(listOf("paced"), sent) + + scheduler.advanceBy(1) + assertEquals(listOf("paced", "next"), sent) + } + + @Test + fun `an op that fails to send advances at once`() { + queue.enqueue { sent += "refused"; false } + queue.enqueue(op = send("next")) + scheduler.runDue() + + assertEquals(listOf("refused", "next"), sent) + } + + @Test + fun `reset drops the op in flight, for a closed connection`() { + queue.enqueue(op = send("orphan")) + scheduler.runDue() + queue.reset() + queue.enqueue(op = send("fresh")) + scheduler.runDue() + + assertEquals(listOf("orphan", "fresh"), sent) + } +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ManualScheduler.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ManualScheduler.kt new file mode 100644 index 00000000..ff37bfc4 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ManualScheduler.kt @@ -0,0 +1,34 @@ +package com.joegec.joycon2android.connection + +internal class ManualScheduler : OpScheduler { + + private data class Task(val runnable: Runnable, val dueMs: Long) + + private val tasks = mutableListOf() + private var nowMs = 0L + + override fun post(task: Runnable) { + postDelayed(task, 0L) + } + + override fun postDelayed(task: Runnable, delayMs: Long) { + tasks += Task(task, nowMs + delayMs) + } + + override fun cancel(task: Runnable) { + tasks.removeAll { it.runnable === task } + } + + fun runDue() { + while (true) { + val next = tasks.filter { it.dueMs <= nowMs }.minByOrNull { it.dueMs } ?: return + tasks.remove(next) + next.runnable.run() + } + } + + fun advanceBy(ms: Long) { + nowMs += ms + runDue() + } +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ReturnWindowTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ReturnWindowTest.kt new file mode 100644 index 00000000..004d3e11 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ReturnWindowTest.kt @@ -0,0 +1,53 @@ +package com.joegec.joycon2android.connection + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class ReturnWindowTest { + + private val scheduler = ManualScheduler() + private var expiries = 0 + private val window = ReturnWindow(windowMs = 1_000, scheduler = scheduler) { expiries++ } + + @Test + fun `expires once the window passes`() { + window.open() + scheduler.advanceBy(1_000) + + assertEquals(1, expiries) + assertFalse(window.isOpen) + } + + @Test + fun `a return closes it without expiring`() { + window.open() + window.close() + scheduler.advanceBy(1_000) + + assertEquals(0, expiries) + } + + @Test + fun `a second drop after a return gets a full window of its own`() { + window.open() + scheduler.advanceBy(600) + window.close() + window.open() + scheduler.advanceBy(600) + assertEquals(0, expiries) + + scheduler.advanceBy(400) + assertEquals(1, expiries) + } + + @Test + fun `a failed attempt while open keeps the original deadline`() { + window.open() + scheduler.advanceBy(600) + window.open() + scheduler.advanceBy(400) + + assertEquals(1, expiries) + } +} 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 99a5a117..e27bbe76 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -27,6 +28,7 @@ import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.JoyconDefaultColor import com.joegec.joycon2android.ui.theme.TextDim +import com.joegec.joycon2android.ui.theme.WarningText import com.joegec.joycon2android.ui.theme.joyconBorderColor @Composable @@ -98,7 +100,16 @@ private fun ControllerChip( horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), ) { val battery = @Composable { - joycon.input.battery?.let { BatteryGlyph(it) } + if (joycon.connectionLost) { + Icon( + Icons.Filled.LinkOff, + contentDescription = stringResource(R.string.connection_lost), + tint = WarningText, + modifier = Modifier.size(Dimens.iconSizeSmall), + ) + } else { + joycon.input.battery?.let { BatteryGlyph(it) } + } } if (batteryFirst) battery() diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/DualJoyconLayout.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/DualJoyconLayout.kt index a25d291f..504b7ca5 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/DualJoyconLayout.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/DualJoyconLayout.kt @@ -34,7 +34,7 @@ private fun LeftJoyconVertical( ) { val pressed = state.pressed JoyconCard( - accentColor = state.left?.accentColor, + controller = state.left, onClick = { onUnassign(state.left!!.address) }, modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -60,7 +60,7 @@ private fun RightJoyconVertical( ) { val pressed = state.pressed JoyconCard( - accentColor = state.right?.accentColor, + controller = state.right, onClick = { onUnassign(state.right!!.address) }, modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/JoyconCard.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/JoyconCard.kt index b8dd8612..dcde2908 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/JoyconCard.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/JoyconCard.kt @@ -13,6 +13,10 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.connection.presentation.R +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.ui.components.WarningBox import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.Dimens import com.joegec.joycon2android.ui.theme.JoyconDefaultColor @@ -22,12 +26,13 @@ import com.joegec.joycon2android.ui.theme.readableInkOn @Composable internal fun JoyconCard( - accentColor: Int?, + controller: ConnectedJoycon?, onClick: () -> Unit, modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.Start, content: @Composable ColumnScope.() -> Unit, ) { + val accentColor = controller?.accentColor val shape = RoundedCornerShape(Dimens.cardCorner) val borderColor = joyconBorderColor(accentColor, JoyconDefaultColor) val active = controllerActiveColor(accentColor) @@ -43,7 +48,11 @@ internal fun JoyconCard( .padding(Dimens.cardPadding), horizontalAlignment = horizontalAlignment, verticalArrangement = Arrangement.spacedBy(Dimens.sectionSpacing), - content = content, - ) + ) { + if (controller?.connectionLost == true) { + WarningBox(stringResource(R.string.connection_lost_press_sync)) + } + content() + } } } 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 bb44ebdc..76de6df7 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 @@ -22,7 +22,7 @@ internal fun ProControllerLayout( ) { val pressed = state.pressed JoyconCard( - accentColor = state.left?.accentColor, + controller = state.left, onClick = { onUnassign(state.left!!.address) }, modifier = modifier.fillMaxWidth(), ) { 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 22ceb8a5..4c4b32b2 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 @@ -27,7 +27,7 @@ internal fun LeftSidewaysLayout( modifier: Modifier = Modifier, ) { JoyconCard( - accentColor = state.left?.accentColor, + controller = state.left, onClick = { onUnassign(state.left!!.address) }, modifier = modifier.fillMaxWidth(), ) { 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 d5b6539f..74415eb8 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 @@ -27,7 +27,7 @@ internal fun RightSidewaysLayout( modifier: Modifier = Modifier, ) { JoyconCard( - accentColor = state.right?.accentColor, + controller = state.right, onClick = { onUnassign(state.right!!.address) }, modifier = modifier.fillMaxWidth(), ) { diff --git a/feature/connection/presentation/src/main/res/values/strings.xml b/feature/connection/presentation/src/main/res/values/strings.xml index 926a9fa9..aa508897 100644 --- a/feature/connection/presentation/src/main/res/values/strings.xml +++ b/feature/connection/presentation/src/main/res/values/strings.xml @@ -10,4 +10,6 @@ Right Pro Remove player + Connection lost + Connection lost. Press SYNC on the controller to bring it back.