Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ android {

buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.joegec.joycon2android.ui

import android.bluetooth.BluetoothDevice
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
Expand Down Expand Up @@ -82,6 +83,7 @@ import com.joegec.joycon2android.model.PlayerNumber
import com.joegec.joycon2android.model.PlayerState
import com.joegec.joycon2android.gamepad.presentation.ShizukuSetupCard
import com.joegec.joycon2android.assignment.presentation.AssignmentPanel
import com.joegec.joycon2android.connection.JoyconConnection
import com.joegec.joycon2android.connection.presentation.CompactPlayerRow
import com.joegec.joycon2android.model.ConnectionViewMode
import com.joegec.joycon2android.ui.components.DolphinSetupPhase
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ data class JoyconConnectionState(
val connecting: Boolean = false,
val ready: Boolean = false,
val deviceName: String? = null,
val bondState: Int = 10, // BluetoothDevice.BOND_NONE
val error: String? = null,
/** Shell accent color read from SPI flash, packed as 0xRRGGBB. Null until read (or unset on the controller). */
val accentColor: Int? = null,
val notificationCount: Int = 0,
)
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ private fun JoyconAssignmentRow(
strokeWidth = 2.dp,
)
Text(
stringResource(R.string.status_connecting),
color = TextDim,
joycon.connectionState.error ?: stringResource(R.string.status_connecting),
color = if (joycon.connectionState.error != null) MaterialTheme.colorScheme.error else TextDim,
style = MaterialTheme.typography.bodySmall,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,151 +1,146 @@
package com.joegec.joycon2android.connection

import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.joegec.joycon2android.model.Side

/**
* Handles BLE scanning for Nintendo Joy-Con 2 controllers.
* Emits discovered devices via the [onDeviceFound] callback.
*
* All BLE operations require BLUETOOTH_SCAN and BLUETOOTH_CONNECT permissions,
* which are verified by the permission launcher in MainActivity before any BLE code is reached.
*/
@SuppressLint("MissingPermission")
class BleScanner(context: Context) {

companion object {
private const val TAG = "Joycon2"
private const val NINTENDO_MANUFACTURER_ID = 0x0553
private const val SIDE_TYPE_INDEX = 5
private const val SCAN_TIMEOUT_MS = 15_000L
}

var onDeviceFound: ((ScanResult, Side, String) -> Unit)? = null
var onScanFailed: ((Int) -> Unit)? = null
var onTimeout: (() -> Unit)? = null

private val handler = Handler(Looper.getMainLooper())
private val adapter: BluetoothAdapter? =
(context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter

@Volatile
var isScanning = false
private set

val isAvailable: Boolean get() = adapter?.bluetoothLeScanner != null

fun start(isKnownAddress: (String) -> Boolean) {
if (isScanning) return
val scanner = adapter?.bluetoothLeScanner ?: return

isScanning = true
scanner.startScan(null, lowLatencySettings(), createCallback(isKnownAddress))
Log.i(TAG, "Scanning for Joy-Con 2 controllers...")
scheduleTimeout()
}

fun stop() {
if (!isScanning) return
isScanning = false
handler.removeCallbacksAndMessages(null)
adapter?.bluetoothLeScanner?.stopScan(activeCallback)
activeCallback = null
}

private var activeCallback: ScanCallback? = null

private fun createCallback(isKnownAddress: (String) -> Boolean): ScanCallback {
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (!isScanning) return
val manufacturerData = nintendoData(result) ?: return
logAdvertisement(result, manufacturerData)
// A button press wakes a synced Joy-Con into a short-lived reconnect
// advertisement that only its bonded host can connect to (foreign
// connects fail with status 133) — connecting just flashes the UI
if (!JoyconAdvertisement.isPairing(manufacturerData)) return
if (isKnownAddress(result.device.address)) return

val name = result.device.name
?: result.scanRecord?.deviceName
?: "Joy-Con 2"
val side = detectSide(result, name)
onDeviceFound?.invoke(result, side, name)
}

override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "Scan failed: $errorCode")
isScanning = false
onScanFailed?.invoke(errorCode)
}
}
activeCallback = callback
return callback
}

private fun scheduleTimeout() {
handler.postDelayed({
if (!isScanning) return@postDelayed
stop()
onTimeout?.invoke()
}, SCAN_TIMEOUT_MS)
}

private fun nintendoData(result: ScanResult): ByteArray? =
result.scanRecord?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID)

private fun logAdvertisement(result: ScanResult, data: ByteArray) {
Log.d(
TAG,
"Adv ${result.device.address} name=${result.device.name ?: result.scanRecord?.deviceName} " +
"mfg=${data.joinToString(" ") { "%02X".format(it) }}",
)
}

private fun lowLatencySettings() = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()

private fun detectSide(result: ScanResult, name: String): Side {
sideFromName(name)?.let { return it }
sideFromManufacturerData(result)?.let { return it }
return Side.UNKNOWN
}

private fun sideFromName(name: String): Side? = when {
name.contains("(L)") || name.contains("Left") -> Side.LEFT
name.contains("(R)") || name.contains("Right") -> Side.RIGHT
name.contains("Pro") -> Side.PRO
else -> null
}

/**
* 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
?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) ?: return null
if (mfgData.size <= SIDE_TYPE_INDEX) return null
return when (mfgData[SIDE_TYPE_INDEX].toInt() and 0xFF) {
0x67 -> Side.LEFT
0x66 -> Side.RIGHT
0x69 -> Side.PRO
else -> null
}
}
}
package com.joegec.joycon2android.connection

import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.joegec.joycon2android.model.Side

/**
* Handles BLE scanning for Nintendo Joy-Con 2 and compatible controllers (like Nyxi).
* Emits discovered devices via the [onDeviceFound] callback.
*/
@SuppressLint("MissingPermission")
class BleScanner(context: Context) {

companion object {
private const val TAG = "Joycon2"
private const val NINTENDO_MANUFACTURER_ID = 0x0553
private const val SIDE_TYPE_INDEX = 5
private const val SCAN_TIMEOUT_MS = 15_000L
}

var onDeviceFound: ((ScanResult, Side, String) -> Unit)? = null
var onScanFailed: ((Int) -> Unit)? = null
var onTimeout: (() -> Unit)? = null

private val handler = Handler(Looper.getMainLooper())
private val adapter: BluetoothAdapter? =
(context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter

@Volatile
var isScanning = false
private set

val isAvailable: Boolean get() = adapter?.bluetoothLeScanner != null

fun start(isKnownAddress: (String) -> Boolean) {
if (isScanning) return
val scanner = adapter?.bluetoothLeScanner ?: return

isScanning = true
scanner.startScan(null, lowLatencySettings(), createCallback(isKnownAddress))
Log.i(TAG, "Scanning for Joy-Con 2/Compatible controllers...")
scheduleTimeout()
}

fun stop() {
if (!isScanning) return
isScanning = false
handler.removeCallbacksAndMessages(null)
adapter?.bluetoothLeScanner?.stopScan(activeCallback)
activeCallback = null
}

private var activeCallback: ScanCallback? = null

private fun createCallback(isKnownAddress: (String) -> Boolean): ScanCallback {
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (!isScanning) return

val scanRecord = result.scanRecord ?: return

// Accept devices carrying the Joy-Con 2 manufacturer record (0x0553)
if (scanRecord.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) == null) return

if (isKnownAddress(result.device.address)) {
Log.d(TAG, "Filtered out: Already known address ${result.device.address}")
return
}

val name = result.device.name
?: scanRecord.deviceName
?: "Joy-Con 2"

// Pass the data and the type to detect side correctly
val side = detectSide(result, name)
onDeviceFound?.invoke(result, side, name)
}

override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "Scan failed: $errorCode")
isScanning = false
onScanFailed?.invoke(errorCode)
}
}
activeCallback = callback
return callback
}

private fun scheduleTimeout() {
handler.postDelayed({
if (!isScanning) return@postDelayed
stop()
onTimeout?.invoke()
}, SCAN_TIMEOUT_MS)
}

private fun lowLatencySettings() = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()

private fun detectSide(result: ScanResult, name: String): Side {
// 1. Try name-based detection first (most reliable for third-party)
sideFromName(name)?.let { return it }

// 2. Try manufacturer-based detection
sideFromManufacturerData(result)?.let { return it }

return Side.UNKNOWN
}

private fun sideFromName(name: String): Side? = when {
// Matches "Joy-Con (L)", "NJ22-L", "Left Hyperion"
name.contains("(L)") || name.contains("Left") || name.contains("-L") -> Side.LEFT
// Matches "Joy-Con (R)", "NJ22-R", "Right Hyperion"
name.contains("(R)") || name.contains("Right") || name.contains("-R") -> Side.RIGHT
// Matches "Pro Controller", "NJ22"
name.contains("Pro") || name.contains("NJ22") -> Side.PRO
else -> null
}

/**
* Extracts side info from Nintendo-specific data packets.
* Note: Nyxi packets are usually too short for this index, so we skip this for Nyxi.
*/
private fun sideFromManufacturerData(result: ScanResult): Side? {
val mfgData = result.scanRecord
?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) ?: return null

if (mfgData.size <= SIDE_TYPE_INDEX) return null

return when (mfgData[SIDE_TYPE_INDEX].toInt() and 0xFF) {
0x67 -> Side.LEFT
0x66 -> Side.RIGHT
0x69 -> Side.PRO
else -> null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.joegec.joycon2android.connection
import android.annotation.SuppressLint
import android.bluetooth.le.ScanResult
import android.content.Context
import android.os.Handler
import android.os.Looper
import com.joegec.joycon2android.model.Side
import java.util.concurrent.ConcurrentHashMap

Expand All @@ -16,6 +18,7 @@ import java.util.concurrent.ConcurrentHashMap
@SuppressLint("MissingPermission")
class ConnectionPool(private val context: Context) {

private val mainHandler = Handler(Looper.getMainLooper())
private val connections = ConcurrentHashMap<String, JoyconConnection>()

var onPoolChanged: (() -> Unit)? = null
Expand All @@ -37,6 +40,12 @@ class ConnectionPool(private val context: Context) {
connection.setHighPriority(highPriority)
if (connections.putIfAbsent(address, connection) != null) return null
connection.connect(result.device)

// Re-assert HIGH priority on all existing connections after new connection settles
mainHandler.postDelayed({
setHighPriority(true)
}, 1200L)

return connection
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class GattOpQueue {
}

fun enqueue(op: () -> Boolean) {
Log.d(TAG, "Operation enqueued. Current queue size: ${queue.size}")
queue.add(op)
runNext()
}
Expand All @@ -49,6 +50,7 @@ class GattOpQueue {
private fun runNext() {
if (inFlight) return
val op = queue.poll() ?: return
Log.d(TAG, "Operation dequeued. Remaining queue size: ${queue.size}")
inFlight = true
val success = op()
if (!success) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class Joycon2Manager(
private val connectionJobs = mutableMapOf<String, Job>()

@Volatile
private var highPriority = false
private var highPriority = true

private val _controllers = MutableStateFlow<List<ConnectedJoycon>>(emptyList())
override val controllers: StateFlow<List<ConnectedJoycon>> = _controllers.asStateFlow()
Expand Down
Loading