package com.logitech.vc.kirbytest import android.annotation.SuppressLint import android.app.Service import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice.BOND_BONDED import android.bluetooth.BluetoothDevice.BOND_BONDING import android.bluetooth.BluetoothDevice.BOND_NONE import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGatt.GATT_SUCCESS import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings import android.bluetooth.le.ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.ParcelUuid import android.util.Log import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue val CCC_DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB") private const val GATT_MIN_MTU_SIZE = 23 /** Maximum BLE MTU size as defined in gatt_api.h. */ private const val GATT_MAX_MTU_SIZE = 517 /** Abstract sealed class representing a type of BLE operation */ sealed class BleOperationType { abstract val device: BluetoothDevice } /** Connect to [device] and perform service discovery */ data class Connect(override val device: BluetoothDevice, val context: Context) : BleOperationType() /** Disconnect from [device] and release all connection resources */ data class Disconnect(override val device: BluetoothDevice) : BleOperationType() /** Request for an MTU of [mtu] */ data class MtuRequest( override val device: BluetoothDevice, val mtu: Int ) : BleOperationType() data class ReadChar( override val device: BluetoothDevice, val serviceId: UUID, val charId: UUID ) : BleOperationType() data class SetNotification( override val device: BluetoothDevice, val serviceId: UUID, val charId: UUID, val enable: Boolean ) : BleOperationType() data class DiscoverServicesRequest( override val device: BluetoothDevice, ) : BleOperationType() data class BondRequest( override val device: BluetoothDevice, ) : BleOperationType() data class UnbondRequest( override val device: BluetoothDevice, ) : BleOperationType() data class ReadRemoteRssi( override val device: BluetoothDevice, ) : BleOperationType() open class BleListener(private val deviceAddress: String?) { open fun isRelevantMessage(address: String?): Boolean { if (deviceAddress != null && deviceAddress == address) { return true } return deviceAddress == null } open fun onConnect(gatt: BluetoothGatt) {} open fun onConnectToBondedFailed(gatt: BluetoothGatt) {} open fun onDisconnect(gatt: BluetoothGatt) {} open fun onSuccessfulCharRead( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { } open fun onCharChange( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { } open fun onSubscribe( gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, ) { } open fun onUnsubscribe( gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, ) { } open fun onScanningStateChange(isScanning: Boolean) { } open fun onScanResult(callbackType: Int, result: ScanResult) { } open fun onQueueSizeChange(groupedOps: Map>) { } open fun onBonded(device: BluetoothDevice) { } open fun onUnbonded(device: BluetoothDevice) { } open fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int) { } } private fun BluetoothGatt.printGattTable() { if (services.isEmpty()) { Log.i( "printGattTable", "No service and characteristic available, call discoverServices() first?" ) return } services.forEach { service -> val characteristicsTable = service.characteristics.joinToString( separator = "\n|--", prefix = "|--" ) { "${it.uuid} | " + "readable: ${it.isReadable()}, " + "writable: ${it.isWritable()}, " + "writableWithoutResponse: ${it.isWritableWithoutResponse()}, " + "notifiable: ${it.isNotifiable()}, " + "indicatable: ${it.isIndicatable()}, " } Log.i( "printGattTable", "\nService ${service.uuid}\nCharacteristics:\n$characteristicsTable" ) } } fun BluetoothGattCharacteristic.isReadable(): Boolean = containsProperty(BluetoothGattCharacteristic.PROPERTY_READ) fun BluetoothGattCharacteristic.isWritable(): Boolean = containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE) fun BluetoothGattCharacteristic.isWritableWithoutResponse(): Boolean = containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) fun BluetoothGattCharacteristic.isIndicatable(): Boolean = containsProperty(BluetoothGattCharacteristic.PROPERTY_INDICATE) fun BluetoothGattCharacteristic.isNotifiable(): Boolean = containsProperty(BluetoothGattCharacteristic.PROPERTY_NOTIFY) fun BluetoothGattCharacteristic.containsProperty(property: Int): Boolean = properties and property != 0 fun ByteArray.toHexString(): String = joinToString(separator = "", prefix = "0x") { String.format("%02X", it) } @SuppressLint("MissingPermission") class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) { private val deviceGattMap = ConcurrentHashMap() private val operationQueue = ConcurrentLinkedQueue() private var pendingOperation: BleOperationType? = null private var listeners = ArrayList() private val scanSettings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT) .build() private val bleScanner by lazy { bleAdapter.bluetoothLeScanner } init { val broadcastReceiver = object : BroadcastReceiver() { private fun successfulBondingAttempt(prev: Int, curr: Int): Boolean { return prev == BOND_BONDING && curr == BOND_BONDED || prev == BOND_NONE && curr == BOND_BONDED } private fun unsuccessfulBondingAttempt(prev: Int, curr: Int): Boolean { return prev == BOND_BONDING && curr == BOND_NONE } override fun onReceive(context: Context, intent: Intent) { if (intent.action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) { val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)!! val previousBondState = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1) val bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1) val bondTransition = "${previousBondState.toBondStateDescription()} to " + bondState.toBondStateDescription() Log.i( "ConnectionManager", "${device.address} bond state changed | $bondTransition" ) if (bondState == BOND_BONDED) { notifyListeners(device.address) { it.onBonded(device) } } if (bondState == BOND_NONE) { notifyListeners(device.address) { it.onUnbonded(device) } } val operation = pendingOperation if ( (operation is BondRequest && (successfulBondingAttempt( previousBondState, bondState ) || unsuccessfulBondingAttempt( previousBondState, bondState )) || operation is UnbondRequest && bondState == BOND_NONE) && operation.device.address == device.address ) { signalEndOfOperation(operation) } } } private fun Int.toBondStateDescription() = when (this) { BOND_BONDED -> "BONDED" BOND_BONDING -> "BONDING" BOND_NONE -> "NOT BONDED" else -> "ERROR: $this" } } context.registerReceiver( broadcastReceiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED) ) } var isScanning = false set(value) { field = value notifyListeners(null) { it.onScanningStateChange(value) } } @SuppressLint("MissingPermission") val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { notifyListeners(result.device.address) { it.onScanResult(callbackType, result) } } override fun onScanFailed(errorCode: Int) { Log.e("ScanCallback", "onScanFailed: code $errorCode") } } fun register(listener: BleListener) { listeners.add(listener) } fun startScan() { if (!isScanning) { isScanning = true val filter = ScanFilter.Builder().setDeviceName("KIRBY").build() bleScanner.startScan(listOf(filter), scanSettings, scanCallback) } } fun stopScan() { bleScanner.stopScan(scanCallback) isScanning = false } fun connect(device: BluetoothDevice) { Log.i("ConnectionManager", "connecting to " +device.address) val isConnecting = pendingOperation != null && pendingOperation is Connect && (pendingOperation as Connect).device.address == device.address if(device.isConnected() or isConnecting or operationQueue.any { it.device.address === device.address && it is Connect }) { Log.e("ConnectionManager", "Already connected to ${device.address}!") } else { enqueueOperation(Connect(device, context)) } } fun teardownConnection(device: BluetoothDevice) { if (device.isConnected()) { enqueueOperation(Disconnect(device)) } else { Log.e( "ConnectionManager", "Not connected to ${device.address}, cannot teardown connection!" ) } } fun requestMtu(device: BluetoothDevice, mtu: Int = Int.MAX_VALUE) { enqueueOperation(MtuRequest(device, mtu.coerceIn(GATT_MIN_MTU_SIZE, GATT_MAX_MTU_SIZE))) } fun discoverServices(device: BluetoothDevice) { if(operationQueue.any { it.device.address === device.address && it is DiscoverServicesRequest }) { return } enqueueOperation(DiscoverServicesRequest(device)) } fun readChar(device: BluetoothDevice, service: UUID, char: UUID) { enqueueOperation(ReadChar(device, service, char)) } fun enableNotification(device: BluetoothDevice, service: UUID, char: UUID) { enqueueOperation(SetNotification(device, service, char, true)) } fun disableNotification(device: BluetoothDevice, service: UUID, char: UUID) { enqueueOperation(SetNotification(device, service, char, false)) } fun bond(device: BluetoothDevice) { enqueueOperation(BondRequest(device)) } fun unbond(device: BluetoothDevice) { enqueueOperation(UnbondRequest(device)) } fun readRemoteRssi(device: BluetoothDevice) { if(operationQueue.any { it.device.address === device.address && it is ReadRemoteRssi }) { return } enqueueOperation(ReadRemoteRssi(device)) } // - Beginning of PRIVATE functions private fun notifyListeners(address: String?, notifier: (listener: BleListener) -> Unit) { listeners.filter { n -> n.isRelevantMessage(address) }.forEach(notifier) } private fun notifyListenersOfQueueChange(address: String) { notifyListeners(address) { listener -> listener.onQueueSizeChange( operationQueue.groupBy { o -> o.device.address } ) } } @Synchronized private fun enqueueOperation(operation: BleOperationType) { /* if (isScanning) { stopScan() } */ operationQueue.add(operation) notifyListenersOfQueueChange(operation.device.address) if (pendingOperation == null) { doNextOperation() } } @Synchronized private fun signalEndOfOperation(op: BleOperationType) { Log.d("ConnectionManager", "End of $op") pendingOperation = null notifyListenersOfQueueChange(op.device.address) if (operationQueue.isNotEmpty()) { doNextOperation() } } @Synchronized private fun doNextOperation() { if (pendingOperation != null) { Log.e( "ConnectionManager", "doNextOperation() called when an operation is pending! Aborting." ) return } val operation = operationQueue.poll() ?: run { Log.d("ConnectionManager", "Operation queue empty, returning") return } pendingOperation = operation // Handle Connect separately from other operations that require device to be connected if (operation is Connect) { with(operation) { Log.w("ConnectionManager", "Connecting to ${device.address}") device.connectGatt( context, true, callback, BluetoothDevice.TRANSPORT_LE ) } return } if (operation is BondRequest) { if (operation.device.bondState == BOND_NONE) { if (!operation.device.createBond()) { Log.e( "ConnectionManager", "createBond() returned false " + "for device ${operation.device.address}" ) signalEndOfOperation(operation) } } else { Log.e( "ConnectionManager", "BondRequest for device ${operation.device.address} aborted. " + "operation.device.bondState (${operation.device.bondState}) == BOND_NONE (${BOND_NONE})" ) signalEndOfOperation(operation) } return } if (operation is UnbondRequest) { if (operation.device.bondState == BOND_BONDED) { val method = operation.device.javaClass.getMethod("removeBond") val removeBondSuccessful = method.invoke(operation.device) as Boolean if (!removeBondSuccessful) { Log.e( "ConnectionManager", "removeBond() returned false " + "for device ${operation.device.address}" ) signalEndOfOperation(operation) } } else { Log.e( "ConnectionManager", "UnbondRequest for device ${operation.device.address} aborted. " + "operation.device.bondState (${operation.device.bondState}) == BOND_BONDED (${BOND_BONDED})" ) signalEndOfOperation(operation) } return } // Check BluetoothGatt availability for other operations val gatt = deviceGattMap[operation.device] ?: this@ConnectionManager.run { Log.w( "ConnectionManager", "Not connected to ${operation.device.address}! Aborting $operation operation." ) signalEndOfOperation(operation) return } when (operation) { is Disconnect -> with(operation) { Log.w("ConnectionManager", "Disconnecting from ${device.address}") gatt.close() deviceGattMap.remove(device) notifyListeners(gatt.device.address) { it.onDisconnect(gatt) } signalEndOfOperation(operation) } is MtuRequest -> with(operation) { gatt.requestMtu(mtu) } is DiscoverServicesRequest -> with(operation) { gatt.discoverServices() } is ReadChar -> with(operation) { val characteristic = gatt.getService(serviceId)?.getCharacteristic(charId) if (characteristic?.isReadable() == true) { gatt.readCharacteristic(characteristic) } else { Log.e("ConnectionManager", "Char $charId (${serviceId}) is not readable!") signalEndOfOperation(operation) } } is SetNotification -> with(operation) { val characteristic = gatt.getService(serviceId)?.getCharacteristic(charId) if (characteristic == null) { Log.e("ConnectionManager", "Char $charId (${serviceId}) not found!") signalEndOfOperation(operation) return } val descriptor = characteristic.getDescriptor(CCC_DESCRIPTOR_UUID) if (!characteristic.isNotifiable() && !characteristic.isIndicatable()) { Log.e( "ConnectionManager", "Char ${characteristic.uuid} (service: $serviceId)" + " doesn't support notifications/indications" ) signalEndOfOperation(operation) return } val payload: ByteArray if (enable) { if (!gatt.setCharacteristicNotification(characteristic, true)) { Log.e( "ConnectionManager", "setCharacteristicNotification to true " + "failed for ${characteristic.uuid} (service: $serviceId)" ) signalEndOfOperation(operation) return } payload = if (characteristic.isIndicatable()) BluetoothGattDescriptor.ENABLE_INDICATION_VALUE else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE } else { if (!gatt.setCharacteristicNotification(characteristic, false)) { Log.e( "ConnectionManager", "setCharacteristicNotification to false " + "failed for ${characteristic.uuid} (service: $serviceId)" ) signalEndOfOperation(operation) return } payload = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE } gatt.let { gatt -> descriptor.value = payload gatt.writeDescriptor(descriptor) } } is ReadRemoteRssi -> { gatt.readRemoteRssi() } // necessary because of IDE type inference bug // https://youtrack.jetbrains.com/issue/KTIJ-20749/Exhaustive-when-check-does-not-take-into-account-the-values-excluded-by-previous-if-conditions is Connect, is BondRequest, is UnbondRequest -> { Log.e("ConnectionManager", "Shouldn't get here: $operation") } } } private val callback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { val deviceAddress = gatt.device.address val operation = pendingOperation if (operation is Connect && operation.device.bondState == BOND_BONDED && deviceAddress == operation.device.address && status == 133 && newState == STATE_DISCONNECTED ) { Log.i( "ConnectionManager", "onConnectionStateChange: Timeout when connecting to bonded device $deviceAddress. " + "Device probably not in vicinity." ) notifyListeners(gatt.device.address) { it.onConnectToBondedFailed(gatt) } signalEndOfOperation(operation) } else if (status == GATT_SUCCESS) { if (newState == BluetoothProfile.STATE_CONNECTED) { Log.i( "ConnectionManager", "onConnectionStateChange: connected to $deviceAddress" ) deviceGattMap[gatt.device] = gatt notifyListeners(gatt.device.address) { it.onConnect(gatt) } if (operation is Connect) { signalEndOfOperation(operation) } } else if (newState == STATE_DISCONNECTED) { Log.e( "ConnectionManager", "onConnectionStateChange: disconnected from $deviceAddress" ) notifyListeners(gatt.device.address) { it.onDisconnect(gatt) } teardownConnection(gatt.device) } } else { Log.e( "ConnectionManager", "onConnectionStateChange: status $status encountered for $deviceAddress!" ) teardownConnection(gatt.device) } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { with(gatt) { if (status == GATT_SUCCESS) { Log.w( "ConnectionManager", "Discovered ${services.size} services for ${device.address}." ) printGattTable() notifyListeners(gatt.device.address) { it.onConnect(gatt) } } else { Log.e("ConnectionManager", "Service discovery failed due to status $status") teardownConnection(gatt.device) } } val operation = pendingOperation if (operation is DiscoverServicesRequest) { signalEndOfOperation(operation) } } override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { Log.w( "ConnectionManager", "ATT MTU changed to $mtu, success: ${status == GATT_SUCCESS}" ) val operation = pendingOperation if (operation is MtuRequest) { signalEndOfOperation(operation) } } override fun onCharacteristicRead( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int ) { with(characteristic) { when (status) { GATT_SUCCESS -> { Log.i( "ConnectionManager", "Read characteristic $uuid (service: ${service.uuid}): ${value.toHexString()}" ) notifyListeners(gatt.device.address) { listener -> listener.onSuccessfulCharRead( gatt, characteristic ) } } BluetoothGatt.GATT_READ_NOT_PERMITTED -> { Log.e( "BluetoothGattCallback", "Read not permitted for $uuid (service: ${service.uuid})!" ) } else -> { Log.e( "BluetoothGattCallback", "Characteristic read failed for $uuid (service: ${service.uuid}), error: $status" ) } } } val op = pendingOperation if (op is ReadChar && op.charId == characteristic.uuid && op.serviceId == characteristic.service.uuid) { signalEndOfOperation(op) } } override fun onDescriptorWrite( gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int ) { val op = pendingOperation if (op is SetNotification && descriptor.uuid == CCC_DESCRIPTOR_UUID && op.charId == descriptor.characteristic.uuid && op.serviceId == descriptor.characteristic.service.uuid ) { if (!descriptor.value.contentEquals(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE)) { notifyListeners(gatt.device.address) { it.onSubscribe(gatt, descriptor) } } else { notifyListeners(gatt.device.address) { it.onUnsubscribe(gatt, descriptor) } } signalEndOfOperation(op) } } override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { notifyListeners(gatt.device.address) { listener -> listener.onCharChange( gatt, characteristic ) } } override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { if (status == GATT_SUCCESS) { notifyListeners(gatt.device.address) { it.onReadRemoteRssi(gatt, rssi) } } else { Log.e( "BluetoothGattCallback", "ReadRemoteRssi failed for ${gatt.device.address}, error: $status" ) } val op = pendingOperation if (op is ReadRemoteRssi && gatt.device.address == op.device.address) { signalEndOfOperation(op) } } } private fun BluetoothDevice.isConnected() = deviceGattMap.containsKey(this) }