Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add implicit bonding support #494

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
}
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 Down Expand Up @@ -197,8 +212,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 +240,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 +274,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 +301,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 +405,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
41 changes: 41 additions & 0 deletions kable-core/src/androidMain/kotlin/Bond.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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
import kotlinx.coroutines.flow.onEach

internal fun bondStateFor(bluetoothDevice: BluetoothDevice): Flow<Bond> =
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)

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