diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 85289d7..2f77153 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,16 +3,21 @@ xmlns:tools="http://schemas.android.com/tools"> - - - - - diff --git a/app/src/main/java/com/example/sensortestingapp/ConnectionManager.kt b/app/src/main/java/com/example/sensortestingapp/ConnectionManager.kt index 0ff5f37..f6ea2fb 100644 --- a/app/src/main/java/com/example/sensortestingapp/ConnectionManager.kt +++ b/app/src/main/java/com/example/sensortestingapp/ConnectionManager.kt @@ -3,15 +3,23 @@ package com.example.sensortestingapp import android.annotation.SuppressLint 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.ScanResult import android.bluetooth.le.ScanSettings +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.util.Log import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -62,6 +70,18 @@ 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?) { @@ -74,6 +94,8 @@ open class BleListener(private val deviceAddress: String?) { open fun onConnect(gatt: BluetoothGatt) {} + open fun onConnectToBondedFailed(gatt: BluetoothGatt) {} + open fun onDisconnect(gatt: BluetoothGatt) {} open fun onSuccessfulCharRead( @@ -112,6 +134,18 @@ open class BleListener(private val deviceAddress: String?) { } + open fun onBonded(device: BluetoothDevice) { + + } + + open fun onUnbonded(device: BluetoothDevice) { + + } + + open fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int) { + + } + } @@ -179,6 +213,69 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) { 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 @@ -260,6 +357,18 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) { 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 @@ -325,10 +434,52 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) { 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.e( + Log.w( "ConnectionManager", "Not connected to ${operation.device.address}! Aborting $operation operation." ) @@ -419,11 +570,15 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) { } } + 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 -> { - Log.e("ConnectionManager", "Shouldn't get here") + is Connect, is BondRequest, is UnbondRequest -> { + Log.e("ConnectionManager", "Shouldn't get here: $operation") } } } @@ -431,8 +586,24 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) { private val callback = object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { val deviceAddress = gatt.device.address + val operation = pendingOperation - if (status == BluetoothGatt.GATT_SUCCESS) { + 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", @@ -442,11 +613,11 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) { notifyListeners(gatt.device.address) { it.onConnect(gatt) } - val operation = pendingOperation + if (operation is Connect) { signalEndOfOperation(operation) } - } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + } else if (newState == STATE_DISCONNECTED) { Log.e( "ConnectionManager", "onConnectionStateChange: disconnected from $deviceAddress" @@ -579,6 +750,20 @@ class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) { } } + 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) + } + } } diff --git a/app/src/main/java/com/example/sensortestingapp/DeviceListAdapter.kt b/app/src/main/java/com/example/sensortestingapp/DeviceListAdapter.kt index 97084d3..d986f83 100644 --- a/app/src/main/java/com/example/sensortestingapp/DeviceListAdapter.kt +++ b/app/src/main/java/com/example/sensortestingapp/DeviceListAdapter.kt @@ -31,7 +31,7 @@ interface Measurement { interface DeviceListEntry { val address: String - var rssi: Int + var rssi: Int? val name: String? @@ -80,14 +80,17 @@ class DeviceListAdapter( @SuppressLint("RestrictedApi") fun bind(result: DeviceListEntry) { + val rssi = result.rssi deviceNameView.text = result.name ?: "" deviceProgress.visibility = if (result.hasRunningOp) VISIBLE else INVISIBLE macAddressView.text = result.address - signalStrengthView.text = "${result.rssi ?: "-"} dBm" + signalStrengthView.text = "${rssi ?: "-"} dBm" var signalStrengthIcon = R.drawable.signal_strength_weak - if (result.rssi >= -55) { + if (rssi == null) { + signalStrengthIcon = R.drawable.signal_strength_none + } else if (rssi >= -55) { signalStrengthIcon = R.drawable.signal_strength_strong - } else if (result.rssi >= -80) { + } else if (rssi >= -80) { signalStrengthIcon = R.drawable.signal_strength_medium } signalStrengthView.setCompoundDrawablesWithIntrinsicBounds( diff --git a/app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt b/app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt index d8cb813..018e474 100644 --- a/app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt +++ b/app/src/main/java/com/example/sensortestingapp/KirbyDevice.kt @@ -2,6 +2,7 @@ package com.example.sensortestingapp import android.annotation.SuppressLint import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothDevice.BOND_BONDED import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor @@ -15,6 +16,7 @@ import java.util.EnumSet import java.util.UUID import java.util.stream.Collectors + private val DEMO_SERVICE_UUID = UUID.fromString("00000000-0001-11E1-9AB4-0002A5D5C51B") private val DEMO_CHAR_UUID = UUID.fromString("00140000-0001-11E1-AC36-0002A5D5C51B") @@ -92,26 +94,30 @@ private fun demoPayloadToMeasurements(payload: DemoPayload): List { } enum class DeviceStatus { - DISCOVERED, CONNECTED, BONDED, SUBSCRIBED + CONNECTED, BONDED, SUBSCRIBED, MISSING } @SuppressLint("MissingPermission") class KirbyDevice( private val connectionManager: ConnectionManager, private val bleDevice: BluetoothDevice, - initialRssi: Int, - override var hasRunningOp: Boolean = false, private val onStateChange: (device: KirbyDevice) -> Unit ) : BleListener(bleDevice.address), DeviceListEntry { override fun onScanResult(callbackType: Int, result: ScanResult) { - statuses.add(DeviceStatus.DISCOVERED) + rssi = result.rssi onStateChange(this) } override fun onConnect(gatt: BluetoothGatt) { statuses.add(DeviceStatus.CONNECTED) + statuses.remove(DeviceStatus.MISSING) + onStateChange(this) + } + + override fun onConnectToBondedFailed(gatt: BluetoothGatt) { + statuses.add(DeviceStatus.MISSING) onStateChange(this) } @@ -131,7 +137,6 @@ class KirbyDevice( override fun onCharChange(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { addMeasurement(characteristic) - //statuses.add(DeviceStatus.SUBSCRIBED) onStateChange(this) } @@ -153,11 +158,25 @@ class KirbyDevice( onStateChange(this) } - override var rssi = initialRssi - set(value) { - field = value - onStateChange(this) - } + override fun onBonded(device: BluetoothDevice) { + statuses.add(DeviceStatus.BONDED) + onStateChange(this) + } + + override fun onUnbonded(device: BluetoothDevice) { + statuses.remove(DeviceStatus.BONDED) + onStateChange(this) + } + + override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int) { + this.rssi = rssi + onStateChange(this) + } + + override var hasRunningOp: Boolean = false + + override var rssi: Int? = null + private fun addMeasurement(characteristic: BluetoothGattCharacteristic) { if (characteristic.service.uuid == DEMO_SERVICE_UUID && characteristic.uuid == DEMO_CHAR_UUID) { @@ -171,6 +190,12 @@ class KirbyDevice( private val statuses = EnumSet.noneOf(DeviceStatus::class.java) + init { + if (bleDevice.bondState == BOND_BONDED) { + statuses.add(DeviceStatus.BONDED) + } + } + override val address: String get() = bleDevice.address @@ -181,10 +206,6 @@ class KirbyDevice( override val status: String? get() = statuses.stream().map { it.name }.collect(Collectors.joining(", ")) - // override fun getRssi(): Int? { -// return rssi -// } - override fun getMeasurements(): List { if (measurements.isEmpty()) { return emptyList() @@ -195,7 +216,23 @@ class KirbyDevice( override fun getActions(): List { val actions = mutableListOf() - if (statuses.contains(DeviceStatus.DISCOVERED) && !statuses.contains(DeviceStatus.CONNECTED) + if (!statuses.contains(DeviceStatus.BONDED)) { + actions.add(object : Action { + override fun getLabel(): String { + return "Bond" + } + + override fun getIcon(): Int { + return R.drawable.action_icon_bond + } + + override fun execute() { + connectionManager.bond(bleDevice) + } + }) + } + + if (!statuses.contains(DeviceStatus.CONNECTED) ) { actions.add(object : Action { override fun getLabel(): String { @@ -208,6 +245,7 @@ class KirbyDevice( override fun execute() { connectionManager.connect(bleDevice) + connectionManager.readRemoteRssi(bleDevice) connectionManager.discoverServices(bleDevice) } }) @@ -260,6 +298,21 @@ class KirbyDevice( } }) } + + actions.add(object : Action { + override fun getLabel(): String { + return "Update Signal Strength" + } + + override fun getIcon(): Int { + return R.drawable.action_icon_update_signal_strength + } + + override fun execute() { + connectionManager.readRemoteRssi(bleDevice) + } + }) + } if (statuses.contains(DeviceStatus.SUBSCRIBED)) { @@ -280,6 +333,22 @@ class KirbyDevice( }) } + if (statuses.contains(DeviceStatus.BONDED) + ) { + actions.add(object : Action { + override fun getLabel(): String { + return "Unbond" + } + + override fun getIcon(): Int { + return R.drawable.action_icon_unbond + } + + override fun execute() { + connectionManager.unbond(bleDevice) + } + }) + } return actions; } diff --git a/app/src/main/java/com/example/sensortestingapp/MainActivity.kt b/app/src/main/java/com/example/sensortestingapp/MainActivity.kt index 1c5ba41..9d5e523 100644 --- a/app/src/main/java/com/example/sensortestingapp/MainActivity.kt +++ b/app/src/main/java/com/example/sensortestingapp/MainActivity.kt @@ -5,6 +5,7 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.AlertDialog import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager import android.bluetooth.le.ScanResult import android.content.Context @@ -22,8 +23,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import com.example.sensortestingapp.databinding.ActivityMainBinding @@ -91,21 +90,24 @@ class MainActivity : AppCompatActivity() { } + private fun isKirbyDevice(device: BluetoothDevice): Boolean { + return (device.name ?: "").lowercase().contains("kirby") + } + private fun setupDevicesList() { binding.devicesList.apply { adapter = deviceListAdapter - layoutManager = LinearLayoutManager( - this@MainActivity, - RecyclerView.VERTICAL, - false - ) - //isNestedScrollingEnabled = false } val animator = binding.devicesList.itemAnimator if (animator is SimpleItemAnimator) { animator.supportsChangeAnimations = false } + + bluetoothAdapter.bondedDevices.filter { isKirbyDevice(it) }.forEach { + newKirbyDevice(it) + } + //addDummyDevices() } @@ -131,7 +133,7 @@ class MainActivity : AppCompatActivity() { } override fun onScanResult(callbackType: Int, result: ScanResult) { - if ((result.device.name ?: "").lowercase().contains("kirby")) { + if (isKirbyDevice(result.device)) { Log.i( "ScanCallback", @@ -139,9 +141,7 @@ class MainActivity : AppCompatActivity() { ) val kirbyDevice = kirbyDevices.find { it.address == result.device.address } if (kirbyDevice == null) { - newKirbyDevice(callbackType, result) - } else { - kirbyDevice.rssi = result.rssi + newKirbyDevice(result.device).onScanResult(callbackType, result) } } @@ -150,8 +150,8 @@ class MainActivity : AppCompatActivity() { return mngr } - private fun newKirbyDevice(callbackType: Int, scanResult: ScanResult): KirbyDevice { - val device = KirbyDevice(connectionManager, scanResult.device, scanResult.rssi) { + private fun newKirbyDevice(bleDevice: BluetoothDevice): KirbyDevice { + val device = KirbyDevice(connectionManager, bleDevice) { val i = kirbyDevices.indexOfFirst { d -> d === it } runOnUiThread { deviceListAdapter.notifyItemChanged(i) @@ -159,7 +159,6 @@ class MainActivity : AppCompatActivity() { } connectionManager.register(device) kirbyDevices.add(device) - device.onScanResult(callbackType, scanResult) deviceListAdapter.notifyItemInserted(kirbyDevices.size - 1) return device } @@ -169,8 +168,6 @@ class MainActivity : AppCompatActivity() { requestRelevantRuntimePermissions() } else { connectionManager.startScan() - // kirbyDevices.clear() - //deviceListAdapter.notifyDataSetChanged() } } @@ -332,7 +329,7 @@ class MainActivity : AppCompatActivity() { @SuppressLint("MissingPermission") class DummyListEntry(override val address: String) : DeviceListEntry { - override var rssi: Int = -30 + override var rssi: Int? = -30 override val name: String = "Device 123" override val status: String diff --git a/app/src/main/res/drawable/action_icon_bond.xml b/app/src/main/res/drawable/action_icon_bond.xml new file mode 100644 index 0000000..ffc7f47 --- /dev/null +++ b/app/src/main/res/drawable/action_icon_bond.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/action_icon_unbond.xml b/app/src/main/res/drawable/action_icon_unbond.xml new file mode 100644 index 0000000..2fbc2cf --- /dev/null +++ b/app/src/main/res/drawable/action_icon_unbond.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/action_icon_update_signal_strength.xml b/app/src/main/res/drawable/action_icon_update_signal_strength.xml new file mode 100644 index 0000000..2e7e1a5 --- /dev/null +++ b/app/src/main/res/drawable/action_icon_update_signal_strength.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/signal_strength_none.xml b/app/src/main/res/drawable/signal_strength_none.xml new file mode 100644 index 0000000..8c2c9f7 --- /dev/null +++ b/app/src/main/res/drawable/signal_strength_none.xml @@ -0,0 +1,12 @@ + + + + + +