Skip to content

Commit 5c5069b

Browse files
committed
Add support for implicit bonding
1 parent 7ffd7cb commit 5c5069b

5 files changed

Lines changed: 161 additions & 9 deletions

File tree

kable-core/api/android/kable-core.api

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public abstract interface class com/juul/kable/Advertisement {
1313

1414
public abstract interface class com/juul/kable/AndroidPeripheral : com/juul/kable/Peripheral {
1515
public abstract fun getAddress ()Ljava/lang/String;
16+
public abstract fun getBondState ()Lkotlinx/coroutines/flow/StateFlow;
1617
public abstract fun getMtu ()Lkotlinx/coroutines/flow/StateFlow;
1718
public abstract fun getType ()Lcom/juul/kable/AndroidPeripheral$Type;
1819
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
2122
public abstract fun write (Lcom/juul/kable/Descriptor;[BLkotlin/coroutines/Continuation;)Ljava/lang/Object;
2223
}
2324

25+
public final class com/juul/kable/AndroidPeripheral$Bond : java/lang/Enum {
26+
public static final field Bonded Lcom/juul/kable/AndroidPeripheral$Bond;
27+
public static final field Bonding Lcom/juul/kable/AndroidPeripheral$Bond;
28+
public static final field None Lcom/juul/kable/AndroidPeripheral$Bond;
29+
public static fun getEntries ()Lkotlin/enums/EnumEntries;
30+
public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/AndroidPeripheral$Bond;
31+
public static fun values ()[Lcom/juul/kable/AndroidPeripheral$Bond;
32+
}
33+
2434
public final class com/juul/kable/AndroidPeripheral$Priority : java/lang/Enum {
2535
public static final field Balanced Lcom/juul/kable/AndroidPeripheral$Priority;
2636
public static final field High Lcom/juul/kable/AndroidPeripheral$Priority;

kable-core/src/androidMain/kotlin/AndroidPeripheral.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public interface AndroidPeripheral : Peripheral {
2020

2121
public enum class Priority { Low, Balanced, High }
2222

23+
public enum class Bond { None, Bonding, Bonded }
24+
2325
public enum class Type {
2426

2527
/** https://developer.android.com/reference/android/bluetooth/BluetoothDevice#DEVICE_TYPE_CLASSIC */
@@ -160,4 +162,6 @@ public interface AndroidPeripheral : Peripheral {
160162
* is negotiated.
161163
*/
162164
public val mtu: StateFlow<Int?>
165+
166+
public val bondState: StateFlow<Bond>
163167
}

kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1515
import android.bluetooth.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
1616
import android.bluetooth.BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
1717
import android.bluetooth.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
18+
import com.juul.kable.AndroidPeripheral.Bond
1819
import com.juul.kable.AndroidPeripheral.Priority
1920
import com.juul.kable.AndroidPeripheral.Type
2021
import com.juul.kable.State.Disconnected
@@ -36,10 +37,14 @@ import com.juul.kable.logs.detail
3637
import kotlinx.coroutines.CoroutineScope
3738
import kotlinx.coroutines.flow.Flow
3839
import kotlinx.coroutines.flow.MutableStateFlow
40+
import kotlinx.coroutines.flow.SharingStarted
41+
import kotlinx.coroutines.flow.StateFlow
3942
import kotlinx.coroutines.flow.asStateFlow
4043
import kotlinx.coroutines.flow.filter
44+
import kotlinx.coroutines.flow.first
4145
import kotlinx.coroutines.flow.launchIn
4246
import kotlinx.coroutines.flow.onEach
47+
import kotlinx.coroutines.flow.stateIn
4348
import kotlin.coroutines.cancellation.CancellationException
4449
import kotlin.time.Duration
4550

@@ -67,6 +72,13 @@ internal class BluetoothDeviceAndroidPeripheral(
6772
}
6873
disconnect()
6974
}
75+
76+
onBondState { state ->
77+
logger.debug {
78+
message = "Bond state"
79+
detail("state", state.toString())
80+
}
81+
}
7082
}
7183

7284
private val connectAction = sharedRepeatableAction(::establishConnection)
@@ -100,6 +112,9 @@ internal class BluetoothDeviceAndroidPeripheral(
100112
override val name: String?
101113
get() = bluetoothDevice.name
102114

115+
override val bondState: StateFlow<Bond> = bondStateFor(bluetoothDevice)
116+
.stateIn(this, SharingStarted.Eagerly, Bond(bluetoothDevice.bondState))
117+
103118
private suspend fun establishConnection(scope: CoroutineScope): CoroutineScope {
104119
checkBluetoothIsSupported()
105120
checkBluetoothIsOn()
@@ -123,6 +138,10 @@ internal class BluetoothDeviceAndroidPeripheral(
123138
disconnectTimeout,
124139
)
125140

141+
if (bondState.value == Bond.Bonding) {
142+
logger.debug { message = "Awaiting bond state" }
143+
awaitNotBonding()
144+
}
126145
suspendUntil<State.Connecting.Services>()
127146
discoverServices()
128147
configureCharacteristicObservations()
@@ -197,8 +216,22 @@ internal class BluetoothDeviceAndroidPeripheral(
197216
}
198217

199218
val platformCharacteristic = servicesOrThrow().obtain(characteristic, writeType.properties)
200-
connectionOrThrow().execute<OnCharacteristicWrite> {
201-
writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue)
219+
try {
220+
connectionOrThrow().execute<OnCharacteristicWrite> {
221+
writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue)
222+
}
223+
} catch (e: BondRequiredException) {
224+
logInsufficientAuthentication(e)
225+
awaitNotBonding()
226+
logger.debug {
227+
message = "Retrying write"
228+
detail(characteristic)
229+
detail(writeType)
230+
detail(data, Operation.Write)
231+
}
232+
connectionOrThrow().execute<OnCharacteristicWrite> {
233+
writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue)
234+
}
202235
}
203236
}
204237

@@ -211,8 +244,20 @@ internal class BluetoothDeviceAndroidPeripheral(
211244
}
212245

213246
val platformCharacteristic = servicesOrThrow().obtain(characteristic, Read)
214-
return connectionOrThrow().execute<OnCharacteristicRead> {
215-
readCharacteristicOrThrow(platformCharacteristic)
247+
return try {
248+
connectionOrThrow().execute<OnCharacteristicRead> {
249+
readCharacteristicOrThrow(platformCharacteristic)
250+
}
251+
} catch (e: BondRequiredException) {
252+
logInsufficientAuthentication(e)
253+
awaitNotBonding()
254+
logger.debug {
255+
message = "Retrying read"
256+
detail(characteristic)
257+
}
258+
connectionOrThrow().execute<OnCharacteristicRead> {
259+
readCharacteristicOrThrow(platformCharacteristic)
260+
}
216261
}.value!!
217262
}
218263

@@ -233,8 +278,21 @@ internal class BluetoothDeviceAndroidPeripheral(
233278
detail(data, Operation.Write)
234279
}
235280

236-
connectionOrThrow().execute<OnDescriptorWrite> {
237-
writeDescriptorOrThrow(platformDescriptor, data)
281+
try {
282+
connectionOrThrow().execute<OnDescriptorWrite> {
283+
writeDescriptorOrThrow(platformDescriptor, data)
284+
}
285+
} catch (e: BondRequiredException) {
286+
logInsufficientAuthentication(e)
287+
awaitNotBonding()
288+
logger.debug {
289+
message = "Retrying write"
290+
detail(platformDescriptor)
291+
detail(data, Operation.Write)
292+
}
293+
connectionOrThrow().execute<OnDescriptorWrite> {
294+
writeDescriptorOrThrow(platformDescriptor, data)
295+
}
238296
}
239297
}
240298

@@ -247,8 +305,20 @@ internal class BluetoothDeviceAndroidPeripheral(
247305
}
248306

249307
val platformDescriptor = servicesOrThrow().obtain(descriptor)
250-
return connectionOrThrow().execute<OnDescriptorRead> {
251-
readDescriptorOrThrow(platformDescriptor)
308+
return try {
309+
connectionOrThrow().execute<OnDescriptorRead> {
310+
readDescriptorOrThrow(platformDescriptor)
311+
}
312+
} catch (e: BondRequiredException) {
313+
logInsufficientAuthentication(e)
314+
awaitNotBonding()
315+
logger.debug {
316+
message = "Retrying read"
317+
detail(descriptor)
318+
}
319+
connectionOrThrow().execute<OnDescriptorRead> {
320+
readDescriptorOrThrow(platformDescriptor)
321+
}
252322
}.value!!
253323
}
254324

@@ -339,13 +409,28 @@ internal class BluetoothDeviceAndroidPeripheral(
339409
}
340410
}
341411

412+
private suspend fun awaitNotBonding(): Bond = bondState.first { it != Bond.Bonding }
413+
414+
private fun logInsufficientAuthentication(exception: BondRequiredException) {
415+
logger.warn {
416+
message = "Insufficient authentication"
417+
detail(exception.status)
418+
}
419+
}
420+
342421
private fun onBluetoothDisabled(action: suspend (bluetoothState: Int) -> Unit) {
343422
bluetoothState
344423
.filter { state -> state == STATE_TURNING_OFF || state == STATE_OFF }
345424
.onEach(action)
346425
.launchIn(this)
347426
}
348427

428+
private fun onBondState(action: (bondState: Bond) -> Unit) {
429+
bondState
430+
.onEach(action)
431+
.launchIn(this)
432+
}
433+
349434
override fun toString(): String = "Peripheral(bluetoothDevice=$bluetoothDevice)"
350435
}
351436

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.juul.kable
2+
3+
import android.bluetooth.BluetoothDevice
4+
import android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED
5+
import android.bluetooth.BluetoothDevice.BOND_BONDED
6+
import android.bluetooth.BluetoothDevice.BOND_BONDING
7+
import android.bluetooth.BluetoothDevice.BOND_NONE
8+
import android.bluetooth.BluetoothDevice.ERROR
9+
import android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE
10+
import android.bluetooth.BluetoothDevice.EXTRA_DEVICE
11+
import android.content.Intent
12+
import android.content.IntentFilter
13+
import androidx.core.content.IntentCompat
14+
import com.juul.kable.AndroidPeripheral.Bond
15+
import com.juul.tuulbox.coroutines.flow.broadcastReceiverFlow
16+
import kotlinx.coroutines.flow.Flow
17+
import kotlinx.coroutines.flow.filter
18+
import kotlinx.coroutines.flow.map
19+
20+
internal fun bondStateFor(bluetoothDevice: BluetoothDevice): Flow<Bond> =
21+
broadcastReceiverFlow(IntentFilter(ACTION_BOND_STATE_CHANGED))
22+
.filter { intent -> bluetoothDevice == intent.bluetoothDevice }
23+
.map { intent -> intent.bondState }
24+
.map(::Bond)
25+
26+
internal fun Bond(state: Int): Bond = when (state) {
27+
BOND_NONE -> Bond.None
28+
BOND_BONDING -> Bond.Bonding
29+
BOND_BONDED -> Bond.Bonded
30+
else -> error("Unsupported bond state: $state")
31+
}
32+
33+
private val Intent.bluetoothDevice: BluetoothDevice?
34+
get() = IntentCompat.getParcelableExtra(this, EXTRA_DEVICE, BluetoothDevice::class.java)
35+
36+
private val Intent.bondState: Int
37+
get() = getIntExtra(EXTRA_BOND_STATE, ERROR)

kable-core/src/androidMain/kotlin/Connection.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.juul.kable
22

33
import android.bluetooth.BluetoothGatt
4+
import android.bluetooth.BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION
5+
import android.bluetooth.BluetoothGatt.GATT_INSUFFICIENT_ENCRYPTION
46
import android.bluetooth.BluetoothGatt.GATT_SUCCESS
57
import android.os.Handler
68
import com.juul.kable.State.Disconnected
79
import com.juul.kable.coroutines.childSupervisor
10+
import com.juul.kable.external.GATT_AUTH_FAIL
811
import com.juul.kable.gatt.Callback
912
import com.juul.kable.gatt.GattStatus
1013
import com.juul.kable.gatt.Response
@@ -42,8 +45,16 @@ import kotlin.reflect.KClass
4245
import kotlin.time.Duration
4346
import kotlin.time.Duration.Companion.ZERO
4447

48+
internal class BondRequiredException(val status: GattStatus) : IllegalStateException()
49+
4550
private val GattSuccess = GattStatus(GATT_SUCCESS)
4651

52+
private val BondingStatuses = listOf(
53+
GattStatus(GATT_AUTH_FAIL),
54+
GattStatus(GATT_INSUFFICIENT_AUTHENTICATION),
55+
GattStatus(GATT_INSUFFICIENT_ENCRYPTION),
56+
)
57+
4758
/**
4859
* Represents a Bluetooth Low Energy connection. [Connection] should be initialized with the
4960
* provided [BluetoothGatt] in a connecting or connected state. When a disconnect occurs (either by
@@ -178,7 +189,8 @@ internal class Connection(
178189
coroutineContext.ensureActive()
179190
throw e.unwrapCancellationException()
180191
}
181-
}.also(::checkResponse)
192+
}.also(::checkBondingStatus)
193+
.also(::checkResponse)
182194

183195
// `guard` should always enforce a 1:1 matching of request-to-response, but if an Android
184196
// `BluetoothGattCallback` method is called out-of-order then we'll cast to the wrong type.
@@ -271,6 +283,10 @@ internal class Connection(
271283
private fun dispose(cause: Throwable) = connectionJob.completeExceptionally(cause)
272284
}
273285

286+
private fun checkBondingStatus(response: Response) {
287+
if (response.status in BondingStatuses) throw BondRequiredException(response.status)
288+
}
289+
274290
private fun checkResponse(response: Response) {
275291
if (response.status != GattSuccess) throw GattStatusException(response.toString())
276292
}

0 commit comments

Comments
 (0)