Skip to content

Commit ff220fc

Browse files
authored
Provide write failure as property of exception (#648)
1 parent e079dee commit ff220fc

8 files changed

Lines changed: 243 additions & 46 deletions

File tree

core/api/android/core.api

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ public abstract interface class com/juul/kable/AndroidPeripheral : com/juul/kabl
3232
public abstract fun getType ()Lcom/juul/kable/AndroidPeripheral$Type;
3333
public abstract fun requestConnectionPriority (Lcom/juul/kable/AndroidPeripheral$Priority;)Z
3434
public abstract fun requestMtu (ILkotlin/coroutines/Continuation;)Ljava/lang/Object;
35+
public abstract fun write (Lcom/juul/kable/Characteristic;[BLcom/juul/kable/WriteType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
36+
public abstract fun write (Lcom/juul/kable/Descriptor;[BLkotlin/coroutines/Continuation;)Ljava/lang/Object;
3537
}
3638

3739
public final class com/juul/kable/AndroidPeripheral$Priority : java/lang/Enum {
@@ -53,6 +55,18 @@ public final class com/juul/kable/AndroidPeripheral$Type : java/lang/Enum {
5355
public static fun values ()[Lcom/juul/kable/AndroidPeripheral$Type;
5456
}
5557

58+
public final class com/juul/kable/AndroidPeripheral$WriteResult : java/lang/Enum {
59+
public static final field MissingBluetoothConnectPermission Lcom/juul/kable/AndroidPeripheral$WriteResult;
60+
public static final field NotConnected Lcom/juul/kable/AndroidPeripheral$WriteResult;
61+
public static final field ProfileServiceNotBound Lcom/juul/kable/AndroidPeripheral$WriteResult;
62+
public static final field Unknown Lcom/juul/kable/AndroidPeripheral$WriteResult;
63+
public static final field WriteNotAllowed Lcom/juul/kable/AndroidPeripheral$WriteResult;
64+
public static final field WriteRequestBusy Lcom/juul/kable/AndroidPeripheral$WriteResult;
65+
public static fun getEntries ()Lkotlin/enums/EnumEntries;
66+
public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/AndroidPeripheral$WriteResult;
67+
public static fun values ()[Lcom/juul/kable/AndroidPeripheral$WriteResult;
68+
}
69+
5670
public abstract interface class com/juul/kable/AndroidScanner : com/juul/kable/Scanner {
5771
public abstract fun getAdvertisements ()Lkotlinx/coroutines/flow/Flow;
5872
}
@@ -208,10 +222,14 @@ public final class com/juul/kable/Filter$Service : com/juul/kable/Filter {
208222
public fun toString ()Ljava/lang/String;
209223
}
210224

211-
public final class com/juul/kable/GattRequestRejectedException : com/juul/kable/BluetoothException {
225+
public class com/juul/kable/GattRequestRejectedException : com/juul/kable/BluetoothException {
212226
public fun <init> ()V
213227
}
214228

229+
public final class com/juul/kable/GattWriteException : com/juul/kable/GattRequestRejectedException {
230+
public final fun getResult ()Lcom/juul/kable/AndroidPeripheral$WriteResult;
231+
}
232+
215233
public final class com/juul/kable/Kable {
216234
public static final field INSTANCE Lcom/juul/kable/Kable;
217235
}

core/src/androidMain/kotlin/AndroidPeripheral.kt

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

33
import android.Manifest
4+
import android.Manifest.permission.BLUETOOTH_CONNECT
5+
import android.bluetooth.BluetoothAdapter
6+
import android.bluetooth.BluetoothGatt
7+
import android.bluetooth.BluetoothStatusCodes
48
import android.os.Build
59
import androidx.annotation.RequiresPermission
610
import kotlinx.coroutines.flow.StateFlow
@@ -36,6 +40,62 @@ public interface AndroidPeripheral : Peripheral {
3640
Unknown,
3741
}
3842

43+
/**
44+
* Represents possible write operation results, as defined by Android's
45+
* [WriteOperationReturnValues](https://cs.android.com/android/platform/superproject/main/+/b7a389a145ff443550e1a942bf713c60c2bd6a14:packages/modules/Bluetooth/framework/java/android/bluetooth/BluetoothGatt.java;l=1587-1593)
46+
* `IntDef`.
47+
*/
48+
public enum class WriteResult {
49+
50+
/**
51+
* Error code indicating that the Bluetooth Device specified is not connected, but is bonded.
52+
*
53+
* https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/framework/java/android/bluetooth/BluetoothStatusCodes.java;l=50
54+
*/
55+
NotConnected,
56+
57+
/**
58+
* A GATT writeCharacteristic request is not permitted on the remote device.
59+
*
60+
* See: [BluetoothStatusCodes.ERROR_GATT_WRITE_NOT_ALLOWED]
61+
* https://developer.android.com/reference/kotlin/android/bluetooth/BluetoothStatusCodes#error_gatt_write_not_allowed
62+
*/
63+
WriteNotAllowed,
64+
65+
/**
66+
* A GATT writeCharacteristic request is issued to a busy remote device.
67+
*
68+
* See: [BluetoothStatusCodes.ERROR_GATT_WRITE_REQUEST_BUSY]
69+
* https://developer.android.com/reference/kotlin/android/bluetooth/BluetoothStatusCodes#error_gatt_write_request_busy
70+
*/
71+
WriteRequestBusy,
72+
73+
/**
74+
* Error code indicating that the caller does not have the [BLUETOOTH_CONNECT] permission.
75+
*
76+
* See: [BluetoothStatusCodes.ERROR_MISSING_BLUETOOTH_CONNECT_PERMISSION]
77+
* https://developer.android.com/reference/kotlin/android/bluetooth/BluetoothStatusCodes#error_missing_bluetooth_connect_permission
78+
*/
79+
MissingBluetoothConnectPermission,
80+
81+
/**
82+
* Error code indicating that the profile service is not bound. You can bind a profile service
83+
* by calling [BluetoothAdapter.getProfileProxy].
84+
*
85+
* See: [BluetoothStatusCodes.ERROR_PROFILE_SERVICE_NOT_BOUND]
86+
* https://developer.android.com/reference/kotlin/android/bluetooth/BluetoothStatusCodes#error_profile_service_not_bound
87+
*/
88+
ProfileServiceNotBound,
89+
90+
/**
91+
* Indicates that an unknown error has occurred.
92+
*
93+
* See: [BluetoothStatusCodes.ERROR_UNKNOWN]
94+
* https://developer.android.com/reference/kotlin/android/bluetooth/BluetoothStatusCodes#error_unknown
95+
*/
96+
Unknown,
97+
}
98+
3999
/**
40100
* Get the type of the peripheral.
41101
*
@@ -75,6 +135,24 @@ public interface AndroidPeripheral : Peripheral {
75135
*/
76136
public suspend fun requestMtu(mtu: Int): Int
77137

138+
/**
139+
* @see Peripheral.write
140+
* @throws NotReadyException if invoked without an established [connection][connect].
141+
* @throws GattWriteException if underlying [BluetoothGatt] write operation call fails.
142+
*/
143+
override suspend fun write(
144+
characteristic: Characteristic,
145+
data: ByteArray,
146+
writeType: WriteType,
147+
)
148+
149+
/**
150+
* @see Peripheral.write
151+
* @throws NotReadyException if invoked without an established [connection][connect].
152+
* @throws GattWriteException if underlying [BluetoothGatt] write operation call fails.
153+
*/
154+
override suspend fun write(descriptor: Descriptor, data: ByteArray)
155+
78156
/**
79157
* [StateFlow] of the most recently negotiated MTU. The MTU will change upon a successful request to change the MTU
80158
* (via [requestMtu]), or if the peripheral initiates an MTU change. [StateFlow]'s `value` will be `null` until MTU

core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ import kotlinx.coroutines.flow.Flow
4141
import kotlinx.coroutines.flow.MutableStateFlow
4242
import kotlinx.coroutines.flow.StateFlow
4343
import kotlinx.coroutines.flow.asStateFlow
44-
import kotlinx.coroutines.flow.collect
4544
import kotlinx.coroutines.flow.filter
4645
import kotlinx.coroutines.flow.launchIn
4746
import kotlinx.coroutines.flow.onEach
@@ -221,15 +220,15 @@ internal class BluetoothDeviceAndroidPeripheral(
221220
}
222221

223222
override suspend fun rssi(): Int = connection.execute<OnReadRemoteRssi> {
224-
readRemoteRssi()
223+
readRemoteRssiOrThrow()
225224
}.rssi
226225

227226
private suspend fun discoverServices() {
228227
logger.verbose { message = "discoverServices" }
229228

230229
repeat(DISCOVER_SERVICES_RETRIES) { attempt ->
231230
connection.execute<OnServicesDiscovered> {
232-
discoverServices()
231+
discoverServicesOrThrow()
233232
}
234233
val services = withContext(connection.dispatcher) {
235234
connection.bluetoothGatt.services.map(::DiscoveredService)
@@ -268,9 +267,7 @@ internal class BluetoothDeviceAndroidPeripheral(
268267

269268
val platformCharacteristic = discoveredServices.obtain(characteristic, writeType.properties)
270269
connection.execute<OnCharacteristicWrite> {
271-
platformCharacteristic.value = data
272-
platformCharacteristic.writeType = writeType.intValue
273-
writeCharacteristic(platformCharacteristic)
270+
writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue)
274271
}
275272
}
276273

@@ -284,7 +281,7 @@ internal class BluetoothDeviceAndroidPeripheral(
284281

285282
val platformCharacteristic = discoveredServices.obtain(characteristic, Read)
286283
return connection.execute<OnCharacteristicRead> {
287-
readCharacteristic(platformCharacteristic)
284+
readCharacteristicOrThrow(platformCharacteristic)
288285
}.value!!
289286
}
290287

@@ -306,8 +303,7 @@ internal class BluetoothDeviceAndroidPeripheral(
306303
}
307304

308305
connection.execute<OnDescriptorWrite> {
309-
platformDescriptor.value = data
310-
writeDescriptor(platformDescriptor)
306+
writeDescriptorOrThrow(platformDescriptor, data)
311307
}
312308
}
313309

@@ -321,7 +317,7 @@ internal class BluetoothDeviceAndroidPeripheral(
321317

322318
val platformDescriptor = discoveredServices.obtain(descriptor)
323319
return connection.execute<OnDescriptorRead> {
324-
readDescriptor(platformDescriptor)
320+
readDescriptorOrThrow(platformDescriptor)
325321
}.value!!
326322
}
327323

@@ -418,15 +414,6 @@ private val Priority.intValue: Int
418414
Priority.High -> BluetoothGatt.CONNECTION_PRIORITY_HIGH
419415
}
420416

421-
/** @throws GattRequestRejectedException if [BluetoothGatt.setCharacteristicNotification] returns `false`. */
422-
private fun BluetoothGatt.setCharacteristicNotificationOrThrow(
423-
characteristic: PlatformCharacteristic,
424-
enable: Boolean,
425-
) {
426-
setCharacteristicNotification(characteristic, enable) ||
427-
throw GattRequestRejectedException()
428-
}
429-
430417
private val PlatformCharacteristic.configDescriptor: PlatformDescriptor?
431418
get() = descriptors.firstOrNull { clientCharacteristicConfigUuid == it.uuid }
432419

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.juul.kable
2+
3+
import android.bluetooth.BluetoothGatt
4+
import android.bluetooth.BluetoothStatusCodes.ERROR_GATT_WRITE_NOT_ALLOWED
5+
import android.bluetooth.BluetoothStatusCodes.ERROR_GATT_WRITE_REQUEST_BUSY
6+
import android.bluetooth.BluetoothStatusCodes.ERROR_MISSING_BLUETOOTH_CONNECT_PERMISSION
7+
import android.bluetooth.BluetoothStatusCodes.ERROR_PROFILE_SERVICE_NOT_BOUND
8+
import android.bluetooth.BluetoothStatusCodes.SUCCESS
9+
import android.os.Build
10+
import com.juul.kable.AndroidPeripheral.WriteResult
11+
12+
internal fun BluetoothGatt.discoverServicesOrThrow() {
13+
if (!discoverServices()) {
14+
throw GattRequestRejectedException()
15+
}
16+
}
17+
18+
internal fun BluetoothGatt.setCharacteristicNotificationOrThrow(
19+
characteristic: PlatformCharacteristic,
20+
enable: Boolean,
21+
) {
22+
if (!setCharacteristicNotification(characteristic, enable)) {
23+
throw GattRequestRejectedException()
24+
}
25+
}
26+
27+
internal fun BluetoothGatt.readCharacteristicOrThrow(
28+
characteristic: PlatformCharacteristic,
29+
) {
30+
if (!readCharacteristic(characteristic)) {
31+
throw GattRequestRejectedException()
32+
}
33+
}
34+
35+
internal fun BluetoothGatt.readDescriptorOrThrow(
36+
descriptor: PlatformDescriptor,
37+
) {
38+
if (!readDescriptor(descriptor)) {
39+
throw GattRequestRejectedException()
40+
}
41+
}
42+
43+
internal fun BluetoothGatt.readRemoteRssiOrThrow() {
44+
if (!readRemoteRssi()) {
45+
throw GattRequestRejectedException()
46+
}
47+
}
48+
49+
@Suppress("DEPRECATION")
50+
internal fun BluetoothGatt.writeCharacteristicOrThrow(
51+
characteristic: PlatformCharacteristic,
52+
data: ByteArray,
53+
writeType: Int,
54+
) {
55+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
56+
val result = writeCharacteristic(characteristic, data, writeType)
57+
if (result != SUCCESS) {
58+
throw GattWriteException(writeResultFrom(result))
59+
}
60+
} else {
61+
characteristic.value = data
62+
characteristic.writeType = writeType
63+
if (!writeCharacteristic(characteristic)) {
64+
throw GattWriteException(WriteResult.Unknown)
65+
}
66+
}
67+
}
68+
69+
@Suppress("DEPRECATION")
70+
internal fun BluetoothGatt.writeDescriptorOrThrow(
71+
descriptor: PlatformDescriptor,
72+
data: ByteArray,
73+
) {
74+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
75+
val result = writeDescriptor(descriptor, data)
76+
if (result != SUCCESS) {
77+
throw GattWriteException(writeResultFrom(result))
78+
}
79+
} else {
80+
descriptor.value = data
81+
if (!writeDescriptor(descriptor)) {
82+
throw GattRequestRejectedException()
83+
}
84+
}
85+
}
86+
87+
/**
88+
* Possible return value of [BluetoothGatt.writeCharacteristic] or [BluetoothGatt.writeDescriptor],
89+
* yet marked as `@hide` in Android source:
90+
* https://cs.android.com/android/platform/superproject/main/+/b7a389a145ff443550e1a942bf713c60c2bd6a14:packages/modules/Bluetooth/framework/java/android/bluetooth/BluetoothStatusCodes.java;l=45-50
91+
*/
92+
private const val ERROR_DEVICE_NOT_CONNECTED = 4
93+
94+
private fun writeResultFrom(value: Int): WriteResult = when (value) {
95+
ERROR_DEVICE_NOT_CONNECTED -> WriteResult.NotConnected
96+
ERROR_GATT_WRITE_NOT_ALLOWED -> WriteResult.WriteNotAllowed
97+
ERROR_GATT_WRITE_REQUEST_BUSY -> WriteResult.WriteRequestBusy
98+
ERROR_MISSING_BLUETOOTH_CONNECT_PERMISSION -> WriteResult.MissingBluetoothConnectPermission
99+
ERROR_PROFILE_SERVICE_NOT_BOUND -> WriteResult.ProfileServiceNotBound
100+
else -> WriteResult.Unknown
101+
}

core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,6 @@ import kotlinx.coroutines.flow.Flow
2222
import kotlinx.coroutines.flow.callbackFlow
2323
import kotlinx.coroutines.flow.filter
2424

25-
public class ScanFailedException internal constructor(
26-
public val errorCode: Int,
27-
) : IllegalStateException("Bluetooth scan failed with error code $errorCode")
28-
2925
internal class BluetoothLeScannerAndroidScanner(
3026
private val filters: List<Filter>,
3127
private val scanSettings: ScanSettings,

core/src/androidMain/kotlin/Connection.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,10 @@ internal class Connection(
4545
* a single threaded [CoroutineDispatcher] is used, Android O and newer a [CoroutineDispatcher] backed by an Android
4646
* `Handler` is used (and is also used in the Android BLE [Callback]).
4747
*
48-
* @throws GattRequestRejectedException if underlying `BluetoothGatt` method call returns `false`.
4948
* @throws GattStatusException if response has a non-`GATT_SUCCESS` status.
5049
*/
5150
suspend inline fun <reified T> execute(
52-
crossinline action: BluetoothGatt.() -> Boolean,
51+
crossinline action: BluetoothGatt.() -> Unit,
5352
): T = lock.withLock {
5453
deferredResponse?.let {
5554
if (it.isActive) {
@@ -67,7 +66,7 @@ internal class Connection(
6766
}
6867

6968
withContext(dispatcher) {
70-
if (!bluetoothGatt.action()) throw GattRequestRejectedException()
69+
bluetoothGatt.action()
7170
}
7271
val deferred = scope.async { callback.onResponse.receive() }
7372
deferredResponse = deferred
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.BluetoothGatt
5+
import android.os.RemoteException
6+
import com.juul.kable.AndroidPeripheral.WriteResult
7+
8+
public class ScanFailedException internal constructor(
9+
public val errorCode: Int,
10+
) : IllegalStateException("Bluetooth scan failed with error code $errorCode")
11+
12+
/**
13+
* Thrown when underlying [BluetoothGatt] method call returns `false`. This can occur under the
14+
* following conditions:
15+
*
16+
* - Request isn't allowed (e.g. reading a non-readable characteristic)
17+
* - Underlying service or client interface is missing or invalid (e.g. `mService == null || mClientIf == 0`)
18+
* - Associated [BluetoothDevice] is unavailable
19+
* - Device is busy (i.e. a previous request is still in-progress)
20+
* - An Android internal failure occurred (i.e. an underlying [RemoteException] was thrown)
21+
*/
22+
public open class GattRequestRejectedException internal constructor(
23+
message: String? = null,
24+
) : BluetoothException(message)
25+
26+
/**
27+
* Thrown when underlying [BluetoothGatt] write operation call fails.
28+
*
29+
* The reason for the failure is available via the [result] property on Android 13 (API 33) and
30+
* newer.
31+
*
32+
* On Android prior to API 33, [result] is always [Unknown][WriteResult.Unknown], but the failure
33+
* may have been due to any of the conditions listed for [GattRequestRejectedException].
34+
*/
35+
public class GattWriteException internal constructor(
36+
public val result: WriteResult,
37+
) : GattRequestRejectedException("Write failed: $result")

0 commit comments

Comments
 (0)