From 249d9df2d258739af36245dcf1032fd1c72c441e Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Wed, 2 Oct 2024 12:02:02 -0700 Subject: [PATCH 1/3] Add support for implicit bonding --- kable-core/api/android/kable-core.api | 10 ++ .../androidMain/kotlin/AndroidPeripheral.kt | 4 + .../BluetoothDeviceAndroidPeripheral.kt | 101 ++++++++++++++++-- kable-core/src/androidMain/kotlin/Bond.kt | 37 +++++++ .../src/androidMain/kotlin/Connection.kt | 18 +++- 5 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 kable-core/src/androidMain/kotlin/Bond.kt diff --git a/kable-core/api/android/kable-core.api b/kable-core/api/android/kable-core.api index 833cf3ada..e5f8687c7 100644 --- a/kable-core/api/android/kable-core.api +++ b/kable-core/api/android/kable-core.api @@ -13,6 +13,7 @@ public abstract interface class com/juul/kable/Advertisement { public abstract interface class com/juul/kable/AndroidPeripheral : com/juul/kable/Peripheral { public abstract fun getAddress ()Ljava/lang/String; + public abstract fun getBondState ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getMtu ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getType ()Lcom/juul/kable/AndroidPeripheral$Type; public abstract fun requestConnectionPriority (Lcom/juul/kable/AndroidPeripheral$Priority;)Z @@ -21,6 +22,15 @@ public abstract interface class com/juul/kable/AndroidPeripheral : com/juul/kabl public abstract fun write (Lcom/juul/kable/Descriptor;[BLkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class com/juul/kable/AndroidPeripheral$Bond : java/lang/Enum { + public static final field Bonded Lcom/juul/kable/AndroidPeripheral$Bond; + public static final field Bonding Lcom/juul/kable/AndroidPeripheral$Bond; + public static final field None Lcom/juul/kable/AndroidPeripheral$Bond; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/AndroidPeripheral$Bond; + public static fun values ()[Lcom/juul/kable/AndroidPeripheral$Bond; +} + public final class com/juul/kable/AndroidPeripheral$Priority : java/lang/Enum { public static final field Balanced Lcom/juul/kable/AndroidPeripheral$Priority; public static final field High Lcom/juul/kable/AndroidPeripheral$Priority; diff --git a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt index e59d6c544..a6bb7c7ff 100644 --- a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt @@ -20,6 +20,8 @@ public interface AndroidPeripheral : Peripheral { public enum class Priority { Low, Balanced, High } + public enum class Bond { None, Bonding, Bonded } + public enum class Type { /** https://developer.android.com/reference/android/bluetooth/BluetoothDevice#DEVICE_TYPE_CLASSIC */ @@ -160,4 +162,6 @@ public interface AndroidPeripheral : Peripheral { * is negotiated. */ public val mtu: StateFlow + + public val bondState: StateFlow } diff --git a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt index 4f18a8e61..bdc718899 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt @@ -15,6 +15,7 @@ import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE import android.bluetooth.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE import android.bluetooth.BluetoothGattDescriptor.ENABLE_INDICATION_VALUE import android.bluetooth.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE +import com.juul.kable.AndroidPeripheral.Bond import com.juul.kable.AndroidPeripheral.Priority import com.juul.kable.AndroidPeripheral.Type import com.juul.kable.State.Disconnected @@ -36,10 +37,14 @@ import com.juul.kable.logs.detail import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration @@ -67,6 +72,13 @@ internal class BluetoothDeviceAndroidPeripheral( } disconnect() } + + onBondState { state -> + logger.debug { + message = "Bond state" + detail("state", state.toString()) + } + } } private val connectAction = sharedRepeatableAction(::establishConnection) @@ -100,6 +112,9 @@ internal class BluetoothDeviceAndroidPeripheral( override val name: String? get() = bluetoothDevice.name + override val bondState: StateFlow = bondStateFor(bluetoothDevice) + .stateIn(this, SharingStarted.Eagerly, Bond(bluetoothDevice.bondState)) + private suspend fun establishConnection(scope: CoroutineScope): CoroutineScope { checkBluetoothIsSupported() checkBluetoothIsOn() @@ -123,6 +138,10 @@ internal class BluetoothDeviceAndroidPeripheral( disconnectTimeout, ) + if (bondState.value == Bond.Bonding) { + logger.debug { message = "Awaiting bond state" } + awaitNotBonding() + } suspendUntil() discoverServices() configureCharacteristicObservations() @@ -197,8 +216,22 @@ internal class BluetoothDeviceAndroidPeripheral( } val platformCharacteristic = servicesOrThrow().obtain(characteristic, writeType.properties) - connectionOrThrow().execute { - writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue) + try { + connectionOrThrow().execute { + writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue) + } + } catch (e: BondRequiredException) { + logInsufficientAuthentication(e) + awaitNotBonding() + logger.debug { + message = "Retrying write" + detail(characteristic) + detail(writeType) + detail(data, Operation.Write) + } + connectionOrThrow().execute { + writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue) + } } } @@ -211,8 +244,20 @@ internal class BluetoothDeviceAndroidPeripheral( } val platformCharacteristic = servicesOrThrow().obtain(characteristic, Read) - return connectionOrThrow().execute { - readCharacteristicOrThrow(platformCharacteristic) + return try { + connectionOrThrow().execute { + readCharacteristicOrThrow(platformCharacteristic) + } + } catch (e: BondRequiredException) { + logInsufficientAuthentication(e) + awaitNotBonding() + logger.debug { + message = "Retrying read" + detail(characteristic) + } + connectionOrThrow().execute { + readCharacteristicOrThrow(platformCharacteristic) + } }.value!! } @@ -233,8 +278,21 @@ internal class BluetoothDeviceAndroidPeripheral( detail(data, Operation.Write) } - connectionOrThrow().execute { - writeDescriptorOrThrow(platformDescriptor, data) + try { + connectionOrThrow().execute { + writeDescriptorOrThrow(platformDescriptor, data) + } + } catch (e: BondRequiredException) { + logInsufficientAuthentication(e) + awaitNotBonding() + logger.debug { + message = "Retrying write" + detail(platformDescriptor) + detail(data, Operation.Write) + } + connectionOrThrow().execute { + writeDescriptorOrThrow(platformDescriptor, data) + } } } @@ -247,8 +305,20 @@ internal class BluetoothDeviceAndroidPeripheral( } val platformDescriptor = servicesOrThrow().obtain(descriptor) - return connectionOrThrow().execute { - readDescriptorOrThrow(platformDescriptor) + return try { + connectionOrThrow().execute { + readDescriptorOrThrow(platformDescriptor) + } + } catch (e: BondRequiredException) { + logInsufficientAuthentication(e) + awaitNotBonding() + logger.debug { + message = "Retrying read" + detail(descriptor) + } + connectionOrThrow().execute { + readDescriptorOrThrow(platformDescriptor) + } }.value!! } @@ -339,6 +409,15 @@ internal class BluetoothDeviceAndroidPeripheral( } } + private suspend fun awaitNotBonding(): Bond = bondState.first { it != Bond.Bonding } + + private fun logInsufficientAuthentication(exception: BondRequiredException) { + logger.warn { + message = "Insufficient authentication" + detail(exception.status) + } + } + private fun onBluetoothDisabled(action: suspend (bluetoothState: Int) -> Unit) { bluetoothState .filter { state -> state == STATE_TURNING_OFF || state == STATE_OFF } @@ -346,6 +425,12 @@ internal class BluetoothDeviceAndroidPeripheral( .launchIn(this) } + private fun onBondState(action: (bondState: Bond) -> Unit) { + bondState + .onEach(action) + .launchIn(this) + } + override fun toString(): String = "Peripheral(bluetoothDevice=$bluetoothDevice)" } diff --git a/kable-core/src/androidMain/kotlin/Bond.kt b/kable-core/src/androidMain/kotlin/Bond.kt new file mode 100644 index 000000000..545827e25 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/Bond.kt @@ -0,0 +1,37 @@ +package com.juul.kable + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED +import android.bluetooth.BluetoothDevice.BOND_BONDED +import android.bluetooth.BluetoothDevice.BOND_BONDING +import android.bluetooth.BluetoothDevice.BOND_NONE +import android.bluetooth.BluetoothDevice.ERROR +import android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE +import android.bluetooth.BluetoothDevice.EXTRA_DEVICE +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.IntentCompat +import com.juul.kable.AndroidPeripheral.Bond +import com.juul.tuulbox.coroutines.flow.broadcastReceiverFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map + +internal fun bondStateFor(bluetoothDevice: BluetoothDevice): Flow = + broadcastReceiverFlow(IntentFilter(ACTION_BOND_STATE_CHANGED)) + .filter { intent -> bluetoothDevice == intent.bluetoothDevice } + .map { intent -> intent.bondState } + .map(::Bond) + +internal fun Bond(state: Int): Bond = when (state) { + BOND_NONE -> Bond.None + BOND_BONDING -> Bond.Bonding + BOND_BONDED -> Bond.Bonded + else -> error("Unsupported bond state: $state") +} + +private val Intent.bluetoothDevice: BluetoothDevice? + get() = IntentCompat.getParcelableExtra(this, EXTRA_DEVICE, BluetoothDevice::class.java) + +private val Intent.bondState: Int + get() = getIntExtra(EXTRA_BOND_STATE, ERROR) diff --git a/kable-core/src/androidMain/kotlin/Connection.kt b/kable-core/src/androidMain/kotlin/Connection.kt index 1d069455e..613bcf56a 100644 --- a/kable-core/src/androidMain/kotlin/Connection.kt +++ b/kable-core/src/androidMain/kotlin/Connection.kt @@ -1,10 +1,13 @@ package com.juul.kable import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION +import android.bluetooth.BluetoothGatt.GATT_INSUFFICIENT_ENCRYPTION import android.bluetooth.BluetoothGatt.GATT_SUCCESS import android.os.Handler import com.juul.kable.State.Disconnected import com.juul.kable.coroutines.childSupervisor +import com.juul.kable.external.GATT_AUTH_FAIL import com.juul.kable.gatt.Callback import com.juul.kable.gatt.GattStatus import com.juul.kable.gatt.Response @@ -42,8 +45,16 @@ import kotlin.reflect.KClass import kotlin.time.Duration import kotlin.time.Duration.Companion.ZERO +internal class BondRequiredException(val status: GattStatus) : IllegalStateException() + private val GattSuccess = GattStatus(GATT_SUCCESS) +private val BondingStatuses = listOf( + GattStatus(GATT_AUTH_FAIL), + GattStatus(GATT_INSUFFICIENT_AUTHENTICATION), + GattStatus(GATT_INSUFFICIENT_ENCRYPTION), +) + /** * Represents a Bluetooth Low Energy connection. [Connection] should be initialized with the * provided [BluetoothGatt] in a connecting or connected state. When a disconnect occurs (either by @@ -178,7 +189,8 @@ internal class Connection( coroutineContext.ensureActive() throw e.unwrapCancellationException() } - }.also(::checkResponse) + }.also(::checkBondingStatus) + .also(::checkResponse) // `guard` should always enforce a 1:1 matching of request-to-response, but if an Android // `BluetoothGattCallback` method is called out-of-order then we'll cast to the wrong type. @@ -271,6 +283,10 @@ internal class Connection( private fun dispose(cause: Throwable) = connectionJob.completeExceptionally(cause) } +private fun checkBondingStatus(response: Response) { + if (response.status in BondingStatuses) throw BondRequiredException(response.status) +} + private fun checkResponse(response: Response) { if (response.status != GattSuccess) throw GattStatusException(response.toString()) } From 51345655cd6bd0fcdc0976dff67d924123864f38 Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Mon, 4 Nov 2024 14:07:53 -0800 Subject: [PATCH 2/3] Remove await for bond on connect --- .../androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt index bdc718899..720e8471e 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt @@ -138,10 +138,6 @@ internal class BluetoothDeviceAndroidPeripheral( disconnectTimeout, ) - if (bondState.value == Bond.Bonding) { - logger.debug { message = "Awaiting bond state" } - awaitNotBonding() - } suspendUntil() discoverServices() configureCharacteristicObservations() From a1598d152def34b124d78a7b26f4290fab2e2b36 Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Mon, 4 Nov 2024 14:08:03 -0800 Subject: [PATCH 3/3] Add debug logging [skip ci] --- kable-core/src/androidMain/kotlin/Bond.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kable-core/src/androidMain/kotlin/Bond.kt b/kable-core/src/androidMain/kotlin/Bond.kt index 545827e25..5284d211c 100644 --- a/kable-core/src/androidMain/kotlin/Bond.kt +++ b/kable-core/src/androidMain/kotlin/Bond.kt @@ -16,9 +16,13 @@ import com.juul.tuulbox.coroutines.flow.broadcastReceiverFlow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach internal fun bondStateFor(bluetoothDevice: BluetoothDevice): Flow = broadcastReceiverFlow(IntentFilter(ACTION_BOND_STATE_CHANGED)) + .onEach { intent -> + println("Bond state for ${intent.bluetoothDevice}: ${intent.bondState}") + } .filter { intent -> bluetoothDevice == intent.bluetoothDevice } .map { intent -> intent.bondState } .map(::Bond)