Skip to content

Commit

Permalink
Provide write failure as property of exception (#648)
Browse files Browse the repository at this point in the history
  • Loading branch information
twyatt authored Mar 21, 2024
1 parent e079dee commit ff220fc
Show file tree
Hide file tree
Showing 8 changed files with 243 additions and 46 deletions.
20 changes: 19 additions & 1 deletion core/api/android/core.api
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public abstract interface class com/juul/kable/AndroidPeripheral : com/juul/kabl
public abstract fun getType ()Lcom/juul/kable/AndroidPeripheral$Type;
public abstract fun requestConnectionPriority (Lcom/juul/kable/AndroidPeripheral$Priority;)Z
public abstract fun requestMtu (ILkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun write (Lcom/juul/kable/Characteristic;[BLcom/juul/kable/WriteType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun write (Lcom/juul/kable/Descriptor;[BLkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

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

public final class com/juul/kable/AndroidPeripheral$WriteResult : java/lang/Enum {
public static final field MissingBluetoothConnectPermission Lcom/juul/kable/AndroidPeripheral$WriteResult;
public static final field NotConnected Lcom/juul/kable/AndroidPeripheral$WriteResult;
public static final field ProfileServiceNotBound Lcom/juul/kable/AndroidPeripheral$WriteResult;
public static final field Unknown Lcom/juul/kable/AndroidPeripheral$WriteResult;
public static final field WriteNotAllowed Lcom/juul/kable/AndroidPeripheral$WriteResult;
public static final field WriteRequestBusy Lcom/juul/kable/AndroidPeripheral$WriteResult;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/AndroidPeripheral$WriteResult;
public static fun values ()[Lcom/juul/kable/AndroidPeripheral$WriteResult;
}

public abstract interface class com/juul/kable/AndroidScanner : com/juul/kable/Scanner {
public abstract fun getAdvertisements ()Lkotlinx/coroutines/flow/Flow;
}
Expand Down Expand Up @@ -208,10 +222,14 @@ public final class com/juul/kable/Filter$Service : com/juul/kable/Filter {
public fun toString ()Ljava/lang/String;
}

public final class com/juul/kable/GattRequestRejectedException : com/juul/kable/BluetoothException {
public class com/juul/kable/GattRequestRejectedException : com/juul/kable/BluetoothException {
public fun <init> ()V
}

public final class com/juul/kable/GattWriteException : com/juul/kable/GattRequestRejectedException {
public final fun getResult ()Lcom/juul/kable/AndroidPeripheral$WriteResult;
}

public final class com/juul/kable/Kable {
public static final field INSTANCE Lcom/juul/kable/Kable;
}
Expand Down
78 changes: 78 additions & 0 deletions core/src/androidMain/kotlin/AndroidPeripheral.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.juul.kable

import android.Manifest
import android.Manifest.permission.BLUETOOTH_CONNECT
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothStatusCodes
import android.os.Build
import androidx.annotation.RequiresPermission
import kotlinx.coroutines.flow.StateFlow
Expand Down Expand Up @@ -36,6 +40,62 @@ public interface AndroidPeripheral : Peripheral {
Unknown,
}

/**
* Represents possible write operation results, as defined by Android's
* [WriteOperationReturnValues](https://cs.android.com/android/platform/superproject/main/+/b7a389a145ff443550e1a942bf713c60c2bd6a14:packages/modules/Bluetooth/framework/java/android/bluetooth/BluetoothGatt.java;l=1587-1593)
* `IntDef`.
*/
public enum class WriteResult {

/**
* Error code indicating that the Bluetooth Device specified is not connected, but is bonded.
*
* https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/framework/java/android/bluetooth/BluetoothStatusCodes.java;l=50
*/
NotConnected,

/**
* A GATT writeCharacteristic request is not permitted on the remote device.
*
* See: [BluetoothStatusCodes.ERROR_GATT_WRITE_NOT_ALLOWED]
* https://developer.android.com/reference/kotlin/android/bluetooth/BluetoothStatusCodes#error_gatt_write_not_allowed
*/
WriteNotAllowed,

/**
* A GATT writeCharacteristic request is issued to a busy remote device.
*
* See: [BluetoothStatusCodes.ERROR_GATT_WRITE_REQUEST_BUSY]
* https://developer.android.com/reference/kotlin/android/bluetooth/BluetoothStatusCodes#error_gatt_write_request_busy
*/
WriteRequestBusy,

/**
* Error code indicating that the caller does not have the [BLUETOOTH_CONNECT] permission.
*
* See: [BluetoothStatusCodes.ERROR_MISSING_BLUETOOTH_CONNECT_PERMISSION]
* https://developer.android.com/reference/kotlin/android/bluetooth/BluetoothStatusCodes#error_missing_bluetooth_connect_permission
*/
MissingBluetoothConnectPermission,

/**
* Error code indicating that the profile service is not bound. You can bind a profile service
* by calling [BluetoothAdapter.getProfileProxy].
*
* See: [BluetoothStatusCodes.ERROR_PROFILE_SERVICE_NOT_BOUND]
* https://developer.android.com/reference/kotlin/android/bluetooth/BluetoothStatusCodes#error_profile_service_not_bound
*/
ProfileServiceNotBound,

/**
* Indicates that an unknown error has occurred.
*
* See: [BluetoothStatusCodes.ERROR_UNKNOWN]
* https://developer.android.com/reference/kotlin/android/bluetooth/BluetoothStatusCodes#error_unknown
*/
Unknown,
}

/**
* Get the type of the peripheral.
*
Expand Down Expand Up @@ -75,6 +135,24 @@ public interface AndroidPeripheral : Peripheral {
*/
public suspend fun requestMtu(mtu: Int): Int

/**
* @see Peripheral.write
* @throws NotReadyException if invoked without an established [connection][connect].
* @throws GattWriteException if underlying [BluetoothGatt] write operation call fails.
*/
override suspend fun write(
characteristic: Characteristic,
data: ByteArray,
writeType: WriteType,
)

/**
* @see Peripheral.write
* @throws NotReadyException if invoked without an established [connection][connect].
* @throws GattWriteException if underlying [BluetoothGatt] write operation call fails.
*/
override suspend fun write(descriptor: Descriptor, data: ByteArray)

/**
* [StateFlow] of the most recently negotiated MTU. The MTU will change upon a successful request to change the MTU
* (via [requestMtu]), or if the peripheral initiates an MTU change. [StateFlow]'s `value` will be `null` until MTU
Expand Down
25 changes: 6 additions & 19 deletions core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
Expand Down Expand Up @@ -221,15 +220,15 @@ internal class BluetoothDeviceAndroidPeripheral(
}

override suspend fun rssi(): Int = connection.execute<OnReadRemoteRssi> {
readRemoteRssi()
readRemoteRssiOrThrow()
}.rssi

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

repeat(DISCOVER_SERVICES_RETRIES) { attempt ->
connection.execute<OnServicesDiscovered> {
discoverServices()
discoverServicesOrThrow()
}
val services = withContext(connection.dispatcher) {
connection.bluetoothGatt.services.map(::DiscoveredService)
Expand Down Expand Up @@ -268,9 +267,7 @@ internal class BluetoothDeviceAndroidPeripheral(

val platformCharacteristic = discoveredServices.obtain(characteristic, writeType.properties)
connection.execute<OnCharacteristicWrite> {
platformCharacteristic.value = data
platformCharacteristic.writeType = writeType.intValue
writeCharacteristic(platformCharacteristic)
writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue)
}
}

Expand All @@ -284,7 +281,7 @@ internal class BluetoothDeviceAndroidPeripheral(

val platformCharacteristic = discoveredServices.obtain(characteristic, Read)
return connection.execute<OnCharacteristicRead> {
readCharacteristic(platformCharacteristic)
readCharacteristicOrThrow(platformCharacteristic)
}.value!!
}

Expand All @@ -306,8 +303,7 @@ internal class BluetoothDeviceAndroidPeripheral(
}

connection.execute<OnDescriptorWrite> {
platformDescriptor.value = data
writeDescriptor(platformDescriptor)
writeDescriptorOrThrow(platformDescriptor, data)
}
}

Expand All @@ -321,7 +317,7 @@ internal class BluetoothDeviceAndroidPeripheral(

val platformDescriptor = discoveredServices.obtain(descriptor)
return connection.execute<OnDescriptorRead> {
readDescriptor(platformDescriptor)
readDescriptorOrThrow(platformDescriptor)
}.value!!
}

Expand Down Expand Up @@ -418,15 +414,6 @@ private val Priority.intValue: Int
Priority.High -> BluetoothGatt.CONNECTION_PRIORITY_HIGH
}

/** @throws GattRequestRejectedException if [BluetoothGatt.setCharacteristicNotification] returns `false`. */
private fun BluetoothGatt.setCharacteristicNotificationOrThrow(
characteristic: PlatformCharacteristic,
enable: Boolean,
) {
setCharacteristicNotification(characteristic, enable) ||
throw GattRequestRejectedException()
}

private val PlatformCharacteristic.configDescriptor: PlatformDescriptor?
get() = descriptors.firstOrNull { clientCharacteristicConfigUuid == it.uuid }

Expand Down
101 changes: 101 additions & 0 deletions core/src/androidMain/kotlin/BluetoothGatt.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.juul.kable

import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothStatusCodes.ERROR_GATT_WRITE_NOT_ALLOWED
import android.bluetooth.BluetoothStatusCodes.ERROR_GATT_WRITE_REQUEST_BUSY
import android.bluetooth.BluetoothStatusCodes.ERROR_MISSING_BLUETOOTH_CONNECT_PERMISSION
import android.bluetooth.BluetoothStatusCodes.ERROR_PROFILE_SERVICE_NOT_BOUND
import android.bluetooth.BluetoothStatusCodes.SUCCESS
import android.os.Build
import com.juul.kable.AndroidPeripheral.WriteResult

internal fun BluetoothGatt.discoverServicesOrThrow() {
if (!discoverServices()) {
throw GattRequestRejectedException()
}
}

internal fun BluetoothGatt.setCharacteristicNotificationOrThrow(
characteristic: PlatformCharacteristic,
enable: Boolean,
) {
if (!setCharacteristicNotification(characteristic, enable)) {
throw GattRequestRejectedException()
}
}

internal fun BluetoothGatt.readCharacteristicOrThrow(
characteristic: PlatformCharacteristic,
) {
if (!readCharacteristic(characteristic)) {
throw GattRequestRejectedException()
}
}

internal fun BluetoothGatt.readDescriptorOrThrow(
descriptor: PlatformDescriptor,
) {
if (!readDescriptor(descriptor)) {
throw GattRequestRejectedException()
}
}

internal fun BluetoothGatt.readRemoteRssiOrThrow() {
if (!readRemoteRssi()) {
throw GattRequestRejectedException()
}
}

@Suppress("DEPRECATION")
internal fun BluetoothGatt.writeCharacteristicOrThrow(
characteristic: PlatformCharacteristic,
data: ByteArray,
writeType: Int,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val result = writeCharacteristic(characteristic, data, writeType)
if (result != SUCCESS) {
throw GattWriteException(writeResultFrom(result))
}
} else {
characteristic.value = data
characteristic.writeType = writeType
if (!writeCharacteristic(characteristic)) {
throw GattWriteException(WriteResult.Unknown)
}
}
}

@Suppress("DEPRECATION")
internal fun BluetoothGatt.writeDescriptorOrThrow(
descriptor: PlatformDescriptor,
data: ByteArray,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val result = writeDescriptor(descriptor, data)
if (result != SUCCESS) {
throw GattWriteException(writeResultFrom(result))
}
} else {
descriptor.value = data
if (!writeDescriptor(descriptor)) {
throw GattRequestRejectedException()
}
}
}

/**
* Possible return value of [BluetoothGatt.writeCharacteristic] or [BluetoothGatt.writeDescriptor],
* yet marked as `@hide` in Android source:
* https://cs.android.com/android/platform/superproject/main/+/b7a389a145ff443550e1a942bf713c60c2bd6a14:packages/modules/Bluetooth/framework/java/android/bluetooth/BluetoothStatusCodes.java;l=45-50
*/
private const val ERROR_DEVICE_NOT_CONNECTED = 4

private fun writeResultFrom(value: Int): WriteResult = when (value) {
ERROR_DEVICE_NOT_CONNECTED -> WriteResult.NotConnected
ERROR_GATT_WRITE_NOT_ALLOWED -> WriteResult.WriteNotAllowed
ERROR_GATT_WRITE_REQUEST_BUSY -> WriteResult.WriteRequestBusy
ERROR_MISSING_BLUETOOTH_CONNECT_PERMISSION -> WriteResult.MissingBluetoothConnectPermission
ERROR_PROFILE_SERVICE_NOT_BOUND -> WriteResult.ProfileServiceNotBound
else -> WriteResult.Unknown
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filter

public class ScanFailedException internal constructor(
public val errorCode: Int,
) : IllegalStateException("Bluetooth scan failed with error code $errorCode")

internal class BluetoothLeScannerAndroidScanner(
private val filters: List<Filter>,
private val scanSettings: ScanSettings,
Expand Down
5 changes: 2 additions & 3 deletions core/src/androidMain/kotlin/Connection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,10 @@ internal class Connection(
* a single threaded [CoroutineDispatcher] is used, Android O and newer a [CoroutineDispatcher] backed by an Android
* `Handler` is used (and is also used in the Android BLE [Callback]).
*
* @throws GattRequestRejectedException if underlying `BluetoothGatt` method call returns `false`.
* @throws GattStatusException if response has a non-`GATT_SUCCESS` status.
*/
suspend inline fun <reified T> execute(
crossinline action: BluetoothGatt.() -> Boolean,
crossinline action: BluetoothGatt.() -> Unit,
): T = lock.withLock {
deferredResponse?.let {
if (it.isActive) {
Expand All @@ -67,7 +66,7 @@ internal class Connection(
}

withContext(dispatcher) {
if (!bluetoothGatt.action()) throw GattRequestRejectedException()
bluetoothGatt.action()
}
val deferred = scope.async { callback.onResponse.receive() }
deferredResponse = deferred
Expand Down
37 changes: 37 additions & 0 deletions core/src/androidMain/kotlin/Exceptions.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.BluetoothGatt
import android.os.RemoteException
import com.juul.kable.AndroidPeripheral.WriteResult

public class ScanFailedException internal constructor(
public val errorCode: Int,
) : IllegalStateException("Bluetooth scan failed with error code $errorCode")

/**
* Thrown when underlying [BluetoothGatt] method call returns `false`. This can occur under the
* following conditions:
*
* - Request isn't allowed (e.g. reading a non-readable characteristic)
* - Underlying service or client interface is missing or invalid (e.g. `mService == null || mClientIf == 0`)
* - Associated [BluetoothDevice] is unavailable
* - Device is busy (i.e. a previous request is still in-progress)
* - An Android internal failure occurred (i.e. an underlying [RemoteException] was thrown)
*/
public open class GattRequestRejectedException internal constructor(
message: String? = null,
) : BluetoothException(message)

/**
* Thrown when underlying [BluetoothGatt] write operation call fails.
*
* The reason for the failure is available via the [result] property on Android 13 (API 33) and
* newer.
*
* On Android prior to API 33, [result] is always [Unknown][WriteResult.Unknown], but the failure
* may have been due to any of the conditions listed for [GattRequestRejectedException].
*/
public class GattWriteException internal constructor(
public val result: WriteResult,
) : GattRequestRejectedException("Write failed: $result")
Loading

0 comments on commit ff220fc

Please sign in to comment.