diff --git a/README.md b/README.md index be131ee65..7aeb4f1aa 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ The following filters are supported: ✓ = Support provided by Kable via flow filter ✓✓* = Supported natively if the only filter type used, otherwise falls back to flow filter -_When a filter is supported natively, the system will often be able to perform scan optimizations. If feasible, it is -recommended to provide only `Filter.Service` filters (and at least one) — as it is natively supported on all platforms._ +> [!TIP] +> _When a filter is supported natively, the system will often be able to perform scan optimizations. If feasible, it is +> recommended to provide only `Filter.Service` filters (and at least one) — as it is natively supported on all platforms._ When filters are specified, only [`Advertisement`]s that match at least one [`Filter`] will be emitted. For example, if you had the following peripherals nearby when performing a scan: @@ -103,8 +104,9 @@ val scanner = Scanner { } ``` -_The `scanSettings` property is only available on Android and is considered a Kable obsolete API, meaning it will be -removed when a DSL specific API becomes available._ +> [!NOTE] +> _The `scanSettings` property is only available on Android and is considered a Kable obsolete API, meaning it will be +> removed when a DSL specific API becomes available._ ### JavaScript @@ -113,35 +115,46 @@ features" enabled via:_ `chrome://flags/#enable-experimental-web-platform-featur ## Peripheral -Once an [`Advertisement`] is obtained, it can be converted to a [`Peripheral`] via the [`CoroutineScope.peripheral`] -extension function. [`Peripheral`] objects represent actions that can be performed against a remote peripheral, such as -connection handling and I/O operations. +Once an [`Advertisement`] is obtained, it can be converted to a [`Peripheral`] via the `Peripheral` builder function: ```kotlin -val peripheral = scope.peripheral(advertisement) +val peripheral = Peripheral(advertisement) { + // Configure peripheral. +} ``` -Note that if the scope is closed, the peripheral is automatically disconnected. Ensure that the lifetime of the used -scope matches the required lifetime of the peripheral connection. See more details about this in the [Structured -Concurrency](#structured-concurrency) section below. - -### Configuration - -To configure a `peripheral`, options may be set in the builder lambda: +[`Peripheral`] objects represent actions that can be performed against a remote peripheral, such as connection +handling and I/O operations. [`Peripheral`] objects are themselves [`CoroutineScope`]s, and coroutines can be +`launch`ed from them: ```kotlin -val peripheral = scope.peripheral(advertisement) { - // Set peripheral configuration. +peripheral.launch { + // Long running task that will be cancelled when peripheral + // is disposed (i.e. `Peripheral.cancel()` is called). } ``` +> [!IMPORTANT] +> When a [`Peripheral`] is no longer needed, it should be disposed via `cancel`: +> +> ```kotlin +> peripheral.cancel() +> ``` + +> [!TIP] +> `launch`ed coroutines from a `Peripheral` object are permitted to run until `Peripheral.cancel()` is called +> (i.e. can span across reconnects); for tasks that should only run for the duration of a single connection +> (i.e. shutdown on disconnect), `launch` via the `CoroutineScope` returned from `Peripheral.connect` instead. + +### Configuration + #### Logging By default, Kable only logs a small number of warnings when unexpected failures occur. To aid in debugging, additional logging may be enabled and configured via the `logging` DSL, for example: ```kotlin -val peripheral = scope.peripheral(advertisement) { +val peripheral = Peripheral(advertisement) { logging { level = Events // or Data } @@ -157,7 +170,7 @@ The available log levels are: Available logging settings are as follows (all settings are optional; shown are defaults, when not specified): ```kotlin -val peripheral = scope.peripheral(advertisement) { +val peripheral = Peripheral(advertisement) { logging { engine = SystemLogEngine level = Warnings @@ -178,7 +191,7 @@ Display format of I/O data may be customized, either by configuring the `Hex` re `DataProcessor`, for example: ```kotlin -val peripheral = scope.peripheral(advertisement) { +val peripheral = Peripheral(advertisement) { logging { data = Hex { separator = " " @@ -201,7 +214,7 @@ When logging, the identity of the peripheral is prefixed on log messages to diff peripherals are logging. The identifier (for the purposes of logging) can be set via the `identifier` property: ```kotlin -val peripheral = scope.peripheral(advertisement) { +val peripheral = Peripheral(advertisement) { logging { identifier = "Example" } @@ -220,7 +233,7 @@ All platforms support an `onServicesDiscovered` action (that is executed after s are wired up): ```kotlin -val peripheral = scope.peripheral(advertisement) { +val peripheral = Peripheral(advertisement) { onServicesDiscovered { // Perform any desired I/O operations. } @@ -234,7 +247,7 @@ _Exceptions thrown in `onServicesDiscovered` are propagated to the `Peripheral`' On Android targets, additional configuration options are available (all configuration directives are optional): ```kotlin -val peripheral = scope.peripheral(advertisement) { +val peripheral = Peripheral(advertisement) { autoConnectIf { false } // default onServicesDiscovered { requestMtu(...) @@ -269,7 +282,7 @@ One possible strategy for a fast initial connection attempt that falls back to l ```kotlin val autoConnect = MutableStateFlow(false) -val peripheral = scope.peripheral { +val peripheral = Peripheral { autoConnectIf { autoConnect.value } } @@ -304,7 +317,7 @@ val options = Options { uuidFrom("f000aa81-0451-4000-b000-000000000000"), ) } -val peripheral = requestPeripheral(options, scope) +val peripheral = requestPeripheral(options) ``` > After the user selects a device to pair with this origin, the origin is allowed to access any service whose UUID was @@ -321,12 +334,17 @@ method suspends until a connection is established and ready (or a failure occurs connected, services have been discovered, and observations (if any) have been re-wired. _Service discovery occurs automatically upon connection._ -_Multiple concurrent calls to [`connect`] will all suspend until connection is ready._ +> [!TIP] +> _Multiple concurrent calls to [`connect`] will all suspend until connection is ready._ ```kotlin peripheral.connect() ``` +The [`connect`] function returns a [`CoroutineScope`] that can be used to `launch` tasks that should run until peripheral +disconnects. When [`disconnect`] is called, any coroutines [`launch`]ed from the [`CoroutineScope`] returned by [`connect`] +will be cancelled prior to performing the underlying disconnect process. + To disconnect, the [`disconnect`] function will disconnect an active connection, or cancel an in-flight connection attempt. The [`disconnect`] function suspends until the peripheral has settled on a disconnected state. @@ -348,8 +366,9 @@ The [`state`] will typically transition through the following [`State`][connecti ![Connection states](artwork/connection-states.png) -_[`Disconnecting`] state only occurs on Android platform. JavaScript and Apple-based platforms transition directly from -[`Connected`] to [`Disconnected`] (upon calling [`disconnect`] function, or when a connection is dropped)._ +> [!NOTE] +> [`Disconnecting`] state is skipped on Apple and JavaScript when connection closure is initiated by peripheral (or +> peripheral goes out-of-range). ### I/O @@ -393,7 +412,7 @@ objects retrieved from [`Peripheral.services`] when no longer needed. To access "Descriptor D3" using a discovered descriptor: ```kotlin -val services = peripheral.services ?: error("Services have not been discovered") +val services = peripheral.services.value ?: error("Services have not been discovered") val descriptor = services .first { it.serviceUuid == uuidFrom("00001815-0000-1000-8000-00805f9b34fb") } .characteristics @@ -402,22 +421,24 @@ val descriptor = services .first { it.descriptorUuid == uuidFrom("00002902-0000-1000-8000-00805f9b34fb") } ``` -_This example uses a similar search algorithm as `descriptorOf`, but other search methods may be utilized. For example, -properties of the characteristic could be queried to find a specific characteristic that is expected to be the parent of -the sought after descriptor. When searching for a specific characteristic, descriptors can be read that may identity the -sought after characteristic._ +> [!TIP] +> _This example uses a similar search algorithm as `descriptorOf`, but other search methods may be utilized. For example, +> properties of the characteristic could be queried to find a specific characteristic that is expected to be the parent of +> the sought after descriptor. When searching for a specific characteristic, descriptors can be read that may identity the +> sought after characteristic._ When connected, data can be read from, or written to, characteristics and/or descriptors via [`read`] and [`write`] functions. -_The [`read`] and [`write`] functions throw [`NotReadyException`] until a connection is established._ - ```kotlin val data = peripheral.read(characteristic) peripheral.write(descriptor, byteArrayOf(1, 2, 3)) ``` +> [!NOTE] +> _The [`read`] and [`write`] functions throw [`NotConnectedException`] until a connection is established._ + ### Observation Bluetooth Low Energy provides the capability of subscribing to characteristic changes by means of notifications and/or @@ -449,7 +470,7 @@ associated characteristic is invalid or cannot be found, then a `NoSuchElementEx failures are propagated through (and terminate) the [`observe`] [`Flow`], for example: ```kotlin -scope.peripheral(advertisement) { +Peripheral(advertisement) { observationExceptionHandler { cause -> // Log failure instead of propagating associated `observe` flow. println("Observation failure suppressed: $cause") @@ -479,28 +500,6 @@ The `onSubscription` action is useful in situations where an initial operation i (such as writing a configuration to the peripheral and expecting the response to come back in the form of a characteristic change). -## Structured Concurrency - -Peripheral objects/connections are scoped to a [Coroutine scope]. When creating a [`Peripheral`], the -[`CoroutineScope.peripheral`] extension function is used, which scopes the returned [`Peripheral`] to the -[`CoroutineScope`] receiver. If the [`CoroutineScope`] receiver is cancelled then the [`Peripheral`] will disconnect and -be disposed. - -```kotlin -Scanner() - .advertisements - .filter { advertisement -> advertisement.name?.startsWith("Example") } - .map { advertisement -> scope.peripheral(advertisement) } - .onEach { peripheral -> peripheral.connect() } - .launchIn(scope) - -delay(60_000L) -scope.cancel() // All `peripherals` will implicitly disconnect and be disposed. -``` - -_[`Peripheral.disconnect`] is the preferred method of disconnecting peripherals, but disposal via Coroutine scope -cancellation is provided to prevent connection leaks._ - ## Background Support To enable [background support] on Apple, configure the `CentralManager` _before_ using most of Kable's functionality: @@ -671,7 +670,7 @@ limitations under the License. [`Disconnecting`]: https://juullabs.github.io/kable/core/com.juul.kable/-state/-disconnecting/index.html [`Filter`]: https://juullabs.github.io/kable/core/com.juul.kable/-filter/index.html [`Flow`]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/ -[`NotReadyException`]: https://juullabs.github.io/kable/core/com.juul.kable/-not-ready-exception/index.html +[`NotConnectedException`]: https://juullabs.github.io/kable/kable-exceptions/com.juul.kable/-not-connected-exception/index.html [`Options`]: https://juullabs.github.io/kable/core/com.juul.kable/-options/index.html [`Peripheral.disconnect`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral/disconnect.html [`Peripheral.services`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral/services.html @@ -682,17 +681,17 @@ limitations under the License. [`WriteType`]: https://juullabs.github.io/kable/core/com.juul.kable/-write-type/index.html [`advertisements`]: https://juullabs.github.io/kable/core/com.juul.kable/-scanner/advertisements.html [`characteristicOf`]: https://juullabs.github.io/kable/core/com.juul.kable/characteristic-of.html -[`connect`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral/connect.html [`connectGatt`]: https://developer.android.com/reference/android/bluetooth/BluetoothDevice#connectGatt(android.content.Context,%20boolean,%20android.bluetooth.BluetoothGattCallback) +[`connect`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral/connect.html [`descriptorOf`]: https://juullabs.github.io/kable/core/com.juul.kable/descriptor-of.html [`disconnect`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral/disconnect.html [`first`]: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/first.html +[`observationExceptionHandler`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral-builder/observation-exception-handler.html [`observe`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral/observe.html [`read`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral/read.html [`state`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral/state.html [`writeWithoutResponse`]: https://juullabs.github.io/kable/core/com.juul.kable/write-without-response.html [`write`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral/write.html -[`observationExceptionHandler`]: https://juullabs.github.io/kable/core/com.juul.kable/-peripheral-builder/observation-exception-handler.html [background support]: https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/CoreBluetoothBackgroundProcessingForIOSApps/PerformingTasksWhileYourAppIsInTheBackground.html [badge-android]: http://img.shields.io/badge/platform-android-6EDB8D.svg?style=flat [badge-ios]: http://img.shields.io/badge/platform-ios-CDCDCD.svg?style=flat diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6360fab75..755c5ff6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ wrappers-bom = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", v wrappers-web = { module = "org.jetbrains.kotlin-wrappers:kotlin-web" } [plugins] -android-library = { id = "com.android.library", version = "8.5.2" } +android-library = { id = "com.android.library", version = "8.6.0" } api = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.16.3" } atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } dokka = { id = "org.jetbrains.dokka", version = "1.9.20" } diff --git a/kable-core/api/android/kable-core.api b/kable-core/api/android/kable-core.api index da053bac3..eb1ea55a1 100644 --- a/kable-core/api/android/kable-core.api +++ b/kable-core/api/android/kable-core.api @@ -149,6 +149,9 @@ public final class com/juul/kable/DiscoveredService : com/juul/kable/Service { public fun toString ()Ljava/lang/String; } +public abstract interface annotation class com/juul/kable/ExperimentalApi : java/lang/annotation/Annotation { +} + public abstract class com/juul/kable/Filter { } @@ -225,7 +228,7 @@ public final class com/juul/kable/FiltersBuilder { public final fun match (Lkotlin/jvm/functions/Function1;)V } -public class com/juul/kable/GattRequestRejectedException : com/juul/kable/BluetoothException { +public class com/juul/kable/GattRequestRejectedException : java/lang/IllegalStateException { public fun ()V } @@ -233,6 +236,10 @@ public final class com/juul/kable/GattWriteException : com/juul/kable/GattReques public final fun getResult ()Lcom/juul/kable/AndroidPeripheral$WriteResult; } +public final class com/juul/kable/IdentifierKt { + public static final fun toIdentifier (Ljava/lang/String;)Ljava/lang/String; +} + public final class com/juul/kable/Kable { public static final field INSTANCE Lcom/juul/kable/Kable; } @@ -290,15 +297,12 @@ public final class com/juul/kable/OnDemandThreadingStrategy : com/juul/kable/Thr public fun release (Lcom/juul/kable/Threading;)V } -public final class com/juul/kable/OutOfOrderGattCallbackException : java/lang/IllegalStateException { -} - -public abstract interface class com/juul/kable/Peripheral { +public abstract interface class com/juul/kable/Peripheral : kotlinx/coroutines/CoroutineScope { public abstract fun connect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun disconnect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getIdentifier ()Ljava/lang/String; public abstract fun getName ()Ljava/lang/String; - public abstract fun getServices ()Ljava/util/List; + public abstract fun getServices ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getState ()Lkotlinx/coroutines/flow/StateFlow; 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; @@ -315,25 +319,31 @@ public final class com/juul/kable/Peripheral$DefaultImpls { public final class com/juul/kable/PeripheralBuilder { public final fun autoConnectIf (Lkotlin/jvm/functions/Function0;)V + public final fun getDisconnectTimeout-UwyO8pc ()J public final fun getPhy ()Lcom/juul/kable/Phy; public final fun getThreadingStrategy ()Lcom/juul/kable/ThreadingStrategy; public final fun getTransport ()Lcom/juul/kable/Transport; public final fun logging (Lkotlin/jvm/functions/Function1;)V public final fun observationExceptionHandler (Lkotlin/jvm/functions/Function3;)V public final fun onServicesDiscovered (Lkotlin/jvm/functions/Function2;)V + public final fun setDisconnectTimeout-LRDsOJo (J)V public final fun setPhy (Lcom/juul/kable/Phy;)V public final fun setThreadingStrategy (Lcom/juul/kable/ThreadingStrategy;)V public final fun setTransport (Lcom/juul/kable/Transport;)V } 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 final class com/juul/kable/Peripheral_deprecatedKt { public static final fun peripheral (Lkotlinx/coroutines/CoroutineScope;Landroid/bluetooth/BluetoothDevice;Lkotlin/jvm/functions/Function1;)Lcom/juul/kable/Peripheral; public static final fun peripheral (Lkotlinx/coroutines/CoroutineScope;Lcom/juul/kable/Advertisement;Lkotlin/jvm/functions/Function1;)Lcom/juul/kable/Peripheral; public static final fun peripheral (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/juul/kable/Peripheral; public static synthetic fun peripheral$default (Lkotlinx/coroutines/CoroutineScope;Landroid/bluetooth/BluetoothDevice;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/juul/kable/Peripheral; public static synthetic fun peripheral$default (Lkotlinx/coroutines/CoroutineScope;Lcom/juul/kable/Advertisement;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/juul/kable/Peripheral; public static synthetic fun peripheral$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/juul/kable/Peripheral; - public static final fun toIdentifier (Ljava/lang/String;)Ljava/lang/String; } public final class com/juul/kable/Phy : java/lang/Enum { diff --git a/kable-core/api/jvm/kable-core.api b/kable-core/api/jvm/kable-core.api index eac9080aa..ec7e0a8d0 100644 --- a/kable-core/api/jvm/kable-core.api +++ b/kable-core/api/jvm/kable-core.api @@ -90,6 +90,9 @@ public final class com/juul/kable/DiscoveredService : com/juul/kable/Service { public fun getServiceUuid ()Ljava/util/UUID; } +public abstract interface annotation class com/juul/kable/ExperimentalApi : java/lang/annotation/Annotation { +} + public abstract class com/juul/kable/Filter { } @@ -210,12 +213,12 @@ public final class com/juul/kable/ObservationExceptionPeripheral { public abstract interface annotation class com/juul/kable/ObsoleteKableApi : java/lang/annotation/Annotation { } -public abstract interface class com/juul/kable/Peripheral { +public abstract interface class com/juul/kable/Peripheral : kotlinx/coroutines/CoroutineScope { public abstract fun connect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun disconnect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getIdentifier ()Ljava/lang/String; public abstract fun getName ()Ljava/lang/String; - public abstract fun getServices ()Ljava/util/List; + public abstract fun getServices ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getState ()Lkotlinx/coroutines/flow/StateFlow; 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; @@ -231,12 +234,18 @@ public final class com/juul/kable/Peripheral$DefaultImpls { } public final class com/juul/kable/PeripheralBuilder { + public final fun getDisconnectTimeout-UwyO8pc ()J public final fun logging (Lkotlin/jvm/functions/Function1;)V public final fun observationExceptionHandler (Lkotlin/jvm/functions/Function3;)V public final fun onServicesDiscovered (Lkotlin/jvm/functions/Function2;)V + public final fun setDisconnectTimeout-LRDsOJo (J)V } public final class com/juul/kable/PeripheralKt { + public static final fun Peripheral (Lcom/juul/kable/Advertisement;Lkotlin/jvm/functions/Function1;)Lcom/juul/kable/Peripheral; +} + +public final class com/juul/kable/Peripheral_deprecatedKt { public static final fun peripheral (Lkotlinx/coroutines/CoroutineScope;Lcom/juul/kable/Advertisement;Lkotlin/jvm/functions/Function1;)Lcom/juul/kable/Peripheral; public static synthetic fun peripheral$default (Lkotlinx/coroutines/CoroutineScope;Lcom/juul/kable/Advertisement;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/juul/kable/Peripheral; } diff --git a/kable-core/build.gradle.kts b/kable-core/build.gradle.kts index 9df380b50..749163e35 100644 --- a/kable-core/build.gradle.kts +++ b/kable-core/build.gradle.kts @@ -32,6 +32,7 @@ kotlin { } commonTest.dependencies { + implementation(kotlin("reflect")) // For `assertIs`. implementation(kotlin("test")) implementation(libs.khronicle) implementation(libs.kotlinx.coroutines.test) diff --git a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt index 25906baf8..e59d6c544 100644 --- a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt @@ -130,7 +130,7 @@ public interface AndroidPeripheral : Peripheral { * negotiated MTU value is returned, which may not be [mtu] value requested if the remote peripheral negotiated an * alternate MTU. * - * @throws NotReadyException if invoked without an established [connection][Peripheral.connect]. + * @throws NotConnectedException if invoked without an established [connection][Peripheral.connect]. * @throws GattRequestRejectedException if Android was unable to fulfill the MTU change request. * @throws GattStatusException if MTU change request failed. */ @@ -138,7 +138,7 @@ public interface AndroidPeripheral : Peripheral { /** * @see Peripheral.write - * @throws NotReadyException if invoked without an established [connection][connect]. + * @throws NotConnectedException if invoked without an established [connection][connect]. * @throws GattWriteException if underlying [BluetoothGatt] write operation call fails. */ override suspend fun write( @@ -149,7 +149,7 @@ public interface AndroidPeripheral : Peripheral { /** * @see Peripheral.write - * @throws NotReadyException if invoked without an established [connection][connect]. + * @throws NotConnectedException if invoked without an established [connection][connect]. * @throws GattWriteException if underlying [BluetoothGatt] write operation call fails. */ override suspend fun write(descriptor: Descriptor, data: ByteArray) diff --git a/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt b/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt index 33fbb25e7..edbe16f5b 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt @@ -23,26 +23,3 @@ internal fun getBluetoothAdapterOrNull(): BluetoothAdapter? = /** @throws IllegalStateException If bluetooth is not supported. */ internal fun getBluetoothAdapter(): BluetoothAdapter = getBluetoothManager().adapter ?: error("Bluetooth not supported") - -/** - * Explicitly check the adapter state before connecting in order to respect system settings. - * Android doesn't actually turn bluetooth off when the setting is disabled, so without this - * check we're able to reconnect the device illegally. - */ -internal fun checkBluetoothAdapterState( - expected: Int, -) { - fun nameFor(value: Int) = when (value) { - BluetoothAdapter.STATE_OFF -> "Off" - BluetoothAdapter.STATE_ON -> "On" - BluetoothAdapter.STATE_TURNING_OFF -> "TurningOff" - BluetoothAdapter.STATE_TURNING_ON -> "TurningOn" - else -> "Unknown" - } - val actual = getBluetoothAdapter().state - if (expected != actual) { - val actualName = nameFor(actual) - val expectedName = nameFor(expected) - throw BluetoothDisabledException("Bluetooth adapter state is $actualName ($actual), but $expectedName ($expected) was required.") - } -} diff --git a/kable-core/src/androidMain/kotlin/BluetoothDevice.kt b/kable-core/src/androidMain/kotlin/BluetoothDevice.kt index 3b41a1227..4d5f29157 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDevice.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDevice.kt @@ -14,25 +14,28 @@ import android.content.Context import android.os.Build import com.juul.kable.gatt.Callback import com.juul.kable.logs.Logging -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration /** * @param transport is only used on API level >= 23. * @param phy is only used on API level >= 26. */ internal fun BluetoothDevice.connect( - scope: CoroutineScope, + coroutineContext: CoroutineContext, context: Context, autoConnect: Boolean, transport: Transport, phy: Phy, state: MutableStateFlow, + services: MutableStateFlow?>, mtu: MutableStateFlow, onCharacteristicChanged: MutableSharedFlow>, logging: Logging, threadingStrategy: ThreadingStrategy, + disconnectTimeout: Duration, ): Connection? { val callback = Callback(state, mtu, onCharacteristicChanged, logging, address) val threading = threadingStrategy.acquire() @@ -60,7 +63,7 @@ internal fun BluetoothDevice.connect( return null } - return Connection(scope, bluetoothGatt, threading, callback, logging) + return Connection(coroutineContext, bluetoothGatt, threading, callback, services, disconnectTimeout, logging) } private fun BluetoothDevice.connectGattCompat( diff --git a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt index b3897cd80..6ba388ee7 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt @@ -1,7 +1,6 @@ package com.juul.kable import android.bluetooth.BluetoothAdapter.STATE_OFF -import android.bluetooth.BluetoothAdapter.STATE_ON import android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice.DEVICE_TYPE_CLASSIC @@ -16,50 +15,33 @@ import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE import android.bluetooth.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE import android.bluetooth.BluetoothGattDescriptor.ENABLE_INDICATION_VALUE import android.bluetooth.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE -import com.benasher44.uuid.uuidFrom import com.juul.kable.AndroidPeripheral.Priority import com.juul.kable.AndroidPeripheral.Type import com.juul.kable.State.Disconnected import com.juul.kable.WriteType.WithResponse import com.juul.kable.WriteType.WithoutResponse -import com.juul.kable.external.CLIENT_CHARACTERISTIC_CONFIG_UUID +import com.juul.kable.bluetooth.checkBluetoothIsOn +import com.juul.kable.bluetooth.clientCharacteristicConfigUuid import com.juul.kable.gatt.Response.OnCharacteristicRead import com.juul.kable.gatt.Response.OnCharacteristicWrite import com.juul.kable.gatt.Response.OnDescriptorRead import com.juul.kable.gatt.Response.OnDescriptorWrite import com.juul.kable.gatt.Response.OnReadRemoteRssi -import com.juul.kable.gatt.Response.OnServicesDiscovered import com.juul.kable.logs.Logger import com.juul.kable.logs.Logging import com.juul.kable.logs.Logging.DataProcessor.Operation import com.juul.kable.logs.detail -import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException - -private val clientCharacteristicConfigUuid = uuidFrom(CLIENT_CHARACTERISTIC_CONFIG_UUID) - -// Number of service discovery attempts to make if no services are discovered. -// https://github.com/JuulLabs/kable/issues/295 -private const val DISCOVER_SERVICES_RETRIES = 5 +import kotlin.time.Duration internal class BluetoothDeviceAndroidPeripheral( - parentCoroutineContext: CoroutineContext, private val bluetoothDevice: BluetoothDevice, private val autoConnectPredicate: () -> Boolean, private val transport: Transport, @@ -68,155 +50,100 @@ internal class BluetoothDeviceAndroidPeripheral( observationExceptionHandler: ObservationExceptionHandler, private val onServicesDiscovered: ServicesDiscoveredAction, private val logging: Logging, -) : AndroidPeripheral { - - private val logger = Logger(logging, tag = "Kable/Peripheral", identifier = bluetoothDevice.address) + private val disconnectTimeout: Duration, +) : BasePeripheral(bluetoothDevice.toString()), AndroidPeripheral { + + init { + onBluetoothDisabled { state -> + logger.debug { + message = "Bluetooth disabled" + detail("state", state) + } + disconnect() + } + } - private val _state = MutableStateFlow(Disconnected()) - override val state: StateFlow = _state.asStateFlow() + private val connectAction = sharedRepeatableAction(::establishConnection) override val identifier: String = bluetoothDevice.address + private val logger = Logger(logging, "Kable/Peripheral", bluetoothDevice.toString()) - private val _mtu = MutableStateFlow(null) - override val mtu: StateFlow = _mtu.asStateFlow() + private val _state = MutableStateFlow(Disconnected()) + override val state = _state.asStateFlow() - private val observers = Observers(this, logging, exceptionHandler = observationExceptionHandler) + private val _services = MutableStateFlow?>(null) + override val services = _services.asStateFlow() + private fun servicesOrThrow() = services.value ?: error("Services have not been discovered") - @Volatile - private var _discoveredServices: List? = null - private val discoveredServices: List - get() = _discoveredServices - ?: throw IllegalStateException("Services have not been discovered for $this") + private val _mtu = MutableStateFlow(null) + override val mtu = _mtu.asStateFlow() - override val services: List? - get() = _discoveredServices?.toList() + private val observers = Observers(this, logging, observationExceptionHandler) - @Volatile - private var _connection: Connection? = null - private val connection: Connection - inline get() = _connection ?: throw NotReadyException(toString()) + private val connection = MutableStateFlow(null) + private fun connectionOrThrow() = + connection.value + ?: throw NotConnectedException("Connection not established, current state: ${state.value}") - override val name: String? get() = bluetoothDevice.name + override val type: Type + get() = typeFrom(bluetoothDevice.type) - /** - * It's important that we instantiate this scope as late as possible, since [dispose] will be - * called immediately if the parent job is already complete. Doing so late in is fine, - * but early in it could reference non-nullable variables that are not yet set and crash. - */ - private val scope = CoroutineScope( - parentCoroutineContext + - SupervisorJob(parentCoroutineContext.job).apply { invokeOnCompletion(::dispose) } + - CoroutineName("Kable/Peripheral/${bluetoothDevice.address}"), - ) + override val address: String = bluetoothDevice.address - private val connectAction = scope.sharedRepeatableAction(::establishConnection) + @ExperimentalApi + override val name: String? + get() = bluetoothDevice.name - private suspend fun establishConnection(scope: CoroutineScope) { - checkBluetoothAdapterState(expected = STATE_ON) - bluetoothState.watchForDisablingIn(scope) + private suspend fun establishConnection(scope: CoroutineScope): CoroutineScope { + checkBluetoothIsOn() logger.info { message = "Connecting" } _state.value = State.Connecting.Bluetooth try { - _connection = bluetoothDevice.connect( - scope, + connection.value = bluetoothDevice.connect( + scope.coroutineContext, applicationContext, autoConnectPredicate(), transport, phy, _state, + _services, _mtu, observers.characteristicChanges, logging, threadingStrategy, + disconnectTimeout, ) ?: throw ConnectionRejectedException() - suspendUntilOrThrow() + suspendUntil() discoverServices() - onServicesDiscovered(ServicesDiscoveredPeripheral(this@BluetoothDeviceAndroidPeripheral)) - - _state.value = State.Connecting.Observes - logger.verbose { message = "Configuring characteristic observations" } - observers.onConnected() + configureCharacteristicObservations() } catch (e: Exception) { - closeConnection() - val failure = e.unwrapCancellationCause() - logger.error(failure) { message = "Failed to connect" } + val failure = e.unwrapCancellationException() + logger.error(failure) { message = "Failed to establish connection" } throw failure } logger.info { message = "Connected" } _state.value = State.Connected - state.watchForConnectionLossIn(scope) + return connectionOrThrow().taskScope } - private fun Flow.watchForDisablingIn(scope: CoroutineScope): Job = - scope.launch(start = UNDISPATCHED) { - filter { state -> state == STATE_TURNING_OFF || state == STATE_OFF } - .collect { state -> - logger.debug { - message = "Bluetooth disabled" - detail("state", state) - } - closeConnection() - throw BluetoothDisabledException() - } - } - - private fun Flow.watchForConnectionLossIn(scope: CoroutineScope) = - state - .filter { it == State.Disconnecting || it is Disconnected } - .onEach { state -> - logger.debug { - message = "Disconnect detected" - detail("state", state.toString()) - } - throw ConnectionLostException("$this $state") - } - .launchIn(scope) - - override val type: Type - get() = typeFrom(bluetoothDevice.type) - - override val address: String = bluetoothDevice.address + private suspend fun configureCharacteristicObservations() { + logger.verbose { message = "Configuring characteristic observations" } + _state.value = State.Connecting.Observes + observers.onConnected() + } - override suspend fun connect() { + override suspend fun connect(): CoroutineScope = connectAction.await() - } override suspend fun disconnect() { - if (state.value is State.Connected) { - // Disconnect from active connection. - _connection?.bluetoothGatt?.disconnect() - } else { - // Cancel in-flight connection attempt. - connectAction.cancelAndJoin(CancellationException(NotConnectedException())) - } - suspendUntil() - releaseThread() - logger.info { message = "Disconnected" } - } - - private fun releaseThread() { - _connection?.threading?.release() - } - - private fun dispose(cause: Throwable?) { - closeConnection() - logger.info(cause) { message = "Disposed" } - } - - private fun closeConnection() { - _connection?.bluetoothGatt?.close() - releaseThread() - setDisconnected() - } - - private fun setDisconnected() { - // Avoid trampling existing `Disconnected` state (and its properties) by only updating if not already `Disconnected`. - _state.update { previous -> previous as? Disconnected ?: Disconnected() } + connectAction.cancelAndJoin( + CancellationException(NotConnectedException("Disconnect requested")), + ) } override fun requestConnectionPriority(priority: Priority): Boolean { @@ -224,34 +151,22 @@ internal class BluetoothDeviceAndroidPeripheral( message = "requestConnectionPriority" detail("priority", priority.name) } - return connection.bluetoothGatt + return connectionOrThrow() + .gatt .requestConnectionPriority(priority.intValue) } - override suspend fun rssi(): Int = connection.execute { - readRemoteRssiOrThrow() - }.rssi + @ExperimentalApi // Experimental until Web Bluetooth advertisements APIs are stable. + override suspend fun rssi(): Int = + connectionOrThrow().execute { + readRemoteRssiOrThrow() + }.rssi private suspend fun discoverServices() { - logger.verbose { message = "discoverServices" } - - repeat(DISCOVER_SERVICES_RETRIES) { attempt -> - connection.execute { - discoverServicesOrThrow() - } - val services = withContext(connection.dispatcher) { - connection.bluetoothGatt.services.map(::DiscoveredService) - } - - if (services.isEmpty()) { - logger.warn { message = "Empty services (attempt ${attempt + 1} of $DISCOVER_SERVICES_RETRIES)" } - } else { - logger.verbose { message = "Discovered ${services.count()} services" } - _discoveredServices = services - return - } + connectionOrThrow().discoverServices() + unwrapCancellationExceptions { + onServicesDiscovered(ServicesDiscoveredPeripheral(this)) } - _discoveredServices = emptyList() } override suspend fun requestMtu(mtu: Int): Int { @@ -259,7 +174,7 @@ internal class BluetoothDeviceAndroidPeripheral( message = "requestMtu" detail("mtu", mtu) } - return connection.requestMtu(mtu) + return connectionOrThrow().requestMtu(mtu) } override suspend fun write( @@ -274,8 +189,8 @@ internal class BluetoothDeviceAndroidPeripheral( detail(data, Operation.Write) } - val platformCharacteristic = discoveredServices.obtain(characteristic, writeType.properties) - connection.execute { + val platformCharacteristic = servicesOrThrow().obtain(characteristic, writeType.properties) + connectionOrThrow().execute { writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue) } } @@ -288,8 +203,8 @@ internal class BluetoothDeviceAndroidPeripheral( detail(characteristic) } - val platformCharacteristic = discoveredServices.obtain(characteristic, Read) - return connection.execute { + val platformCharacteristic = servicesOrThrow().obtain(characteristic, Read) + return connectionOrThrow().execute { readCharacteristicOrThrow(platformCharacteristic) }.value!! } @@ -298,7 +213,7 @@ internal class BluetoothDeviceAndroidPeripheral( descriptor: Descriptor, data: ByteArray, ) { - write(discoveredServices.obtain(descriptor), data) + write(servicesOrThrow().obtain(descriptor), data) } private suspend fun write( @@ -311,7 +226,7 @@ internal class BluetoothDeviceAndroidPeripheral( detail(data, Operation.Write) } - connection.execute { + connectionOrThrow().execute { writeDescriptorOrThrow(platformDescriptor, data) } } @@ -324,8 +239,8 @@ internal class BluetoothDeviceAndroidPeripheral( detail(descriptor) } - val platformDescriptor = discoveredServices.obtain(descriptor) - return connection.execute { + val platformDescriptor = servicesOrThrow().obtain(descriptor) + return connectionOrThrow().execute { readDescriptorOrThrow(platformDescriptor) }.value!! } @@ -337,29 +252,39 @@ internal class BluetoothDeviceAndroidPeripheral( internal suspend fun startObservation(characteristic: Characteristic) { logger.debug { + message = "Starting observation" + detail(characteristic) + } + + val platformCharacteristic = servicesOrThrow().obtain(characteristic, Notify or Indicate) + + logger.verbose { message = "setCharacteristicNotification" detail(characteristic) detail("value", "true") } - - val platformCharacteristic = discoveredServices.obtain(characteristic, Notify or Indicate) - connection - .bluetoothGatt + connectionOrThrow() + .gatt .setCharacteristicNotificationOrThrow(platformCharacteristic, true) setConfigDescriptor(platformCharacteristic, enable = true) } internal suspend fun stopObservation(characteristic: Characteristic) { - val platformCharacteristic = discoveredServices.obtain(characteristic, Notify or Indicate) + logger.debug { + message = "Stopping observation" + detail(characteristic) + } + + val platformCharacteristic = servicesOrThrow().obtain(characteristic, Notify or Indicate) setConfigDescriptor(platformCharacteristic, enable = false) - logger.debug { + logger.verbose { message = "setCharacteristicNotification" detail(characteristic) detail("value", "false") } - connection - .bluetoothGatt + connectionOrThrow() + .gatt .setCharacteristicNotificationOrThrow(platformCharacteristic, false) } @@ -407,7 +332,14 @@ internal class BluetoothDeviceAndroidPeripheral( } } - override fun toString(): String = "Peripheral(bluetoothDevice=$bluetoothDevice)" + private fun onBluetoothDisabled(action: suspend (bluetoothState: Int) -> Unit) { + bluetoothState + .filter { state -> state == STATE_TURNING_OFF || state == STATE_OFF } + .onEach(action) + .launchIn(this) + } + + override fun toString(): String = "Peripheral(delegate=$bluetoothDevice)" } private val WriteType.intValue: Int diff --git a/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt b/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt index 40bf8b603..84ad570cc 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt @@ -9,11 +9,11 @@ import com.juul.kable.Filter.Address import com.juul.kable.Filter.ManufacturerData import com.juul.kable.Filter.Name import com.juul.kable.Filter.Service +import com.juul.kable.bluetooth.checkBluetoothIsOn import com.juul.kable.logs.Logger import com.juul.kable.logs.Logging import com.juul.kable.scan.ScanError import com.juul.kable.scan.message -import com.juul.kable.scan.requirements.checkBluetoothIsOn import com.juul.kable.scan.requirements.checkLocationServicesEnabled import com.juul.kable.scan.requirements.checkScanPermissions import com.juul.kable.scan.requirements.requireBluetoothLeScanner diff --git a/kable-core/src/androidMain/kotlin/Connection.kt b/kable-core/src/androidMain/kotlin/Connection.kt index 5e0709fb9..8d25e58c4 100644 --- a/kable-core/src/androidMain/kotlin/Connection.kt +++ b/kable-core/src/androidMain/kotlin/Connection.kt @@ -2,125 +2,273 @@ package com.juul.kable import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGatt.GATT_SUCCESS +import android.os.Handler +import com.juul.kable.State.Disconnected +import com.juul.kable.coroutines.childSupervisor import com.juul.kable.gatt.Callback import com.juul.kable.gatt.GattStatus import com.juul.kable.gatt.Response +import com.juul.kable.gatt.Response.OnServicesDiscovered import com.juul.kable.logs.Logger import com.juul.kable.logs.Logging +import kotlinx.coroutines.CompletableJob import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred +import kotlinx.coroutines.CoroutineStart.ATOMIC +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.coroutineContext +import kotlin.reflect.KClass +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO -public class OutOfOrderGattCallbackException internal constructor( - message: String, -) : IllegalStateException(message) +// Number of service discovery attempts to make if no services are discovered. +// https://github.com/JuulLabs/kable/issues/295 +private const val DISCOVER_SERVICES_RETRIES = 5 private val GattSuccess = GattStatus(GATT_SUCCESS) +/** + * Represents a Bluetooth Low Energy connection. [Connection] should be initialized with the + * provided [BluetoothGatt] in a connecting or connected state. When a disconnect occurs (either by + * invoking [disconnect], or peripheral initiated disconnect), this [Connection] will be + * [disposed][close] (and cannot be re-used). + * + * To disconnect: simply call [disconnect] ([Connection] will be implicitly [closed][close] at the + * end of the [disconnect] sequence). + * + * If [scope], or parent [CoroutineContext] is cancelled prior to [disconnecting][disconnect], then + * [Connection] will be abruptly [closed][close] (upon completion of [job]) without a prior + * [disconnect] sequence. + */ internal class Connection( - private val scope: CoroutineScope, - internal val bluetoothGatt: BluetoothGatt, - internal val threading: Threading, + parentContext: CoroutineContext, + internal val gatt: BluetoothGatt, + private val threading: Threading, private val callback: Callback, + private val services: MutableStateFlow?>, + private val disconnectTimeout: Duration, logging: Logging, ) { - private val logger = Logger(logging, tag = "Kable/Connection", identifier = bluetoothGatt.device.address) + private val name = "Kable/Connection/${gatt.device}" - private val lock = Mutex() - private var deferredResponse: Deferred? = null + private val connectionJob = (parentContext.job as CompletableJob).apply { + invokeOnCompletion(::close) + } + private val connectionScope = CoroutineScope( + parentContext + connectionJob + CoroutineName(name), + ) + + val taskScope = connectionScope.childSupervisor("$name/Tasks") + + private val logger = + Logger(logging, tag = "Kable/Connection", identifier = gatt.device.toString()) + + init { + // todo: Move this `require` to the PeripheralBuilder. + require(disconnectTimeout > ZERO) { "Disconnect timeout must be >0, was $disconnectTimeout" } + + onDispose(::disconnect) + + on { + val state = it.toString() + logger.debug { + message = "Disconnect detected" + detail("state", state) + } + dispose(NotConnectedException("Disconnect detected")) + } + + // todo: Monitor onServicesChanged event to re-`discoverServices`. + // https://github.com/JuulLabs/kable/issues/662 + } + + private val dispatcher = connectionScope.coroutineContext + threading.dispatcher + private val guard = Mutex() + + suspend fun discoverServices() { + logger.verbose { message = "Discovering services" } - internal val dispatcher = threading.dispatcher + repeat(DISCOVER_SERVICES_RETRIES) { attempt -> + val discoveredServices = execute { + discoverServicesOrThrow() + }.services.map(::DiscoveredService) + + if (discoveredServices.isEmpty()) { + logger.warn { + message = "Empty services" + detail("attempt", "${attempt + 1} of $DISCOVER_SERVICES_RETRIES") + } + } else { + logger.verbose { message = "Discovered ${discoveredServices.count()} services" } + services.value = discoveredServices + return + } + } + services.value = emptyList() + } /** * Executes specified [BluetoothGatt] [action]. * - * Android Bluetooth Low Energy has strict requirements: all I/O must be executed sequentially. In other words, the - * response for an [action] must be received before another [action] can be performed. Additionally, the Android BLE - * stack can become unstable if I/O isn't performed on a dedicated thread. + * Android Bluetooth Low Energy has strict requirements: all I/O must be executed sequentially. + * In other words, the response for an [action] must be received before another [action] can be + * performed. Additionally, the Android BLE stack can become unstable if I/O isn't performed on + * a dedicated thread. * - * These requirements are fulfilled by ensuring that all [action]s are performed behind a [Mutex]. On Android pre-O - * 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]). + * These requirements are fulfilled by ensuring that all [action]s are performed behind a + * [Mutex]. On Android pre-O 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 GattStatusException if response has a non-`GATT_SUCCESS` status. + * @throws GattStatusException If response has a non-`GATT_SUCCESS` status. + * @throws NotConnectedException If connection has been closed. */ - suspend inline fun execute( - crossinline action: BluetoothGatt.() -> Unit, - ): T = lock.withLock { - deferredResponse?.let { - if (it.isActive) { - // Discard response as we've performed another `execute` without the previous finishing. This happens if - // a previous `execute` was cancelled after invoking GATT action, but before receiving response from - // callback channel. See the following issues for more details: - // https://github.com/JuulLabs/kable/issues/326 - // https://github.com/JuulLabs/kable/issues/450 - val response = it.await() - logger.warn { - message = "Discarded response" - detail("response", response.toString()) + suspend inline fun execute( + noinline action: BluetoothGatt.() -> Unit, + ): T = execute(T::class, action) + + suspend fun execute( + type: KClass, + action: BluetoothGatt.() -> Unit, + ): T { + val response = guard.withLock { + var executed = false + try { + withContext(dispatcher) { + gatt.action() + executed = true } + } catch (e: CancellationException) { + if (executed) { + // Ensure response buffer is received even when calling context is cancelled. + // UNDISPATCHED to ensure we're within the `lock` for the `receive`. + connectionScope.launch(start = UNDISPATCHED) { + val response = callback.onResponse.receive() + logger.debug { + message = "Discarded response to cancelled request" + detail("response", response.toString()) + } + } + } + coroutineContext.ensureActive() + throw e.unwrapCancellationException() } - } - withContext(dispatcher) { - bluetoothGatt.action() - } - val deferred = scope.async { callback.onResponse.receive() } - deferredResponse = deferred - - val response = try { - deferred.await() - } catch (e: Exception) { - // The `async` above is a sibling coroutine to that of the coroutines launched from the - // connect process. When (for example) BLE is disabled, it fails the connection scope; - // being that `async` is a sibling scope, the `async` above will **cancel** when BLE is - // disabled (rather than fail). We actually want to fail this `execute`, so we unwrap - // `CancellationException` for the underlying exception that failed the connection scope. - // todo: Figure out how to handle cancellation (discard GATT callback response) while running in the calling coroutine context (rather than using passed in scope). - when (val unwrapped = e.unwrapCancellationCause()) { - is ConnectionLostException -> throw ConnectionLostException(cause = unwrapped) - else -> throw unwrapped + try { + connectionScope.async { + callback.onResponse.receive() + }.await() + } catch (e: CancellationException) { + coroutineContext.ensureActive() + throw e.unwrapCancellationException() } - } - deferredResponse = null - - if (response.status != GattSuccess) throw GattStatusException(response.toString()) + }.also(::checkResponse) - // `lock` should always enforce a 1:1 matching of request to response, but if an Android `BluetoothGattCallback` - // method gets called out of order then we'll cast to the wrong response type. - response as? T - ?: throw OutOfOrderGattCallbackException( - "Unexpected response type ${response.javaClass.simpleName} received", + // `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( + "Expected response type ${type.simpleName} but received ${response::class.simpleName}", ) } /** - * Mimics [execute] in order to uphold the same sequential execution behavior, while having a dedicated channel for - * receiving MTU change events (so that peripheral initiated MTU changes don't result in - * [OutOfOrderGattCallbackException]). + * Mimics [execute] in order to uphold the same sequential execution behavior, while having a + * dedicated channel for receiving MTU change events. * * See https://github.com/JuulLabs/kable/issues/86 for more details. * * @throws GattRequestRejectedException if underlying `BluetoothGatt` method call returns `false`. * @throws GattStatusException if response has a non-`GATT_SUCCESS` status. */ - suspend fun requestMtu(mtu: Int): Int = lock.withLock { - withContext(dispatcher) { - if (!bluetoothGatt.requestMtu(mtu)) throw GattRequestRejectedException() + suspend fun requestMtu(mtu: Int): Int = guard.withLock { + try { + withContext(dispatcher) { + if (!gatt.requestMtu(mtu)) throw GattRequestRejectedException() + } + connectionScope.async { callback.onMtuChanged.receive() }.await() + } catch (e: CancellationException) { + coroutineContext.ensureActive() + throw e.unwrapCancellationException() + } + }.also(::checkResponse).mtu + + private suspend fun disconnect() { + if (callback.state.value is Disconnected) return + + withContext(NonCancellable) { + try { + withTimeout(disconnectTimeout) { + logger.verbose { message = "Waiting for connection tasks to complete" } + taskScope.coroutineContext.job.join() + + logger.debug { message = "Disconnecting" } + disconnectGatt() + + callback.state.filterIsInstance().first() + } + logger.info { message = "Disconnected" } + } catch (e: TimeoutCancellationException) { + logger.warn { message = "Timed out after $disconnectTimeout waiting for disconnect" } + } finally { + disconnectGatt() + } } + } + + private var didDisconnectGatt = false + private fun disconnectGatt() { + if (didDisconnectGatt) return + logger.verbose { message = "gatt.disconnect" } + gatt.disconnect() + didDisconnectGatt = true + } - val response = try { - callback.onMtuChanged.receive() - } catch (e: ConnectionLostException) { - throw ConnectionLostException(cause = e) + private fun close(cause: Throwable?) { + logger.debug(cause) { message = "Closing" } + gatt.close() + threading.release() + logger.info { message = "Closed" } + } + + private inline fun on(crossinline action: suspend (T) -> Unit) { + taskScope.launch { + action(callback.state.filterIsInstance().first()) } + } - if (response.status != GattSuccess) throw GattStatusException(response.toString()) - response.mtu + private fun onDispose(action: suspend () -> Unit) { + @Suppress("OPT_IN_USAGE") + connectionScope.launch(start = ATOMIC) { + try { + awaitCancellation() + } finally { + action() + } + } } + + private fun dispose(cause: Throwable) = connectionJob.completeExceptionally(cause) +} + +private fun checkResponse(response: Response) { + if (response.status != GattSuccess) throw GattStatusException(response.toString()) } diff --git a/kable-core/src/androidMain/kotlin/Exceptions.kt b/kable-core/src/androidMain/kotlin/Exceptions.kt index 235cb2300..674b6c199 100644 --- a/kable-core/src/androidMain/kotlin/Exceptions.kt +++ b/kable-core/src/androidMain/kotlin/Exceptions.kt @@ -17,7 +17,7 @@ import com.juul.kable.AndroidPeripheral.WriteResult */ public open class GattRequestRejectedException internal constructor( message: String? = null, -) : BluetoothException(message) +) : IllegalStateException(message) /** * Thrown when underlying [BluetoothGatt] write operation call fails. diff --git a/kable-core/src/androidMain/kotlin/Identifier.kt b/kable-core/src/androidMain/kotlin/Identifier.kt new file mode 100644 index 000000000..1e79258e6 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/Identifier.kt @@ -0,0 +1,12 @@ +package com.juul.kable + +import android.bluetooth.BluetoothAdapter + +public actual typealias Identifier = String + +public actual fun String.toIdentifier(): Identifier { + require(BluetoothAdapter.checkBluetoothAddress(this)) { + "MAC Address has invalid format: $this" + } + return this +} diff --git a/kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt b/kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt new file mode 100644 index 000000000..ec97ffae6 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt @@ -0,0 +1,50 @@ +package com.juul.kable + +import android.bluetooth.BluetoothDevice +import kotlinx.coroutines.CoroutineScope + +@Deprecated( + message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", + replaceWith = ReplaceWith("Peripheral(advertisement, builderAction)"), +) +public actual fun CoroutineScope.peripheral( + advertisement: Advertisement, + builderAction: PeripheralBuilderAction, +): Peripheral { + advertisement as ScanResultAndroidAdvertisement + return peripheral(advertisement.bluetoothDevice, builderAction) +} + +@Deprecated( + message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", + replaceWith = ReplaceWith("Peripheral(bluetoothDevice, builderAction)"), +) +public fun CoroutineScope.peripheral( + bluetoothDevice: BluetoothDevice, + builderAction: PeripheralBuilderAction = {}, +): Peripheral { + val builder = PeripheralBuilder().apply(builderAction) + return BluetoothDeviceAndroidPeripheral( + bluetoothDevice, + builder.autoConnectPredicate, + builder.transport, + builder.phy, + builder.threadingStrategy, + builder.observationExceptionHandler, + builder.onServicesDiscovered, + builder.logging, + builder.disconnectTimeout, + ) +} + +@Deprecated( + message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", + replaceWith = ReplaceWith("Peripheral(identifier, builderAction)"), +) +public fun CoroutineScope.peripheral( + identifier: Identifier, + builderAction: PeripheralBuilderAction = {}, +): Peripheral { + val bluetoothDevice = getBluetoothAdapter().getRemoteDevice(identifier) + return peripheral(bluetoothDevice, builderAction) +} diff --git a/kable-core/src/androidMain/kotlin/Peripheral.kt b/kable-core/src/androidMain/kotlin/Peripheral.kt index 5eb26efd4..360ff9b64 100644 --- a/kable-core/src/androidMain/kotlin/Peripheral.kt +++ b/kable-core/src/androidMain/kotlin/Peripheral.kt @@ -1,34 +1,21 @@ package com.juul.kable -import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice -import kotlinx.coroutines.CoroutineScope -public actual typealias Identifier = String - -public actual fun String.toIdentifier(): Identifier { - require(BluetoothAdapter.checkBluetoothAddress(this)) { - "MAC Address has invalid format: $this" - } - return this -} - -public actual fun CoroutineScope.peripheral( +public actual fun Peripheral( advertisement: Advertisement, builderAction: PeripheralBuilderAction, ): Peripheral { advertisement as ScanResultAndroidAdvertisement - return peripheral(advertisement.bluetoothDevice, builderAction) + return Peripheral(advertisement.bluetoothDevice, builderAction) } -public fun CoroutineScope.peripheral( +public fun Peripheral( bluetoothDevice: BluetoothDevice, - builderAction: PeripheralBuilderAction = {}, + builderAction: PeripheralBuilderAction, ): Peripheral { - val builder = PeripheralBuilder() - builder.builderAction() + val builder = PeripheralBuilder().apply(builderAction) return BluetoothDeviceAndroidPeripheral( - coroutineContext, bluetoothDevice, builder.autoConnectPredicate, builder.transport, @@ -37,13 +24,6 @@ public fun CoroutineScope.peripheral( builder.observationExceptionHandler, builder.onServicesDiscovered, builder.logging, + builder.disconnectTimeout, ) } - -public fun CoroutineScope.peripheral( - identifier: Identifier, - builderAction: PeripheralBuilderAction = {}, -): Peripheral { - val bluetoothDevice = getBluetoothAdapter().getRemoteDevice(identifier) - return peripheral(bluetoothDevice, builderAction) -} diff --git a/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt b/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt index 6ae4ea0af..f97caba94 100644 --- a/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt +++ b/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt @@ -2,6 +2,7 @@ package com.juul.kable import com.juul.kable.logs.Logging import com.juul.kable.logs.LoggingBuilder +import kotlin.time.Duration /** Preferred transport for GATT connections to remote dual-mode devices. */ public enum class Transport { @@ -114,4 +115,6 @@ public actual class PeripheralBuilder internal actual constructor() { public var phy: Phy = Phy.Le1M public var threadingStrategy: ThreadingStrategy = OnDemandThreadingStrategy + + public actual var disconnectTimeout: Duration = defaultDisconnectTimeout } diff --git a/kable-core/src/androidMain/kotlin/scan/requirements/BluetoothIsOn.kt b/kable-core/src/androidMain/kotlin/bluetooth/CheckBluetoothIsOn.kt similarity index 96% rename from kable-core/src/androidMain/kotlin/scan/requirements/BluetoothIsOn.kt rename to kable-core/src/androidMain/kotlin/bluetooth/CheckBluetoothIsOn.kt index 49a0cca20..4db4b1cf1 100644 --- a/kable-core/src/androidMain/kotlin/scan/requirements/BluetoothIsOn.kt +++ b/kable-core/src/androidMain/kotlin/bluetooth/CheckBluetoothIsOn.kt @@ -1,4 +1,4 @@ -package com.juul.kable.scan.requirements +package com.juul.kable.bluetooth import android.bluetooth.BluetoothAdapter.STATE_OFF import android.bluetooth.BluetoothAdapter.STATE_ON diff --git a/kable-core/src/androidMain/kotlin/bluetooth/ClientCharacteristicConfigUuid.kt b/kable-core/src/androidMain/kotlin/bluetooth/ClientCharacteristicConfigUuid.kt new file mode 100644 index 000000000..89a3e2b29 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/bluetooth/ClientCharacteristicConfigUuid.kt @@ -0,0 +1,6 @@ +package com.juul.kable.bluetooth + +import com.benasher44.uuid.uuidFrom +import com.juul.kable.external.CLIENT_CHARACTERISTIC_CONFIG_UUID + +internal val clientCharacteristicConfigUuid = uuidFrom(CLIENT_CHARACTERISTIC_CONFIG_UUID) diff --git a/kable-core/src/androidMain/kotlin/gatt/Callback.kt b/kable-core/src/androidMain/kotlin/gatt/Callback.kt index 6643817f0..7641eaa45 100644 --- a/kable-core/src/androidMain/kotlin/gatt/Callback.kt +++ b/kable-core/src/androidMain/kotlin/gatt/Callback.kt @@ -11,7 +11,7 @@ import android.bluetooth.BluetoothProfile.STATE_CONNECTED import android.bluetooth.BluetoothProfile.STATE_CONNECTING import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED import android.bluetooth.BluetoothProfile.STATE_DISCONNECTING -import com.juul.kable.ConnectionLostException +import com.juul.kable.NotConnectedException import com.juul.kable.ObservationEvent import com.juul.kable.ObservationEvent.CharacteristicChange import com.juul.kable.State @@ -50,7 +50,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow internal class Callback( - private val state: MutableStateFlow, + val state: MutableStateFlow, private val mtu: MutableStateFlow, private val onCharacteristicChanged: MutableSharedFlow>, logging: Logging, @@ -103,8 +103,6 @@ internal class Callback( detail("newState", newState.connectionStateString) } - if (newState == STATE_DISCONNECTED) gatt.close() - when (newState) { STATE_CONNECTING -> state.value = State.Connecting.Bluetooth STATE_CONNECTED -> state.value = State.Connecting.Services @@ -113,12 +111,12 @@ internal class Callback( } if (newState == STATE_DISCONNECTING || newState == STATE_DISCONNECTED) { - onResponse.close(ConnectionLostException()) + onResponse.close(NotConnectedException()) } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { - val event = OnServicesDiscovered(GattStatus(status)) + val event = OnServicesDiscovered(GattStatus(status), gatt.services) logger.debug { message = "onServicesDiscovered" detail(event.status) @@ -275,6 +273,11 @@ internal class Callback( if (status == GATT_SUCCESS) this.mtu.value = mtu } + override fun onServiceChanged(gatt: BluetoothGatt) { + logger.debug { message = "onServiceChanged" } + // todo + } + private fun SendChannel.trySendOrLog(element: E) { trySend(element).onFailure { cause -> logger.warn(cause) { diff --git a/kable-core/src/androidMain/kotlin/gatt/Response.kt b/kable-core/src/androidMain/kotlin/gatt/Response.kt index 52e0fdf7f..e9154ef19 100644 --- a/kable-core/src/androidMain/kotlin/gatt/Response.kt +++ b/kable-core/src/androidMain/kotlin/gatt/Response.kt @@ -13,6 +13,7 @@ import android.bluetooth.BluetoothGatt.GATT_SUCCESS import android.bluetooth.BluetoothGatt.GATT_WRITE_NOT_PERMITTED import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService import com.juul.kable.external.GATT_AUTH_FAIL import com.juul.kable.external.GATT_BUSY import com.juul.kable.external.GATT_CCC_CFG_ERR @@ -53,6 +54,7 @@ internal sealed class Response { data class OnServicesDiscovered( override val status: GattStatus, + val services: List, ) : Response() data class OnCharacteristicRead( diff --git a/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt b/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt index 8c22fddb9..bda406298 100644 --- a/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt +++ b/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt @@ -1,316 +1,170 @@ package com.juul.kable -import com.benasher44.uuid.Uuid import com.juul.kable.CentralManagerDelegate.ConnectionEvent -import com.juul.kable.CentralManagerDelegate.ConnectionEvent.DidConnect -import com.juul.kable.CentralManagerDelegate.ConnectionEvent.DidDisconnect -import com.juul.kable.CentralManagerDelegate.ConnectionEvent.DidFailToConnect import com.juul.kable.Endianness.LittleEndian -import com.juul.kable.PeripheralDelegate.Response.DidDiscoverCharacteristicsForService -import com.juul.kable.PeripheralDelegate.Response.DidDiscoverDescriptorsForCharacteristic -import com.juul.kable.PeripheralDelegate.Response.DidDiscoverServices import com.juul.kable.PeripheralDelegate.Response.DidReadRssi import com.juul.kable.PeripheralDelegate.Response.DidUpdateNotificationStateForCharacteristic import com.juul.kable.PeripheralDelegate.Response.DidUpdateValueForDescriptor import com.juul.kable.PeripheralDelegate.Response.DidWriteValueForCharacteristic -import com.juul.kable.State.Disconnected.Status.Cancelled -import com.juul.kable.State.Disconnected.Status.ConnectionLimitReached -import com.juul.kable.State.Disconnected.Status.EncryptionTimedOut -import com.juul.kable.State.Disconnected.Status.Failed -import com.juul.kable.State.Disconnected.Status.PeripheralDisconnected -import com.juul.kable.State.Disconnected.Status.Timeout -import com.juul.kable.State.Disconnected.Status.Unknown -import com.juul.kable.State.Disconnected.Status.UnknownDevice import com.juul.kable.WriteType.WithResponse import com.juul.kable.WriteType.WithoutResponse +import com.juul.kable.bluetooth.checkBluetoothIsOn import com.juul.kable.logs.Logger import com.juul.kable.logs.Logging import com.juul.kable.logs.Logging.DataProcessor.Operation.Write import com.juul.kable.logs.detail -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onSubscription -import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet -import kotlinx.coroutines.job -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext import kotlinx.io.IOException -import platform.CoreBluetooth.CBCharacteristic -import platform.CoreBluetooth.CBCharacteristicWriteWithResponse -import platform.CoreBluetooth.CBCharacteristicWriteWithoutResponse import platform.CoreBluetooth.CBDescriptor -import platform.CoreBluetooth.CBErrorConnectionFailed -import platform.CoreBluetooth.CBErrorConnectionLimitReached -import platform.CoreBluetooth.CBErrorConnectionTimeout -import platform.CoreBluetooth.CBErrorEncryptionTimedOut -import platform.CoreBluetooth.CBErrorOperationCancelled -import platform.CoreBluetooth.CBErrorPeripheralDisconnected -import platform.CoreBluetooth.CBErrorUnknownDevice import platform.CoreBluetooth.CBManagerState -import platform.CoreBluetooth.CBManagerStatePoweredOff import platform.CoreBluetooth.CBManagerStatePoweredOn -import platform.CoreBluetooth.CBManagerStateResetting -import platform.CoreBluetooth.CBManagerStateUnauthorized -import platform.CoreBluetooth.CBManagerStateUnknown -import platform.CoreBluetooth.CBManagerStateUnsupported import platform.CoreBluetooth.CBPeripheral -import platform.CoreBluetooth.CBService -import platform.CoreBluetooth.CBUUID import platform.CoreBluetooth.CBUUIDCharacteristicExtendedPropertiesString import platform.CoreBluetooth.CBUUIDClientCharacteristicConfigurationString import platform.CoreBluetooth.CBUUIDL2CAPPSMCharacteristicString import platform.CoreBluetooth.CBUUIDServerCharacteristicConfigurationString import platform.Foundation.NSData -import platform.Foundation.NSError import platform.Foundation.NSNumber import platform.Foundation.NSString import platform.Foundation.NSUTF8StringEncoding -import platform.Foundation.NSUUID import platform.Foundation.dataUsingEncoding import platform.darwin.UInt16 -import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration +import platform.CoreBluetooth.CBCharacteristicWriteWithResponse as CBWithResponse +import platform.CoreBluetooth.CBCharacteristicWriteWithoutResponse as CBWithoutResponse internal class CBPeripheralCoreBluetoothPeripheral( - parentCoroutineContext: CoroutineContext, - internal val cbPeripheral: CBPeripheral, + internal val peripheral: CBPeripheral, observationExceptionHandler: ObservationExceptionHandler, private val onServicesDiscovered: ServicesDiscoveredAction, private val logging: Logging, -) : CoreBluetoothPeripheral { + private val disconnectTimeout: Duration, +) : BasePeripheral(peripheral.identifier.toUuid()), CoreBluetoothPeripheral { - private val logger = Logger(logging, identifier = cbPeripheral.identifier.UUIDString) + private val central = CentralManager.Default - private val centralManager: CentralManager = CentralManager.Default + override val identifier: Identifier = peripheral.identifier.toUuid() + private val logger = Logger(logging, identifier = identifier.toString()) private val _state = MutableStateFlow(State.Disconnected()) override val state: StateFlow = _state.asStateFlow() - override val identifier: Uuid = cbPeripheral.identifier.toUuid() - - private val observers = Observers(this, logging, exceptionHandler = observationExceptionHandler) - - /** - * It's important that we instantiate this scope as late as possible, since [dispose] will be - * called immediately if the parent job is already complete. Doing so late in is fine, - * but early in it could reference non-nullable variables that are not yet set and crash. - */ - private val scope = CoroutineScope( - parentCoroutineContext + - SupervisorJob(parentCoroutineContext.job).apply { invokeOnCompletion(::dispose) } + - CoroutineName("Kable/Peripheral/${cbPeripheral.identifier.UUIDString}"), - ) - init { - centralManager.delegate - .connectionState - .filter { event -> event.identifier == cbPeripheral.identifier } - .onEach { event -> - logger.debug { - message = "CentralManagerDelegate state change" - detail("state", event.toString()) - } + onStateChanged { state -> + _state.value = state + logger.debug { + message = "CentralManagerDelegate state change" + detail("state", state.toString()) } - .map { event -> event.toState() } - .onEach { _state.value = it } - .launchIn(scope) - } - - internal val canSendWriteWithoutResponse = MutableStateFlow(cbPeripheral.canSendWriteWithoutResponse) + } - private val _discoveredServices = atomic?>(null) - private val discoveredServices: List - get() = _discoveredServices.value - ?: throw IllegalStateException("Services have not been discovered for $this") + onBluetoothPoweredOff { state -> + logger.info { + message = "Bluetooth powered off" + detail("state", state) + } + disconnect() + } + } - override val services: List? - get() = _discoveredServices.value?.toList() + private val connectAction = sharedRepeatableAction(::establishConnection) - private val _connection = atomic(null) - private val connection: Connection - inline get() = _connection.value ?: throw NotReadyException(toString()) + private val observers = Observers(this, logging, exceptionHandler = observationExceptionHandler) + private val canSendWriteWithoutResponse = MutableStateFlow(peripheral.canSendWriteWithoutResponse) - override val name: String? get() = cbPeripheral.name + private val _services = MutableStateFlow?>(null) + override val services = _services.asStateFlow() + private fun servicesOrThrow() = services.value ?: error("Services have not been discovered") - private val connectAction = scope.sharedRepeatableAction(::establishConnection) + private val connection = MutableStateFlow(null) + private fun connectionOrThrow() = + connection.value + ?: throw NotConnectedException("Connection not established, current state: ${state.value}") - override suspend fun connect() { - connectAction.await() - } + @ExperimentalApi + override val name: String? + get() = peripheral.name - private suspend fun establishConnection(scope: CoroutineScope) { - // Check CBCentral State since connecting can result in an API misuse message. - centralManager.checkBluetoothState(CBManagerStatePoweredOn) - centralManager.delegate.state.watchForDisablingIn(scope) + private suspend fun establishConnection(scope: CoroutineScope): CoroutineScope { + central.checkBluetoothIsOn() logger.info { message = "Connecting" } _state.value = State.Connecting.Bluetooth - val failureWatcher = centralManager.delegate - .connectionState - .watchForConnectionFailureIn(scope) - try { - _connection.value = centralManager.connectPeripheral( - scope, - this@CBPeripheralCoreBluetoothPeripheral, - observers.characteristicChanges, + connection.value = central.connectPeripheral( + scope.coroutineContext, + peripheral, + createPeripheralDelegate(), + _state, + _services, + disconnectTimeout, logging, ) - - suspendUntilOrThrow() + suspendUntil() discoverServices() - onServicesDiscovered(ServicesDiscoveredPeripheral(this@CBPeripheralCoreBluetoothPeripheral)) - - _state.value = State.Connecting.Observes - logger.verbose { message = "Configuring characteristic observations" } - observers.onConnected() + configureCharacteristicObservations() } catch (e: Exception) { - closeConnection() - val failure = e.unwrapCancellationCause() - logger.error(failure) { message = "Failed to connect" } + val failure = e.unwrapCancellationException() + logger.error(failure) { message = "Failed to establish connection" } throw failure - } finally { - failureWatcher.cancel() } logger.info { message = "Connected" } _state.value = State.Connected - centralManager.delegate.onDisconnected.watchForConnectionLossIn(scope) + return connectionOrThrow().taskScope } - private fun Flow.watchForDisablingIn(scope: CoroutineScope) = - scope.launch(start = UNDISPATCHED) { - filter { state -> state != CBManagerStatePoweredOn } - .collect { state -> - logger.info { - message = "Bluetooth unavailable" - detail("state", state) - } - closeConnection() - throw ConnectionLostException("$this $state") - } - } - - private fun Flow.watchForConnectionLossIn(scope: CoroutineScope) = - filter { identifier -> identifier == cbPeripheral.identifier } - .onEach { - logger.info { message = "Disconnected" } - throw ConnectionLostException("$this disconnected") - } - .launchIn(scope) - - private fun Flow.watchForConnectionFailureIn(scope: CoroutineScope) = - filter { identifier -> identifier == cbPeripheral.identifier } - .filterNot { event -> event is DidConnect } - .onEach { event -> - val error = when (event) { - is DidFailToConnect -> event.error - is DidDisconnect -> event.error - else -> null - } - val failure = error - ?.let { ": ${it.toStatus()} (${it.localizedDescription})" } - .orEmpty() - logger.info { message = "Disconnected$failure" } - throw ConnectionLostException("$this disconnected$failure") - } - .launchIn(scope) - - override suspend fun disconnect() { - closeConnection() - suspendUntil() - logger.info { message = "Disconnected" } - } - - private suspend fun closeConnection() { - withContext(NonCancellable) { - centralManager.cancelPeripheralConnection(cbPeripheral) - } + private suspend fun configureCharacteristicObservations() { + logger.verbose { message = "Configuring characteristic observations" } + _state.value = State.Connecting.Observes + observers.onConnected() } - private fun dispose(cause: Throwable?) { - GlobalScope.launch(start = UNDISPATCHED) { - closeConnection() - setDisconnected() - logger.info(cause) { message = "$this disposed" } - } - } + override suspend fun connect(): CoroutineScope = + connectAction.await() - private fun setDisconnected() { - // Avoid trampling existing `Disconnected` state (and its properties) by only updating if not already `Disconnected`. - _state.update { previous -> previous as? State.Disconnected ?: State.Disconnected() } + override suspend fun disconnect() { + connectAction.cancelAndJoin( + CancellationException(NotConnectedException("Disconnect requested")), + ) } - @Throws(CancellationException::class, IOException::class, NotReadyException::class) - override suspend fun rssi(): Int = connection.execute { - centralManager.readRssi(cbPeripheral) + @ExperimentalApi // Experimental until Web Bluetooth advertisements APIs are stable. + @Throws(CancellationException::class, IOException::class) + override suspend fun rssi(): Int = connectionOrThrow().execute { + peripheral.readRSSI() }.rssi.intValue - private suspend fun discoverServices(): Unit = discoverServices(services = null) - - /** @param services to discover (list of service UUIDs), or `null` for all. */ - private suspend fun discoverServices( - services: List?, - ) { - logger.verbose { message = "discoverServices" } - val servicesToDiscover = services?.map { CBUUID.UUIDWithNSUUID(it.toNSUUID()) } - - connection.execute { - centralManager.discoverServices(cbPeripheral, servicesToDiscover) + private suspend fun discoverServices() { + connectionOrThrow().discoverServices() + unwrapCancellationExceptions { + onServicesDiscovered(ServicesDiscoveredPeripheral(this)) } - - // Cast should be safe since `CBPeripheral.services` type is `[CBService]?`, according to: - // https://developer.apple.com/documentation/corebluetooth/cbperipheral/services - @Suppress("UNCHECKED_CAST") - val discoveredServices = cbPeripheral.services as List? - discoveredServices?.forEach { cbService -> - connection.execute { - centralManager.discoverCharacteristics(cbPeripheral, cbService) - } - // Cast should be safe since `CBService.characteristics` type is `[CBCharacteristic]?`, - // according to: https://developer.apple.com/documentation/corebluetooth/cbservice/characteristics - @Suppress("UNCHECKED_CAST") - val discoveredCharacteristics = cbService.characteristics as List? - discoveredCharacteristics?.forEach { cbCharacteristic -> - connection.execute { - centralManager.discoverDescriptors(cbPeripheral, cbCharacteristic) - } - } - } - - _discoveredServices.value = cbPeripheral.services - .orEmpty() - .map { it as PlatformService } - .map(::DiscoveredService) } - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) override suspend fun write( characteristic: Characteristic, data: ByteArray, writeType: WriteType, ): Unit = write(characteristic, data.toNSData(), writeType) - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) override suspend fun write( characteristic: Characteristic, data: NSData, @@ -323,26 +177,26 @@ internal class CBPeripheralCoreBluetoothPeripheral( detail(data, Write) } - val platformCharacteristic = discoveredServices.obtain(characteristic, writeType.properties) + val platformCharacteristic = servicesOrThrow().obtain(characteristic, writeType.properties) when (writeType) { - WithResponse -> connection.execute { - centralManager.write(cbPeripheral, data, platformCharacteristic, CBCharacteristicWriteWithResponse) + WithResponse -> connectionOrThrow().execute { + peripheral.writeValue(data, platformCharacteristic, CBWithResponse) } - WithoutResponse -> connection.guard.withLock { - if (!canSendWriteWithoutResponse.updateAndGet { cbPeripheral.canSendWriteWithoutResponse }) { + WithoutResponse -> connectionOrThrow().guard.withLock { + if (!canSendWriteWithoutResponse.updateAndGet { peripheral.canSendWriteWithoutResponse }) { canSendWriteWithoutResponse.first { it } } - centralManager.write(cbPeripheral, data, platformCharacteristic, CBCharacteristicWriteWithoutResponse) + central.writeValue(peripheral, data, platformCharacteristic, CBWithoutResponse) } } } - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) override suspend fun read( characteristic: Characteristic, ): ByteArray = readAsNSData(characteristic).toByteArray() - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) override suspend fun readAsNSData( characteristic: Characteristic, ): NSData { @@ -351,23 +205,23 @@ internal class CBPeripheralCoreBluetoothPeripheral( detail(characteristic) } - val platformCharacteristic = discoveredServices.obtain(characteristic, Read) + val platformCharacteristic = servicesOrThrow().obtain(characteristic, Read) - val event = connection.guard.withLock { + val event = connectionOrThrow().guard.withLock { observers .characteristicChanges - .onSubscription { centralManager.read(cbPeripheral, platformCharacteristic) } + .onSubscription { central.readValue(peripheral, platformCharacteristic) } .first { event -> event.isAssociatedWith(characteristic) } } return when (event) { is ObservationEvent.CharacteristicChange -> event.data is ObservationEvent.Error -> throw IOException(cause = event.cause) - ObservationEvent.Disconnected -> throw ConnectionLostException() + ObservationEvent.Disconnected -> throw NotConnectedException() } } - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) override suspend fun write( descriptor: Descriptor, data: ByteArray, @@ -384,18 +238,18 @@ internal class CBPeripheralCoreBluetoothPeripheral( detail(data, Write) } - val platformDescriptor = discoveredServices.obtain(descriptor) - connection.execute { - centralManager.write(cbPeripheral, data, platformDescriptor) + val platformDescriptor = servicesOrThrow().obtain(descriptor) + connectionOrThrow().execute { + writeValue(data, platformDescriptor) } } - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) override suspend fun read( descriptor: Descriptor, ): ByteArray = readAsNSData(descriptor).toByteArray() - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) override suspend fun readAsNSData( descriptor: Descriptor, ): NSData { @@ -404,9 +258,9 @@ internal class CBPeripheralCoreBluetoothPeripheral( detail(descriptor) } - val platformDescriptor = discoveredServices.obtain(descriptor) - val updatedDescriptor = connection.execute { - centralManager.read(cbPeripheral, platformDescriptor) + val platformDescriptor = servicesOrThrow().obtain(descriptor) + val updatedDescriptor = connectionOrThrow().execute { + readValueForDescriptor(platformDescriptor) }.descriptor return when (val value = updatedDescriptor.value) { @@ -457,9 +311,9 @@ internal class CBPeripheralCoreBluetoothPeripheral( detail(characteristic) } - val platformCharacteristic = discoveredServices.obtain(characteristic, Notify or Indicate) - connection.execute { - centralManager.notify(cbPeripheral, platformCharacteristic) + val platformCharacteristic = servicesOrThrow().obtain(characteristic, Notify or Indicate) + connectionOrThrow().execute { + setNotifyValue(true, platformCharacteristic) } } @@ -469,48 +323,36 @@ internal class CBPeripheralCoreBluetoothPeripheral( detail(characteristic) } - val platformCharacteristic = discoveredServices.obtain(characteristic, Notify or Indicate) - connection.execute { - centralManager.cancelNotify(cbPeripheral, platformCharacteristic) + val platformCharacteristic = servicesOrThrow().obtain(characteristic, Notify or Indicate) + connectionOrThrow().execute { + setNotifyValue(false, platformCharacteristic) } } - override fun toString(): String = "Peripheral(cbPeripheral=$cbPeripheral)" -} + private fun onStateChanged(action: (State) -> Unit) { + central.delegate + .connectionEvents + .filter { event -> event.identifier == peripheral.identifier } + .map(ConnectionEvent::toState) + .onEach(action) + .launchIn(this) + } -private fun ConnectionEvent.toState(): State = when (this) { - is DidConnect -> State.Connecting.Services - is DidFailToConnect -> State.Disconnected(error?.toStatus()) - is DidDisconnect -> State.Disconnected(error?.toStatus()) -} + private fun onBluetoothPoweredOff(action: suspend (CBManagerState) -> Unit) { + central.delegate + .state.filter { state -> state != CBManagerStatePoweredOn } + .onEach(action) + .launchIn(this) + } -private fun NSError.toStatus(): State.Disconnected.Status = when (code) { - CBErrorPeripheralDisconnected -> PeripheralDisconnected - CBErrorConnectionFailed -> Failed - CBErrorConnectionTimeout -> Timeout - CBErrorUnknownDevice -> UnknownDevice - CBErrorOperationCancelled -> Cancelled - CBErrorConnectionLimitReached -> ConnectionLimitReached - CBErrorEncryptionTimedOut -> EncryptionTimedOut - else -> Unknown(code.toInt()) -} + private fun createPeripheralDelegate() = PeripheralDelegate( + canSendWriteWithoutResponse, + observers.characteristicChanges, + logging, + peripheral.identifier.UUIDString, + ) -private fun CentralManager.checkBluetoothState(expected: CBManagerState) { - val actual = delegate.state.value - if (expected != actual) { - fun nameFor(value: Number) = when (value) { - CBManagerStatePoweredOff -> "PoweredOff" - CBManagerStatePoweredOn -> "PoweredOn" - CBManagerStateResetting -> "Resetting" - CBManagerStateUnauthorized -> "Unauthorized" - CBManagerStateUnknown -> "Unknown" - CBManagerStateUnsupported -> "Unsupported" - else -> "Unknown" - } - val actualName = nameFor(actual) - val expectedName = nameFor(expected) - throw BluetoothDisabledException("Bluetooth state is $actualName ($actual), but $expectedName ($expected) was required.") - } + override fun toString(): String = "Peripheral(delegate=$peripheral)" } private val CBDescriptor.isUnsignedShortValue: Boolean diff --git a/kable-core/src/appleMain/kotlin/CentralManager.kt b/kable-core/src/appleMain/kotlin/CentralManager.kt index 858f6d2c7..75b4cb889 100644 --- a/kable-core/src/appleMain/kotlin/CentralManager.kt +++ b/kable-core/src/appleMain/kotlin/CentralManager.kt @@ -3,17 +3,15 @@ package com.juul.kable import com.benasher44.uuid.Uuid import com.juul.kable.logs.Logging import kotlinx.atomicfu.atomic -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import platform.CoreBluetooth.CBCentralManager import platform.CoreBluetooth.CBCharacteristic import platform.CoreBluetooth.CBCharacteristicWriteType -import platform.CoreBluetooth.CBDescriptor import platform.CoreBluetooth.CBPeripheral -import platform.CoreBluetooth.CBService -import platform.CoreBluetooth.CBUUID import platform.Foundation.NSData +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration private const val DISPATCH_QUEUE_LABEL = "central" @@ -26,7 +24,9 @@ public class CentralManager internal constructor( ) { public class Builder internal constructor() { - /** Enables support for + + /** + * Enables support for * [Core Bluetooth Background Processing for iOS Apps](https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/CoreBluetoothBackgroundProcessingForIOSApps/PerformingTasksWhileYourAppIsInTheBackground.html) * by enabling [CentralManager] state preservation and restoration. */ public var stateRestoration: Boolean = false @@ -45,16 +45,16 @@ public class CentralManager internal constructor( } } - private val dispatcher = QueueDispatcher(DISPATCH_QUEUE_LABEL) + internal val dispatcher = QueueDispatcher(DISPATCH_QUEUE_LABEL) internal val delegate = CentralManagerDelegate() - private val cbCentralManager = CBCentralManager(delegate, dispatcher.dispatchQueue, options) + private val centralManager = CBCentralManager(delegate, dispatcher.dispatchQueue, options) internal suspend fun scanForPeripheralsWithServices( services: List?, options: Map?, ) { withContext(dispatcher) { - cbCentralManager.scanForPeripheralsWithServices( + centralManager.scanForPeripheralsWithServices( serviceUUIDs = services?.map { it.toCBUUID() }, options = options, ) @@ -64,76 +64,51 @@ public class CentralManager internal constructor( internal fun stopScan() { // Check scanning state to prevent API misuse warning. // https://github.com/JuulLabs/kable/issues/81 - if (cbCentralManager.isScanning) cbCentralManager.stopScan() + if (centralManager.isScanning) centralManager.stopScan() } internal fun retrievePeripheral(withIdentifier: Uuid): CBPeripheral? = - cbCentralManager + centralManager .retrievePeripheralsWithIdentifiers(listOf(withIdentifier.toNSUUID())) .firstOrNull() as? CBPeripheral internal suspend fun connectPeripheral( - scope: CoroutineScope, - peripheral: CBPeripheralCoreBluetoothPeripheral, - characteristicChanges: MutableSharedFlow>, + coroutineContext: CoroutineContext, + peripheral: CBPeripheral, + delegate: PeripheralDelegate, + state: MutableStateFlow, + services: MutableStateFlow?>, + disconnectTimeout: Duration, logging: Logging, options: Map? = null, ): Connection { - val cbPeripheral = peripheral.cbPeripheral - val identifier = cbPeripheral.identifier.UUIDString - val delegate = PeripheralDelegate(peripheral.canSendWriteWithoutResponse, characteristicChanges, logging, identifier) withContext(dispatcher) { - cbPeripheral.delegate = delegate - cbCentralManager.connectPeripheral(cbPeripheral, options) + peripheral.delegate = delegate + centralManager.connectPeripheral(peripheral, options) } - return Connection(scope, delegate, logging, identifier) + return Connection( + coroutineContext, + this, + peripheral, + delegate, + state, + services, + disconnectTimeout, + delegate.identifier, + logging, + ) } internal suspend fun cancelPeripheralConnection( cbPeripheral: CBPeripheral, ) { withContext(dispatcher) { - cbCentralManager.cancelPeripheralConnection(cbPeripheral) + centralManager.cancelPeripheralConnection(cbPeripheral) cbPeripheral.delegate = null } } - internal suspend fun readRssi( - cbPeripheral: CBPeripheral, - ) { - withContext(dispatcher) { - cbPeripheral.readRSSI() - } - } - - internal suspend fun discoverServices( - cbPeripheral: CBPeripheral, - services: List?, - ) { - withContext(dispatcher) { - cbPeripheral.discoverServices(services) - } - } - - internal suspend fun discoverCharacteristics( - cbPeripheral: CBPeripheral, - cbService: CBService, - ) { - withContext(dispatcher) { - cbPeripheral.discoverCharacteristics(null, cbService) - } - } - - internal suspend fun discoverDescriptors( - cbPeripheral: CBPeripheral, - cbCharacteristic: CBCharacteristic, - ) { - withContext(dispatcher) { - cbPeripheral.discoverDescriptorsForCharacteristic(cbCharacteristic) - } - } - - internal suspend fun write( + internal suspend fun writeValue( cbPeripheral: CBPeripheral, data: NSData, cbCharacteristic: CBCharacteristic, @@ -144,7 +119,7 @@ public class CentralManager internal constructor( } } - internal suspend fun read( + internal suspend fun readValue( cbPeripheral: CBPeripheral, cbCharacteristic: CBCharacteristic, ) { @@ -152,41 +127,4 @@ public class CentralManager internal constructor( cbPeripheral.readValueForCharacteristic(cbCharacteristic) } } - - internal suspend fun write( - cbPeripheral: CBPeripheral, - data: NSData, - cbDescriptor: CBDescriptor, - ) { - withContext(dispatcher) { - cbPeripheral.writeValue(data, cbDescriptor) - } - } - - internal suspend fun read( - cbPeripheral: CBPeripheral, - cbDescriptor: CBDescriptor, - ) { - withContext(dispatcher) { - cbPeripheral.readValueForDescriptor(cbDescriptor) - } - } - - internal suspend fun notify( - cbPeripheral: CBPeripheral, - cbCharacteristic: CBCharacteristic, - ) { - withContext(dispatcher) { - cbPeripheral.setNotifyValue(true, cbCharacteristic) - } - } - - internal suspend fun cancelNotify( - cbPeripheral: CBPeripheral, - cbCharacteristic: CBCharacteristic, - ) { - withContext(dispatcher) { - cbPeripheral.setNotifyValue(false, cbCharacteristic) - } - } } diff --git a/kable-core/src/appleMain/kotlin/CentralManagerDelegate.kt b/kable-core/src/appleMain/kotlin/CentralManagerDelegate.kt index 1930f31e7..c6ee7a293 100644 --- a/kable-core/src/appleMain/kotlin/CentralManagerDelegate.kt +++ b/kable-core/src/appleMain/kotlin/CentralManagerDelegate.kt @@ -24,9 +24,6 @@ import platform.darwin.NSObject // https://developer.apple.com/documentation/corebluetooth/cbcentralmanagerdelegate internal class CentralManagerDelegate : NSObject(), CBCentralManagerDelegateProtocol { - private val _onDisconnected = MutableSharedFlow() - internal val onDisconnected = _onDisconnected.asSharedFlow() - private val _state = MutableStateFlow(CBManagerStateUnknown) val state: StateFlow = _state.asStateFlow() @@ -61,12 +58,10 @@ internal class CentralManagerDelegate : NSObject(), CBCentralManagerDelegateProt ) : ConnectionEvent() } - // `SharedFlow` (instead of `StateFlow`) as downstream needs non-distinct items, as it feeds individual `Peripheral` - // states. If, for example, this flow emits `Disconnected` then downstream `Peripheral` connects and updates its own - // state to `Connected`, this flow may still hold `Disconnected` but we'll need to emit another `Disconnected` to - // update the `Peripheral` state with. - private val _connectionState = MutableSharedFlow() - val connectionState: Flow = _connectionState.asSharedFlow() + // `SharedFlow` (instead of `StateFlow`) for non-conflated behavior, as this flow feeds + // individual downstream `Peripheral`s. + private val _connectionEvents = MutableSharedFlow() + val connectionEvents: Flow = _connectionEvents.asSharedFlow() /* Monitoring Connections with Peripherals */ @@ -74,7 +69,7 @@ internal class CentralManagerDelegate : NSObject(), CBCentralManagerDelegateProt central: CBCentralManager, didConnectPeripheral: CBPeripheral, ) { - _connectionState.emitBlocking(DidConnect(didConnectPeripheral.identifier)) + _connectionEvents.emitBlocking(DidConnect(didConnectPeripheral.identifier)) } @ObjCSignatureOverride @@ -83,8 +78,7 @@ internal class CentralManagerDelegate : NSObject(), CBCentralManagerDelegateProt didDisconnectPeripheral: CBPeripheral, error: NSError?, ) { - _onDisconnected.emitBlocking(didDisconnectPeripheral.identifier) // Used to notify `Peripheral` of disconnect. - _connectionState.emitBlocking(DidDisconnect(didDisconnectPeripheral.identifier, error)) + _connectionEvents.emitBlocking(DidDisconnect(didDisconnectPeripheral.identifier, error)) } @ObjCSignatureOverride @@ -93,7 +87,7 @@ internal class CentralManagerDelegate : NSObject(), CBCentralManagerDelegateProt didFailToConnectPeripheral: CBPeripheral, error: NSError?, ) { - _connectionState.emitBlocking(DidFailToConnect(didFailToConnectPeripheral.identifier, error)) + _connectionEvents.emitBlocking(DidFailToConnect(didFailToConnectPeripheral.identifier, error)) } // todo: func centralManager(CBCentralManager, connectionEventDidOccur: CBConnectionEvent, for: CBPeripheral) diff --git a/kable-core/src/appleMain/kotlin/Connection.kt b/kable-core/src/appleMain/kotlin/Connection.kt index e9dba561c..c420d6435 100644 --- a/kable-core/src/appleMain/kotlin/Connection.kt +++ b/kable-core/src/appleMain/kotlin/Connection.kt @@ -1,67 +1,231 @@ package com.juul.kable +import com.benasher44.uuid.Uuid +import com.juul.kable.PeripheralDelegate.Response +import com.juul.kable.PeripheralDelegate.Response.DidDiscoverCharacteristicsForService +import com.juul.kable.PeripheralDelegate.Response.DidDiscoverDescriptorsForCharacteristic +import com.juul.kable.PeripheralDelegate.Response.DidDiscoverServices +import com.juul.kable.State.Disconnected +import com.juul.kable.coroutines.childSupervisor import com.juul.kable.logs.Logger import com.juul.kable.logs.Logging +import kotlinx.coroutines.CompletableJob +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred +import kotlinx.coroutines.CoroutineStart.ATOMIC +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.job +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import kotlinx.io.IOException +import platform.CoreBluetooth.CBCharacteristic +import platform.CoreBluetooth.CBPeripheral +import platform.CoreBluetooth.CBService +import platform.CoreBluetooth.CBUUID +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.coroutineContext +import kotlin.reflect.KClass +import kotlin.time.Duration internal class Connection( - private val scope: CoroutineScope, - val delegate: PeripheralDelegate, - logging: Logging, + parentContext: CoroutineContext, + private val central: CentralManager, + private val peripheral: CBPeripheral, + private val delegate: PeripheralDelegate, + private val state: MutableStateFlow, + private val services: MutableStateFlow?>, + private val disconnectTimeout: Duration, identifier: String, + logging: Logging, ) { + private val name = "Kable/Connection/$identifier" + + private val connectionJob = (parentContext.job as CompletableJob).apply { + invokeOnCompletion(::close) + } + private val connectionScope = CoroutineScope( + parentContext + connectionJob + CoroutineName(name), + ) + + val taskScope = connectionScope.childSupervisor("$name/Tasks") + + private val logger = Logger(logging, tag = "Kable/Connection", identifier = identifier) + init { - scope.coroutineContext.job.invokeOnCompletion { - delegate.close() + onDispose(::disconnect) + + on { + val state = it.toString() + logger.debug { + message = "Disconnect detected" + detail("state", state) + } + dispose(NotConnectedException("Disconnect detected")) } } - private val logger = Logger(logging, tag = "Kable/Connection", identifier = identifier) + private val dispatcher = connectionScope.coroutineContext + central.dispatcher + internal val guard = Mutex() + + suspend fun discoverServices(): Unit = discoverServices(serviceUuids = null) - private var deferredResponse: Deferred? = null - val guard = Mutex() - - suspend inline fun execute( - action: () -> Unit, - ): T = guard.withLock { - deferredResponse?.let { - if (it.isActive) { - // Discard response as we've performed another `execute` without the previous finishing. This happens if - // a previous `execute` was cancelled after invoking GATT action, but before receiving response from - // callback channel. See the following issues for more details: - // https://github.com/JuulLabs/kable/issues/326 - // https://github.com/JuulLabs/kable/issues/450 - val response = it.await() - logger.warn { - message = "Discarded response" - detail("response", response.toString()) + /** @param serviceUuids to discover (list of service UUIDs), or `null` for all. */ + suspend fun discoverServices(serviceUuids: List?) { + logger.verbose { message = "discoverServices" } + val cbUuids = serviceUuids?.map { uuid -> CBUUID.UUIDWithNSUUID(uuid.toNSUUID()) } + + execute { + peripheral.discoverServices(cbUuids) + } + + // Cast should be safe since `CBPeripheral.services` type is `[CBService]?`, according to: + // https://developer.apple.com/documentation/corebluetooth/cbperipheral/services + @Suppress("UNCHECKED_CAST") + val discoveredServices = peripheral.services as List? + + discoveredServices?.forEach { service -> + execute { + peripheral.discoverCharacteristics(null, service) + } + + // Cast should be safe since `CBService.characteristics` type is `[CBCharacteristic]?`, + // according to: https://developer.apple.com/documentation/corebluetooth/cbservice/characteristics + @Suppress("UNCHECKED_CAST") + val discoveredCharacteristics = service.characteristics as List? + + discoveredCharacteristics?.forEach { characteristic -> + execute { + peripheral.discoverDescriptorsForCharacteristic(characteristic) } } } - action.invoke() - val deferred = scope.async { delegate.response.receive() } - deferredResponse = deferred + services.value = peripheral.services + .orEmpty() + .map { it as PlatformService } + .map(::DiscoveredService) + } + + suspend inline fun execute( + noinline action: CBPeripheral.() -> Unit, + ): T = execute(T::class, action) + + suspend fun execute( + type: KClass, + action: CBPeripheral.() -> Unit, + ): T { + val response = guard.withLock { + var executed = false + try { + withContext(dispatcher) { + peripheral.action() + executed = true + } + } catch (e: CancellationException) { + if (executed) { + // Ensure response buffer is received even when calling context is cancelled. + // UNDISPATCHED to ensure we're within the `lock` for the `receive`. + connectionScope.launch(start = UNDISPATCHED) { + val response = delegate.response.receive() + logger.debug { + message = "Discarded response to cancelled request" + detail("response", response.toString()) + } + } + } + coroutineContext.ensureActive() + throw e.unwrapCancellationException() + } + + try { + connectionScope.async { + delegate.response.receive() + }.await() + } catch (e: CancellationException) { + coroutineContext.ensureActive() + throw e.unwrapCancellationException() + } + }.also(::checkResponse) + + // `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( + "Expected response type ${type.simpleName} but received ${response::class.simpleName}", + ) + } + + private suspend fun disconnect() { + if (state.value is Disconnected) return + + withContext(NonCancellable) { + try { + withTimeout(disconnectTimeout) { + logger.verbose { message = "Waiting for connection tasks to complete" } + taskScope.coroutineContext.job.join() + + logger.debug { message = "Disconnecting" } + cancelPeripheralConnection() - val response = try { - deferred.await() - } catch (e: Exception) { - when (val unwrapped = e.unwrapCancellationCause()) { - is ConnectionLostException -> throw ConnectionLostException(cause = unwrapped) - else -> throw unwrapped + state.filterIsInstance().first() + } + logger.info { message = "Disconnected" } + } catch (e: TimeoutCancellationException) { + logger.warn { message = "Timed out after $disconnectTimeout waiting for disconnect" } + } finally { + cancelPeripheralConnection() } } - deferredResponse = null + } - val error = response.error - if (error != null) throw IOException(error.description, cause = null) - response as T + private var didCancelPeripheralConnection = false + private suspend fun cancelPeripheralConnection() { + if (didCancelPeripheralConnection) return + logger.verbose { message = "cancelPeripheralConnection" } + central.cancelPeripheralConnection(peripheral) + didCancelPeripheralConnection = true } + + private fun close(cause: Throwable?) { + logger.debug(cause) { message = "Closing" } + delegate.close(cause) + logger.info { message = "Closed" } + } + + private inline fun on(crossinline action: suspend (T) -> Unit) { + taskScope.launch { + action(state.filterIsInstance().first()) + } + } + + private fun onDispose(action: suspend () -> Unit) { + @Suppress("OPT_IN_USAGE") + connectionScope.launch(start = ATOMIC) { + try { + awaitCancellation() + } finally { + action() + } + } + } + + private fun dispose(cause: Throwable) = connectionJob.completeExceptionally(cause) +} + +private fun checkResponse(response: Response) { + val error = response.error + if (error != null) throw IOException(error.description, cause = null) } diff --git a/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt b/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt index a5775847a..8ce8f3c2f 100644 --- a/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt +++ b/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt @@ -10,10 +10,10 @@ public interface CoreBluetoothPeripheral : Peripheral { @Throws(CancellationException::class, IOException::class) public suspend fun write(descriptor: Descriptor, data: NSData) - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) public suspend fun write(characteristic: Characteristic, data: NSData, writeType: WriteType) - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) public suspend fun readAsNSData(descriptor: Descriptor): NSData public fun observeAsNSData( @@ -21,6 +21,6 @@ public interface CoreBluetoothPeripheral : Peripheral { onSubscription: OnSubscriptionAction = {}, ): Flow - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) public suspend fun readAsNSData(characteristic: Characteristic): NSData } diff --git a/kable-core/src/appleMain/kotlin/Peripheral.deprecated.kt b/kable-core/src/appleMain/kotlin/Peripheral.deprecated.kt new file mode 100644 index 000000000..b2cd8fbb7 --- /dev/null +++ b/kable-core/src/appleMain/kotlin/Peripheral.deprecated.kt @@ -0,0 +1,31 @@ +package com.juul.kable + +import kotlinx.coroutines.CoroutineScope +import platform.CoreBluetooth.CBPeripheral + +@Deprecated( + message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", + replaceWith = ReplaceWith("Peripheral(cbPeripheral, builderAction)"), +) +public actual fun CoroutineScope.peripheral( + advertisement: Advertisement, + builderAction: PeripheralBuilderAction, +): Peripheral = Peripheral(advertisement, builderAction) + +@Deprecated( + message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", + replaceWith = ReplaceWith("Peripheral(cbPeripheral, builderAction)"), +) +public fun CoroutineScope.peripheral( + identifier: Identifier, + builderAction: PeripheralBuilderAction = {}, +): Peripheral = Peripheral(identifier, builderAction) + +@Deprecated( + message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", + replaceWith = ReplaceWith("Peripheral(cbPeripheral, builderAction)"), +) +public fun CoroutineScope.peripheral( + cbPeripheral: CBPeripheral, + builderAction: PeripheralBuilderAction, +): CoreBluetoothPeripheral = Peripheral(cbPeripheral, builderAction) diff --git a/kable-core/src/appleMain/kotlin/Peripheral.kt b/kable-core/src/appleMain/kotlin/Peripheral.kt index c22bb7a1b..6b21439fa 100644 --- a/kable-core/src/appleMain/kotlin/Peripheral.kt +++ b/kable-core/src/appleMain/kotlin/Peripheral.kt @@ -1,36 +1,36 @@ package com.juul.kable -import kotlinx.coroutines.CoroutineScope import platform.CoreBluetooth.CBPeripheral -public actual fun CoroutineScope.peripheral( +public actual fun Peripheral( advertisement: Advertisement, builderAction: PeripheralBuilderAction, ): Peripheral { advertisement as CBPeripheralCoreBluetoothAdvertisement - return peripheral(advertisement.cbPeripheral, builderAction) + return Peripheral(advertisement.cbPeripheral, builderAction) } -public fun CoroutineScope.peripheral( +@Suppress("FunctionName") // Builder function. +public fun Peripheral( identifier: Identifier, builderAction: PeripheralBuilderAction = {}, -): Peripheral { +): CoreBluetoothPeripheral { val cbPeripheral = CentralManager.Default.retrievePeripheral(identifier) ?: throw NoSuchElementException("Peripheral with UUID $identifier not found") - return peripheral(cbPeripheral, builderAction) + return Peripheral(cbPeripheral, builderAction) } -public fun CoroutineScope.peripheral( +@Suppress("FunctionName") // Builder function. +public fun Peripheral( cbPeripheral: CBPeripheral, builderAction: PeripheralBuilderAction, ): CoreBluetoothPeripheral { - val builder = PeripheralBuilder() - builder.builderAction() + val builder = PeripheralBuilder().apply(builderAction) return CBPeripheralCoreBluetoothPeripheral( - coroutineContext, cbPeripheral, builder.observationExceptionHandler, builder.onServicesDiscovered, builder.logging, + builder.disconnectTimeout, ) } diff --git a/kable-core/src/appleMain/kotlin/PeripheralBuilder.kt b/kable-core/src/appleMain/kotlin/PeripheralBuilder.kt index 76246cc3f..31760d472 100644 --- a/kable-core/src/appleMain/kotlin/PeripheralBuilder.kt +++ b/kable-core/src/appleMain/kotlin/PeripheralBuilder.kt @@ -4,22 +4,23 @@ import com.juul.kable.logs.Logging import com.juul.kable.logs.LoggingBuilder import kotlinx.io.IOException import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration public actual class ServicesDiscoveredPeripheral internal constructor( private val peripheral: CoreBluetoothPeripheral, ) { - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class, NotConnectedException::class) public actual suspend fun read( characteristic: Characteristic, ): ByteArray = peripheral.read(characteristic) - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class, NotConnectedException::class) public actual suspend fun read( descriptor: Descriptor, ): ByteArray = peripheral.read(descriptor) - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class, NotConnectedException::class) public actual suspend fun write( characteristic: Characteristic, data: ByteArray, @@ -28,7 +29,7 @@ public actual class ServicesDiscoveredPeripheral internal constructor( peripheral.write(characteristic, data, writeType) } - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class, NotConnectedException::class) public actual suspend fun write( descriptor: Descriptor, data: ByteArray, @@ -55,4 +56,6 @@ public actual class PeripheralBuilder internal actual constructor() { public actual fun observationExceptionHandler(handler: ObservationExceptionHandler) { observationExceptionHandler = handler } + + public actual var disconnectTimeout: Duration = defaultDisconnectTimeout } diff --git a/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt b/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt index 5b583816d..a66a6fe09 100644 --- a/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt +++ b/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt @@ -34,7 +34,7 @@ internal class PeripheralDelegate( private val canSendWriteWithoutResponse: MutableStateFlow, private val characteristicChanges: MutableSharedFlow>, logging: Logging, - identifier: String, + internal val identifier: String, ) : NSObject(), CBPeripheralDelegateProtocol { sealed class Response { @@ -307,8 +307,8 @@ internal class PeripheralDelegate( // todo } - fun close() { - _response.close(ConnectionLostException()) + fun close(cause: Throwable?) { + _response.close(NotConnectedException(cause = cause)) characteristicChanges.emitBlocking(ObservationEvent.Disconnected) } } diff --git a/kable-core/src/appleMain/kotlin/State.kt b/kable-core/src/appleMain/kotlin/State.kt new file mode 100644 index 000000000..d427b30bc --- /dev/null +++ b/kable-core/src/appleMain/kotlin/State.kt @@ -0,0 +1,12 @@ +package com.juul.kable + +import com.juul.kable.CentralManagerDelegate.ConnectionEvent +import com.juul.kable.CentralManagerDelegate.ConnectionEvent.DidConnect +import com.juul.kable.CentralManagerDelegate.ConnectionEvent.DidDisconnect +import com.juul.kable.CentralManagerDelegate.ConnectionEvent.DidFailToConnect + +internal fun ConnectionEvent.toState(): State = when (this) { + is DidConnect -> State.Connecting.Services + is DidFailToConnect -> State.Disconnected(error?.toStatus()) + is DidDisconnect -> State.Disconnected(error?.toStatus()) +} diff --git a/kable-core/src/appleMain/kotlin/Status.kt b/kable-core/src/appleMain/kotlin/Status.kt new file mode 100644 index 000000000..367649b9f --- /dev/null +++ b/kable-core/src/appleMain/kotlin/Status.kt @@ -0,0 +1,29 @@ +package com.juul.kable + +import com.juul.kable.State.Disconnected.Status.Cancelled +import com.juul.kable.State.Disconnected.Status.ConnectionLimitReached +import com.juul.kable.State.Disconnected.Status.EncryptionTimedOut +import com.juul.kable.State.Disconnected.Status.Failed +import com.juul.kable.State.Disconnected.Status.PeripheralDisconnected +import com.juul.kable.State.Disconnected.Status.Timeout +import com.juul.kable.State.Disconnected.Status.Unknown +import com.juul.kable.State.Disconnected.Status.UnknownDevice +import platform.CoreBluetooth.CBErrorConnectionFailed +import platform.CoreBluetooth.CBErrorConnectionLimitReached +import platform.CoreBluetooth.CBErrorConnectionTimeout +import platform.CoreBluetooth.CBErrorEncryptionTimedOut +import platform.CoreBluetooth.CBErrorOperationCancelled +import platform.CoreBluetooth.CBErrorPeripheralDisconnected +import platform.CoreBluetooth.CBErrorUnknownDevice +import platform.Foundation.NSError + +internal fun NSError.toStatus(): State.Disconnected.Status = when (code) { + CBErrorPeripheralDisconnected -> PeripheralDisconnected + CBErrorConnectionFailed -> Failed + CBErrorConnectionTimeout -> Timeout + CBErrorUnknownDevice -> UnknownDevice + CBErrorOperationCancelled -> Cancelled + CBErrorConnectionLimitReached -> ConnectionLimitReached + CBErrorEncryptionTimedOut -> EncryptionTimedOut + else -> Unknown(code.toInt()) +} diff --git a/kable-core/src/appleMain/kotlin/bluetooth/CheckBluetoothIsOn.kt b/kable-core/src/appleMain/kotlin/bluetooth/CheckBluetoothIsOn.kt new file mode 100644 index 000000000..5354fc4f1 --- /dev/null +++ b/kable-core/src/appleMain/kotlin/bluetooth/CheckBluetoothIsOn.kt @@ -0,0 +1,37 @@ +package com.juul.kable.bluetooth + +import com.juul.kable.CentralManager +import com.juul.kable.InternalException +import com.juul.kable.UnmetRequirementException +import com.juul.kable.UnmetRequirementReason.BluetoothDisabled +import platform.CoreBluetooth.CBManagerState +import platform.CoreBluetooth.CBManagerStatePoweredOff +import platform.CoreBluetooth.CBManagerStatePoweredOn +import platform.CoreBluetooth.CBManagerStateResetting +import platform.CoreBluetooth.CBManagerStateUnauthorized +import platform.CoreBluetooth.CBManagerStateUnknown +import platform.CoreBluetooth.CBManagerStateUnsupported + +/** + * @throws UnmetRequirementException If [CentralManager] state is not [CBManagerStatePoweredOn]. + */ +internal fun CentralManager.checkBluetoothIsOn() { + val actual = delegate.state.value + val expected = CBManagerStatePoweredOn + if (actual != expected) { + throw UnmetRequirementException( + reason = BluetoothDisabled, + message = "Bluetooth was ${nameFor(actual)}, but ${nameFor(expected)} was required", + ) + } +} + +private fun nameFor(state: CBManagerState) = when (state) { + CBManagerStatePoweredOff -> "PoweredOff" + CBManagerStatePoweredOn -> "PoweredOn" + CBManagerStateResetting -> "Resetting" + CBManagerStateUnauthorized -> "Unauthorized" + CBManagerStateUnknown -> "Unknown" + CBManagerStateUnsupported -> "Unsupported" + else -> throw InternalException("Unsupported bluetooth state: $state") +} diff --git a/kable-core/src/commonMain/kotlin/Advertisement.kt b/kable-core/src/commonMain/kotlin/Advertisement.kt index 929ad48bd..8374b6d0b 100644 --- a/kable-core/src/commonMain/kotlin/Advertisement.kt +++ b/kable-core/src/commonMain/kotlin/Advertisement.kt @@ -27,15 +27,15 @@ public interface Advertisement { * to "restore" a previously known peripheral for reconnection. * * On Android, this is a MAC address represented as a [String]. A [Peripheral] can be created - * from this MAC address using the `CoroutineScope.peripheral(String, PeripheralBuilderAction)` + * from this MAC address using the `Peripheral(String, PeripheralBuilderAction)` builder * function unless the peripheral makes "use of a Bluetooth Smart feature known as 'LE Privacy'" * (whereas the peripheral may provide a random MAC address, see * [Bluetooth Technology Protecting Your Privacy](https://www.bluetooth.com/blog/bluetooth-technology-protecting-your-privacy/) * for more details)). * * On Apple, this is a unique identifier represented as a [Uuid]. A [Peripheral] can be created - * from this identifier using the `CoroutineScope.peripheral(Uuid, PeripheralBuilderAction)` - * function. According to + * from this identifier using the `Peripheral(Uuid, PeripheralBuilderAction)` builder function + * According to * [The Ultimate Guide to Apple’s Core Bluetooth](https://punchthrough.com/core-bluetooth-basics/): * * > This UUID isn't guaranteed to stay the same across scanning sessions and should not be 100% @@ -43,9 +43,8 @@ public interface Advertisement { * > relatively stable and reliable over the long term assuming a major device settings reset * > has not occurred. * - * If `CoroutineScope.peripheral(Uuid, PeripheralBuilderAction)` throws a - * [NoSuchElementException] then a scan will be necessary to obtain an [Advertisement] for - * [Peripheral] creation. + * If `Peripheral(Uuid, PeripheralBuilderAction)` throws a [NoSuchElementException] then a scan + * will be necessary to obtain an [Advertisement] for [Peripheral] creation. * * On JavaScript, this is a unique identifier represented as a [String]. "Restoring" a * peripheral from this identifier is not yet supported in Kable (as JavaScript requires user to diff --git a/kable-core/src/commonMain/kotlin/BaseConnection.kt b/kable-core/src/commonMain/kotlin/BaseConnection.kt new file mode 100644 index 000000000..62aac0d27 --- /dev/null +++ b/kable-core/src/commonMain/kotlin/BaseConnection.kt @@ -0,0 +1,16 @@ +package com.juul.kable + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.job +import kotlin.coroutines.CoroutineContext + +internal abstract class BaseConnection( + parentContext: CoroutineContext, + name: String, +) : CoroutineScope { + + val job = Job(parentContext.job) + override val coroutineContext = parentContext + job + CoroutineName(name) +} diff --git a/kable-core/src/commonMain/kotlin/BasePeripheral.kt b/kable-core/src/commonMain/kotlin/BasePeripheral.kt new file mode 100644 index 000000000..10b5860a9 --- /dev/null +++ b/kable-core/src/commonMain/kotlin/BasePeripheral.kt @@ -0,0 +1,9 @@ +package com.juul.kable + +import kotlinx.coroutines.CoroutineName + +internal abstract class BasePeripheral(identifier: Identifier) : Peripheral { + + override val coroutineContext = + SilentSupervisor() + CoroutineName("Kable/Peripheral/$identifier") +} diff --git a/kable-core/src/commonMain/kotlin/ExperimentalApi.kt b/kable-core/src/commonMain/kotlin/ExperimentalApi.kt new file mode 100644 index 000000000..fae9623fe --- /dev/null +++ b/kable-core/src/commonMain/kotlin/ExperimentalApi.kt @@ -0,0 +1,12 @@ +package com.juul.kable + +import kotlin.annotation.AnnotationTarget.CLASS +import kotlin.annotation.AnnotationTarget.FUNCTION +import kotlin.annotation.AnnotationTarget.PROPERTY +import kotlin.annotation.AnnotationTarget.TYPEALIAS + +/** Marks API that is experimental and/or likely to change. */ +@Target(TYPEALIAS, FUNCTION, PROPERTY, CLASS) +@Retention(AnnotationRetention.BINARY) +@RequiresOptIn(level = RequiresOptIn.Level.ERROR) +public annotation class ExperimentalApi diff --git a/kable-core/src/commonMain/kotlin/Flow.kt b/kable-core/src/commonMain/kotlin/Flow.kt deleted file mode 100644 index 517e288c0..000000000 --- a/kable-core/src/commonMain/kotlin/Flow.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.juul.kable - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch - -internal fun Flow.launchIn( - scope: CoroutineScope, - start: CoroutineStart = CoroutineStart.DEFAULT, -): Job = scope.launch(start = start) { - collect() -} diff --git a/kable-core/src/commonMain/kotlin/Observation.kt b/kable-core/src/commonMain/kotlin/Observation.kt index 7db1fede4..0cc3517cc 100644 --- a/kable-core/src/commonMain/kotlin/Observation.kt +++ b/kable-core/src/commonMain/kotlin/Observation.kt @@ -38,7 +38,7 @@ internal class Observation( subscribers += action val shouldStartObservation = !didStartObservation && subscribers.isNotEmpty() && isConnected if (shouldStartObservation) { - suppressConnectionExceptions { + suppressNotConnectedException { startObservation() action() } @@ -54,7 +54,7 @@ internal class Observation( suspend fun onConnected() = mutex.withLock { if (isConnected) { if (subscribers.isNotEmpty()) { - suppressConnectionExceptions { + suppressNotConnectedException { startObservation() subscribers.forEach { it() } } @@ -70,27 +70,25 @@ internal class Observation( } private suspend fun stopObservation() { - suppressConnectionExceptions { + suppressNotConnectedException { handler.stopObservation(characteristic) } didStartObservation = false } /** - * While spinning up or down an observation the connection may drop, resulting in an unnecessary connection related - * exception being thrown. + * While spinning up or down an observation the connection may drop, resulting in an unnecessary + * [NotConnectedException] being thrown. * - * Since it is assumed that observations are automatically cleared on disconnect, these exceptions can be ignored, - * as the corresponding [action] will be rendered unnecessary (clearing an observation is not needed if connection - * has been lost, or [action] will be re-attempted on [reconnect][onConnected]). + * Since observations are automatically cleared (by the underlying platform) on disconnect, + * these exceptions can be ignored, as the corresponding [action] will be rendered unnecessary + * (clearing an observation is not needed if connection has been lost). */ - private inline fun suppressConnectionExceptions(action: () -> Unit) { + private inline fun suppressNotConnectedException(action: () -> Unit) { try { action.invoke() } catch (e: NotConnectedException) { logger.verbose { message = "Suppressed failure: $e" } - } catch (e: BluetoothException) { - logger.verbose { message = "Suppressed failure: $e" } } } } diff --git a/kable-core/src/commonMain/kotlin/Observers.kt b/kable-core/src/commonMain/kotlin/Observers.kt index 54f352c14..03c1b4e9a 100644 --- a/kable-core/src/commonMain/kotlin/Observers.kt +++ b/kable-core/src/commonMain/kotlin/Observers.kt @@ -6,6 +6,7 @@ import com.juul.kable.logs.Logging import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.filter @@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.withContext import kotlinx.io.IOException import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.coroutineContext internal expect fun Peripheral.observationHandler(): Observation.Handler @@ -90,9 +92,8 @@ internal class Observers( withContext(NonCancellable) { observation.onCompletion(onSubscription) } - } catch (e: CancellationException) { - throw e } catch (e: Exception) { + coroutineContext.ensureActive() exceptionHandler(ObservationExceptionPeripheral(peripheral), e) } } @@ -105,9 +106,8 @@ internal class Observers( // Pipe failures to `characteristicChanges` while honoring in-flight connection cancellations. try { observation.onConnected() - } catch (cancellation: CancellationException) { - throw cancellation } catch (e: Exception) { + coroutineContext.ensureActive() throw IOException("Failed to observe characteristic during connection attempt", e) } } diff --git a/kable-core/src/commonMain/kotlin/Peripheral.deprecated.kt b/kable-core/src/commonMain/kotlin/Peripheral.deprecated.kt new file mode 100644 index 000000000..a455dd14f --- /dev/null +++ b/kable-core/src/commonMain/kotlin/Peripheral.deprecated.kt @@ -0,0 +1,12 @@ +package com.juul.kable + +import kotlinx.coroutines.CoroutineScope + +@Deprecated( + message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", + replaceWith = ReplaceWith("Peripheral(advertisement, builderAction)"), +) +public expect fun CoroutineScope.peripheral( + advertisement: Advertisement, + builderAction: PeripheralBuilderAction = {}, +): Peripheral diff --git a/kable-core/src/commonMain/kotlin/Peripheral.kt b/kable-core/src/commonMain/kotlin/Peripheral.kt index fedffe265..34a61954d 100644 --- a/kable-core/src/commonMain/kotlin/Peripheral.kt +++ b/kable-core/src/commonMain/kotlin/Peripheral.kt @@ -4,8 +4,10 @@ package com.juul.kable import com.benasher44.uuid.Uuid +import com.juul.kable.State.Disconnecting import com.juul.kable.WriteType.WithoutResponse import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first @@ -14,25 +16,20 @@ import kotlinx.io.IOException import kotlin.coroutines.cancellation.CancellationException import kotlin.jvm.JvmName -internal typealias PeripheralBuilderAction = PeripheralBuilder.() -> Unit internal typealias OnSubscriptionAction = suspend () -> Unit -public expect fun CoroutineScope.peripheral( - advertisement: Advertisement, - builderAction: PeripheralBuilderAction = {}, -): Peripheral - public enum class WriteType { WithResponse, WithoutResponse, } -public interface Peripheral { +public interface Peripheral : CoroutineScope { /** * Provides a conflated [Flow] of the [Peripheral]'s [State]. * - * After [connect] is called, the [state] will typically transition through the following [states][State]: + * After [connect] is called, the [state] will typically transition through the following + * [states][State]: * * ``` * connect() @@ -56,10 +53,13 @@ public interface Peripheral { * connection drop * : * v - * .---------------. .--------------. - * | Disconnecting | ----> | Disconnected | - * '---------------' '--------------' + * .---------------. .--------------. + * | Disconnecting | ---> | Disconnected | + * '---------------' '--------------' * ``` + * + * Note that [Disconnecting] state is skipped on Apple and JavaScript when connection closure is + * initiated by peripheral (or peripheral goes out-of-range). */ public val state: StateFlow @@ -67,16 +67,20 @@ public interface Peripheral { * Platform specific identifier for the remote peripheral. On some platforms, this can be used * to "restore" a previously known peripheral for reconnection. * + * ## Android + * * On Android, this is a MAC address represented as a [String]. A [Peripheral] can be created - * from this MAC address using the `CoroutineScope.peripheral(String, PeripheralBuilderAction)` + * from this MAC address using the `Peripheral(String, PeripheralBuilderAction)` builder * function unless the peripheral makes "use of a Bluetooth Smart feature known as 'LE Privacy'" * (whereas the peripheral may provide a random MAC address, see * [Bluetooth Technology Protecting Your Privacy](https://www.bluetooth.com/blog/bluetooth-technology-protecting-your-privacy/) * for more details)). * + * ## Apple + * * On Apple, this is a unique identifier represented as a [Uuid]. A [Peripheral] can be created - * from this identifier using the `CoroutineScope.peripheral(Uuid, PeripheralBuilderAction)` - * function. According to + * from this identifier using the `Peripheral(Uuid, PeripheralBuilderAction)` builder function. + * According to * [The Ultimate Guide to Apple’s Core Bluetooth](https://punchthrough.com/core-bluetooth-basics/): * * > This UUID isn't guaranteed to stay the same across scanning sessions and should not be 100% @@ -84,9 +88,10 @@ public interface Peripheral { * > relatively stable and reliable over the long term assuming a major device settings reset * > has not occurred. * - * If `CoroutineScope.peripheral(Uuid, PeripheralBuilderAction)` throws a - * [NoSuchElementException] then a scan will be necessary to obtain an [Advertisement] for - * [Peripheral] creation. + * If `Peripheral(Uuid, PeripheralBuilderAction)` throws a [NoSuchElementException] then a scan + * will be necessary to obtain an [Advertisement] for [Peripheral] creation. + * + * ## JavaScript * * On JavaScript, this is a unique identifier represented as a [String]. "Restoring" a * peripheral from this identifier is not yet supported in Kable (as JavaScript requires user to @@ -95,43 +100,71 @@ public interface Peripheral { public val identifier: Identifier /** - * The peripheral name, as provided by the underlying bluetooth system. This value is system dependent - * and is not necessarily the Generic Access Profile (GAP) device name. + * The peripheral name, as provided by the underlying bluetooth system. This value is system + * dependent and is not necessarily the Generic Access Profile (GAP) device name. + * + * This API is experimental as it may be changed to a [StateFlow] in the future (to notify of + * name changes on Apple platform). */ + @ExperimentalApi public val name: String? /** - * Initiates a connection, suspending until connected, or failure occurs. Multiple concurrent invocations will all - * suspend until connected (or failure occurs). If already connected, then returns immediately. + * Initiates a connection, suspending until connected, or failure occurs. Multiple concurrent + * invocations will all suspend until connected (or failure occurs). If already connected, then + * returns immediately. + * + * The returned [CoroutineScope] can be used to launch coroutines, and is cancelled upon + * disconnect or [Peripheral] [cancellation][Peripheral.cancel]. The [CoroutineScope] is a + * supervisor scope, meaning any failures in launched coroutines will not fail other launched + * coroutines nor cause a disconnect. * * @throws ConnectionRejectedException when a connection request is rejected by the system (e.g. bluetooth hardware unavailable). - * @throws CancellationException if [Peripheral]'s Coroutine scope has been cancelled. + * @throws CancellationException if [Peripheral]'s [CoroutineScope] has been [cancelled][Peripheral.cancel]. */ - public suspend fun connect(): Unit + public suspend fun connect(): CoroutineScope /** - * Disconnects the active connection, or cancels an in-flight [connection][connect] attempt, suspending until - * [Peripheral] has settled on a [disconnected][State.Disconnected] state. + * Disconnects the active connection, or cancels an in-flight [connection][connect] attempt, + * suspending until [Peripheral] has settled on a [disconnected][State.Disconnected] state. * * Multiple concurrent invocations will all suspend until disconnected (or failure occurs). + * + * Any coroutines launched from [connect] will be spun down prior to closing underlying + * peripheral connection. + * + * @throws CancellationException if [Peripheral]'s [CoroutineScope] has been [cancelled][Peripheral.cancel]. */ public suspend fun disconnect(): Unit /** * The list of services (GATT profile) which have been discovered on the remote peripheral. * - * The list contains a tree of [DiscoveredService]s, [DiscoveredCharacteristic]s and [DiscoveredDescriptor]s. These - * types all hold strong references to the underlying platform type, so no guarantees are provided on the validity - * of the objects beyond a connection. If a reconnect occurs, it is recommended to retrieve the desired object from - * [services] again. Any references to objects obtained from this tree should be cleared upon disconnect or disposal - * (when parent [CoroutineScope] is cancelled) of this [Peripheral]. + * The list contains a tree of [DiscoveredService]s, [DiscoveredCharacteristic]s and + * [DiscoveredDescriptor]s. These types all hold strong references to the underlying platform + * type, so no guarantees are provided on the validity of the objects beyond a connection. If a + * reconnect occurs, it is recommended to retrieve the desired object from [services] again. Any + * references to objects obtained from this tree should be cleared upon [disconnect] or disposal + * (when [Peripheral] is [cancelled][Peripheral.cancel]). * - * @return [discovered services][DiscoveredService], or `null` until a [connection][connect] has been established. + * @return [discovered services][DiscoveredService], or `null` until services have been discovered. */ - public val services: List? + public val services: StateFlow?> - /** @throws NotReadyException if invoked without an established [connection][connect]. */ - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + /** + * On JavaScript, requires Chrome 79+ with the + * `chrome://flags/#enable-experimental-web-platform-features` flag enabled. + * + * Note that even with the above flag enabled (as of Chrome 128), RSSI is not supported and this + * function will throw [UnsupportedOperationException]. + * + * This API is experimental until Web Bluetooth advertisement APIs are stable. + * + * @throws NotConnectedException if invoked without an established [connection][connect]. + * @throws UnsupportedOperationException on JavaScript. + */ + @ExperimentalApi + @Throws(CancellationException::class, IOException::class) public suspend fun rssi(): Int /** @@ -142,9 +175,9 @@ public interface Peripheral { * with the same UUID and [Read] characteristic property exist in the GATT profile, then a * [discovered characteristic][DiscoveredCharacteristic] from [services] should be used instead. * - * @throws NotReadyException if invoked without an established [connection][connect]. + * @throws NotConnectedException if invoked without an established [connection][connect]. */ - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) public suspend fun read( characteristic: Characteristic, ): ByteArray @@ -154,12 +187,12 @@ public interface Peripheral { * * If [characteristic] was created via [characteristicOf] then the first found characteristic with a property * matching the specified [writeType] and matching the service UUID and characteristic UUID in the GATT profile will - * be used. If multiple characteristics with the same UUID and property exist in the GATT profile, then a + * be used. If multiple characteristics with the same UUID and properties exist in the GATT profile, then a * [discovered characteristic][DiscoveredCharacteristic] from [services] should be used instead. * - * @throws NotReadyException if invoked without an established [connection][connect]. + * @throws NotConnectedException if invoked without an established [connection][connect]. */ - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) public suspend fun write( characteristic: Characteristic, data: ByteArray, @@ -174,9 +207,9 @@ public interface Peripheral { * UUID exist in the GATT profile, then a [discovered descriptor][DiscoveredDescriptor] from [services] should be * used instead. * - * @throws NotReadyException if invoked without an established [connection][connect]. + * @throws NotConnectedException if invoked without an established [connection][connect]. */ - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) public suspend fun read( descriptor: Descriptor, ): ByteArray @@ -189,9 +222,9 @@ public interface Peripheral { * UUID exist in the GATT profile, then a [discovered descriptor][DiscoveredDescriptor] from [services] should be * used instead. * - * @throws NotReadyException if invoked without an established [connection][connect]. + * @throws NotConnectedException if invoked without an established [connection][connect]. */ - @Throws(CancellationException::class, IOException::class, NotReadyException::class) + @Throws(CancellationException::class, IOException::class) public suspend fun write( descriptor: Descriptor, data: ByteArray, @@ -250,6 +283,13 @@ public interface Peripheral { ): Flow } +internal typealias PeripheralBuilderAction = PeripheralBuilder.() -> Unit + +public expect fun Peripheral( + advertisement: Advertisement, + builderAction: PeripheralBuilderAction, +): Peripheral + /** * Suspends until [Peripheral] receiver arrives at the [State] specified. * @@ -259,16 +299,6 @@ internal suspend inline fun Peripheral.suspendUntil() { state.first { it is T } } -/** - * Suspends until [Peripheral] receiver arrives at the [State] specified or any [State] above it. - * - * @see [State] for a description of the potential states. - * @see [State.isAtLeast] for state ordering. - */ -internal suspend inline fun Peripheral.suspendUntilAtLeast() { - state.first { it.isAtLeast() } -} - /** * Suspends until [Peripheral] receiver arrives at the [State] specified. * @@ -280,6 +310,6 @@ internal suspend inline fun Peripheral.suspendUntilOrThrow() "Peripheral.suspendUntilOrThrow() throws on State.Disconnected, not intended for use with that State." } state - .onEach { if (it is State.Disconnected) throw ConnectionLostException() } + .onEach { if (it is State.Disconnected) throw NotConnectedException() } .first { it is T } } diff --git a/kable-core/src/commonMain/kotlin/PeripheralBuilder.kt b/kable-core/src/commonMain/kotlin/PeripheralBuilder.kt index bbcf47206..ae674ff2b 100644 --- a/kable-core/src/commonMain/kotlin/PeripheralBuilder.kt +++ b/kable-core/src/commonMain/kotlin/PeripheralBuilder.kt @@ -2,6 +2,8 @@ package com.juul.kable import com.juul.kable.logs.LoggingBuilder import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds public expect class ServicesDiscoveredPeripheral { @@ -32,6 +34,8 @@ public class ObservationExceptionPeripheral internal constructor(peripheral: Per internal typealias ServicesDiscoveredAction = suspend ServicesDiscoveredPeripheral.() -> Unit internal typealias ObservationExceptionHandler = suspend ObservationExceptionPeripheral.(cause: Exception) -> Unit +internal val defaultDisconnectTimeout = 5.seconds + public expect class PeripheralBuilder internal constructor() { public fun logging(init: LoggingBuilder) public fun onServicesDiscovered(action: ServicesDiscoveredAction) @@ -46,7 +50,7 @@ public expect class PeripheralBuilder internal constructor() { * [ObservationExceptionHandler] can be useful for ignoring failures that precursor a connection drop: * * ``` - * scope.peripheral(advertisement) { + * Peripheral(advertisement) { * observationExceptionHandler { cause -> * // Only propagate failure if we don't see a disconnect within a second. * withTimeoutOrNull(1_000L) { @@ -58,4 +62,12 @@ public expect class PeripheralBuilder internal constructor() { * ``` */ public fun observationExceptionHandler(handler: ObservationExceptionHandler) + + /** + * Amount of time to allow system to gracefully disconnect before forcefully closing the + * peripheral. + * + * Only applicable on Android. + */ + public var disconnectTimeout: Duration } diff --git a/kable-core/src/commonMain/kotlin/SharedRepeatableAction.kt b/kable-core/src/commonMain/kotlin/SharedRepeatableAction.kt index 88f3831b6..4fccd78a6 100644 --- a/kable-core/src/commonMain/kotlin/SharedRepeatableAction.kt +++ b/kable-core/src/commonMain/kotlin/SharedRepeatableAction.kt @@ -1,36 +1,64 @@ package com.juul.kable -import kotlinx.atomicfu.locks.reentrantLock -import kotlinx.atomicfu.locks.withLock +import com.juul.kable.SharedRepeatableAction.State import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.job -import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal fun CoroutineScope.sharedRepeatableAction( + action: suspend (scope: CoroutineScope) -> T, +) = SharedRepeatableAction(this, action) /** - * A mechanism for launching and awaiting a shared action ([Deferred]) repeatedly. + * A mechanism for launching and awaiting a shared [action][State.action] ([Deferred]) repeatedly. + * + * The [action][State.action] is created as a child of [root][State.root] [Job] and encapsulated via + * a [State]: * - * The [action] is started by calling [await]. Subsequent calls to [await] will return the same - * (i.e. shared) [action] until failure occurs. + * ``` + * .-------. + * | scope | + * '-------' + * | + * child + * | + * .------------|------------. + * | State v | + * | .------------. | .----------------. + * | | root (Job) | •••••••••• | CoroutineScope | + * | '------------' | '________________' + * | | | : + * | child | passed as + * | | | argument to + * | v | : + * | .-------------------. | v + * | | action (Deferred) | ••••• `action(scope)` + * | '-------------------' | + * '-------------------------' + * ``` * - * The [action] is passed a [scope][CoroutineScope] that can be used to spawn coroutines that can - * outlive the [action]. [await] will continue to return the same action until a failures occurs in - * either the [action] or any coroutines spawned from the [scope][CoroutineScope] provided to - * [action]. + * The [action] is started by calling [await]. Subsequent calls to [await] will return + * the same (i.e. shared) [action] until failure occurs. + * + * The [action] is passed a [scope][CoroutineScope] (of the [root][State.root]) that can be used to + * spawn coroutines that can outlive the [action]. [await] will continue to return the same + * action until a failure occurs in either the [action] or any coroutines spawned from the + * [scope][CoroutineScope] provided to [action]. * * An exception thrown from [action] will cancel any coroutines spawned from the * [scope][CoroutineScope] that was provided to the [action]. * - * Calling [cancel] or [cancelAndJoin] will cancel the [action] and any coroutines created from the - * [scope][CoroutineScope] provided to the [action]. A subsequent call to [await] will then start - * the [action] again. + * Calling [cancelAndJoin] will cancel the [action] and any coroutines created from the + * [scope][CoroutineScope] provided to the [action]. A subsequent call to [await] will then + * start the [action] again. */ internal class SharedRepeatableAction( - private val coroutineContext: CoroutineContext, + private val scope: CoroutineScope, private val action: suspend (scope: CoroutineScope) -> T, ) { @@ -40,55 +68,33 @@ internal class SharedRepeatableAction( ) private var state: State? = null - private val guard = reentrantLock() - - suspend fun await() = getOrAsync().await() + private val guard = Mutex() - @Suppress("ktlint:standard:indent") - private fun getOrAsync(): Deferred = guard.withLock { - ( - state?.takeIf { it.root.isActive } ?: run { - val rootJob = Job(coroutineContext.job) - // No-op exception handler prevents any child failures being considered unhandled - // (which on Android crashes the app) while propagating cancellation to parent and - // honoring parent cancellation. - val rootScope = CoroutineScope(coroutineContext + rootJob + noopExceptionHandler) - val actionDeferred = rootScope.async { - action(rootScope) - } - State(rootJob, actionDeferred) - }.also { state = it } - ).action + private suspend fun getOrCreate(): State = guard.withLock { + state + ?.takeUnless { it.root.isCompleted } + ?: create().also { state = it } } - fun cancel(cause: CancellationException? = null) { - guard.withLock { state } - ?.root - ?.cancel(cause) + private fun create(): State { + check(scope.coroutineContext.job.isActive) { "Scope is not active" } + val root = Job(scope.coroutineContext.job) + val scope = CoroutineScope(scope.coroutineContext + root) + val action = scope.async { action(scope) } + return State(root, action) } - suspend fun cancelAndJoin(cause: CancellationException? = null) { - guard.withLock { state } - ?.root - ?.cancelAndJoin(cause) - } + private suspend fun stateOrNull(): State? = guard.withLock { state } - suspend fun join() { - guard.withLock { state } - ?.root - ?.join() + suspend fun await() = getOrCreate().action.await() + + /** [Cancels][Job.cancelAndJoin] the [root][State.root] if available. */ + suspend fun cancelAndJoin(cause: CancellationException?) { + stateOrNull()?.root?.cancelAndJoin(cause) } } -internal fun CoroutineScope.sharedRepeatableAction( - action: suspend (scope: CoroutineScope) -> T, -) = SharedRepeatableAction(coroutineContext, action) - -private suspend fun Job.cancelAndJoin(cause: CancellationException? = null) { +private suspend fun Job.cancelAndJoin(cause: CancellationException?) { cancel(cause) join() } - -private val noopExceptionHandler = CoroutineExceptionHandler { _, _ -> - // No-op -} diff --git a/kable-core/src/commonMain/kotlin/SilentSupervisor.kt b/kable-core/src/commonMain/kotlin/SilentSupervisor.kt new file mode 100644 index 000000000..398c34b4f --- /dev/null +++ b/kable-core/src/commonMain/kotlin/SilentSupervisor.kt @@ -0,0 +1,15 @@ +package com.juul.kable + +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.CoroutineContext + +/** + * Supervisor with empty coroutine exception handler ignoring all exceptions. + * + * https://github.com/ktorio/ktor/blob/c9cd3308b3d0f9f1c3f5407036921e5d5aeb3f15/ktor-utils/common/src/io/ktor/util/CoroutinesUtils.kt#L23-L28 + */ +@Suppress("FunctionName") +internal fun SilentSupervisor(parent: Job? = null): CoroutineContext = + SupervisorJob(parent) + CoroutineExceptionHandler { _, _ -> } diff --git a/kable-core/src/commonMain/kotlin/State.kt b/kable-core/src/commonMain/kotlin/State.kt index 177ffffcb..f96ccb9d8 100644 --- a/kable-core/src/commonMain/kotlin/State.kt +++ b/kable-core/src/commonMain/kotlin/State.kt @@ -6,7 +6,7 @@ public sealed class State { /** * [Peripheral] has initiated the process of connecting, via Bluetooth. * - * I/O operations (e.g. [write][Peripheral.write] and [read][Peripheral.read]) will throw [NotReadyException] + * I/O operations (e.g. [write][Peripheral.write] and [read][Peripheral.read]) will throw [NotConnectedException] * while in this state. */ public object Bluetooth : Connecting() @@ -14,7 +14,7 @@ public sealed class State { /** * [Peripheral] has connected, but has not yet discovered services. * - * I/O operations (e.g. [write][Peripheral.write] and [read][Peripheral.read]) will throw [IllegalStateOperation] + * I/O operations (e.g. [write][Peripheral.write] and [read][Peripheral.read]) will throw [IllegalStateException] * while in this state. */ public object Services : Connecting() @@ -122,8 +122,11 @@ public sealed class State { } } - override fun toString(): String = - "Disconnected(${if (status is Status.Unknown) status.status else status.toString()})" + override fun toString(): String = when (status) { + null -> "Disconnected" + is Status.Unknown -> "Disconnected(${status.status})" + else -> "Disconnected($status)" + } } override fun toString(): String = when (this) { diff --git a/kable-core/src/commonMain/kotlin/Throwable.kt b/kable-core/src/commonMain/kotlin/Throwable.kt index 34776d406..db8b5449b 100644 --- a/kable-core/src/commonMain/kotlin/Throwable.kt +++ b/kable-core/src/commonMain/kotlin/Throwable.kt @@ -1,28 +1,35 @@ package com.juul.kable import kotlinx.coroutines.CancellationException -import kotlinx.io.IOException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive /** - * Unwraps the [cause][Throwable.cause] of a [CancellationException]. - * - * Traverses the [cause][Throwable.cause] until it finds an exception that originated from Kable and - * returns that [Exception]. If a Kable exception cannot be found, then the receiver [Throwable] is - * returned. + * If the exception contains cause that differs from [CancellationException] returns it otherwise + * returns itself. * * Useful when wanting to convert a coroutine cancellation into a failure, for example: * If a failure occurs in a sibling coroutine to that of an async connection process * (e.g. `connect()` function), the connection process coroutine will cancel via a * [CancellationException] being thrown. The [CancellationException] can be - * [unwrapped][unwrapCancellationCause] to propagate (from the `connect()` function) the sibling + * [unwrapped][unwrapCancellationException] to propagate (from the `connect()` function) the sibling * failure rather than [cancellation][CancellationException]. + * + * Copied from: https://github.com/ktorio/ktor/blob/bcd9de62518add3322dc0aa6d19235c551aaf315/ktor-client/ktor-client-core/jvm/src/io/ktor/client/utils/ExceptionUtilsJvm.kt */ -internal fun Throwable.unwrapCancellationCause(): Throwable { - var exception: Throwable = this +internal fun Throwable.unwrapCancellationException(): Throwable { + var exception: Throwable? = this while (exception is CancellationException) { + // If there is a cycle, we return the initial exception. if (exception == exception.cause) return this - exception = exception.cause ?: return this - if (exception is IOException || exception is BluetoothException) return exception + exception = exception.cause } - return this + return exception ?: this +} + +internal suspend fun unwrapCancellationExceptions(action: suspend () -> T): T = try { + action() +} catch (e: CancellationException) { + currentCoroutineContext().ensureActive() + throw e.unwrapCancellationException() } diff --git a/kable-core/src/commonMain/kotlin/coroutines/CoroutineScope.kt b/kable-core/src/commonMain/kotlin/coroutines/CoroutineScope.kt new file mode 100644 index 000000000..5a4d17a0f --- /dev/null +++ b/kable-core/src/commonMain/kotlin/coroutines/CoroutineScope.kt @@ -0,0 +1,9 @@ +package com.juul.kable.coroutines + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.job + +internal fun CoroutineScope.childSupervisor(name: String) = + CoroutineScope(coroutineContext + SupervisorJob(coroutineContext.job) + CoroutineName(name)) diff --git a/kable-core/src/commonTest/kotlin/SharedRepeatableActionTests.kt b/kable-core/src/commonTest/kotlin/SharedRepeatableActionTests.kt index e15039488..7d521141a 100644 --- a/kable-core/src/commonTest/kotlin/SharedRepeatableActionTests.kt +++ b/kable-core/src/commonTest/kotlin/SharedRepeatableActionTests.kt @@ -1,7 +1,6 @@ package com.juul.kable import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart.UNDISPATCHED import kotlinx.coroutines.Job @@ -10,30 +9,35 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel -import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue class SharedRepeatableActionTests { @Test fun exceptionThrownFromAction_cancelsCoroutinesLaunchedFromScope() = runTest { - supervisorScope { - lateinit var innerJob: Job - val started = MutableStateFlow(false) - val asserted = MutableStateFlow(false) + lateinit var innerJob: Job + val started = MutableStateFlow(false) + val asserted = MutableStateFlow(false) + supervisorScope { val action = sharedRepeatableAction { scope -> innerJob = scope.launch(start = UNDISPATCHED) { awaitCancellation() @@ -59,32 +63,36 @@ class SharedRepeatableActionTests { fun actionCompletes_failureFromLaunch_canStartAgain() = runTest { val actionDidComplete = MutableStateFlow(false) var actionRuns = 0 + supervisorScope { val action = sharedRepeatableAction { scope -> scope.launch { actionDidComplete.first { it } throw IllegalStateException() } - ++actionRuns + actionRuns++ + scope } + val innerScope = action.await() assertEquals( expected = 1, - actual = action.await(), + actual = actionRuns, ) actionDidComplete.value = true - action.join() + innerScope.coroutineContext.job.join() + action.await() assertEquals( expected = 2, - actual = action.await(), + actual = actionRuns, ) } } @Test - fun exceptionThrownFromCoroutineLaunchedFromScope_cancelsAction() = runTest { + fun exceptionThrownFromCoroutineLaunchedFromAction_cancelsAction() = runTest { supervisorScope { val action = sharedRepeatableAction { scope -> scope.launch { @@ -93,63 +101,59 @@ class SharedRepeatableActionTests { awaitCancellation() } - assertFailsWith { + val cancellation = assertFailsWith { action.await() } + + assertIs(cancellation.cause) } } @Test - fun actionIsCancelled_canStartAgain() = runTest { - val innerRuns = Channel() - var innerCounter = 0 - val actionRuns = Channel() - var actionCounter = 0 - - val testScope = CoroutineScope( - coroutineContext + - SupervisorJob(coroutineContext.job) + - CoroutineExceptionHandler { _, cause -> cause.printStackTrace() }, - ) - val action = testScope.sharedRepeatableAction { scope -> - scope.launch { - innerRuns.send(++innerCounter) - awaitCancellation() - } - actionRuns.send(++actionCounter) - awaitCancellation() + fun actionAwaitIsCancelled_actionStillActive_awaitsSameDeferred() = runTest { + val testScope = CoroutineScope(Job()) + + val actions = MutableStateFlow(0) + val ready = Channel() + + val action = testScope.sharedRepeatableAction { + actions.update { it + 1 } + ready.receive() } - val job = testScope.launch(start = UNDISPATCHED) { action.await() } + val deferred1 = async(start = UNDISPATCHED) { + action.await() + } + deferred1.cancel() assertEquals( expected = 1, - actual = innerRuns.receive(), - ) - assertEquals( - expected = 1, - actual = actionRuns.receive(), + actual = actions.filterNot { it == 0 }.first(), + message = "First action await", ) - action.cancelAndJoin() - assertTrue { job.isCancelled } + val deferred2 = async(start = UNDISPATCHED) { + launch { + ready.send(Unit) + } + action.await() + } + deferred2.await() - testScope.launch(start = UNDISPATCHED) { action.await() } + // Validate that we `await`ed the same active "action". assertEquals( - expected = 2, - actual = innerRuns.receive(), - ) - assertEquals( - expected = 2, - actual = actionRuns.receive(), + expected = 1, + actual = actions.value, + message = "Resumed action await", ) - job.join() - coroutineContext.cancelChildren() + testScope.cancel() } @Test fun multipleCallers_allGetResultOfAction() = runTest { - val action = sharedRepeatableAction { scope -> + val testScope = CoroutineScope(SupervisorJob()) + + val action = testScope.sharedRepeatableAction { scope -> scope.launch(start = UNDISPATCHED) { awaitCancellation() } @@ -165,13 +169,15 @@ class SharedRepeatableActionTests { actual = results, ) - coroutineContext.cancelChildren() + testScope.cancel() } @Test fun nothingLaunchedFromScope_remainsActive() = runTest { + val testScope = CoroutineScope(SupervisorJob()) + lateinit var actionScope: CoroutineScope - val action = sharedRepeatableAction { scope -> + val action = testScope.sharedRepeatableAction { scope -> actionScope = scope 1 } @@ -180,25 +186,23 @@ class SharedRepeatableActionTests { expected = 1, actual = action.await(), ) + yield() assertTrue { actionScope.isActive } - coroutineContext.cancelChildren() + testScope.cancel() } @Test fun honorsCancellationOfParentScope() = runTest { - val didCancel = MutableStateFlow(false) - val parentScope = CoroutineScope( - coroutineContext + - SupervisorJob(coroutineContext.job) + - CoroutineExceptionHandler { _, cause -> cause.printStackTrace() }, - ) + val parentScope = CoroutineScope(SupervisorJob()) + + val cancellation = MutableStateFlow(null) val action = parentScope.sharedRepeatableAction { scope -> scope.launch(start = UNDISPATCHED) { try { awaitCancellation() } catch (e: Exception) { - if (e is CancellationException) didCancel.value = true + if (e is CancellationException) cancellation.value = e throw e } } @@ -210,7 +214,74 @@ class SharedRepeatableActionTests { actual = action.await(), ) - parentScope.cancel() - didCancel.first { it } + parentScope.cancel(CancellationException("testing")) + val e = cancellation.filterNotNull().first() + assertIs(e) + assertEquals( + expected = "testing", + actual = e.message, + ) + } + + @Test + fun awaitWithLaunch_awaitTwice_completes() = runTest { + val testScope = CoroutineScope(SupervisorJob()) + + var launches = 0 + var actions = 0 + val didLaunch = Channel() + + val action = testScope.sharedRepeatableAction { scope -> + scope.launch { + launches++ + didLaunch.send(Unit) + awaitCancellation() + } + actions++ + scope + } + + action.await() // A "connect" would invoke this. + didLaunch.receive() + action.cancelAndJoin(null) // A "disconnect" would first invoke this.. + + action.await() // A "re-connect" would invoke this. + didLaunch.receive() + action.cancelAndJoin(null) // A "disconnect" the 2nd time would invoke this... + + assertEquals(launches, 2, "Launch within action") + assertEquals(actions, 2, "Action lambda execution") + + testScope.cancel() + } + + @Test + fun simulation_cancelConnect() = runTest { + val testScope = CoroutineScope(SupervisorJob()) + + val action = testScope.sharedRepeatableAction { + awaitCancellation() // Simulate long connection process. + } + + lateinit var caught: Throwable + launch(start = UNDISPATCHED) { + // Simulate triggering a connection process. + try { + action.await() + } catch (e: Exception) { + caught = e + } + } + + action.cancelAndJoin( + CancellationException("Simulated disconnect", IllegalStateException("disconnect")), + ) + + val e = assertIs(caught) + assertEquals( + expected = "Simulated disconnect", + actual = e.message, + ) + assertIs(e.cause) } } diff --git a/kable-core/src/jsMain/kotlin/BluetoothDeviceWebBluetoothPeripheral.kt b/kable-core/src/jsMain/kotlin/BluetoothDeviceWebBluetoothPeripheral.kt index 9b21541e7..79ce1584f 100644 --- a/kable-core/src/jsMain/kotlin/BluetoothDeviceWebBluetoothPeripheral.kt +++ b/kable-core/src/jsMain/kotlin/BluetoothDeviceWebBluetoothPeripheral.kt @@ -4,9 +4,9 @@ package com.juul.kable import com.juul.kable.WriteType.WithResponse import com.juul.kable.WriteType.WithoutResponse +import com.juul.kable.bluetooth.isWatchingAdvertisementsSupported import com.juul.kable.external.BluetoothAdvertisingEvent import com.juul.kable.external.BluetoothDevice -import com.juul.kable.external.BluetoothRemoteGATTCharacteristic import com.juul.kable.external.BluetoothRemoteGATTServer import com.juul.kable.external.string import com.juul.kable.logs.Logger @@ -14,196 +14,170 @@ import com.juul.kable.logs.Logging import com.juul.kable.logs.Logging.DataProcessor.Operation import com.juul.kable.logs.detail import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED +import kotlinx.coroutines.async import kotlinx.coroutines.await +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.coroutines.job import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.io.IOException import org.khronos.webgl.DataView -import kotlin.coroutines.CoroutineContext -import org.w3c.dom.events.Event as JsEvent +import org.w3c.dom.events.EventListener +import kotlin.coroutines.cancellation.CancellationException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.time.Duration -private const val GATT_SERVER_DISCONNECTED = "gattserverdisconnected" private const val ADVERTISEMENT_RECEIVED = "advertisementreceived" -private const val CHARACTERISTIC_VALUE_CHANGED = "characteristicvaluechanged" - -private typealias ObservationListener = (JsEvent) -> Unit internal class BluetoothDeviceWebBluetoothPeripheral( - parentCoroutineContext: CoroutineContext, private val bluetoothDevice: BluetoothDevice, observationExceptionHandler: ObservationExceptionHandler, private val onServicesDiscovered: ServicesDiscoveredAction, - logging: Logging, -) : WebBluetoothPeripheral { + private val disconnectTimeout: Duration, + private val logging: Logging, +) : BasePeripheral(bluetoothDevice.id), WebBluetoothPeripheral { - private val logger = Logger(logging, identifier = bluetoothDevice.id) + private val connectAction = sharedRepeatableAction(::establishConnection) - private val ioLock = Mutex() + private val logger = Logger(logging, identifier = bluetoothDevice.id) private val _state = MutableStateFlow(State.Disconnected()) override val state: StateFlow = _state.asStateFlow() override val identifier: String = bluetoothDevice.id - private var _discoveredServices: List? = null - private val discoveredServices: List - get() = _discoveredServices - ?: throw IllegalStateException("Services have not been discovered for $this") - - override val services: List? - get() = _discoveredServices?.toList() + private val connection = MutableStateFlow(null) + private fun connectionOrThrow() = connection.value + ?: throw NotConnectedException("Connection not established, current state: ${state.value}") - private val observationListeners = mutableMapOf() - - private val supportsAdvertisements = js("BluetoothDevice.prototype.watchAdvertisements") != null + private val _services = MutableStateFlow?>(null) + override val services = _services.asStateFlow() + private fun servicesOrThrow() = services.value ?: error("Services have not been discovered") + @ExperimentalApi override val name: String? get() = bluetoothDevice.name - /** - * It's important that we instantiate this job as late as possible, since [finalCleanup] will be - * called immediately if the parent job is already complete. Doing so late in is fine, - * but early in it could reference non-nullable variables that are not yet set and crash. - */ - private val job = SupervisorJob(parentCoroutineContext.job).apply { - invokeOnCompletion { finalCleanup() } - } - - private val scope = CoroutineScope(parentCoroutineContext + job) - - override suspend fun rssi(): Int = suspendCancellableCoroutine { continuation -> - check(supportsAdvertisements) { "watchAdvertisements unavailable" } - - lateinit var listener: (JsEvent) -> Unit - val cleanup = { - bluetoothDevice.removeEventListener(ADVERTISEMENT_RECEIVED, listener) - // At the time of writing `unwatchAdvertisements()` remains unimplemented - if (bluetoothDevice.watchingAdvertisements && js("BluetoothDevice.prototype.unwatchAdvertisements") != null) { - bluetoothDevice.unwatchAdvertisements() - } - } - - listener = { - val event = it as BluetoothAdvertisingEvent - cleanup() - if (continuation.isActive && event.rssi != null) { - continuation.resume(event.rssi, onCancellation = null) - } - } - - if (!bluetoothDevice.watchingAdvertisements) { - bluetoothDevice.watchAdvertisements() - } - bluetoothDevice.addEventListener(ADVERTISEMENT_RECEIVED, listener) - - continuation.invokeOnCancellation { - cleanup() - } - } + private val observers = Observers(this, logging, observationExceptionHandler) - private val gatt: BluetoothRemoteGATTServer - get() = bluetoothDevice.gatt!! // fixme: !! + private suspend fun establishConnection(scope: CoroutineScope): CoroutineScope { + logger.info { message = "Connecting" } + _state.value = State.Connecting.Bluetooth - private val connectJob: SharedRepeatableTask = scope.sharedRepeatableTask { try { - openConnection() - } catch (cause: Throwable) { - logger.error(cause) { message = "Failed to connect" } - @Suppress("DeferredResultUnused") // Safe to ignore Deferred because the result is shared elsewhere - disconnectJob.getOrAsync() - throw IOException("Connection attempt failed", cause) + connection.value = Connection( + scope.coroutineContext, + bluetoothDevice, + _state, + _services, + observers.characteristicChanges, + disconnectTimeout, + logging, + ) + connectionOrThrow().execute(BluetoothRemoteGATTServer::connect) + discoverServices() + configureCharacteristicObservations() + } catch (e: Exception) { + val failure = e.unwrapCancellationException() + logger.error(failure) { message = "Failed to establish connection" } + throw failure } - } - private val disconnectJob: SharedRepeatableTask = scope.sharedRepeatableTask { - connectJob.join() - closeConnection() - } + logger.info { message = "Connected" } + _state.value = State.Connected - override suspend fun connect() { - connectJob.getOrAsync().await() + return connectionOrThrow().taskScope } - override suspend fun disconnect() { - connectJob.cancelAndJoin() - disconnectJob.getOrAsync().await() + private suspend fun discoverServices() { + connectionOrThrow().discoverServices() + unwrapCancellationExceptions { + onServicesDiscovered(ServicesDiscoveredPeripheral(this)) + } } - private suspend fun openConnection() { - // Guard if already connected do nothing - if (_state.value is State.Connected) return - - logger.info { message = "Connecting" } - // Wait until fully disconnected before proceeding - _state.first { it is State.Disconnected } - - _state.value = State.Connecting.Bluetooth - registerDisconnectedListener() - gatt.connect().await() - - _state.value = State.Connecting.Services - discoverServices() - onServicesDiscovered(ServicesDiscoveredPeripheral(this@BluetoothDeviceWebBluetoothPeripheral)) - + private suspend fun configureCharacteristicObservations() { _state.value = State.Connecting.Observes logger.verbose { message = "Configuring characteristic observations" } observers.onConnected() + } - logger.info { message = "Connected" } - _state.value = State.Connected + override suspend fun connect(): CoroutineScope = + connectAction.await() + + override suspend fun disconnect() { + connectAction.cancelAndJoin( + CancellationException(NotConnectedException("Disconnect requested")), + ) } - private suspend fun closeConnection() { - // Guard if already disconnecting/disconnected do nothing - if (_state.value is State.Disconnecting || _state.value is State.Disconnected) return + /** + * Per [Web Bluetooth / Scanning Sample][https://googlechrome.github.io/samples/web-bluetooth/scan.html]: + * + * > Scanning is still under development. You must be using Chrome 79+ with the + * > `chrome://flags/#enable-experimental-web-platform-features` flag enabled. + * + * Note that even with the above flag enabled (as of Chrome 128) + * [BluetoothDevice.unwatchAdvertisements] was not available. + * + * Overview of Chrome's Web Bluetooth implementation status can be found at: + * https://github.com/WebBluetoothCG/web-bluetooth/blob/main/implementation-status.md#chrome + * + * @throws UnsupportedOperationException If feature is not enabled and/or supported. + */ + @ExperimentalApi + override suspend fun rssi(): Int { + if (!isWatchingAdvertisementsSupported) { + throw UnsupportedOperationException("RSSI not supported") + } - try { - _state.value = State.Disconnecting - unregisterDisconnectedListener() - stopAllObservations() - } finally { - forceDisconnectedStateImmediate() + return coroutineScope { + val rssi = async(start = UNDISPATCHED) { receiveRssiEvent() } + var didWatch = false + try { + if (!bluetoothDevice.watchingAdvertisements) { + logger.verbose { message = "watchAdvertisements" } + bluetoothDevice.watchAdvertisements().await() + didWatch = true + } + rssi.await() + } finally { + if (didWatch) { + logger.verbose { message = "unwatchAdvertisements" } + bluetoothDevice.unwatchAdvertisements().await() + } + } } } - private fun finalCleanup() { - try { - unregisterDisconnectedListener() - clearObservationsWithoutStopping() - } finally { - forceDisconnectedStateImmediate() + private suspend fun receiveRssiEvent() = suspendCancellableCoroutine { continuation -> + val listener = EventListener { event -> + val rssi = event.unsafeCast().rssi + if (rssi != null) { + continuation.resume(rssi) + } else { + continuation.resumeWithException( + InternalException("BluetoothAdvertisingEvent.rssi was null"), + ) + } } - } - /** - * We _always_ want to invoke gatt.disconnect() when disconnecting as it does some important - * clean up of the Web BLE internals. We also want to ensure we move to the Disconnected state. - * This method combines those operations so we end up in a sensible place before the next connect(). - */ - private fun forceDisconnectedStateImmediate() = - try { - bluetoothDevice.gatt?.disconnect() - } finally { - _state.value = State.Disconnected() + logger.verbose { + message = "addEventListener" + detail("event", ADVERTISEMENT_RECEIVED) } + bluetoothDevice.addEventListener(ADVERTISEMENT_RECEIVED, listener) - private suspend fun discoverServices() { - logger.verbose { message = "discover services" } - val services = ioLock.withLock { - gatt.getPrimaryServices().await() - .map { it.toDiscoveredService(logger) } + continuation.invokeOnCancellation { + logger.verbose { + message = "removeEventListener" + detail("event", ADVERTISEMENT_RECEIVED) + } + bluetoothDevice.removeEventListener(ADVERTISEMENT_RECEIVED, listener) } - _discoveredServices = services } override suspend fun write( @@ -218,21 +192,21 @@ internal class BluetoothDeviceWebBluetoothPeripheral( detail(data, Operation.Write) } - val platformCharacteristic = discoveredServices.obtain(characteristic, writeType.properties) - ioLock.withLock { + val platformCharacteristic = servicesOrThrow().obtain(characteristic, writeType.properties) + connectionOrThrow().execute { when (writeType) { WithResponse -> platformCharacteristic.writeValueWithResponse(data) WithoutResponse -> platformCharacteristic.writeValueWithoutResponse(data) - }.await() + } } } override suspend fun readAsDataView( characteristic: Characteristic, ): DataView { - val platformCharacteristic = discoveredServices.obtain(characteristic, Read) - val value = ioLock.withLock { - platformCharacteristic.readValue().await() + val platformCharacteristic = servicesOrThrow().obtain(characteristic, Read) + val value = connectionOrThrow().execute { + platformCharacteristic.readValue() } logger.debug { message = "read" @@ -258,18 +232,18 @@ internal class BluetoothDeviceWebBluetoothPeripheral( detail(data, Operation.Write) } - val platformDescriptor = discoveredServices.obtain(descriptor) - ioLock.withLock { - platformDescriptor.writeValue(data).await() + val platformDescriptor = servicesOrThrow().obtain(descriptor) + connectionOrThrow().execute { + platformDescriptor.writeValue(data) } } override suspend fun readAsDataView( descriptor: Descriptor, ): DataView { - val platformDescriptor = discoveredServices.obtain(descriptor) - val value = ioLock.withLock { - platformDescriptor.readValue().await() + val platformDescriptor = servicesOrThrow().obtain(descriptor) + val value = connectionOrThrow().execute { + platformDescriptor.readValue() } logger.debug { message = "read" @@ -285,8 +259,6 @@ internal class BluetoothDeviceWebBluetoothPeripheral( .buffer .toByteArray() - private val observers = Observers(this, logging, observationExceptionHandler) - override fun observeDataView( characteristic: Characteristic, onSubscription: OnSubscriptionAction, @@ -298,107 +270,13 @@ internal class BluetoothDeviceWebBluetoothPeripheral( ): Flow = observeDataView(characteristic, onSubscription) .map { it.buffer.toByteArray() } - private var isDisconnectedListenerRegistered = false - private val disconnectedListener: (JsEvent) -> Unit = { - logger.debug { message = GATT_SERVER_DISCONNECTED } - connectJob.cancel() - @Suppress("DeferredResultUnused") // Safe to ignore Deferred because the result is shared elsewhere - disconnectJob.getOrAsync() - } - internal suspend fun startObservation(characteristic: Characteristic) { - if (characteristic in observationListeners) return - logger.debug { - message = "observe start" - detail(characteristic) - } - - val listener = characteristic.createListener() - observationListeners[characteristic] = listener - - discoveredServices.obtain(characteristic, Notify or Indicate).apply { - addEventListener(CHARACTERISTIC_VALUE_CHANGED, listener) - ioLock.withLock { - withContext(NonCancellable) { - startNotifications().await() - } - } - } + connectionOrThrow().startObservation(characteristic) } internal suspend fun stopObservation(characteristic: Characteristic) { - val listener = observationListeners.remove(characteristic) ?: return - logger.verbose { - message = "observe stop" - detail(characteristic) - } - - discoveredServices.obtain(characteristic, Notify or Indicate).apply { - /* Throws `DOMException` if connection is closed: - * - * DOMException: Failed to execute 'stopNotifications' on 'BluetoothRemoteGATTCharacteristic': - * Characteristic with UUID [...] is no longer valid. Remember to retrieve the characteristic - * again after reconnecting. - * - * Wrapped in `runCatching` to silently ignore failure, as notification will already be - * invalidated due to the connection being closed. - */ - runCatching { - ioLock.withLock { - withContext(NonCancellable) { - stopNotifications().await() - } - } - }.onFailure { - logger.warn { - message = "Stop notification failure ignored." - detail(characteristic) - } - } - - removeEventListener(CHARACTERISTIC_VALUE_CHANGED, listener) - } - } - - private suspend fun stopAllObservations() { - observationListeners.keys.forEach { characteristic -> - stopObservation(characteristic) - } - } - - private fun clearObservationsWithoutStopping() { - observationListeners.forEach { (characteristic, listener) -> - discoveredServices.obtain(characteristic, Notify or Indicate) - .removeEventListener(CHARACTERISTIC_VALUE_CHANGED, listener) - } - observationListeners.clear() - } - - private fun Characteristic.createListener(): ObservationListener = { event -> - val target = event.target as BluetoothRemoteGATTCharacteristic - val data = target.value!! - logger.debug { - message = CHARACTERISTIC_VALUE_CHANGED - detail(this@createListener) - detail(data, Operation.Change) - } - val characteristicChange = ObservationEvent.CharacteristicChange(this, data) - - if (!observers.characteristicChanges.tryEmit(characteristicChange)) { - console.error("Failed to emit $characteristicChange") - } - } - - private fun registerDisconnectedListener() { - if (isDisconnectedListenerRegistered) return - isDisconnectedListenerRegistered = true - bluetoothDevice.addEventListener(GATT_SERVER_DISCONNECTED, disconnectedListener) - } - - private fun unregisterDisconnectedListener() { - isDisconnectedListenerRegistered = false - bluetoothDevice.removeEventListener(GATT_SERVER_DISCONNECTED, disconnectedListener) + connectionOrThrow().stopObservation(characteristic) } - override fun toString(): String = "Peripheral(bluetoothDevice=${bluetoothDevice.string()})" + override fun toString(): String = "Peripheral(delegate=${bluetoothDevice.string()})" } diff --git a/kable-core/src/jsMain/kotlin/Connection.kt b/kable-core/src/jsMain/kotlin/Connection.kt new file mode 100644 index 000000000..c3b108ffc --- /dev/null +++ b/kable-core/src/jsMain/kotlin/Connection.kt @@ -0,0 +1,287 @@ +package com.juul.kable + +import com.juul.kable.State.Connecting +import com.juul.kable.State.Disconnected +import com.juul.kable.coroutines.childSupervisor +import com.juul.kable.external.BluetoothDevice +import com.juul.kable.external.BluetoothRemoteGATTCharacteristic +import com.juul.kable.external.BluetoothRemoteGATTServer +import com.juul.kable.logs.Logger +import com.juul.kable.logs.Logging +import com.juul.kable.logs.Logging.DataProcessor.Operation +import com.juul.kable.logs.detail +import js.errors.JsError +import kotlinx.coroutines.CompletableJob +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.ATOMIC +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.await +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.io.IOException +import org.khronos.webgl.DataView +import org.w3c.dom.events.Event +import web.errors.DOMException +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext +import kotlin.js.Promise +import kotlin.time.Duration + +private typealias ObservationListener = (Event) -> Unit + +private const val GATT_SERVER_DISCONNECTED = "gattserverdisconnected" +private const val CHARACTERISTIC_VALUE_CHANGED = "characteristicvaluechanged" + +internal class Connection( + parentContext: CoroutineContext, + private val bluetoothDevice: BluetoothDevice, + private val state: MutableStateFlow, + private val discoveredServices: MutableStateFlow?>, + private val characteristicChanges: MutableSharedFlow>, + private val disconnectTimeout: Duration, + logging: Logging, +) { + + private val name = "Kable/Connection/${bluetoothDevice.id}" + + private val connectionJob = (parentContext.job as CompletableJob).apply { + invokeOnCompletion(::close) + } + private val connectionScope = CoroutineScope( + parentContext + connectionJob + CoroutineName(name), + ) + + val taskScope = connectionScope.childSupervisor("$name/Tasks") + + private val logger = Logger(logging, tag = "Kable/Connection", identifier = bluetoothDevice.id) + + private val disconnectedListener: (Event) -> Unit = { + logger.debug { message = GATT_SERVER_DISCONNECTED } + state.value = Disconnected() + } + + init { + onDispose(::disconnect) + registerDisconnectedListener() + + on { + val state = it.toString() + logger.debug { + message = "Disconnect detected" + detail("state", state) + } + dispose(NotConnectedException("Disconnect detected")) + } + } + + private val gatt = bluetoothDevice.gatt + // `BluetootDevice.gatt` is `null` on Web Bluetooth Permission denial; as such, when `null` + // 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") + + private fun servicesOrThrow(): List = + discoveredServices.value ?: error("Services have not been discovered") + + suspend fun discoverServices() { + logger.verbose { message = "Discovering services" } + state.value = Connecting.Services + discoveredServices.value = execute(BluetoothRemoteGATTServer::getPrimaryServices) + .map { it.toDiscoveredService(logger) } + } + + private val observationListeners = mutableMapOf() + + suspend fun startObservation(characteristic: Characteristic) { + logger.debug { + message = "Starting observation" + detail(characteristic) + } + + val platformCharacteristic = servicesOrThrow().obtain(characteristic, Notify or Indicate) + if (platformCharacteristic in observationListeners) return + + val listener = characteristic.createObservationListener() + observationListeners[platformCharacteristic] = listener + + platformCharacteristic.apply { + logger.verbose { + message = "addEventListener" + detail(characteristic) + detail("event", CHARACTERISTIC_VALUE_CHANGED) + } + addEventListener(CHARACTERISTIC_VALUE_CHANGED, listener) + + try { + execute { startNotifications() } + } catch (e: JsError) { + removeCharacteristicValueChangedListener(listener) + observationListeners.remove(platformCharacteristic) + + coroutineContext.ensureActive() + throw when (e) { + is DOMException -> IOException("Failed to start notification", e) + else -> InternalException("Unexpected start notification failure", e) + } + } + } + } + + suspend fun stopObservation(characteristic: Characteristic) { + logger.debug { + message = "Stopping observation" + detail(characteristic) + } + + val platformCharacteristic = servicesOrThrow().obtain(characteristic, Notify or Indicate) + + platformCharacteristic.apply { + try { + execute { stopNotifications() } + } catch (e: JsError) { + coroutineContext.ensureActive() + when (e) { + // DOMException: Failed to execute 'stopNotifications' on 'BluetoothRemoteGATTCharacteristic': + // Characteristic with UUID [...] is no longer valid. Remember to retrieve the characteristic + // again after reconnecting. + is DOMException -> throw IOException("Failed to stop notification", e) + + is NotConnectedException -> { + // No-op: System implicitly clears notifications on disconnect. + } + + else -> throw InternalException("Unexpected stop notification failure", e) + } + } finally { + val listener = observationListeners.remove(platformCharacteristic) ?: return + removeCharacteristicValueChangedListener(listener) + } + } + } + + private val guard = Mutex() + + suspend fun execute( + action: BluetoothRemoteGATTServer.() -> Promise, + ): T = guard.withLock { + unwrapCancellationExceptions { + withContext(connectionScope.coroutineContext) { + gatt.action().await() + } + } + } + + private suspend fun disconnect() { + if (state.value is Disconnected) return + + withContext(NonCancellable) { + try { + withTimeout(disconnectTimeout) { + logger.verbose { message = "Waiting for connection tasks to complete" } + taskScope.coroutineContext.job.join() + + logger.debug { message = "Disconnecting" } + disconnectGatt() + + state.filterIsInstance().first() + } + logger.info { message = "Disconnected" } + } catch (e: TimeoutCancellationException) { + logger.warn { message = "Timed out after $disconnectTimeout waiting for disconnect" } + } finally { + disconnectGatt() + } + } + } + + private var didDisconnectGatt = false + private fun disconnectGatt() { + if (didDisconnectGatt) return + logger.verbose { message = "gatt.disconnect" } + gatt.disconnect() + didDisconnectGatt = true + } + + private fun close(cause: Throwable?) { + logger.debug(cause) { message = "Closing" } + unregisterDisconnectedListener() + clearObservationListeners() + logger.info { message = "Closed" } + } + + private fun registerDisconnectedListener() { + bluetoothDevice.addEventListener(GATT_SERVER_DISCONNECTED, disconnectedListener) + } + + private fun unregisterDisconnectedListener() { + bluetoothDevice.removeEventListener(GATT_SERVER_DISCONNECTED, disconnectedListener) + } + + private fun Characteristic.createObservationListener(): ObservationListener = { event -> + val target = event.target.unsafeCast() + val data = target.value!! + logger.debug { + message = CHARACTERISTIC_VALUE_CHANGED + detail(this@createObservationListener) + detail(data, Operation.Change) + } + val characteristicChange = ObservationEvent.CharacteristicChange(this, data) + + if (!characteristicChanges.tryEmit(characteristicChange)) { + logger.error { + message = "Failed to emit characteristic change" + detail("change", characteristicChange.toString()) + } + } + } + + private fun clearObservationListeners() { + observationListeners.forEach { (characteristic, listener) -> + characteristic.removeCharacteristicValueChangedListener(listener) + } + observationListeners.clear() + } + + private fun PlatformCharacteristic.removeCharacteristicValueChangedListener( + listener: ObservationListener, + ) { + logger.verbose { + message = "removeEventListener" + detail(this@removeCharacteristicValueChangedListener) + detail("event", CHARACTERISTIC_VALUE_CHANGED) + } + removeEventListener(CHARACTERISTIC_VALUE_CHANGED, listener) + } + + private inline fun on(crossinline action: suspend (T) -> Unit) { + taskScope.launch { + action(state.filterIsInstance().first()) + } + } + + private fun onDispose(action: suspend () -> Unit) { + @Suppress("OPT_IN_USAGE") + connectionScope.launch(start = ATOMIC) { + try { + awaitCancellation() + } finally { + action() + } + } + } + + private fun dispose(cause: Throwable) = connectionJob.completeExceptionally(cause) +} diff --git a/kable-core/src/jsMain/kotlin/Peripheral.deprecated.kt b/kable-core/src/jsMain/kotlin/Peripheral.deprecated.kt new file mode 100644 index 000000000..f4f5a19be --- /dev/null +++ b/kable-core/src/jsMain/kotlin/Peripheral.deprecated.kt @@ -0,0 +1,30 @@ +package com.juul.kable + +import com.juul.kable.external.BluetoothDevice +import kotlinx.coroutines.CoroutineScope + +@Deprecated( + message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", + replaceWith = ReplaceWith("Peripheral(advertisement, builderAction)"), +) +public actual fun CoroutineScope.peripheral( + advertisement: Advertisement, + builderAction: PeripheralBuilderAction, +): Peripheral { + advertisement as BluetoothAdvertisingEventWebBluetoothAdvertisement + return peripheral(advertisement.bluetoothDevice, builderAction) +} + +@Deprecated( + message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", + replaceWith = ReplaceWith("Peripheral(bluetoothDevice, builderAction)"), +) +internal fun CoroutineScope.peripheral( + bluetoothDevice: BluetoothDevice, + builderAction: PeripheralBuilderAction = {}, +): WebBluetoothPeripheral = peripheral(bluetoothDevice, PeripheralBuilder().apply(builderAction)) + +internal fun CoroutineScope.peripheral( + bluetoothDevice: BluetoothDevice, + builder: PeripheralBuilder, +): WebBluetoothPeripheral = builder.build(bluetoothDevice) diff --git a/kable-core/src/jsMain/kotlin/Peripheral.kt b/kable-core/src/jsMain/kotlin/Peripheral.kt index b43bc8ce5..6725b65c7 100644 --- a/kable-core/src/jsMain/kotlin/Peripheral.kt +++ b/kable-core/src/jsMain/kotlin/Peripheral.kt @@ -1,29 +1,23 @@ package com.juul.kable import com.juul.kable.external.BluetoothDevice -import kotlinx.coroutines.CoroutineScope -/** - * This function will soon be deprecated in favor of suspend version of function (with - * [CoroutineScope] as parameter). - * - * See https://github.com/JuulLabs/kable/issues/286 for more details. - */ -@ObsoleteKableApi -public actual fun CoroutineScope.peripheral( +public actual fun Peripheral( advertisement: Advertisement, builderAction: PeripheralBuilderAction, ): Peripheral { advertisement as BluetoothAdvertisingEventWebBluetoothAdvertisement - return peripheral(advertisement.bluetoothDevice, builderAction) + return Peripheral(advertisement.bluetoothDevice, builderAction) } -internal fun CoroutineScope.peripheral( +@Suppress("FunctionName") // Builder function. +internal fun Peripheral( bluetoothDevice: BluetoothDevice, builderAction: PeripheralBuilderAction = {}, -): WebBluetoothPeripheral = peripheral(bluetoothDevice, PeripheralBuilder().apply(builderAction)) +): WebBluetoothPeripheral = Peripheral(bluetoothDevice, PeripheralBuilder().apply(builderAction)) -internal fun CoroutineScope.peripheral( +@Suppress("FunctionName") // Builder function. +internal fun Peripheral( bluetoothDevice: BluetoothDevice, builder: PeripheralBuilder, -): WebBluetoothPeripheral = builder.build(bluetoothDevice, this) +): WebBluetoothPeripheral = builder.build(bluetoothDevice) diff --git a/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt b/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt index 75b591329..5678f1679 100644 --- a/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt +++ b/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt @@ -3,7 +3,7 @@ package com.juul.kable import com.juul.kable.external.BluetoothDevice import com.juul.kable.logs.Logging import com.juul.kable.logs.LoggingBuilder -import kotlinx.coroutines.CoroutineScope +import kotlin.time.Duration public actual class ServicesDiscoveredPeripheral internal constructor( private val peripheral: WebBluetoothPeripheral, @@ -52,12 +52,14 @@ public actual class PeripheralBuilder internal actual constructor() { observationExceptionHandler = handler } - internal fun build(bluetoothDevice: BluetoothDevice, scope: CoroutineScope) = + public actual var disconnectTimeout: Duration = defaultDisconnectTimeout + + internal fun build(bluetoothDevice: BluetoothDevice) = BluetoothDeviceWebBluetoothPeripheral( - scope.coroutineContext, bluetoothDevice, observationExceptionHandler, onServicesDiscovered, + disconnectTimeout, logging, ) } diff --git a/kable-core/src/jsMain/kotlin/RequestPeripheral.deprecated.kt b/kable-core/src/jsMain/kotlin/RequestPeripheral.deprecated.kt deleted file mode 100644 index c5ada608d..000000000 --- a/kable-core/src/jsMain/kotlin/RequestPeripheral.deprecated.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.juul.kable - -import kotlinx.coroutines.CoroutineScope -import kotlin.js.Promise - -@Deprecated( - message = "Deprecated in favor of `suspend` version of function.", - replaceWith = ReplaceWith("requestPeripheral(options, scope) { }"), -) -public fun CoroutineScope.requestPeripheral( - options: Options, - builderAction: PeripheralBuilderAction = {}, -): Promise = bluetoothDeprecated - .requestDevice(options.toRequestDeviceOptions()) - .then { device -> peripheral(device, builderAction) } diff --git a/kable-core/src/jsMain/kotlin/RequestPeripheral.kt b/kable-core/src/jsMain/kotlin/RequestPeripheral.kt index 0f6bb2fe9..3312ec560 100644 --- a/kable-core/src/jsMain/kotlin/RequestPeripheral.kt +++ b/kable-core/src/jsMain/kotlin/RequestPeripheral.kt @@ -3,7 +3,6 @@ package com.juul.kable import com.juul.kable.logs.Logger import js.errors.JsError import js.errors.TypeError -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.await import kotlinx.coroutines.ensureActive import web.errors.DOMException @@ -27,7 +26,6 @@ import kotlin.coroutines.coroutineContext */ public suspend fun requestPeripheral( options: Options, - scope: CoroutineScope, builderAction: PeripheralBuilderAction = {}, ): Peripheral? { val bluetooth = bluetoothOrThrow() @@ -77,7 +75,5 @@ public suspend fun requestPeripheral( else -> throw InternalException("Failed to request device", e) } - }?.let { device -> - builder.build(device, scope) - } + }?.let(builder::build) } diff --git a/kable-core/src/jsMain/kotlin/SharedRepeatableTask.kt b/kable-core/src/jsMain/kotlin/SharedRepeatableTask.kt deleted file mode 100644 index 2741cd51c..000000000 --- a/kable-core/src/jsMain/kotlin/SharedRepeatableTask.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.juul.kable - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin - -/** - * A mechanism for launching and awaiting some shared job repeatedly. - * - * The job is launched by calling [getOrAsync]. Subsequent calls to [getOrAsync] will return the - * same (i.e. shared) [job][Deferred] if it is still executing. - */ -internal class SharedRepeatableTask( - private val scope: CoroutineScope, - private val task: suspend CoroutineScope.() -> T, -) { - - private var deferred: Deferred? = null - - /** If there is a running [job][Deferred] returns it. Otherwise, launches and returns a new [job][Deferred]. */ - fun getOrAsync() = deferred - ?.takeUnless { it.isCompleted } - ?: scope.async(block = task).apply { - deferred = this - invokeOnCompletion { deferred = null } - } - - /** Cancels the running job (if any). */ - fun cancel() { - deferred?.cancel() - } - - /** Cancels the running job (if any) and suspends until it completes (either normally or exceptionally). */ - suspend fun cancelAndJoin() { - deferred?.cancelAndJoin() - } - - /** Calls [Job.join] on the running job (if any). If no job is running, returns immediately. */ - suspend fun join() { - deferred?.join() - } -} - -internal fun CoroutineScope.sharedRepeatableTask( - task: suspend CoroutineScope.() -> Unit, -) = SharedRepeatableTask(this, task) diff --git a/kable-core/src/jsMain/kotlin/bluetooth/WatchingAdvertisementsSupport.kt b/kable-core/src/jsMain/kotlin/bluetooth/WatchingAdvertisementsSupport.kt new file mode 100644 index 000000000..286dac905 --- /dev/null +++ b/kable-core/src/jsMain/kotlin/bluetooth/WatchingAdvertisementsSupport.kt @@ -0,0 +1,11 @@ +package com.juul.kable.bluetooth + +internal val canWatchAdvertisements by lazy { + js("BluetoothDevice.prototype.watchAdvertisements") != null +} + +internal val canUnwatchAdvertisements by lazy { + js("BluetoothDevice.prototype.unwatchAdvertisements") != null +} + +internal val isWatchingAdvertisementsSupported = canWatchAdvertisements && canUnwatchAdvertisements diff --git a/kable-core/src/jsMain/kotlin/external/BluetoothDevice.kt b/kable-core/src/jsMain/kotlin/external/BluetoothDevice.kt index d3be2fb42..ff50fbdc4 100644 --- a/kable-core/src/jsMain/kotlin/external/BluetoothDevice.kt +++ b/kable-core/src/jsMain/kotlin/external/BluetoothDevice.kt @@ -10,6 +10,17 @@ import kotlin.js.Promise internal abstract external class BluetoothDevice : EventTarget { val id: String val name: String? + + /** + * Non-`null` when: + * + * > [..] "bluetooth"'s extra permission data for `this`'s relevant settings object has an + * > `AllowedBluetoothDevice` _allowedDevice_ in its `allowedDevices` list with + * > `allowedDevice.device` the same device as `this.representedDevice` and + * > `allowedDevice.mayUseGATT` equal to `true` [..] + * + * https://webbluetoothcg.github.io/web-bluetooth/#bluetoothdevice-interface + */ val gatt: BluetoothRemoteGATTServer? // Experimental advertisement features diff --git a/kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTCharacteristic.kt b/kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTCharacteristic.kt index 8407bd42d..6bec05f63 100644 --- a/kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTCharacteristic.kt +++ b/kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTCharacteristic.kt @@ -25,9 +25,10 @@ internal external class BluetoothRemoteGATTCharacteristic : EventTarget { fun writeValueWithoutResponse(value: BufferSource): Promise /** - * > All notifications become inactive when a device is disconnected. A site that wants to keep getting - * > notifications after reconnecting needs to call [startNotifications] again, and there is an unavoidable risk - * > that some notifications will be missed in the gap before [startNotifications] takes effect. + * > All notifications become inactive when a device is disconnected. A site that wants to keep + * > getting notifications after reconnecting needs to call [startNotifications] again, and + * > there is an unavoidable risk that some notifications will be missed in the gap before + * > [startNotifications] takes effect. * * https://webbluetoothcg.github.io/web-bluetooth/#active-notification-context-set */ diff --git a/kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTServer.kt b/kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTServer.kt index 065d4d9da..4256b4416 100644 --- a/kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTServer.kt +++ b/kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTServer.kt @@ -12,7 +12,6 @@ internal external interface BluetoothRemoteGATTServer { val connected: Boolean fun connect(): Promise - fun disconnect(): Unit fun getPrimaryServices(): Promise> diff --git a/kable-core/src/jsMain/kotlin/logs/LogMessage.kt b/kable-core/src/jsMain/kotlin/logs/LogMessage.kt index 784cd7a14..97547aede 100644 --- a/kable-core/src/jsMain/kotlin/logs/LogMessage.kt +++ b/kable-core/src/jsMain/kotlin/logs/LogMessage.kt @@ -12,5 +12,6 @@ internal fun LogMessage.detail(data: DataView?, operation: Operation) { } internal fun LogMessage.detail(characteristic: BluetoothRemoteGATTCharacteristic) { - detail(characteristic.service.uuid, characteristic.uuid) + detail("service", characteristic.service.uuid) + detail("characteristic", characteristic.uuid) } diff --git a/kable-core/src/jsTest/kotlin/RequestPeripheralTests.kt b/kable-core/src/jsTest/kotlin/RequestPeripheralTests.kt index 080c3c525..226fcdf65 100644 --- a/kable-core/src/jsTest/kotlin/RequestPeripheralTests.kt +++ b/kable-core/src/jsTest/kotlin/RequestPeripheralTests.kt @@ -11,7 +11,7 @@ class RequestPeripheralTests { // In browser unit tests, bluetooth is not allowed per security restrictions. // In Node.js unit tests, bluetooth is unavailable. assertFailsWith { - requestPeripheral(Options {}, this) + requestPeripheral(Options {}) } } } diff --git a/kable-core/src/jvmMain/kotlin/com/juul/kable/Peripheral.deprecated.kt b/kable-core/src/jvmMain/kotlin/com/juul/kable/Peripheral.deprecated.kt new file mode 100644 index 000000000..8416b0948 --- /dev/null +++ b/kable-core/src/jvmMain/kotlin/com/juul/kable/Peripheral.deprecated.kt @@ -0,0 +1,12 @@ +package com.juul.kable + +import kotlinx.coroutines.CoroutineScope + +@Deprecated( + message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", + replaceWith = ReplaceWith("Peripheral(advertisement, builderAction)"), +) +public actual fun CoroutineScope.peripheral( + advertisement: Advertisement, + builderAction: PeripheralBuilderAction, +): Peripheral = Peripheral(advertisement, builderAction) diff --git a/kable-core/src/jvmMain/kotlin/com/juul/kable/Peripheral.kt b/kable-core/src/jvmMain/kotlin/com/juul/kable/Peripheral.kt index 2eb7dd1fd..eae44c47d 100644 --- a/kable-core/src/jvmMain/kotlin/com/juul/kable/Peripheral.kt +++ b/kable-core/src/jvmMain/kotlin/com/juul/kable/Peripheral.kt @@ -1,8 +1,6 @@ package com.juul.kable -import kotlinx.coroutines.CoroutineScope - -public actual fun CoroutineScope.peripheral( +public actual fun Peripheral( advertisement: Advertisement, builderAction: PeripheralBuilderAction, ): Peripheral { diff --git a/kable-core/src/jvmMain/kotlin/com/juul/kable/PeripheralBuilder.kt b/kable-core/src/jvmMain/kotlin/com/juul/kable/PeripheralBuilder.kt index 3ad7ee77d..066ce0061 100644 --- a/kable-core/src/jvmMain/kotlin/com/juul/kable/PeripheralBuilder.kt +++ b/kable-core/src/jvmMain/kotlin/com/juul/kable/PeripheralBuilder.kt @@ -1,6 +1,7 @@ package com.juul.kable import com.juul.kable.logs.LoggingBuilder +import kotlin.time.Duration public actual class ServicesDiscoveredPeripheral internal constructor() { @@ -41,4 +42,10 @@ public actual class PeripheralBuilder internal actual constructor() { public actual fun observationExceptionHandler(handler: ObservationExceptionHandler) { jvmNotImplementedException() } + + public actual var disconnectTimeout: Duration + get() = jvmNotImplementedException() + set(value) { + jvmNotImplementedException() + } } diff --git a/kable-exceptions/api/kable-exceptions.api b/kable-exceptions/api/kable-exceptions.api index 0b71b4d42..cb3412723 100644 --- a/kable-exceptions/api/kable-exceptions.api +++ b/kable-exceptions/api/kable-exceptions.api @@ -1,21 +1,9 @@ -public final class com/juul/kable/BluetoothDisabledException : com/juul/kable/BluetoothException { - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/Throwable;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -} - public class com/juul/kable/BluetoothException : java/lang/Exception { public fun ()V public fun (Ljava/lang/String;Ljava/lang/Throwable;)V public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } -public final class com/juul/kable/ConnectionLostException : com/juul/kable/NotConnectedException { - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/Throwable;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -} - public final class com/juul/kable/ConnectionRejectedException : java/io/IOException { public fun ()V public fun (Ljava/lang/String;Ljava/lang/Throwable;)V @@ -45,12 +33,6 @@ public class com/juul/kable/NotConnectedException : java/io/IOException { public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } -public final class com/juul/kable/NotReadyException : com/juul/kable/NotConnectedException { - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/Throwable;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V -} - public class com/juul/kable/UnmetRequirementException : java/io/IOException { public fun (Lcom/juul/kable/UnmetRequirementReason;Ljava/lang/String;Ljava/lang/Throwable;)V public synthetic fun (Lcom/juul/kable/UnmetRequirementReason;Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/kable-exceptions/src/commonMain/kotlin/Exceptions.deprecated.kt b/kable-exceptions/src/commonMain/kotlin/Exceptions.deprecated.kt new file mode 100644 index 000000000..58644f20b --- /dev/null +++ b/kable-exceptions/src/commonMain/kotlin/Exceptions.deprecated.kt @@ -0,0 +1,28 @@ +package com.juul.kable + +@Deprecated( + message = "BluetoothDisabledException replaced by UnmetRequirementException w/ a `reason` of `BluetoothDisabled`.", + replaceWith = ReplaceWith("UnmetRequirementException"), +) +public typealias BluetoothDisabledException = UnmetRequirementException + +@Deprecated( + message = "All connection loss exceptions are now represented as NotConnectedException.", + replaceWith = ReplaceWith("NotConnectedException"), +) +public typealias ConnectionLostException = NotConnectedException + +@Deprecated( + message = "Kable now uses kotlinx-io's IOException.", + replaceWith = ReplaceWith( + "IOException", + imports = ["kotlinx.io.IOException"], + ), +) +public typealias IOException = kotlinx.io.IOException + +@Deprecated( + message = "All connection loss exceptions are now represented as NotConnectedException.", + replaceWith = ReplaceWith("NotConnectedException"), +) +public typealias NotReadyException = NotConnectedException diff --git a/kable-exceptions/src/commonMain/kotlin/Exceptions.kt b/kable-exceptions/src/commonMain/kotlin/Exceptions.kt index c046dece5..8744ab9dc 100644 --- a/kable-exceptions/src/commonMain/kotlin/Exceptions.kt +++ b/kable-exceptions/src/commonMain/kotlin/Exceptions.kt @@ -13,20 +13,6 @@ public class LocationManagerUnavailableException( cause: Throwable? = null, ) : BluetoothException(message, cause) -public class BluetoothDisabledException( - message: String? = null, - cause: Throwable? = null, -) : BluetoothException(message, cause) - -@Deprecated( - message = "Kable now uses kotlinx-io's IOException.", - replaceWith = ReplaceWith( - "IOException", - imports = ["kotlinx.io.IOException"], - ), -) -public typealias IOException = kotlinx.io.IOException - public open class NotConnectedException( message: String? = null, cause: Throwable? = null, @@ -37,17 +23,7 @@ public class ConnectionRejectedException( cause: Throwable? = null, ) : IOException(message, cause) -public class NotReadyException( - message: String? = null, - cause: Throwable? = null, -) : NotConnectedException(message, cause) - public class GattStatusException( message: String? = null, cause: Throwable? = null, ) : IOException(message, cause) - -public class ConnectionLostException( - message: String? = null, - cause: Throwable? = null, -) : NotConnectedException(message, cause) diff --git a/kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt b/kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt index c7151226e..2f59686cd 100644 --- a/kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt +++ b/kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt @@ -1,14 +1,17 @@ package com.juul.kable +import kotlinx.io.IOException + public enum class UnmetRequirementReason { + /** Not applicable on JavaScript. */ + BluetoothDisabled, + /** * Only applicable on Android 11 (API 30) and lower, where location services are required to * perform a scan. */ LocationServicesDisabled, - - BluetoothDisabled, } public open class UnmetRequirementException(