diff --git a/README.md b/README.md
index fe69e8df..ba2493ac 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,10 @@ 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. They need
+ Android 10 or later to stay connected; on older versions they drop about 30 seconds in.
## Setup guide
@@ -228,6 +232,8 @@ 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 |
| 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/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/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/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 65540c2a..ab060ca1 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,152 @@ 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
+
+**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` advances on the matching callback, 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/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/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/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..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
@@ -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
@@ -18,25 +23,44 @@ 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
- val connection = JoyconConnection(context, side, name) {
+ connections[address]?.let {
+ it.reconnect(result.device)
+ return null
+ }
+ 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
}
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) }
}
+ // 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..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,55 +1,133 @@
package com.joegec.joycon2android.connection
-import android.os.Handler
-import android.os.Looper
+import android.bluetooth.BluetoothGatt
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. */
-class GattOpQueue {
+/**
+ * Android silently drops a second outstanding GATT op, so every op on a connection goes through
+ * 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].
+ */
+internal class GattOpQueue(private val scheduler: OpScheduler = MainThreadScheduler()) {
companion object {
private const val TAG = "GattOpQueue"
private const val TIMEOUT_MS = 2000L
}
- private val handler = Handler(Looper.getMainLooper())
- private val queue = ArrayDeque<() -> Boolean>()
- private var inFlight = false
- private val timeoutRunnable = Runnable {
- Log.w(TAG, "Op timed out — advancing queue")
- inFlight = false
- runNext()
+ 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)
+ }
}
- fun enqueue(op: () -> Boolean) {
- queue.add(op)
- runNext()
+ private val queue = ArrayDeque()
+ private var inFlight: Op? = null
+
+ /** 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))
+
+ /** 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)
+ add(entry)
+ return if (entry.finished.await(timeoutMs, TimeUnit.MILLISECONDS)) {
+ entry.status
+ } else {
+ BluetoothGatt.GATT_FAILURE
+ }
}
- fun complete() {
- handler.removeCallbacks(timeoutRunnable)
- inFlight = false
- runNext()
+ /** The GATT callback for the op in flight. */
+ @Synchronized
+ fun complete(status: Int) {
+ 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)
+ queue.forEach { it.finished.countDown() }
queue.clear()
- inFlight = false
+ 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
+ }
+
+ @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 {
- handler.postDelayed(timeoutRunnable, TIMEOUT_MS)
+ inFlight = op
+ scheduler.post { start(op) }
+ }
+
+ // Ops run outside the lock: one of them reports the controller ready.
+ private fun start(op: Op) {
+ if (!claimForSending(op)) return
+ if (op.run()) return
+ Log.w(TAG, "Op returned false — advancing queue immediately")
+ skip(op)
+ }
+
+ // Arms the timeout before the op runs, so a callback that beats it can't leave it armed.
+ @Synchronized
+ 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 release(op: Op) {
+ if (inFlight !== op) return
+ inFlight = null
+ 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 cb75c71f..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
@@ -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,18 @@ 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
- )
- }
-
- // 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
- )
+ // 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
- private const val DESIRED_MTU = 247
- private const val INIT_GAP_MS = 500L
+ private const val RETURN_WINDOW_MS = 120_000L
}
private val _connectionState = MutableStateFlow(
@@ -80,28 +50,84 @@ class JoyconConnection(
val input: StateFlow = _input.asStateFlow()
private val mainHandler = Handler(Looper.getMainLooper())
- private val opQueue = GattOpQueue()
+ private val ops = GattOpQueue()
+ private val returnWindow = ReturnWindow(RETURN_WINDOW_MS, MainThreadScheduler(), ::giveUpOnReturn)
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 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)
+ }
+
+ /** 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)
+ }
+
+ /** 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
+ returnWindow.close()
+ stopProtocols()
mainHandler.removeCallbacksAndMessages(null)
gatt?.disconnect()
gatt?.close()
gatt = null
- opQueue.clear()
+ ops.reset()
_connectionState.value = JoyconConnectionState()
_input.value = JoyconInput()
}
@@ -111,6 +137,7 @@ class JoyconConnection(
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.i(TAG, "[$side] Connected. Requesting MTU $DESIRED_MTU")
+ returnWindow.close()
_connectionState.value = JoyconConnectionState(
connected = true, deviceName = deviceName
)
@@ -118,18 +145,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 && (everReady || awaitingReturn) && awaitReturn(status)) 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 +167,183 @@ 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)
+ }
+
+ 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)
}
- // 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
+ @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
+ everReady = 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.reset()
+ 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 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] Connection lost (status $status); waiting for the controller to come back")
+ gatt = reopened
+ returnWindow.open()
+ _connectionState.value = JoyconConnectionState(deviceName = deviceName, connectionLost = true)
+ 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 giveUpOnReturn() {
+ 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/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/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/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/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/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/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..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 {
- if (joycon.input.batteryVolts > 0f) BatteryGlyph(joycon.input.batteryVolts)
+ 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/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/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/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/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/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..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(),
) {
@@ -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..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(),
) {
@@ -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..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(),
) {
@@ -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..aa508897 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
@@ -8,4 +10,6 @@
Right
Pro
Remove player
+ Connection lost
+ Connection lost. Press SYNC on the controller to bring it back.
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" }