chore: rename project

This commit is contained in:
Stefan Zollinger
2024-04-10 10:39:07 +02:00
parent c94ca3f40f
commit e53a269b4f
16 changed files with 19 additions and 19 deletions

View File

@@ -0,0 +1,779 @@
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.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<String, List<BleOperationType>>) {
}
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.toString()} | " +
"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<BluetoothDevice, BluetoothGatt>()
private val operationQueue = ConcurrentLinkedQueue<BleOperationType>()
private var pendingOperation: BleOperationType? = null
private var listeners = ArrayList<BleListener>()
private val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
private val scanFilters = listOf( ScanFilter.Builder().setServiceUuid(ParcelUuid.fromString(BuildConfig.SERVICE_UUID)).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>(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) {
Log.d(
"ScanCallback",
"Found BLE device with address ${result.device.address} (name: ${result.device.name}, rssi: ${result.rssi})"
)
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
bleScanner.startScan( null, scanSettings, scanCallback)
}
}
fun stopScan() {
bleScanner.stopScan(scanCallback)
isScanning = false
}
fun connect(device: BluetoothDevice) {
if (device.isConnected()) {
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) {
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) {
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 == BluetoothGatt.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 == BluetoothGatt.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 == BluetoothGatt.GATT_SUCCESS}"
)
val operation = pendingOperation
if (operation is MtuRequest) {
signalEndOfOperation(operation)
}
}
override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
with(characteristic) {
when (status) {
BluetoothGatt.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)
}