feat: sensors can be bonded

Known issue: app enters an unrecoverable state if a pairing PIN with less than 6 digits is entered.
This commit is contained in:
Fabian Christoffel
2023-07-04 14:02:47 +02:00
parent 7ad1b4ea86
commit d6ab7faf30
9 changed files with 346 additions and 48 deletions

View File

@@ -3,16 +3,21 @@
xmlns:tools="http://schemas.android.com/tools">
<!-- Request legacy Bluetooth permissions on versions older than API 31 (Android 12). -->
<uses-permission android:name="android.permission.BLUETOOTH"
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

View File

@@ -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>(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)
}
}
}

View File

@@ -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 ?: "<N/A>"
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(

View File

@@ -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<Measurement> {
}
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<Measurement> {
if (measurements.isEmpty()) {
return emptyList()
@@ -195,7 +216,23 @@ class KirbyDevice(
override fun getActions(): List<Action> {
val actions = mutableListOf<Action>()
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;
}

View File

@@ -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

View File

@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M8,11h8v2L8,13zM20.1,12L22,12c0,-2.76 -2.24,-5 -5,-5h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1zM3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM19,12h-2v3h-3v2h3v3h2v-3h3v-2h-3z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1 0,1.43 -0.98,2.63 -2.31,2.98l1.46,1.46C20.88,15.61 22,13.95 22,12c0,-2.76 -2.24,-5 -5,-5zM16,11h-2.19l2,2L16,13zM2,4.27l3.11,3.11C3.29,8.12 2,9.91 2,12c0,2.76 2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1 0,-1.59 1.21,-2.9 2.76,-3.07L8.73,11L8,11v2h2.73L13,15.27L13,17h1.73l4.01,4L20,19.74 3.27,3 2,4.27z"/>
</vector>

View File

@@ -0,0 +1,17 @@
<vector android:height="16dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="16dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@color/greyDarker"
android:pathData="M17,4h3v16h-3zM5,14h3v6L5,20zM11,9h3v11h-3z" />
<path
android:fillColor="@android:color/black"
android:pathData="M5,14h3v6H5V14z" />
<path
android:fillColor="@android:color/black"
android:pathData="M5,14h3v6H5V14zM11,9h3v11h-3V9z" />
</vector>

View File

@@ -0,0 +1,12 @@
<vector android:height="16dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="16dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@color/greyDarker"
android:pathData="M17,4h3v16h-3zM5,14h3v6L5,20zM11,9h3v11h-3z" />
</vector>