Skip to content

Commit 1906561

Browse files
committed
Add support for implicit bonding
1 parent c3df634 commit 1906561

5 files changed

Lines changed: 140 additions & 23 deletions

File tree

core/api/core.api

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,20 @@ public final class com/juul/kable/AndroidAdvertisement$BondState : java/lang/Enu
2626

2727
public abstract interface class com/juul/kable/AndroidPeripheral : com/juul/kable/Peripheral {
2828
public abstract fun getAddress ()Ljava/lang/String;
29+
public abstract fun getBondState ()Lkotlinx/coroutines/flow/Flow;
2930
public abstract fun getMtu ()Lkotlinx/coroutines/flow/StateFlow;
3031
public abstract fun requestConnectionPriority (Lcom/juul/kable/AndroidPeripheral$Priority;)Z
3132
public abstract fun requestMtu (ILkotlin/coroutines/Continuation;)Ljava/lang/Object;
3233
}
3334

35+
public final class com/juul/kable/AndroidPeripheral$Bond : java/lang/Enum {
36+
public static final field Bonded Lcom/juul/kable/AndroidPeripheral$Bond;
37+
public static final field Bonding Lcom/juul/kable/AndroidPeripheral$Bond;
38+
public static final field None Lcom/juul/kable/AndroidPeripheral$Bond;
39+
public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/AndroidPeripheral$Bond;
40+
public static fun values ()[Lcom/juul/kable/AndroidPeripheral$Bond;
41+
}
42+
3443
public final class com/juul/kable/AndroidPeripheral$Priority : java/lang/Enum {
3544
public static final field Balanced Lcom/juul/kable/AndroidPeripheral$Priority;
3645
public static final field High Lcom/juul/kable/AndroidPeripheral$Priority;

core/src/androidMain/kotlin/AndroidPeripheral.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.juul.kable
22

3+
import kotlinx.coroutines.flow.Flow
34
import kotlinx.coroutines.flow.StateFlow
45

56
@Deprecated(
@@ -12,6 +13,8 @@ public interface AndroidPeripheral : Peripheral {
1213

1314
public enum class Priority { Low, Balanced, High }
1415

16+
public enum class Bond { None, Bonding, Bonded }
17+
1518
/**
1619
* Returns the hardware address of this [AndroidPeripheral].
1720
*
@@ -38,4 +41,6 @@ public interface AndroidPeripheral : Peripheral {
3841
* is negotiated.
3942
*/
4043
public val mtu: StateFlow<Int?>
44+
45+
public val bondState: Flow<Bond>
4146
}

core/src/androidMain/kotlin/Connection.kt

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
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
7+
import com.juul.kable.external.GATT_AUTH_FAIL
58
import com.juul.kable.gatt.Callback
69
import com.juul.kable.gatt.GattStatus
710
import com.juul.kable.gatt.Response
@@ -22,7 +25,14 @@ public class OutOfOrderGattCallbackException internal constructor(
2225
message: String,
2326
) : IllegalStateException(message)
2427

25-
private val GattSuccess = GattStatus(GATT_SUCCESS)
28+
internal class BondRequiredException : IllegalStateException()
29+
30+
private val Success = GattStatus(GATT_SUCCESS)
31+
private val BondingStatuses = listOf(
32+
GattStatus(GATT_AUTH_FAIL),
33+
GattStatus(GATT_INSUFFICIENT_AUTHENTICATION),
34+
GattStatus(GATT_INSUFFICIENT_ENCRYPTION),
35+
)
2636

2737
internal class Connection(
2838
parentCoroutineContext: CoroutineContext,
@@ -57,6 +67,7 @@ internal class Connection(
5767
*
5868
* @throws GattRequestRejectedException if underlying `BluetoothGatt` method call returns `false`.
5969
* @throws GattStatusException if response has a non-`GATT_SUCCESS` status.
70+
* @throws BondRequiredException if [action] requires authentication (i.e. bonding).
6071
*/
6172
suspend inline fun <reified T> execute(
6273
crossinline action: BluetoothGatt.() -> Boolean,
@@ -89,14 +100,17 @@ internal class Connection(
89100
}
90101
deferredResponse = null
91102

92-
if (response.status != GattSuccess) throw GattStatusException(response.toString())
93-
94-
// `lock` should always enforce a 1:1 matching of request to response, but if an Android `BluetoothGattCallback`
95-
// method gets called out of order then we'll cast to the wrong response type.
96-
response as? T
97-
?: throw OutOfOrderGattCallbackException(
98-
"Unexpected response type ${response.javaClass.simpleName} received",
99-
)
103+
when (response.status) {
104+
// `lock` should always enforce a 1:1 matching of request to response, but if an Android `BluetoothGattCallback`
105+
// method gets called out of order then we'll cast to the wrong response type.
106+
Success ->
107+
response as? T
108+
?: throw OutOfOrderGattCallbackException(
109+
"Unexpected response type ${response.javaClass.simpleName} received",
110+
)
111+
in BondingStatuses -> throw BondRequiredException()
112+
else -> throw GattStatusException(response.toString())
113+
}
100114
}
101115

102116
/**
@@ -120,7 +134,7 @@ internal class Connection(
120134
throw ConnectionLostException(cause = e)
121135
}
122136

123-
if (response.status != GattSuccess) throw GattStatusException(response.toString())
137+
if (response.status != Success) throw GattStatusException(response.toString())
124138
response.mtu
125139
}
126140

core/src/androidMain/kotlin/Peripheral.kt

Lines changed: 101 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import android.bluetooth.BluetoothAdapter.STATE_ON
66
import android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF
77
import android.bluetooth.BluetoothAdapter.STATE_TURNING_ON
88
import android.bluetooth.BluetoothDevice
9+
import android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED
10+
import android.bluetooth.BluetoothDevice.BOND_BONDED
11+
import android.bluetooth.BluetoothDevice.BOND_BONDING
12+
import android.bluetooth.BluetoothDevice.BOND_NONE
13+
import android.bluetooth.BluetoothDevice.ERROR
14+
import android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE
15+
import android.bluetooth.BluetoothDevice.EXTRA_DEVICE
916
import android.bluetooth.BluetoothGatt
1017
import android.bluetooth.BluetoothGattCharacteristic.PROPERTY_INDICATE
1118
import android.bluetooth.BluetoothGattCharacteristic.PROPERTY_NOTIFY
@@ -14,7 +21,13 @@ import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1421
import android.bluetooth.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
1522
import android.bluetooth.BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
1623
import android.bluetooth.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
24+
import android.content.IntentFilter
25+
import androidx.core.content.IntentCompat
1726
import com.benasher44.uuid.uuidFrom
27+
import com.juul.kable.AndroidPeripheral.Bond
28+
import com.juul.kable.AndroidPeripheral.Bond.Bonded
29+
import com.juul.kable.AndroidPeripheral.Bond.Bonding
30+
import com.juul.kable.AndroidPeripheral.Bond.None
1831
import com.juul.kable.WriteType.WithResponse
1932
import com.juul.kable.WriteType.WithoutResponse
2033
import com.juul.kable.external.CLIENT_CHARACTERISTIC_CONFIG_UUID
@@ -40,10 +53,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
4053
import kotlinx.coroutines.flow.StateFlow
4154
import kotlinx.coroutines.flow.asStateFlow
4255
import kotlinx.coroutines.flow.filter
56+
import kotlinx.coroutines.flow.first
4357
import kotlinx.coroutines.flow.launchIn
58+
import kotlinx.coroutines.flow.map
4459
import kotlinx.coroutines.flow.onEach
4560
import kotlinx.coroutines.flow.update
46-
import kotlinx.coroutines.job
4761
import kotlinx.coroutines.withContext
4862
import kotlin.coroutines.CoroutineContext
4963

@@ -143,6 +157,20 @@ internal class BluetoothDeviceAndroidPeripheral(
143157

144158
override val name: String? get() = bluetoothDevice.name
145159

160+
override val bondState: Flow<Bond> =
161+
broadcastReceiverFlow(IntentFilter(ACTION_BOND_STATE_CHANGED))
162+
.filter { intent ->
163+
bluetoothDevice == IntentCompat.getParcelableExtra(intent, EXTRA_DEVICE, BluetoothDevice::class.java)
164+
}
165+
.map { intent ->
166+
when (val state = intent.getIntExtra(EXTRA_BOND_STATE, ERROR)) {
167+
BOND_NONE -> None
168+
BOND_BONDING -> Bonding
169+
BOND_BONDED -> Bonded
170+
else -> error("Unsupported bond state: $state")
171+
}
172+
}
173+
146174
private fun establishConnection(): Connection {
147175
logger.info { message = "Connecting" }
148176
return bluetoothDevice.connect(
@@ -162,7 +190,11 @@ internal class BluetoothDeviceAndroidPeripheral(
162190
/** Creates a connect [Job] that completes when connection is established, or failure occurs. */
163191
private fun connectAsync() = scope.async(start = LAZY) {
164192
try {
165-
_connection = establishConnection()
193+
_connection = establishConnection().also {
194+
logger.debug { message = "Awaiting bond state" }
195+
val bond = bondState.first { it != Bonding }
196+
logger.debug { message = "Bond state: $bond" }
197+
}
166198
suspendUntilOrThrow<State.Connecting.Services>()
167199
discoverServices()
168200
onServicesDiscovered(ServicesDiscoveredPeripheral(this@BluetoothDeviceAndroidPeripheral))
@@ -261,10 +293,25 @@ internal class BluetoothDeviceAndroidPeripheral(
261293
}
262294

263295
val platformCharacteristic = discoveredServices.obtain(characteristic, writeType.properties)
264-
connection.execute<OnCharacteristicWrite> {
265-
platformCharacteristic.value = data
266-
platformCharacteristic.writeType = writeType.intValue
267-
writeCharacteristic(platformCharacteristic)
296+
try {
297+
connection.execute<OnCharacteristicWrite> {
298+
platformCharacteristic.value = data
299+
platformCharacteristic.writeType = writeType.intValue
300+
writeCharacteristic(platformCharacteristic)
301+
}
302+
} catch (_: BondRequiredException) {
303+
awaitBond()
304+
logger.debug {
305+
message = "Retrying write"
306+
detail(characteristic)
307+
detail(writeType)
308+
detail(data)
309+
}
310+
connection.execute<OnCharacteristicWrite> {
311+
platformCharacteristic.value = data
312+
platformCharacteristic.writeType = writeType.intValue
313+
writeCharacteristic(platformCharacteristic)
314+
}
268315
}
269316
}
270317

@@ -277,8 +324,19 @@ internal class BluetoothDeviceAndroidPeripheral(
277324
}
278325

279326
val platformCharacteristic = discoveredServices.obtain(characteristic, Read)
280-
return connection.execute<OnCharacteristicRead> {
281-
readCharacteristic(platformCharacteristic)
327+
return try {
328+
connection.execute<OnCharacteristicRead> {
329+
readCharacteristic(platformCharacteristic)
330+
}
331+
} catch (_: BondRequiredException) {
332+
awaitBond()
333+
logger.debug {
334+
message = "Retrying read"
335+
detail(characteristic)
336+
}
337+
connection.execute {
338+
readCharacteristic(platformCharacteristic)
339+
}
282340
}.value!!
283341
}
284342

@@ -299,9 +357,22 @@ internal class BluetoothDeviceAndroidPeripheral(
299357
detail(data)
300358
}
301359

302-
connection.execute<OnDescriptorWrite> {
303-
platformDescriptor.value = data
304-
writeDescriptor(platformDescriptor)
360+
try {
361+
connection.execute<OnDescriptorWrite> {
362+
platformDescriptor.value = data
363+
writeDescriptor(platformDescriptor)
364+
}
365+
} catch (_: BondRequiredException) {
366+
awaitBond()
367+
logger.debug {
368+
message = "Retrying write"
369+
detail(platformDescriptor)
370+
detail(data)
371+
}
372+
connection.execute<OnDescriptorWrite> {
373+
platformDescriptor.value = data
374+
writeDescriptor(platformDescriptor)
375+
}
305376
}
306377
}
307378

@@ -314,8 +385,20 @@ internal class BluetoothDeviceAndroidPeripheral(
314385
}
315386

316387
val platformDescriptor = discoveredServices.obtain(descriptor)
317-
return connection.execute<OnDescriptorRead> {
318-
readDescriptor(platformDescriptor)
388+
389+
return try {
390+
connection.execute<OnDescriptorRead> {
391+
readDescriptor(platformDescriptor)
392+
}
393+
} catch (_: BondRequiredException) {
394+
awaitBond()
395+
logger.debug {
396+
message = "Retrying read"
397+
detail(descriptor)
398+
}
399+
connection.execute {
400+
readDescriptor(platformDescriptor)
401+
}
319402
}.value!!
320403
}
321404

@@ -396,6 +479,11 @@ internal class BluetoothDeviceAndroidPeripheral(
396479
}
397480
}
398481

482+
private suspend fun awaitBond() {
483+
logger.warn { message = "Insufficient authentication, awaiting bond" }
484+
bondState.first { it == Bonded }
485+
}
486+
399487
override fun toString(): String = "Peripheral(bluetoothDevice=$bluetoothDevice)"
400488
}
401489

core/src/androidMain/kotlin/broadcastReceiverFlow.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import kotlinx.coroutines.channels.awaitClose
1010
import kotlinx.coroutines.flow.Flow
1111
import kotlinx.coroutines.flow.callbackFlow
1212

13+
@Deprecated("Was not intended to be public. Will not be available (will be `internal`) in a future release.")
1314
public fun broadcastReceiverFlow(
1415
intentFilter: IntentFilter,
1516
): Flow<Intent> = callbackFlow {

0 commit comments

Comments
 (0)