Skip to content

Commit

Permalink
Merge branch 'main' into feature/kotlin-uuid
Browse files Browse the repository at this point in the history
  • Loading branch information
twyatt authored Dec 6, 2024
2 parents 4e6b766 + 1fce4e2 commit 961cf5e
Show file tree
Hide file tree
Showing 22 changed files with 105 additions and 35 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ peripheral.launch {
> ```kotlin
> peripheral.cancel()
> ```
>
> Once a [`Peripheral`] is cancelled (via `cancel`) it can no longer be used (e.g. calling `connect` will throw
> `IllegalStateException`).
> [!TIP]
> `launch`ed coroutines from a `Peripheral` object are permitted to run until `Peripheral.cancel()` is called
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[versions]
android-compile = "34"
android-min = "21"
atomicfu = "0.26.0"
atomicfu = "0.26.1"
coroutines = "1.9.0"
jvm-toolchain = "11"
kotlin = "2.0.21"
Expand Down
5 changes: 4 additions & 1 deletion kable-core/api/android/kable-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ public final class com/juul/kable/IdentifierKt {
public static final fun toIdentifier (Ljava/lang/String;)Ljava/lang/String;
}

public final class com/juul/kable/InternalException : java/lang/IllegalStateException {
public final class com/juul/kable/InternalError : java/lang/Error {
}

public final class com/juul/kable/Kable {
Expand Down Expand Up @@ -319,6 +319,7 @@ public abstract interface class com/juul/kable/Peripheral : kotlinx/coroutines/C
public abstract fun getName ()Ljava/lang/String;
public abstract fun getServices ()Lkotlinx/coroutines/flow/StateFlow;
public abstract fun getState ()Lkotlinx/coroutines/flow/StateFlow;
public abstract fun maximumWriteValueLengthForType (Lcom/juul/kable/WriteType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun observe (Lcom/juul/kable/Characteristic;Lkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/flow/Flow;
public abstract fun read (Lcom/juul/kable/Characteristic;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun read (Lcom/juul/kable/Descriptor;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Expand Down Expand Up @@ -350,8 +351,10 @@ public final class com/juul/kable/PeripheralBuilder {
public final class com/juul/kable/PeripheralKt {
public static final fun Peripheral (Landroid/bluetooth/BluetoothDevice;Lkotlin/jvm/functions/Function1;)Lcom/juul/kable/Peripheral;
public static final fun Peripheral (Lcom/juul/kable/Advertisement;Lkotlin/jvm/functions/Function1;)Lcom/juul/kable/Peripheral;
public static final fun Peripheral (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/juul/kable/Peripheral;
public static synthetic fun Peripheral$default (Landroid/bluetooth/BluetoothDevice;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/juul/kable/Peripheral;
public static synthetic fun Peripheral$default (Lcom/juul/kable/Advertisement;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/juul/kable/Peripheral;
public static synthetic fun Peripheral$default (Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/juul/kable/Peripheral;
}

public final class com/juul/kable/Peripheral_deprecatedKt {
Expand Down
3 changes: 2 additions & 1 deletion kable-core/api/jvm/kable-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ public final class com/juul/kable/IdentifierKt {
public static final fun toIdentifier (Ljava/lang/String;)Ljava/lang/String;
}

public final class com/juul/kable/InternalException : java/lang/IllegalStateException {
public final class com/juul/kable/InternalError : java/lang/Error {
}

public final class com/juul/kable/LazyCharacteristic : com/juul/kable/Characteristic {
Expand Down Expand Up @@ -235,6 +235,7 @@ public abstract interface class com/juul/kable/Peripheral : kotlinx/coroutines/C
public abstract fun getName ()Ljava/lang/String;
public abstract fun getServices ()Lkotlinx/coroutines/flow/StateFlow;
public abstract fun getState ()Lkotlinx/coroutines/flow/StateFlow;
public abstract fun maximumWriteValueLengthForType (Lcom/juul/kable/WriteType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun observe (Lcom/juul/kable/Characteristic;Lkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/flow/Flow;
public abstract fun read (Lcom/juul/kable/Characteristic;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun read (Lcom/juul/kable/Descriptor;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ import kotlin.uuid.toKotlinUuid
// https://github.com/JuulLabs/kable/issues/295
private const val DISCOVER_SERVICES_RETRIES = 5

private const val DEFAULT_ATT_MTU = 23
private const val ATT_MTU_HEADER_SIZE = 3

internal class BluetoothDeviceAndroidPeripheral(
private val bluetoothDevice: BluetoothDevice,
private val autoConnectPredicate: () -> Boolean,
Expand Down Expand Up @@ -146,7 +149,7 @@ internal class BluetoothDeviceAndroidPeripheral(
}

override suspend fun connect(): CoroutineScope =
connectAction.await()
connectAction.awaitConnect()

override suspend fun disconnect() {
connectAction.cancelAndJoin(
Expand All @@ -164,6 +167,9 @@ internal class BluetoothDeviceAndroidPeripheral(
.requestConnectionPriority(priority.intValue)
}

override suspend fun maximumWriteValueLengthForType(writeType: WriteType): Int =
(mtu.value ?: DEFAULT_ATT_MTU) - ATT_MTU_HEADER_SIZE

@ExperimentalApi // Experimental until Web Bluetooth advertisements APIs are stable.
override suspend fun rssi(): Int =
connectionOrThrow().execute<OnReadRemoteRssi> {
Expand Down
2 changes: 1 addition & 1 deletion kable-core/src/androidMain/kotlin/Connection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ internal class Connection(
// `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.
return response as? T
?: throw InternalException(
?: throw InternalError(
"Expected response type ${type.simpleName} but received ${response::class.simpleName}",
)
}
Expand Down
6 changes: 6 additions & 0 deletions kable-core/src/androidMain/kotlin/Peripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ public actual fun Peripheral(
return Peripheral(advertisement.bluetoothDevice, builderAction)
}

/** @throws IllegalStateException If bluetooth is not supported. */
public fun Peripheral(
identifier: Identifier,
builderAction: PeripheralBuilderAction = {},
): Peripheral = Peripheral(getBluetoothAdapter().getRemoteDevice(identifier), builderAction)

public fun Peripheral(
bluetoothDevice: BluetoothDevice,
builderAction: PeripheralBuilderAction = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import android.bluetooth.BluetoothAdapter.STATE_OFF
import android.bluetooth.BluetoothAdapter.STATE_ON
import android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF
import android.bluetooth.BluetoothAdapter.STATE_TURNING_ON
import com.juul.kable.InternalException
import com.juul.kable.InternalError
import com.juul.kable.UnmetRequirementException
import com.juul.kable.UnmetRequirementReason.BluetoothDisabled
import com.juul.kable.getBluetoothAdapter
Expand All @@ -29,5 +29,5 @@ private fun nameFor(state: Int) = when (state) {
STATE_ON -> "STATE_ON"
STATE_TURNING_OFF -> "STATE_TURNING_OFF"
STATE_TURNING_ON -> "STATE_TURNING_ON"
else -> throw InternalException("Unsupported bluetooth state: $state")
else -> throw InternalError("Unsupported bluetooth state: $state")
}
6 changes: 3 additions & 3 deletions kable-core/src/androidMain/kotlin/scan/ScanError.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import android.bluetooth.le.ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED
import android.bluetooth.le.ScanCallback.SCAN_FAILED_INTERNAL_ERROR
import android.bluetooth.le.ScanCallback.SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES
import android.bluetooth.le.ScanCallback.SCAN_FAILED_SCANNING_TOO_FREQUENTLY
import com.juul.kable.InternalException
import com.juul.kable.InternalError

@JvmInline
internal value class ScanError(internal val errorCode: Int) {
Expand All @@ -18,7 +18,7 @@ internal value class ScanError(internal val errorCode: Int) {
SCAN_FAILED_FEATURE_UNSUPPORTED -> "SCAN_FAILED_FEATURE_UNSUPPORTED"
SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES -> "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES"
SCAN_FAILED_SCANNING_TOO_FREQUENTLY -> "SCAN_FAILED_SCANNING_TOO_FREQUENTLY"
else -> throw InternalException("Unsupported error code $errorCode")
else -> throw InternalError("Unsupported error code $errorCode")
}.let { name -> "$name($errorCode)" }
}

Expand All @@ -43,5 +43,5 @@ internal val ScanError.message: String
SCAN_FAILED_SCANNING_TOO_FREQUENTLY ->
"Failed to start scan as application tries to scan too frequently"

else -> throw InternalException("Unsupported error code $errorCode")
else -> throw InternalError("Unsupported error code $errorCode")
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.sync.withLock
import kotlinx.io.IOException
import platform.CoreBluetooth.CBCharacteristicWriteWithResponse
import platform.CoreBluetooth.CBCharacteristicWriteWithoutResponse
import platform.CoreBluetooth.CBDescriptor
import platform.CoreBluetooth.CBManagerState
import platform.CoreBluetooth.CBManagerStatePoweredOn
Expand Down Expand Up @@ -136,14 +138,22 @@ internal class CBPeripheralCoreBluetoothPeripheral(
}

override suspend fun connect(): CoroutineScope =
connectAction.await()
connectAction.awaitConnect()

override suspend fun disconnect() {
connectAction.cancelAndJoin(
CancellationException(NotConnectedException("Disconnect requested")),
)
}

override suspend fun maximumWriteValueLengthForType(writeType: WriteType): Int {
val type = when (writeType) {
WithResponse -> CBCharacteristicWriteWithResponse
WithoutResponse -> CBCharacteristicWriteWithoutResponse
}
return cbPeripheral.maximumWriteValueLengthForType(type).toInt()
}

@ExperimentalApi // Experimental until Web Bluetooth advertisements APIs are stable.
@Throws(CancellationException::class, IOException::class)
override suspend fun rssi(): Int = connectionOrThrow().execute<DidReadRssi> {
Expand Down
2 changes: 1 addition & 1 deletion kable-core/src/appleMain/kotlin/Connection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ internal class Connection(
// `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.
return response as? T
?: throw InternalException(
?: throw InternalError(
"Expected response type ${type.simpleName} but received ${response::class.simpleName}",
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.juul.kable.bluetooth

import com.juul.kable.CentralManager
import com.juul.kable.InternalException
import com.juul.kable.InternalError
import com.juul.kable.UnmetRequirementException
import com.juul.kable.UnmetRequirementReason.BluetoothDisabled
import platform.CoreBluetooth.CBManagerState
Expand Down Expand Up @@ -33,5 +33,5 @@ private fun nameFor(state: CBManagerState) = when (state) {
CBManagerStateUnauthorized -> "Unauthorized"
CBManagerStateUnknown -> "Unknown"
CBManagerStateUnsupported -> "Unsupported"
else -> throw InternalException("Unsupported bluetooth state: $state")
else -> throw InternalError("Unsupported bluetooth state: $state")
}
25 changes: 25 additions & 0 deletions kable-core/src/commonMain/kotlin/InternalError.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.juul.kable

/**
* An [Error] that signifies that an unexpected condition or state was encountered in the Kable
* internals.
*
* May be thrown under the following (non-exhaustive) list of conditions:
* - A new system level feature was added but Kable does not yet properly support it
* - A programming error in Kable was encountered (e.g. a state when outside the designed bounds)
*
* Kable will likely be in an inconsistent state and will unlikely continue to function properly. It
* is recommended that the application be restarted (e.g. by not catching this exception and
* allowing the application to crash).
*
* If encountered, please report this exception (and provide logs) to:
* https://github.com/JuulLabs/kable/issues
*/
@Suppress("ktlint:standard:indent")
public class InternalError internal constructor(
message: String,
cause: Throwable? = null,
) : Error(
"$message, please report issue to https://github.com/JuulLabs/kable/issues and provide logs",
cause,
)
9 changes: 0 additions & 9 deletions kable-core/src/commonMain/kotlin/InternalException.kt

This file was deleted.

9 changes: 9 additions & 0 deletions kable-core/src/commonMain/kotlin/Peripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,15 @@ public interface Peripheral : CoroutineScope {
*/
public val services: StateFlow<List<DiscoveredService>?>

/**
* Return the current ATT MTU size, minus the size of the ATT headers (3 bytes).
*
* On Android, this will be the default (23 - 3) unless you called [requestMtu] when connecting.
* For iOS, this is automatically negotiated, and can also vary depending on the writeType.
* On JavaScript, this will return the default (23 - 3) every time as there is no ATT MTU property available.
*/
public suspend fun maximumWriteValueLengthForType(writeType: WriteType): Int

/**
* On JavaScript, requires Chrome 79+ with the
* `chrome://flags/#enable-experimental-web-platform-features` flag enabled.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.juul.kable

import kotlinx.coroutines.CoroutineScope

internal suspend fun SharedRepeatableAction<CoroutineScope>.awaitConnect(): CoroutineScope =
try {
await()
} catch (e: IllegalStateException) {
throw IllegalStateException("Cannot connect peripheral that has been cancelled", e)
}
1 change: 1 addition & 0 deletions kable-core/src/commonMain/kotlin/SharedRepeatableAction.kt
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ internal class SharedRepeatableAction<T>(

private suspend fun stateOrNull(): State<T>? = guard.withLock { state }

/** @throws IllegalStateException if parent [scope] is not active. */
suspend fun await() = getOrCreate().action.await()

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import kotlin.coroutines.resumeWithException
import kotlin.time.Duration

private const val ADVERTISEMENT_RECEIVED = "advertisementreceived"
private const val DEFAULT_ATT_MTU = 23
private const val ATT_MTU_HEADER_SIZE = 3

internal class BluetoothDeviceWebBluetoothPeripheral(
private val bluetoothDevice: BluetoothDevice,
Expand Down Expand Up @@ -106,14 +108,17 @@ internal class BluetoothDeviceWebBluetoothPeripheral(
}

override suspend fun connect(): CoroutineScope =
connectAction.await()
connectAction.awaitConnect()

override suspend fun disconnect() {
connectAction.cancelAndJoin(
CancellationException(NotConnectedException("Disconnect requested")),
)
}

override suspend fun maximumWriteValueLengthForType(writeType: WriteType): Int =
DEFAULT_ATT_MTU - ATT_MTU_HEADER_SIZE

/**
* Per [Web Bluetooth / Scanning Sample][https://googlechrome.github.io/samples/web-bluetooth/scan.html]:
*
Expand Down Expand Up @@ -160,7 +165,7 @@ internal class BluetoothDeviceWebBluetoothPeripheral(
continuation.resume(rssi)
} else {
continuation.resumeWithException(
InternalException("BluetoothAdvertisingEvent.rssi was null"),
InternalError("BluetoothAdvertisingEvent.rssi was null"),
)
}
}
Expand Down
4 changes: 2 additions & 2 deletions kable-core/src/jsMain/kotlin/BluetoothWebBluetoothScanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ internal class BluetoothWebBluetoothScanner(
throw IllegalStateException("Scanning not supported", e)
} catch (e: JsError) {
ensureActive()
throw InternalException("Failed to request scan", e)
throw InternalError("Failed to request scan", e)
}

logger.verbose { message = "Adding scan listener" }
Expand Down Expand Up @@ -95,7 +95,7 @@ internal class BluetoothWebBluetoothScanner(
detail("options", JSON.stringify(options))
message = e.toString()
}
throw InternalException("Failed to start scan", e)
throw InternalError("Failed to start scan", e)
}

awaitClose {
Expand Down
6 changes: 3 additions & 3 deletions kable-core/src/jsMain/kotlin/Connection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ internal class Connection(
// we throw `InternalException`, as the Web Bluetooth Permission API spec is not stable, nor
// is it utilized by Kable.
// https://webbluetoothcg.github.io/web-bluetooth/#permission-api-integration
?: throw InternalException("GATT server unavailable")
?: throw InternalError("GATT server unavailable")

private fun servicesOrThrow(): List<DiscoveredService> =
discoveredServices.value ?: error("Services have not been discovered")
Expand Down Expand Up @@ -134,7 +134,7 @@ internal class Connection(
coroutineContext.ensureActive()
throw when (e) {
is DOMException -> IOException("Failed to start notification", e)
else -> InternalException("Unexpected start notification failure", e)
else -> InternalError("Unexpected start notification failure", e)
}
}
}
Expand Down Expand Up @@ -163,7 +163,7 @@ internal class Connection(
// No-op: System implicitly clears notifications on disconnect.
}

else -> throw InternalException("Unexpected stop notification failure", e)
else -> throw InternalError("Unexpected stop notification failure", e)
}
} finally {
val listener = observationListeners.remove(platformCharacteristic) ?: return
Expand Down
6 changes: 3 additions & 3 deletions kable-core/src/jsMain/kotlin/RequestPeripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public suspend fun requestPeripheral(
coroutineContext.ensureActive()
throw when (e) {
is TypeError -> IllegalStateException("Requesting a device is not supported", e)
else -> InternalException("Failed to invoke device request", e)
else -> InternalError("Failed to invoke device request", e)
}
}

Expand Down Expand Up @@ -70,10 +70,10 @@ public suspend fun requestPeripheral(
detail("processed", JSON.stringify(requestDeviceOptions))
message = e.toString()
}
throw InternalException("Type error when requesting device", e)
throw InternalError("Type error when requesting device", e)
}

else -> throw InternalException("Failed to request device", e)
else -> throw InternalError("Failed to request device", e)
}
}?.let(builder::build)
}
Loading

0 comments on commit 961cf5e

Please sign in to comment.