Skip to content

Commit

Permalink
Add support for implicit bonding
Browse files Browse the repository at this point in the history
  • Loading branch information
twyatt committed Oct 13, 2024
1 parent 7ffd7cb commit 5c5069b
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 9 deletions.
10 changes: 10 additions & 0 deletions kable-core/api/android/kable-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions kable-core/src/androidMain/kotlin/AndroidPeripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -160,4 +162,6 @@ public interface AndroidPeripheral : Peripheral {
* is negotiated.
*/
public val mtu: StateFlow<Int?>

public val bondState: StateFlow<Bond>
}
101 changes: 93 additions & 8 deletions kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -67,6 +72,13 @@ internal class BluetoothDeviceAndroidPeripheral(
}
disconnect()
}

onBondState { state ->
logger.debug {
message = "Bond state"
detail("state", state.toString())
}
}
}

private val connectAction = sharedRepeatableAction(::establishConnection)
Expand Down Expand Up @@ -100,6 +112,9 @@ internal class BluetoothDeviceAndroidPeripheral(
override val name: String?
get() = bluetoothDevice.name

override val bondState: StateFlow<Bond> = bondStateFor(bluetoothDevice)
.stateIn(this, SharingStarted.Eagerly, Bond(bluetoothDevice.bondState))

private suspend fun establishConnection(scope: CoroutineScope): CoroutineScope {
checkBluetoothIsSupported()
checkBluetoothIsOn()
Expand All @@ -123,6 +138,10 @@ internal class BluetoothDeviceAndroidPeripheral(
disconnectTimeout,
)

if (bondState.value == Bond.Bonding) {
logger.debug { message = "Awaiting bond state" }
awaitNotBonding()
}
suspendUntil<State.Connecting.Services>()
discoverServices()
configureCharacteristicObservations()
Expand Down Expand Up @@ -197,8 +216,22 @@ internal class BluetoothDeviceAndroidPeripheral(
}

val platformCharacteristic = servicesOrThrow().obtain(characteristic, writeType.properties)
connectionOrThrow().execute<OnCharacteristicWrite> {
writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue)
try {
connectionOrThrow().execute<OnCharacteristicWrite> {
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<OnCharacteristicWrite> {
writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue)
}
}
}

Expand All @@ -211,8 +244,20 @@ internal class BluetoothDeviceAndroidPeripheral(
}

val platformCharacteristic = servicesOrThrow().obtain(characteristic, Read)
return connectionOrThrow().execute<OnCharacteristicRead> {
readCharacteristicOrThrow(platformCharacteristic)
return try {
connectionOrThrow().execute<OnCharacteristicRead> {
readCharacteristicOrThrow(platformCharacteristic)
}
} catch (e: BondRequiredException) {
logInsufficientAuthentication(e)
awaitNotBonding()
logger.debug {
message = "Retrying read"
detail(characteristic)
}
connectionOrThrow().execute<OnCharacteristicRead> {
readCharacteristicOrThrow(platformCharacteristic)
}
}.value!!
}

Expand All @@ -233,8 +278,21 @@ internal class BluetoothDeviceAndroidPeripheral(
detail(data, Operation.Write)
}

connectionOrThrow().execute<OnDescriptorWrite> {
writeDescriptorOrThrow(platformDescriptor, data)
try {
connectionOrThrow().execute<OnDescriptorWrite> {
writeDescriptorOrThrow(platformDescriptor, data)
}
} catch (e: BondRequiredException) {
logInsufficientAuthentication(e)
awaitNotBonding()
logger.debug {
message = "Retrying write"
detail(platformDescriptor)
detail(data, Operation.Write)
}
connectionOrThrow().execute<OnDescriptorWrite> {
writeDescriptorOrThrow(platformDescriptor, data)
}
}
}

Expand All @@ -247,8 +305,20 @@ internal class BluetoothDeviceAndroidPeripheral(
}

val platformDescriptor = servicesOrThrow().obtain(descriptor)
return connectionOrThrow().execute<OnDescriptorRead> {
readDescriptorOrThrow(platformDescriptor)
return try {
connectionOrThrow().execute<OnDescriptorRead> {
readDescriptorOrThrow(platformDescriptor)
}
} catch (e: BondRequiredException) {
logInsufficientAuthentication(e)
awaitNotBonding()
logger.debug {
message = "Retrying read"
detail(descriptor)
}
connectionOrThrow().execute<OnDescriptorRead> {
readDescriptorOrThrow(platformDescriptor)
}
}.value!!
}

Expand Down Expand Up @@ -339,13 +409,28 @@ 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 }
.onEach(action)
.launchIn(this)
}

private fun onBondState(action: (bondState: Bond) -> Unit) {
bondState
.onEach(action)
.launchIn(this)
}

override fun toString(): String = "Peripheral(bluetoothDevice=$bluetoothDevice)"
}

Expand Down
37 changes: 37 additions & 0 deletions kable-core/src/androidMain/kotlin/Bond.kt
Original file line number Diff line number Diff line change
@@ -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<Bond> =
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)
18 changes: 17 additions & 1 deletion kable-core/src/androidMain/kotlin/Connection.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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())
}

0 comments on commit 5c5069b

Please sign in to comment.