diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9008eca..0c1ce6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,10 @@ jobs: - name: Build, test, lint, and enforce architecture rules run: ./gradlew build :konsist:test --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconButton.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconButton.kt index bef6583..e14b3a0 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconButton.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconButton.kt @@ -24,4 +24,6 @@ enum class JoyconButton(val id: String, val label: String) { X("X", "X"), B("B", "B"), A("A", "A"), + GL("GL", "GL"), + GR("GR", "GR"), } 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 d81046b..60b6aff 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 @@ -129,11 +129,13 @@ class BleScanner(context: Context) { } /** - * Nintendo manufacturer data (company 0x0553) carries a per-side product byte at index 5: - * 0x67 = Left Joy-Con 2, 0x66 = Right Joy-Con 2. Confirmed on hardware and cross-checked - * against each controller's SPI accent colour (cyan left, coral right). The pairing - * advertisement has no local name, so this byte is the only side signal available before - * the controller starts streaming input. + * Nintendo manufacturer data (company 0x0553) carries the little-endian USB/BLE product ID at + * bytes [5..6], so index 5 is its low byte: 0x67 = Left Joy-Con 2 (PID 0x2067), 0x66 = Right + * Joy-Con 2 (PID 0x2066), 0x69 = Switch 2 Pro Controller (PID 0x2069). Left/Right are confirmed + * on hardware and cross-checked against each controller's SPI accent colour (cyan left, coral + * right); the Pro value comes from community reverse-engineering of the same advertisement + * scheme. The pairing advertisement has no local name, so this byte is the only type signal + * available before the controller starts streaming input. */ private fun sideFromManufacturerData(result: ScanResult): Side? { val mfgData = result.scanRecord @@ -142,6 +144,7 @@ class BleScanner(context: Context) { return when (mfgData[SIDE_TYPE_INDEX].toInt() and 0xFF) { 0x67 -> Side.LEFT 0x66 -> Side.RIGHT + 0x69 -> Side.PRO else -> null } } 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 429d8af..62fd4e2 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 @@ -10,7 +10,9 @@ object PacketParser { private const val MIN_PACKET_SIZE = 0x3B - // Button bitmask → enum (uint32 at packet offset 0x03, little-endian) + // Button bitmask → enum. Bits 0..31 come from the uint32 at packet offset 0x03; the Pro + // Controller's two back paddles live in the next byte (0x07), folded into bits 32..39 so the + // whole set decodes through one mask table. GR is bit 0 of byte 0x07, GL is bit 1. private val buttonMasks: List> = listOf( 0x80000000L to JoyconButton.ZL, 0x40000000L to JoyconButton.L, 0x00010000L to JoyconButton.Minus, 0x00080000L to JoyconButton.LS, 0x01000000L to JoyconButton.Down, 0x02000000L to JoyconButton.Up, @@ -20,13 +22,15 @@ object PacketParser { 0x00002000L to JoyconButton.SlRight, 0x00004000L to JoyconButton.R, 0x00008000L to JoyconButton.ZR, 0x00040000L to JoyconButton.RS, 0x00000100L to JoyconButton.Y, 0x00000200L to JoyconButton.X, 0x00000400L to JoyconButton.B, 0x00000800L to JoyconButton.A, + 0x0100000000L to JoyconButton.GR, 0x0200000000L to JoyconButton.GL, ) fun parse(data: ByteArray, side: Side): JoyconInput? { if (data.size < MIN_PACKET_SIZE) return null val bb = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) - val buttons = bb.getInt(0x03).toLong() and 0xFFFFFFFFL + val buttons = (bb.getInt(0x03).toLong() and 0xFFFFFFFFL) or + ((data[0x07].toInt() and 0xFF).toLong() shl 32) val (sx, sy) = resolveStick(data, side) val (rsx, rsy) = if (side == Side.PRO) decodeStick(data, 0x0D) else (2048 to 2048) diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt new file mode 100644 index 0000000..af47f7a --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt @@ -0,0 +1,67 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.Side +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PacketParserTest { + + // A minimal but valid input report: left stick at 0x0A, right stick at 0x0D, and the Pro + // Controller's back-paddle byte at 0x07 (GR = bit 0, GL = bit 1). + private fun packet( + leftStick: Pair = 0x123 to 0x456, + rightStick: Pair = 0x789 to 0xABC, + paddleByte: Int = 0, + ): ByteArray = ByteArray(64).apply { + putStick(0x0A, leftStick) + putStick(0x0D, rightStick) + this[0x07] = paddleByte.toByte() + } + + private fun ByteArray.putStick(offset: Int, stick: Pair) { + val (x, y) = stick + val v = (x and 0xFFF) or ((y and 0xFFF) shl 12) + this[offset] = (v and 0xFF).toByte() + this[offset + 1] = ((v shr 8) and 0xFF).toByte() + this[offset + 2] = ((v shr 16) and 0xFF).toByte() + } + + @Test + fun `rejects packets below the minimum size`() { + assertNull(PacketParser.parse(ByteArray(16), Side.PRO)) + } + + @Test + fun `pro decodes both sticks from their own offsets`() { + val input = PacketParser.parse(packet(), Side.PRO)!! + assertEquals(0x123, input.stickX) + assertEquals(0x456, input.stickY) + assertEquals(0x789, input.rightStickX) + assertEquals(0xABC, input.rightStickY) + } + + @Test + fun `a single left joy-con reports no right stick`() { + val input = PacketParser.parse(packet(), Side.LEFT)!! + assertEquals(0x123, input.stickX) + assertEquals(2048, input.rightStickX) + assertEquals(2048, input.rightStickY) + } + + @Test + fun `back paddles decode from byte 0x07`() { + val input = PacketParser.parse(packet(paddleByte = 0x03), Side.PRO)!! + assertTrue(JoyconButton.GR.id in input.pressed) + assertTrue(JoyconButton.GL.id in input.pressed) + } + + @Test + fun `paddles are absent when their bits are clear`() { + val input = PacketParser.parse(packet(paddleByte = 0x00), Side.PRO)!! + assertTrue(JoyconButton.GR.id !in input.pressed) + assertTrue(JoyconButton.GL.id !in input.pressed) + } +} 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 2fed3df..48b2e2d 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 @@ -46,6 +46,7 @@ private fun ShoulderButtons(pressed: Set) { ) { ShoulderButton(JoyconButton.ZL.label, JoyconButton.ZL.id in pressed, Modifier.fillMaxWidth()) ShoulderButton(JoyconButton.L.label, JoyconButton.L.id in pressed, Modifier.fillMaxWidth()) + ShoulderButton(JoyconButton.GL.label, JoyconButton.GL.id in pressed, Modifier.fillMaxWidth()) } Column( Modifier.weight(1f), @@ -53,6 +54,7 @@ private fun ShoulderButtons(pressed: Set) { ) { ShoulderButton(JoyconButton.ZR.label, JoyconButton.ZR.id in pressed, Modifier.fillMaxWidth()) ShoulderButton(JoyconButton.R.label, JoyconButton.R.id in pressed, Modifier.fillMaxWidth()) + ShoulderButton(JoyconButton.GR.label, JoyconButton.GR.id in pressed, Modifier.fillMaxWidth()) } } } diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt index 3daae22..086eb72 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt @@ -7,7 +7,7 @@ object ReportMapper { private const val REPORT_SIZE = 13 - // Button bit positions matching the HID report descriptor order (Button 1-14) + // Button bit positions matching the HID report descriptor order (Button 1-16) private val BUTTON_MAP: Map = mapOf( JoyconButton.A.id to 0, JoyconButton.B.id to 1, @@ -23,6 +23,8 @@ object ReportMapper { JoyconButton.RS.id to 11, JoyconButton.Home.id to 12, JoyconButton.Camera.id to 13, + JoyconButton.GL.id to 14, + JoyconButton.GR.id to 15, ) private const val HAT_CENTER = 0x0F @@ -32,7 +34,7 @@ object ReportMapper { val report = ByteArray(REPORT_SIZE) val pressed = gamepad.pressed - // Bytes 0-1: 14 button bits + 2 padding (little-endian) + // Bytes 0-1: 16 button bits (little-endian) var buttons = 0 for (name in pressed) { BUTTON_MAP[name]?.let { bit -> buttons = buttons or (1 shl bit) } diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt index 699b574..68f9fc4 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/UhidRelay.kt @@ -167,18 +167,15 @@ class UhidRelay(private val name: String, private val playerIndex: Int) { 0x09, 0x05, // Usage (Game Pad) 0xA1.toByte(), 0x01, // Collection (Application) - // Buttons (14 buttons, 2 bits padding) + // Buttons (16 buttons; the last two are the Pro Controller's GL/GR back paddles) 0x05, 0x09, // Usage Page (Button) 0x19, 0x01, // Usage Minimum (Button 1) - 0x29, 0x0E, // Usage Maximum (Button 14) + 0x29, 0x10, // Usage Maximum (Button 16) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1) - 0x95.toByte(), 0x0E, // Report Count (14) + 0x95.toByte(), 0x10, // Report Count (16) 0x81.toByte(), 0x02, // Input (Data, Var, Abs) - 0x75, 0x01, // Report Size (1) - 0x95.toByte(), 0x02, // Report Count (2) - padding - 0x81.toByte(), 0x03, // Input (Const, Var, Abs) // Hat Switch (D-pad) 0x05, 0x01, // Usage Page (Generic Desktop)