From d478d3cb142f800cf5f762661a4df256c08d8a40 Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Wed, 7 Aug 2024 22:35:43 -0700 Subject: [PATCH 01/13] Escalate deprecations --- kable-core/api/android/kable-core.api | 11 +--- .../kotlin/AndroidAdvertisement.kt | 1 + .../androidMain/kotlin/AndroidPeripheral.kt | 1 + .../src/androidMain/kotlin/AndroidScanner.kt | 1 + .../BluetoothDeviceAndroidPeripheral.kt | 1 + .../kotlin/PlatformAdvertisement.kt | 2 +- .../src/androidMain/kotlin/ScannerBuilder.kt | 4 +- .../kotlin/WriteNotificationDescriptor.kt | 38 ------------- .../kotlin/broadcastReceiverFlow.kt | 2 +- .../src/appleMain/kotlin/ApplePeripheral.kt | 1 + .../appleMain/kotlin/CoreBluetoothScanner.kt | 1 + .../src/appleMain/kotlin/PeripheralBuilder.kt | 2 +- .../src/appleMain/kotlin/ScannerBuilder.kt | 4 +- .../src/commonMain/kotlin/ScannerBuilder.kt | 2 +- kable-core/src/jsMain/kotlin/Bluetooth.kt | 10 +--- kable-core/src/jsMain/kotlin/FilterSet.kt | 38 ------------- kable-core/src/jsMain/kotlin/JsPeripheral.kt | 1 + .../src/jsMain/kotlin/Options.deprecated.kt | 14 +++++ kable-core/src/jsMain/kotlin/Options.kt | 57 +------------------ .../src/jsMain/kotlin/OptionsBuilder.kt | 6 +- .../src/jsMain/kotlin/PeripheralBuilder.kt | 2 +- .../src/jsMain/kotlin/ScannerBuilder.kt | 4 +- .../kotlin/WebBluetoothAdvertisement.kt | 1 + .../src/jsMain/kotlin/WebBluetoothScanner.kt | 1 + .../kotlin/com/juul/kable/ScannerBuilder.kt | 2 +- 25 files changed, 42 insertions(+), 165 deletions(-) delete mode 100644 kable-core/src/androidMain/kotlin/WriteNotificationDescriptor.kt create mode 100644 kable-core/src/jsMain/kotlin/Options.deprecated.kt diff --git a/kable-core/api/android/kable-core.api b/kable-core/api/android/kable-core.api index 1cffc0f03..7745938da 100644 --- a/kable-core/api/android/kable-core.api +++ b/kable-core/api/android/kable-core.api @@ -84,7 +84,7 @@ public final class com/juul/kable/Bluetooth$BaseUuid { } public final class com/juul/kable/BroadcastReceiverFlowKt { - public static final fun broadcastReceiverFlow (Landroid/content/IntentFilter;)Lkotlinx/coroutines/flow/Flow; + public static final synthetic fun broadcastReceiverFlow (Landroid/content/IntentFilter;)Lkotlinx/coroutines/flow/Flow; } public abstract interface class com/juul/kable/Characteristic { @@ -521,15 +521,6 @@ public final class com/juul/kable/Transport : java/lang/Enum { public static fun values ()[Lcom/juul/kable/Transport; } -public final class com/juul/kable/WriteNotificationDescriptor : java/lang/Enum { - public static final field Always Lcom/juul/kable/WriteNotificationDescriptor; - public static final field Auto Lcom/juul/kable/WriteNotificationDescriptor; - public static final field Never Lcom/juul/kable/WriteNotificationDescriptor; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/WriteNotificationDescriptor; - public static fun values ()[Lcom/juul/kable/WriteNotificationDescriptor; -} - public final class com/juul/kable/WriteType : java/lang/Enum { public static final field WithResponse Lcom/juul/kable/WriteType; public static final field WithoutResponse Lcom/juul/kable/WriteType; diff --git a/kable-core/src/androidMain/kotlin/AndroidAdvertisement.kt b/kable-core/src/androidMain/kotlin/AndroidAdvertisement.kt index d59f8b67a..2dbdd4245 100644 --- a/kable-core/src/androidMain/kotlin/AndroidAdvertisement.kt +++ b/kable-core/src/androidMain/kotlin/AndroidAdvertisement.kt @@ -3,5 +3,6 @@ package com.juul.kable @Deprecated( "Moved to `PlatformAdvertisement`", replaceWith = ReplaceWith("PlatformAdvertisement"), + level = DeprecationLevel.ERROR, ) public typealias AndroidAdvertisement = PlatformAdvertisement diff --git a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt index 10490895c..25906baf8 100644 --- a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow @Deprecated( message = "Moved as nested class of `AndroidPeripheral`.", replaceWith = ReplaceWith("AndroidPeripheral.Priority"), + level = DeprecationLevel.ERROR, ) public typealias Priority = AndroidPeripheral.Priority diff --git a/kable-core/src/androidMain/kotlin/AndroidScanner.kt b/kable-core/src/androidMain/kotlin/AndroidScanner.kt index d8ac47e1a..7b0a4ca60 100644 --- a/kable-core/src/androidMain/kotlin/AndroidScanner.kt +++ b/kable-core/src/androidMain/kotlin/AndroidScanner.kt @@ -3,5 +3,6 @@ package com.juul.kable @Deprecated( "Moved to PlatformScanner.", replaceWith = ReplaceWith("PlatformScanner"), + level = DeprecationLevel.ERROR, ) public typealias AndroidScanner = PlatformScanner diff --git a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt index 52e375b36..a220a49dd 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt @@ -17,6 +17,7 @@ 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 diff --git a/kable-core/src/androidMain/kotlin/PlatformAdvertisement.kt b/kable-core/src/androidMain/kotlin/PlatformAdvertisement.kt index 5f072baac..e887766e6 100644 --- a/kable-core/src/androidMain/kotlin/PlatformAdvertisement.kt +++ b/kable-core/src/androidMain/kotlin/PlatformAdvertisement.kt @@ -5,7 +5,7 @@ import android.os.Parcelable @Deprecated( message = "Moved as nested class of `PlatformAdvertisement`.", replaceWith = ReplaceWith("PlatformAdvertisement.BondState"), - level = DeprecationLevel.ERROR, + level = DeprecationLevel.HIDDEN, ) public typealias BondState = PlatformAdvertisement.BondState diff --git a/kable-core/src/androidMain/kotlin/ScannerBuilder.kt b/kable-core/src/androidMain/kotlin/ScannerBuilder.kt index 867a9bbe5..c0152c0bb 100644 --- a/kable-core/src/androidMain/kotlin/ScannerBuilder.kt +++ b/kable-core/src/androidMain/kotlin/ScannerBuilder.kt @@ -12,7 +12,7 @@ public actual class ScannerBuilder { @Deprecated( message = "Use filters(FiltersBuilder.() -> Unit)", replaceWith = ReplaceWith("filters { }"), - level = DeprecationLevel.WARNING, + level = DeprecationLevel.ERROR, ) public actual var filters: List? = null @@ -52,7 +52,7 @@ public actual class ScannerBuilder { @OptIn(ObsoleteKableApi::class) internal actual fun build(): PlatformScanner = BluetoothLeScannerAndroidScanner( - filters = filters?.convertDeprecatedFilters() ?: filterPredicates, + filters = filterPredicates, scanSettings = scanSettings, logging = logging, preConflate = preConflate, diff --git a/kable-core/src/androidMain/kotlin/WriteNotificationDescriptor.kt b/kable-core/src/androidMain/kotlin/WriteNotificationDescriptor.kt deleted file mode 100644 index 69f520bfa..000000000 --- a/kable-core/src/androidMain/kotlin/WriteNotificationDescriptor.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.juul.kable - -/** Mode specifying if config descriptor (0x2902) should be written to when starting/stopping an observation. */ -@Deprecated( - message = "Writing notification descriptor is handled automatically by 'observe' function. " + - "This class is no longer used and will be removed in a future release.", - level = DeprecationLevel.HIDDEN, -) -public enum class WriteNotificationDescriptor { - - /** - * Always write to config descriptor for characteristic being observed. If it does not exist then an exception is - * thrown when starting/stopping the observation. - * - * This is the default configuration on Android and matches the only supported behavior for Apple and JavaScript. - */ - Always, - - /** - * Never write to config descriptor for characteristic being observed, regardless of its availability. - * - * **Warning:** This option is only supported on Android. If the remote peripheral does not have a config descriptor - * associated with characteristic being observed, then the observation will only work on Android and will fail on - * other targets (e.g. Apple, JavaScript). - */ - Never, - - /** - * If config descriptor exists for characteristic being observed, then it will be written to when starting/stopping - * observations. If it does not exist, then automatically fallback to only enabling/disabling notifications (without - * writing to config descriptor). - * - * **Warning:** This option is only supported on Android. If the remote peripheral does not have a config descriptor - * associated with characteristic being observed, then the observation will only work on Android and will fail on - * other targets (e.g. Apple, JavaScript). - */ - Auto, -} diff --git a/kable-core/src/androidMain/kotlin/broadcastReceiverFlow.kt b/kable-core/src/androidMain/kotlin/broadcastReceiverFlow.kt index 07b2705c5..15f38f2cc 100644 --- a/kable-core/src/androidMain/kotlin/broadcastReceiverFlow.kt +++ b/kable-core/src/androidMain/kotlin/broadcastReceiverFlow.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.Flow "broadcastReceiverFlow(intentFilter)", "com.juul.tuulbox.coroutines.flow.broadcastReceiverFlow", ), - level = DeprecationLevel.ERROR, + level = DeprecationLevel.HIDDEN, ) public fun broadcastReceiverFlow( intentFilter: IntentFilter, diff --git a/kable-core/src/appleMain/kotlin/ApplePeripheral.kt b/kable-core/src/appleMain/kotlin/ApplePeripheral.kt index 098418606..48e7e235e 100644 --- a/kable-core/src/appleMain/kotlin/ApplePeripheral.kt +++ b/kable-core/src/appleMain/kotlin/ApplePeripheral.kt @@ -6,5 +6,6 @@ package com.juul.kable expression = "CoreBluetoothPeripheral", imports = ["com.juul.kable.CoreBluetoothPeripheral"], ), + level = DeprecationLevel.ERROR, ) public typealias ApplePeripheral = CoreBluetoothPeripheral diff --git a/kable-core/src/appleMain/kotlin/CoreBluetoothScanner.kt b/kable-core/src/appleMain/kotlin/CoreBluetoothScanner.kt index 0f52585f6..3ebe197f2 100644 --- a/kable-core/src/appleMain/kotlin/CoreBluetoothScanner.kt +++ b/kable-core/src/appleMain/kotlin/CoreBluetoothScanner.kt @@ -3,5 +3,6 @@ package com.juul.kable @Deprecated( "Moved to PlatformScanner.", replaceWith = ReplaceWith("PlatformScanner"), + level = DeprecationLevel.ERROR, ) public typealias CoreBluetoothScanner = PlatformScanner diff --git a/kable-core/src/appleMain/kotlin/PeripheralBuilder.kt b/kable-core/src/appleMain/kotlin/PeripheralBuilder.kt index 073627c06..9315802f4 100644 --- a/kable-core/src/appleMain/kotlin/PeripheralBuilder.kt +++ b/kable-core/src/appleMain/kotlin/PeripheralBuilder.kt @@ -5,7 +5,7 @@ import com.juul.kable.logs.LoggingBuilder import kotlin.coroutines.cancellation.CancellationException public actual class ServicesDiscoveredPeripheral internal constructor( - private val peripheral: ApplePeripheral, + private val peripheral: CoreBluetoothPeripheral, ) { @Throws(CancellationException::class, IOException::class, NotReadyException::class) diff --git a/kable-core/src/appleMain/kotlin/ScannerBuilder.kt b/kable-core/src/appleMain/kotlin/ScannerBuilder.kt index 336528ac0..7ad496251 100644 --- a/kable-core/src/appleMain/kotlin/ScannerBuilder.kt +++ b/kable-core/src/appleMain/kotlin/ScannerBuilder.kt @@ -11,7 +11,7 @@ public actual class ScannerBuilder { @Deprecated( message = "Use filters(FiltersBuilder.() -> Unit)", replaceWith = ReplaceWith("filters { }"), - level = DeprecationLevel.WARNING, + level = DeprecationLevel.ERROR, ) public actual var filters: List? = null @@ -51,7 +51,7 @@ public actual class ScannerBuilder { return CentralManagerCoreBluetoothScanner( central = CentralManager.Default, - filters = filters?.convertDeprecatedFilters() ?: filterPredicates, + filters = filterPredicates, options = options.toMap(), logging = logging, ) diff --git a/kable-core/src/commonMain/kotlin/ScannerBuilder.kt b/kable-core/src/commonMain/kotlin/ScannerBuilder.kt index b030125a6..c271e435f 100644 --- a/kable-core/src/commonMain/kotlin/ScannerBuilder.kt +++ b/kable-core/src/commonMain/kotlin/ScannerBuilder.kt @@ -12,7 +12,7 @@ public expect class ScannerBuilder internal constructor() { @Deprecated( message = "Use filters(FiltersBuilder.() -> Unit)", replaceWith = ReplaceWith("filters { }"), - level = DeprecationLevel.WARNING, + level = DeprecationLevel.ERROR, ) public var filters: List? diff --git a/kable-core/src/jsMain/kotlin/Bluetooth.kt b/kable-core/src/jsMain/kotlin/Bluetooth.kt index 1872dd917..249a1d6e4 100644 --- a/kable-core/src/jsMain/kotlin/Bluetooth.kt +++ b/kable-core/src/jsMain/kotlin/Bluetooth.kt @@ -5,7 +5,6 @@ import com.juul.kable.Bluetooth.Availability.Available import com.juul.kable.Bluetooth.Availability.Unavailable import com.juul.kable.Reason.BluetoothUndefined import com.juul.kable.external.BluetoothAvailabilityChanged -import com.juul.kable.external.BluetoothLEScanFilterInit import com.juul.kable.external.BluetoothServiceUUID import com.juul.kable.external.RequestDeviceOptions import js.objects.jso @@ -87,7 +86,7 @@ public fun CoroutineScope.requestPeripheral( * ``` */ private fun Options.toRequestDeviceOptions(): RequestDeviceOptions = jso { - val jsFilters = this@toRequestDeviceOptions.filters() + val jsFilters = this@toRequestDeviceOptions.filters.toBluetoothLEScanFilterInit() if (jsFilters.isNotEmpty()) { filters = jsFilters.toTypedArray() } else { @@ -100,12 +99,5 @@ private fun Options.toRequestDeviceOptions(): RequestDeviceOptions = jso { } } -private fun Options.filters(): List = - if (filterSets?.isNotEmpty() == true) { - filterSets.toBluetoothLEScanFilterInit() - } else { - filterPredicates.toBluetoothLEScanFilterInit() - } - // Note: Web Bluetooth requires that UUIDs be provided as lowercase strings. internal fun Uuid.toBluetoothServiceUUID(): BluetoothServiceUUID = toString().lowercase() diff --git a/kable-core/src/jsMain/kotlin/FilterSet.kt b/kable-core/src/jsMain/kotlin/FilterSet.kt index 0d197549a..d6f55eba3 100644 --- a/kable-core/src/jsMain/kotlin/FilterSet.kt +++ b/kable-core/src/jsMain/kotlin/FilterSet.kt @@ -1,10 +1,5 @@ package com.juul.kable -import com.benasher44.uuid.Uuid -import com.juul.kable.external.BluetoothLEScanFilterInit -import com.juul.kable.external.BluetoothManufacturerDataFilterInit -import js.objects.jso - /** * Filtering on Service Data is not supported because it is not implemented: * https://github.com/WebBluetoothCG/web-bluetooth/blob/main/implementation-status.md @@ -31,36 +26,3 @@ public data class FilterSet( public val namePrefix: Filter.Name.Prefix? = null, public val manufacturerData: List = emptyList(), ) - -internal fun FilterSet.toBluetoothLEScanFilterInit(): BluetoothLEScanFilterInit { - val filter = jso() - if (services.isNotEmpty()) { - filter.services = services - .map(Filter.Service::uuid) - .map(Uuid::toBluetoothServiceUUID) - .toTypedArray() - } - if (name != null) { - filter.name = name.exact - } - if (namePrefix != null) { - filter.namePrefix = namePrefix.prefix - } - if (manufacturerData.isNotEmpty()) { - filter.manufacturerData = manufacturerData - .map { filter -> - jso { - companyIdentifier = filter.id - dataPrefix = filter.data - if (filter.dataMask != null) { - mask = filter.dataMask - } - } - } - .toTypedArray() - } - return filter -} - -internal fun List.toBluetoothLEScanFilterInit(): List = - map(FilterSet::toBluetoothLEScanFilterInit) diff --git a/kable-core/src/jsMain/kotlin/JsPeripheral.kt b/kable-core/src/jsMain/kotlin/JsPeripheral.kt index 8157bcb5b..116078aea 100644 --- a/kable-core/src/jsMain/kotlin/JsPeripheral.kt +++ b/kable-core/src/jsMain/kotlin/JsPeripheral.kt @@ -6,5 +6,6 @@ package com.juul.kable expression = "WebBluetoothPeripheral", imports = ["com.juul.kable.WebBluetoothPeripheral"], ), + level = DeprecationLevel.ERROR, ) public typealias JsPeripheral = WebBluetoothPeripheral diff --git a/kable-core/src/jsMain/kotlin/Options.deprecated.kt b/kable-core/src/jsMain/kotlin/Options.deprecated.kt new file mode 100644 index 000000000..e8c493dd3 --- /dev/null +++ b/kable-core/src/jsMain/kotlin/Options.deprecated.kt @@ -0,0 +1,14 @@ +package com.juul.kable + +import com.benasher44.uuid.Uuid + +@Deprecated( + message = "Use Options builder instead. See https://github.com/JuulLabs/kable/issues/723 for details.", + replaceWith = ReplaceWith("Options { }"), + level = DeprecationLevel.ERROR, +) +public fun Options( + filters: List? = null, + filterSets: List? = null, + optionalServices: List? = null, +): Options = throw NotImplementedError("Use Options builder instead. See https://github.com/JuulLabs/kable/issues/723 for details.") diff --git a/kable-core/src/jsMain/kotlin/Options.kt b/kable-core/src/jsMain/kotlin/Options.kt index 8b3b51047..31159a789 100644 --- a/kable-core/src/jsMain/kotlin/Options.kt +++ b/kable-core/src/jsMain/kotlin/Options.kt @@ -6,60 +6,7 @@ import com.benasher44.uuid.Uuid public fun Options(builder: OptionsBuilder.() -> Unit): Options = OptionsBuilder().apply(builder).build() -@Deprecated( - message = "Use Options builder instead. See https://github.com/JuulLabs/kable/issues/723 for details.", - replaceWith = ReplaceWith("Options { }"), -) -public fun Options( - filters: List? = null, - filterSets: List? = null, - optionalServices: List? = null, -): Options = Options { - filters { - if (filters != null) { - filters.forEach { filter -> - match { - when (filter) { - is Filter.Name -> name = filter - is Filter.Service -> services = listOf(filter.uuid) - is Filter.ManufacturerData -> manufacturerData = listOf(filter) - is Filter.Address -> { /* no-op */ } - } - } - } - } else if (filterSets != null) { - filterSets.forEach { filterSet -> - match { - name = filterSet.name - services = filterSet.services.map { it.uuid } - manufacturerData = filterSet.manufacturerData - } - } - } - } - this.optionalServices = optionalServices -} - public data class Options internal constructor( - - @Deprecated( - message = "Replaced with filters builder DSL. See https://github.com/JuulLabs/kable/issues/723 for details.", - replaceWith = ReplaceWith("Options { filters { } }"), - level = DeprecationLevel.WARNING, - ) - val filterSets: List? = null, - - /** - * Access is only granted to services listed as [service filters][Filter.Service] in [filters]. If any additional - * services need to be accessed, they must be specified in [optionalServices]. - * - * https://webbluetoothcg.github.io/web-bluetooth/#device-discovery - */ - @Deprecated( - message = "Use Options builder instead. See https://github.com/JuulLabs/kable/issues/723 for details.", - replaceWith = ReplaceWith("Options { optionalServices = listOf() }"), - ) - val optionalServices: List? = null, - - internal val filterPredicates: List, + internal val filters: List, + internal val optionalServices: List?, ) diff --git a/kable-core/src/jsMain/kotlin/OptionsBuilder.kt b/kable-core/src/jsMain/kotlin/OptionsBuilder.kt index 96fe55705..35245993c 100644 --- a/kable-core/src/jsMain/kotlin/OptionsBuilder.kt +++ b/kable-core/src/jsMain/kotlin/OptionsBuilder.kt @@ -4,7 +4,7 @@ import com.benasher44.uuid.Uuid public class OptionsBuilder internal constructor() { - private var filterPredicates: List = emptyList() + private var filters: List = emptyList() /** * Filters to apply when requesting devices. If predicates are non-empty, then only devices @@ -17,7 +17,7 @@ public class OptionsBuilder internal constructor() { * https://github.com/WebBluetoothCG/web-bluetooth/blob/main/data-filters-explainer.md */ public fun filters(builder: FiltersBuilder.() -> Unit) { - filterPredicates = FiltersBuilder().apply(builder).build() + filters = FiltersBuilder().apply(builder).build() } /** @@ -28,5 +28,5 @@ public class OptionsBuilder internal constructor() { */ public var optionalServices: List? = null - internal fun build() = Options(null, optionalServices, filterPredicates) + internal fun build() = Options(filters, optionalServices) } diff --git a/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt b/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt index 9c56c1017..c0ff49807 100644 --- a/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt +++ b/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt @@ -4,7 +4,7 @@ import com.juul.kable.logs.Logging import com.juul.kable.logs.LoggingBuilder public actual class ServicesDiscoveredPeripheral internal constructor( - private val peripheral: JsPeripheral, + private val peripheral: WebBluetoothPeripheral, ) { public actual suspend fun read( diff --git a/kable-core/src/jsMain/kotlin/ScannerBuilder.kt b/kable-core/src/jsMain/kotlin/ScannerBuilder.kt index 7e45b2c0b..c14add5b7 100644 --- a/kable-core/src/jsMain/kotlin/ScannerBuilder.kt +++ b/kable-core/src/jsMain/kotlin/ScannerBuilder.kt @@ -8,7 +8,7 @@ public actual class ScannerBuilder { @Deprecated( message = "Use filters(FiltersBuilder.() -> Unit)", replaceWith = ReplaceWith("filters { }"), - level = DeprecationLevel.WARNING, + level = DeprecationLevel.ERROR, ) public actual var filters: List? = null @@ -36,7 +36,7 @@ public actual class ScannerBuilder { internal actual fun build(): PlatformScanner = BluetoothWebBluetoothScanner( bluetooth = bluetooth, - filters = filters?.convertDeprecatedFilters() ?: filterPredicates, + filters = filterPredicates, logging = logging, ) } diff --git a/kable-core/src/jsMain/kotlin/WebBluetoothAdvertisement.kt b/kable-core/src/jsMain/kotlin/WebBluetoothAdvertisement.kt index e634062bd..a46ad44c7 100644 --- a/kable-core/src/jsMain/kotlin/WebBluetoothAdvertisement.kt +++ b/kable-core/src/jsMain/kotlin/WebBluetoothAdvertisement.kt @@ -3,5 +3,6 @@ package com.juul.kable @Deprecated( "Moved to PlatformAdvertisement.", replaceWith = ReplaceWith("PlatformAdvertisement"), + level = DeprecationLevel.ERROR, ) public typealias WebBluetoothAdvertisement = PlatformAdvertisement diff --git a/kable-core/src/jsMain/kotlin/WebBluetoothScanner.kt b/kable-core/src/jsMain/kotlin/WebBluetoothScanner.kt index 52437ea31..02bf35c94 100644 --- a/kable-core/src/jsMain/kotlin/WebBluetoothScanner.kt +++ b/kable-core/src/jsMain/kotlin/WebBluetoothScanner.kt @@ -3,5 +3,6 @@ package com.juul.kable @Deprecated( "Moved to PlatformScanner.", replaceWith = ReplaceWith("PlatformScanner"), + level = DeprecationLevel.ERROR, ) public typealias WebBluetoothScanner = PlatformScanner diff --git a/kable-core/src/jvmMain/kotlin/com/juul/kable/ScannerBuilder.kt b/kable-core/src/jvmMain/kotlin/com/juul/kable/ScannerBuilder.kt index e3ab834ab..9989ce8c8 100644 --- a/kable-core/src/jvmMain/kotlin/com/juul/kable/ScannerBuilder.kt +++ b/kable-core/src/jvmMain/kotlin/com/juul/kable/ScannerBuilder.kt @@ -7,7 +7,7 @@ public actual class ScannerBuilder { @Deprecated( message = "Use filters(FiltersBuilder.() -> Unit)", replaceWith = ReplaceWith("filters { }"), - level = DeprecationLevel.WARNING, + level = DeprecationLevel.ERROR, ) public actual var filters: List? = null From 733a3edf024cb1c699279cc300280802ef6be0e4 Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Thu, 8 Aug 2024 13:48:17 -0700 Subject: [PATCH 02/13] Unify scan exceptions --- README.md | 10 +- kable-core/api/android/kable-core.api | 38 +++--- kable-core/api/jvm/kable-core.api | 25 ++-- .../src/androidMain/kotlin/Bluetooth.kt | 83 ++++++++----- .../androidMain/kotlin/BluetoothAdapter.kt | 34 +++-- .../BluetoothLeScannerAndroidScanner.kt | 24 +++- .../src/androidMain/kotlin/Exceptions.kt | 4 - .../src/androidMain/kotlin/scan/ScanError.kt | 47 +++++++ kable-core/src/appleMain/kotlin/Bluetooth.kt | 14 +-- .../CentralManagerCoreBluetoothScanner.kt | 13 +- kable-core/src/commonMain/kotlin/Bluetooth.kt | 19 +-- kable-core/src/commonMain/kotlin/Filter.kt | 10 ++ kable-core/src/commonMain/kotlin/Scanner.kt | 11 ++ .../src/commonMain/kotlin/ScannerBuilder.kt | 2 + .../src/jsMain/kotlin/Bluetooth.deprecated.kt | 15 +++ kable-core/src/jsMain/kotlin/Bluetooth.kt | 117 ++++-------------- .../jsMain/kotlin/BluetoothAvailability.kt | 42 +++++++ .../jsMain/kotlin/BluetoothLEScanOptions.kt | 5 +- .../kotlin/BluetoothWebBluetoothScanner.kt | 70 +++++++++-- kable-core/src/jsMain/kotlin/Options.kt | 21 +++- .../src/jsMain/kotlin/OptionsBuilder.kt | 2 +- kable-core/src/jsMain/kotlin/Peripheral.kt | 24 ++-- .../src/jsMain/kotlin/PeripheralBuilder.kt | 11 ++ .../kotlin/RequestPeripheral.deprecated.kt | 15 +++ .../src/jsMain/kotlin/RequestPeripheral.kt | 83 +++++++++++++ .../src/jsMain/kotlin/ScannerBuilder.kt | 1 - .../src/jsMain/kotlin/{UUID.kt => Uuid.kt} | 9 ++ .../src/jsMain/kotlin/external/Navigator.kt | 7 ++ .../src/jsTest/kotlin/BluetoothJsTests.kt | 27 ++++ kable-core/src/jsTest/kotlin/Environment.kt | 9 ++ .../jsTest/kotlin/RequestPeripheralTests.kt | 17 +++ .../kotlin/com/juul/kable/Bluetooth.kt | 2 +- kable-exceptions/api/kable-exceptions.api | 18 +++ .../src/commonMain/kotlin/Exceptions.kt | 9 -- .../src/commonMain/kotlin/IOException.kt | 10 ++ .../commonMain/kotlin/InternalException.kt | 6 + .../kotlin/UnmetRequirementException.kt | 11 ++ 37 files changed, 624 insertions(+), 241 deletions(-) create mode 100644 kable-core/src/androidMain/kotlin/scan/ScanError.kt create mode 100644 kable-core/src/jsMain/kotlin/Bluetooth.deprecated.kt create mode 100644 kable-core/src/jsMain/kotlin/BluetoothAvailability.kt create mode 100644 kable-core/src/jsMain/kotlin/RequestPeripheral.deprecated.kt create mode 100644 kable-core/src/jsMain/kotlin/RequestPeripheral.kt rename kable-core/src/jsMain/kotlin/{UUID.kt => Uuid.kt} (65%) create mode 100644 kable-core/src/jsMain/kotlin/external/Navigator.kt create mode 100644 kable-core/src/jsTest/kotlin/BluetoothJsTests.kt create mode 100644 kable-core/src/jsTest/kotlin/Environment.kt create mode 100644 kable-core/src/jsTest/kotlin/RequestPeripheralTests.kt create mode 100644 kable-exceptions/src/commonMain/kotlin/IOException.kt create mode 100644 kable-exceptions/src/commonMain/kotlin/InternalException.kt create mode 100644 kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt diff --git a/README.md b/README.md index 038e6195e..be131ee65 100644 --- a/README.md +++ b/README.md @@ -287,10 +287,10 @@ while (peripheral.state.value != Connected) { ### JavaScript On JavaScript, rather than processing a stream of advertisements, a specific peripheral can be requested using the -[`CoroutineScope.requestPeripheral`] extension function. Criteria ([`Options`]) such as expected service UUIDs on the -peripheral and/or the peripheral's name may be specified. When [`CoroutineScope.requestPeripheral`] is called with the -specified options, the browser shows the user a list of peripherals matching the criteria. The peripheral chosen by the -user is then returned (as a [`Peripheral`] object). +[`requestPeripheral`] function. Criteria ([`Options`]) such as expected service UUIDs on the peripheral and/or the +peripheral's name may be specified. When [`requestPeripheral`] is called with the specified options, the browser shows +the user a list of peripherals matching the criteria. The peripheral chosen by the user is then returned (as a +[`Peripheral`] object). If user cancels the dialog, then [`requestPeripheral`] returns `null`. ```kotlin val options = Options { @@ -304,7 +304,7 @@ val options = Options { uuidFrom("f000aa81-0451-4000-b000-000000000000"), ) } -val peripheral = scope.requestPeripheral(options).await() +val peripheral = requestPeripheral(options, scope) ``` > After the user selects a device to pair with this origin, the origin is allowed to access any service whose UUID was diff --git a/kable-core/api/android/kable-core.api b/kable-core/api/android/kable-core.api index 7745938da..67f73bed4 100644 --- a/kable-core/api/android/kable-core.api +++ b/kable-core/api/android/kable-core.api @@ -52,6 +52,17 @@ public final class com/juul/kable/AndroidPeripheral$WriteResult : java/lang/Enum public static fun values ()[Lcom/juul/kable/AndroidPeripheral$WriteResult; } +public final class com/juul/kable/AvailabilityReason : java/lang/Enum { + public static final field AdapterNotAvailable Lcom/juul/kable/AvailabilityReason; + public static final field LocationServicesDisabled Lcom/juul/kable/AvailabilityReason; + public static final field Off Lcom/juul/kable/AvailabilityReason; + public static final field TurningOff Lcom/juul/kable/AvailabilityReason; + public static final field TurningOn Lcom/juul/kable/AvailabilityReason; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/AvailabilityReason; + public static fun values ()[Lcom/juul/kable/AvailabilityReason; +} + public final class com/juul/kable/Bluetooth { public static final field INSTANCE Lcom/juul/kable/Bluetooth; public final fun getAvailability ()Lkotlinx/coroutines/flow/Flow; @@ -62,16 +73,18 @@ public abstract class com/juul/kable/Bluetooth$Availability { public final class com/juul/kable/Bluetooth$Availability$Available : com/juul/kable/Bluetooth$Availability { public static final field INSTANCE Lcom/juul/kable/Bluetooth$Availability$Available; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/juul/kable/Bluetooth$Availability$Unavailable : com/juul/kable/Bluetooth$Availability { - public fun (Lcom/juul/kable/Reason;)V - public final fun component1 ()Lcom/juul/kable/Reason; - public final fun copy (Lcom/juul/kable/Reason;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; - public static synthetic fun copy$default (Lcom/juul/kable/Bluetooth$Availability$Unavailable;Lcom/juul/kable/Reason;ILjava/lang/Object;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; + public fun (Lcom/juul/kable/AvailabilityReason;)V + public final fun component1 ()Lcom/juul/kable/AvailabilityReason; + public final fun copy (Lcom/juul/kable/AvailabilityReason;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; + public static synthetic fun copy$default (Lcom/juul/kable/Bluetooth$Availability$Unavailable;Lcom/juul/kable/AvailabilityReason;ILjava/lang/Object;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; public fun equals (Ljava/lang/Object;)Z - public final fun getReason ()Lcom/juul/kable/Reason; + public final fun getReason ()Lcom/juul/kable/AvailabilityReason; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -168,6 +181,7 @@ public final class com/juul/kable/Filter$ManufacturerData : com/juul/kable/Filte public final fun getData ()[B public final fun getDataMask ()[B public final fun getId ()I + public fun toString ()Ljava/lang/String; } public abstract class com/juul/kable/Filter$Name : com/juul/kable/Filter { @@ -361,20 +375,6 @@ public final class com/juul/kable/ProfileKt { public static final fun getWriteWithoutResponse-G25LNqA (I)Z } -public final class com/juul/kable/Reason : java/lang/Enum { - public static final field LocationServicesDisabled Lcom/juul/kable/Reason; - public static final field Off Lcom/juul/kable/Reason; - public static final field TurningOff Lcom/juul/kable/Reason; - public static final field TurningOn Lcom/juul/kable/Reason; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/Reason; - public static fun values ()[Lcom/juul/kable/Reason; -} - -public final class com/juul/kable/ScanFailedException : java/lang/IllegalStateException { - public final fun getErrorCode ()I -} - public final class com/juul/kable/ScanResultAndroidAdvertisement$Creator : android/os/Parcelable$Creator { public fun ()V public final fun createFromParcel (Landroid/os/Parcel;)Lcom/juul/kable/ScanResultAndroidAdvertisement; diff --git a/kable-core/api/jvm/kable-core.api b/kable-core/api/jvm/kable-core.api index 0178059e5..fbcc88375 100644 --- a/kable-core/api/jvm/kable-core.api +++ b/kable-core/api/jvm/kable-core.api @@ -11,6 +11,12 @@ public abstract interface class com/juul/kable/Advertisement { public abstract fun serviceData (Ljava/util/UUID;)[B } +public final class com/juul/kable/AvailabilityReason : java/lang/Enum { + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/AvailabilityReason; + public static fun values ()[Lcom/juul/kable/AvailabilityReason; +} + public final class com/juul/kable/Bluetooth { public static final field INSTANCE Lcom/juul/kable/Bluetooth; public final fun getAvailability ()Lkotlinx/coroutines/flow/Flow; @@ -21,16 +27,18 @@ public abstract class com/juul/kable/Bluetooth$Availability { public final class com/juul/kable/Bluetooth$Availability$Available : com/juul/kable/Bluetooth$Availability { public static final field INSTANCE Lcom/juul/kable/Bluetooth$Availability$Available; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/juul/kable/Bluetooth$Availability$Unavailable : com/juul/kable/Bluetooth$Availability { - public fun (Lcom/juul/kable/Reason;)V - public final fun component1 ()Lcom/juul/kable/Reason; - public final fun copy (Lcom/juul/kable/Reason;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; - public static synthetic fun copy$default (Lcom/juul/kable/Bluetooth$Availability$Unavailable;Lcom/juul/kable/Reason;ILjava/lang/Object;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; + public fun (Lcom/juul/kable/AvailabilityReason;)V + public final fun component1 ()Lcom/juul/kable/AvailabilityReason; + public final fun copy (Lcom/juul/kable/AvailabilityReason;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; + public static synthetic fun copy$default (Lcom/juul/kable/Bluetooth$Availability$Unavailable;Lcom/juul/kable/AvailabilityReason;ILjava/lang/Object;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; public fun equals (Ljava/lang/Object;)Z - public final fun getReason ()Lcom/juul/kable/Reason; + public final fun getReason ()Lcom/juul/kable/AvailabilityReason; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -109,6 +117,7 @@ public final class com/juul/kable/Filter$ManufacturerData : com/juul/kable/Filte public final fun getData ()[B public final fun getDataMask ()[B public final fun getId ()I + public fun toString ()Ljava/lang/String; } public abstract class com/juul/kable/Filter$Name : com/juul/kable/Filter { @@ -253,12 +262,6 @@ public final class com/juul/kable/ProfileKt { public static final fun getWriteWithoutResponse-G25LNqA (I)Z } -public final class com/juul/kable/Reason : java/lang/Enum { - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/Reason; - public static fun values ()[Lcom/juul/kable/Reason; -} - public abstract interface class com/juul/kable/Scanner { public abstract fun getAdvertisements ()Lkotlinx/coroutines/flow/Flow; } diff --git a/kable-core/src/androidMain/kotlin/Bluetooth.kt b/kable-core/src/androidMain/kotlin/Bluetooth.kt index 8b383cc1d..b3d3530d3 100644 --- a/kable-core/src/androidMain/kotlin/Bluetooth.kt +++ b/kable-core/src/androidMain/kotlin/Bluetooth.kt @@ -6,6 +6,7 @@ import android.bluetooth.BluetoothAdapter.STATE_OFF import android.bluetooth.BluetoothAdapter.STATE_ON import android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF import android.bluetooth.BluetoothAdapter.STATE_TURNING_ON +import android.bluetooth.BluetoothManager import android.content.Context import android.content.IntentFilter import android.location.LocationManager @@ -15,75 +16,89 @@ import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.R import androidx.core.content.ContextCompat import androidx.core.location.LocationManagerCompat +import com.juul.kable.AvailabilityReason.AdapterNotAvailable +import com.juul.kable.AvailabilityReason.LocationServicesDisabled +import com.juul.kable.AvailabilityReason.Off +import com.juul.kable.AvailabilityReason.TurningOff +import com.juul.kable.AvailabilityReason.TurningOn import com.juul.kable.Bluetooth.Availability.Available import com.juul.kable.Bluetooth.Availability.Unavailable -import com.juul.kable.Reason.LocationServicesDisabled -import com.juul.kable.Reason.Off -import com.juul.kable.Reason.TurningOff -import com.juul.kable.Reason.TurningOn import com.juul.tuulbox.coroutines.flow.broadcastReceiverFlow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED as BLUETOOTH_STATE_CHANGED -public actual enum class Reason { +public actual enum class AvailabilityReason { Off, // BluetoothAdapter.STATE_OFF TurningOff, // BluetoothAdapter.STATE_TURNING_OFF or BluetoothAdapter.STATE_BLE_TURNING_OFF TurningOn, // BluetoothAdapter.STATE_TURNING_ON or BluetoothAdapter.STATE_BLE_TURNING_ON + /** + * [BluetoothManager] unavailable or [BluetoothManager.getAdapter] returned `null` (indicating + * that Bluetooth is not available). + */ + AdapterNotAvailable, + /** Only applicable on Android 11 (API 30) and lower. */ LocationServicesDisabled, } -private val Context.locationManager: LocationManager - get() = ContextCompat.getSystemService(this, LocationManager::class.java) - ?: throw LocationManagerUnavailableException("LocationManager system service unavailable") +private fun Context.getLocationManagerOrNull() = + ContextCompat.getSystemService(this, LocationManager::class.java) -private val isLocationEnabled: Boolean - get() = LocationManagerCompat.isLocationEnabled(applicationContext.locationManager) +private fun Context.isLocationEnabledOrNull(): Boolean? = + getLocationManagerOrNull()?.let(LocationManagerCompat::isLocationEnabled) -private val locationEnabledFlow = when { +private val locationEnabledOrNullFlow = when { SDK_INT > R -> flowOf(true) else -> broadcastReceiverFlow(IntentFilter(PROVIDERS_CHANGED_ACTION)) .map { intent -> if (SDK_INT == R) { intent.getBooleanExtra(EXTRA_PROVIDER_ENABLED, false) } else { - isLocationEnabled + applicationContext.isLocationEnabledOrNull() } } - .onStart { emit(isLocationEnabled) } + .onStart { emit(applicationContext.isLocationEnabledOrNull()) } .distinctUntilChanged() } -private val bluetoothStateFlow = - broadcastReceiverFlow(IntentFilter(BLUETOOTH_STATE_CHANGED)) - .map { intent -> intent.getIntExtra(EXTRA_STATE, ERROR) } - .map { state -> - when (state) { - STATE_ON -> Available - STATE_OFF -> Unavailable(reason = Off) - STATE_TURNING_OFF -> Unavailable(reason = TurningOff) - STATE_TURNING_ON -> Unavailable(reason = TurningOn) - else -> error("Unexpected bluetooth state: $state") - } - } - .onStart { - val isEnabled = when (getBluetoothAdapterOrNull()?.isEnabled) { - true -> Available - else -> Unavailable(reason = Off) - } - emit(isEnabled) - } +private val bluetoothStateFlow = flow { + when (val adapter = getBluetoothAdapterOrNull()) { + null -> emit(Unavailable(reason = AdapterNotAvailable)) + else -> emitAll( + broadcastReceiverFlow(IntentFilter(BLUETOOTH_STATE_CHANGED)) + .map { intent -> intent.getIntExtra(EXTRA_STATE, ERROR) } + .onStart { + emit(if (adapter.isEnabled) STATE_ON else STATE_OFF) + } + .map { state -> + when (state) { + STATE_ON -> Available + STATE_OFF -> Unavailable(reason = Off) + STATE_TURNING_OFF -> Unavailable(reason = TurningOff) + STATE_TURNING_ON -> Unavailable(reason = TurningOn) + else -> error("Unexpected bluetooth state: $state") + } + }, + ) + } +} internal actual val bluetoothAvailability: Flow = combine( - locationEnabledFlow, + locationEnabledOrNullFlow, bluetoothStateFlow, ) { locationEnabled, bluetoothState -> - if (locationEnabled) bluetoothState else Unavailable(reason = LocationServicesDisabled) + when (locationEnabled) { + true -> bluetoothState + false -> Unavailable(reason = LocationServicesDisabled) + null -> Unavailable(reason = AdapterNotAvailable) + } } diff --git a/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt b/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt index 5ddc3eac8..a6e3c68dd 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt @@ -3,10 +3,12 @@ package com.juul.kable import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager import androidx.core.content.ContextCompat +import com.juul.kable.UnmetRequirementReason.BluetoothDisabled private fun getBluetoothManagerOrNull(): BluetoothManager? = ContextCompat.getSystemService(applicationContext, BluetoothManager::class.java) +/** @throws IllegalStateException If bluetooth is unavailable. */ private fun getBluetoothManager(): BluetoothManager = getBluetoothManagerOrNull() ?: error("BluetoothManager is not a supported system service.") @@ -17,30 +19,36 @@ private fun getBluetoothManager(): BluetoothManager = * https://developer.android.com/reference/android/bluetooth/BluetoothAdapter#getDefaultAdapter() */ internal fun getBluetoothAdapterOrNull(): BluetoothAdapter? = - getBluetoothManager().adapter + getBluetoothManagerOrNull()?.adapter +/** @throws IllegalStateException If bluetooth is not supported. */ internal fun getBluetoothAdapter(): BluetoothAdapter = - getBluetoothAdapterOrNull() ?: error("Bluetooth not supported") + 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. + * + * @throws IllegalStateException If bluetooth is not supported. + * @throws UnmetRequirementException In bluetooth adapter is in an unexpected state. */ -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" - } +internal fun checkBluetoothAdapterState(expected: Int) { 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.") + throw UnmetRequirementException( + reason = BluetoothDisabled, + message = "Bluetooth adapter state is $actualName ($actual), but $expectedName ($expected) was required.", + ) } } + +private fun nameFor(state: Int) = when (state) { + BluetoothAdapter.STATE_OFF -> "Off" + BluetoothAdapter.STATE_ON -> "On" + BluetoothAdapter.STATE_TURNING_OFF -> "TurningOff" + BluetoothAdapter.STATE_TURNING_ON -> "TurningOn" + else -> "Unknown" +} diff --git a/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt b/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt index de331a341..38569291b 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt @@ -10,9 +10,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.UnmetRequirementReason.BluetoothDisabled import com.juul.kable.logs.Logger import com.juul.kable.logs.Logging -import kotlinx.coroutines.cancel +import com.juul.kable.scan.ScanError +import com.juul.kable.scan.message import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.onFailure import kotlinx.coroutines.channels.trySendBlocking @@ -32,7 +34,10 @@ internal class BluetoothLeScannerAndroidScanner( private val scanFilters = filters.toNativeScanFilters() override val advertisements: Flow = callbackFlow { - val scanner = getBluetoothAdapter().bluetoothLeScanner ?: throw BluetoothDisabledException() + logger.verbose { message = "Initializing scan" } + val bluetoothAdapter = getBluetoothAdapter() + val scanner = bluetoothAdapter.bluetoothLeScanner + ?: throw UnmetRequirementException(BluetoothDisabled, "Bluetooth disabled") fun sendResult(scanResult: ScanResult) { val advertisement = ScanResultAndroidAdvertisement(scanResult) @@ -45,7 +50,6 @@ internal class BluetoothLeScannerAndroidScanner( } val callback = object : ScanCallback() { - override fun onScanResult(callbackType: Int, result: ScanResult) { sendResult(result) } @@ -55,8 +59,12 @@ internal class BluetoothLeScannerAndroidScanner( } override fun onScanFailed(errorCode: Int) { - logger.error { message = "Scan could not be started, error code $errorCode." } - cancel("Bluetooth scan failed", ScanFailedException(errorCode)) + val scanError = ScanError(errorCode) + logger.error { + detail("code", scanError.toString()) + message = "Scan could not be started" + } + close(IllegalStateException(scanError.message)) } } @@ -91,7 +99,11 @@ internal class BluetoothLeScannerAndroidScanner( } } -private fun logMessage(prefix: String, preConflate: Boolean, scanFilters: List) = buildString { +private fun logMessage( + prefix: String, + preConflate: Boolean, + scanFilters: List, +) = buildString { append(prefix) append(' ') append("scan ") diff --git a/kable-core/src/androidMain/kotlin/Exceptions.kt b/kable-core/src/androidMain/kotlin/Exceptions.kt index 13f6b207b..235cb2300 100644 --- a/kable-core/src/androidMain/kotlin/Exceptions.kt +++ b/kable-core/src/androidMain/kotlin/Exceptions.kt @@ -5,10 +5,6 @@ import android.bluetooth.BluetoothGatt import android.os.RemoteException import com.juul.kable.AndroidPeripheral.WriteResult -public class ScanFailedException internal constructor( - public val errorCode: Int, -) : IllegalStateException("Bluetooth scan failed with error code $errorCode") - /** * Thrown when underlying [BluetoothGatt] method call returns `false`. This can occur under the * following conditions: diff --git a/kable-core/src/androidMain/kotlin/scan/ScanError.kt b/kable-core/src/androidMain/kotlin/scan/ScanError.kt new file mode 100644 index 000000000..6301814f8 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/scan/ScanError.kt @@ -0,0 +1,47 @@ +package com.juul.kable.scan + +import android.bluetooth.le.ScanCallback.SCAN_FAILED_ALREADY_STARTED +import android.bluetooth.le.ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED +import android.bluetooth.le.ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED +import android.bluetooth.le.ScanCallback.SCAN_FAILED_INTERNAL_ERROR +import android.bluetooth.le.ScanCallback.SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES +import android.bluetooth.le.ScanCallback.SCAN_FAILED_SCANNING_TOO_FREQUENTLY +import com.juul.kable.InternalException + +@JvmInline +internal value class ScanError(internal val errorCode: Int) { + + override fun toString(): String = when (errorCode) { + SCAN_FAILED_ALREADY_STARTED -> "SCAN_FAILED_ALREADY_STARTED" + SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED" + SCAN_FAILED_INTERNAL_ERROR -> "SCAN_FAILED_INTERNAL_ERROR" + SCAN_FAILED_FEATURE_UNSUPPORTED -> "SCAN_FAILED_FEATURE_UNSUPPORTED" + SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES -> "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES" + SCAN_FAILED_SCANNING_TOO_FREQUENTLY -> "SCAN_FAILED_SCANNING_TOO_FREQUENTLY" + else -> throw InternalException("Unsupported error code $errorCode") + }.let { name -> "$name($errorCode)" } +} + +internal val ScanError.message: String + get() = when (errorCode) { + SCAN_FAILED_ALREADY_STARTED -> + "Failed to start scan as BLE scan with the same settings is already started by the app" + + // Can occur if app has not been granted permission to scan (e.g. missing location permission). + // https://github.com/NordicSemiconductor/Android-Scanner-Compat-Library/issues/73 + SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> + "Failed to start scan as app cannot be registered" + + SCAN_FAILED_INTERNAL_ERROR -> "Failed to start scan due to an internal error" + + SCAN_FAILED_FEATURE_UNSUPPORTED -> + "Failed to start power optimized scan as this feature is not supported" + + SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES -> + "Failed to start scan as it is out of hardware resources" + + SCAN_FAILED_SCANNING_TOO_FREQUENTLY -> + "Failed to start scan as application tries to scan too frequently" + + else -> throw InternalException("Unsupported error code $errorCode") + } diff --git a/kable-core/src/appleMain/kotlin/Bluetooth.kt b/kable-core/src/appleMain/kotlin/Bluetooth.kt index 6e3da5818..d30e83fb9 100644 --- a/kable-core/src/appleMain/kotlin/Bluetooth.kt +++ b/kable-core/src/appleMain/kotlin/Bluetooth.kt @@ -1,13 +1,12 @@ package com.juul.kable +import com.juul.kable.AvailabilityReason.Off +import com.juul.kable.AvailabilityReason.Resetting +import com.juul.kable.AvailabilityReason.Unauthorized +import com.juul.kable.AvailabilityReason.Unknown +import com.juul.kable.AvailabilityReason.Unsupported import com.juul.kable.Bluetooth.Availability.Available import com.juul.kable.Bluetooth.Availability.Unavailable -import com.juul.kable.Reason.Off -import com.juul.kable.Reason.Resetting -import com.juul.kable.Reason.Unauthorized -import com.juul.kable.Reason.Unknown -import com.juul.kable.Reason.Unsupported -import kotlinx.cinterop.UnsafeNumber import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow @@ -19,7 +18,7 @@ import platform.CoreBluetooth.CBCentralManagerStateUnauthorized import platform.CoreBluetooth.CBCentralManagerStateUnsupported /** https://developer.apple.com/documentation/corebluetooth/cbmanagerstate */ -public actual enum class Reason { +public actual enum class AvailabilityReason { Off, // CBManagerState.poweredOff Resetting, // CBManagerState.resetting Unauthorized, // CBManagerState.unauthorized @@ -27,7 +26,6 @@ public actual enum class Reason { Unknown, // CBManagerState.unknown } -@OptIn(UnsafeNumber::class) internal actual val bluetoothAvailability: Flow = flow { // flow + emitAll dance so that lazy `CentralManager.Default` is not initialized until this flow is active. emitAll(CentralManager.Default.delegate.state) diff --git a/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt b/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt index 9f26bcaa2..ca72aefd4 100644 --- a/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt +++ b/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt @@ -3,9 +3,9 @@ package com.juul.kable import com.benasher44.uuid.Uuid import com.juul.kable.CentralManagerDelegate.Response.DidDiscoverPeripheral import com.juul.kable.Filter.Service +import com.juul.kable.UnmetRequirementReason.BluetoothDisabled import com.juul.kable.logs.Logger import com.juul.kable.logs.Logging -import kotlinx.cinterop.UnsafeNumber import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart +import platform.CoreBluetooth.CBManagerStatePoweredOff import platform.CoreBluetooth.CBManagerStatePoweredOn import platform.CoreBluetooth.CBManagerStateUnauthorized import platform.CoreBluetooth.CBManagerStateUnsupported @@ -51,6 +52,7 @@ internal class CentralManagerCoreBluetoothScanner( central.delegate .response .onStart { + logger.verbose { message = "Initializing scan" } central.awaitPoweredOn() if (nativeServiceFilters != null) { logger.info { message = "Starting scan with native service filtering" } @@ -78,12 +80,13 @@ internal class CentralManagerCoreBluetoothScanner( } } -@OptIn(UnsafeNumber::class) private suspend fun CentralManager.awaitPoweredOn() { delegate.state - .onEach { - if (it == CBManagerStateUnsupported || it == CBManagerStateUnauthorized) { - error("Invalid bluetooth state: $it") + .onEach { state -> + when (state) { + CBManagerStateUnsupported -> error("This device doesn't support the Bluetooth low energy central or client role") + CBManagerStateUnauthorized -> error("Application isn't authorized to use the Bluetooth low energy role.") + CBManagerStatePoweredOff -> throw UnmetRequirementException(BluetoothDisabled, "Bluetooth disabled") } } .first { it == CBManagerStatePoweredOn } diff --git a/kable-core/src/commonMain/kotlin/Bluetooth.kt b/kable-core/src/commonMain/kotlin/Bluetooth.kt index 4239bc486..22848d422 100644 --- a/kable-core/src/commonMain/kotlin/Bluetooth.kt +++ b/kable-core/src/commonMain/kotlin/Bluetooth.kt @@ -1,9 +1,18 @@ +@file:JvmName("BluetoothCommon") + package com.juul.kable import com.benasher44.uuid.Uuid import kotlinx.coroutines.flow.Flow +import kotlin.jvm.JvmName + +@Deprecated( + message = "Renamed to AvailabilityReason.", + replaceWith = ReplaceWith("AvailabilityReason"), +) +public typealias Reason = AvailabilityReason -public expect enum class Reason +public expect enum class AvailabilityReason public object Bluetooth { @@ -27,12 +36,8 @@ public object Bluetooth { } public sealed class Availability { - - public object Available : Availability() { - override fun toString(): String = "Available" - } - - public data class Unavailable(val reason: Reason?) : Availability() + public data object Available : Availability() + public data class Unavailable(val reason: AvailabilityReason?) : Availability() } public val availability: Flow = bluetoothAvailability diff --git a/kable-core/src/commonMain/kotlin/Filter.kt b/kable-core/src/commonMain/kotlin/Filter.kt index 8a60e5d7d..fdc0b6424 100644 --- a/kable-core/src/commonMain/kotlin/Filter.kt +++ b/kable-core/src/commonMain/kotlin/Filter.kt @@ -119,6 +119,16 @@ public sealed class Filter { require(id <= 65535) { "Company identifier cannot be more than 16-bits (65535), was $id" } if (dataMask != null) requireDataAndMaskHaveSameLength(data, dataMask) } + + override fun toString(): String = buildString { + append("ManufacturerData(id=") + append(id) + append(", data=") + append(data.toHexString()) + append(", dataMask=") + append(dataMask?.toHexString()) + append(')') + } } } diff --git a/kable-core/src/commonMain/kotlin/Scanner.kt b/kable-core/src/commonMain/kotlin/Scanner.kt index 66e674531..ddbbb31bd 100644 --- a/kable-core/src/commonMain/kotlin/Scanner.kt +++ b/kable-core/src/commonMain/kotlin/Scanner.kt @@ -1,11 +1,22 @@ package com.juul.kable +import com.juul.kable.Bluetooth.Availability.Available import kotlinx.coroutines.flow.Flow public interface Scanner { + + /** + * [Bluetooth.availability] flow should emit [Available] before collecting from [advertisements] flow. + * + * @throws IllegalStateException If scanning could not be initiated (e.g. bluetooth or scan feature unavailable). + * @throws UnmetRequirementException If a transient state was not satisfied (e.g. bluetooth disabled). + */ public val advertisements: Flow } +/** + * @throws IllegalArgumentException If an invalid configuration is specified (e.g. using MAC address filter on Apple platforms). + */ @Suppress("FunctionName") // Builder function. public fun Scanner( builderAction: ScannerBuilder.() -> Unit = {}, diff --git a/kable-core/src/commonMain/kotlin/ScannerBuilder.kt b/kable-core/src/commonMain/kotlin/ScannerBuilder.kt index c271e435f..1ecbda3f9 100644 --- a/kable-core/src/commonMain/kotlin/ScannerBuilder.kt +++ b/kable-core/src/commonMain/kotlin/ScannerBuilder.kt @@ -23,6 +23,8 @@ public expect class ScannerBuilder internal constructor() { public fun filters(builderAction: FiltersBuilder.() -> Unit) public fun logging(init: LoggingBuilder) + + /** @throws IllegalStateException If bluetooth is unavailable. */ internal fun build(): PlatformScanner } diff --git a/kable-core/src/jsMain/kotlin/Bluetooth.deprecated.kt b/kable-core/src/jsMain/kotlin/Bluetooth.deprecated.kt new file mode 100644 index 000000000..fb1a65803 --- /dev/null +++ b/kable-core/src/jsMain/kotlin/Bluetooth.deprecated.kt @@ -0,0 +1,15 @@ +package com.juul.kable + +import com.juul.kable.external.Bluetooth + +@Deprecated( + message = "Replaced with `bluetoothOrThrow` function.", + replaceWith = ReplaceWith("bluetoothOrThrow()"), +) +@Suppress("UnsafeCastFromDynamic") +internal val bluetoothDeprecated: Bluetooth + get() = checkNotNull(safeWebBluetooth) { "Bluetooth unavailable" } + +// In a node build environment (e.g. unit test) there is no window, guard for that to avoid build errors. +private val safeWebBluetooth: dynamic = + js("typeof(window) !== 'undefined' && window.navigator.bluetooth") diff --git a/kable-core/src/jsMain/kotlin/Bluetooth.kt b/kable-core/src/jsMain/kotlin/Bluetooth.kt index 249a1d6e4..2cd2df01f 100644 --- a/kable-core/src/jsMain/kotlin/Bluetooth.kt +++ b/kable-core/src/jsMain/kotlin/Bluetooth.kt @@ -1,103 +1,36 @@ package com.juul.kable -import com.benasher44.uuid.Uuid -import com.juul.kable.Bluetooth.Availability.Available -import com.juul.kable.Bluetooth.Availability.Unavailable -import com.juul.kable.Reason.BluetoothUndefined -import com.juul.kable.external.BluetoothAvailabilityChanged -import com.juul.kable.external.BluetoothServiceUUID -import com.juul.kable.external.RequestDeviceOptions -import js.objects.jso -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.await -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.onStart -import org.w3c.dom.events.Event -import kotlin.js.Promise -import com.juul.kable.external.Bluetooth as JsBluetooth +import com.juul.kable.external.Bluetooth +import com.juul.kable.external.bluetooth +import js.errors.ReferenceError +import kotlinx.browser.window -public actual enum class Reason { - /** `window.navigator.bluetooth` is undefined. */ - BluetoothUndefined, -} - -private const val AVAILABILITY_CHANGED = "availabilitychanged" - -internal actual val bluetoothAvailability: Flow = callbackFlow { - if (safeWebBluetooth == null) return@callbackFlow - - // https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/onavailabilitychanged - val listener: (Event) -> Unit = { event -> - val isAvailable = event.unsafeCast().value - trySend(if (isAvailable) Available else Unavailable(reason = null)) - } - - bluetooth.apply { - addEventListener(AVAILABILITY_CHANGED, listener) - awaitClose { - removeEventListener(AVAILABILITY_CHANGED, listener) - } - } -}.onStart { - val availability = if (safeWebBluetooth == null) { - Unavailable(reason = BluetoothUndefined) - } else { - val isAvailable = bluetooth.getAvailability().await() - if (isAvailable) Available else Unavailable(reason = null) +/** + * @return [Bluetooth] object or `null` if bluetooth is [unavailable](https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth#browser_compatibility). + */ +internal fun bluetoothOrNull(): Bluetooth? { + val navigator = try { + window.navigator + } catch (e: ReferenceError) { + // ReferenceError: window is not defined + return null } - emit(availability) + return navigator.bluetooth.takeIf { it !== undefined } } -// Deliberately NOT cast `as Bluetooth` to avoid potential class name collisions. -@Suppress("UnsafeCastFromDynamic") -internal val bluetooth: JsBluetooth - get() = checkNotNull(safeWebBluetooth) { "Bluetooth unavailable" } - -// In a node build environment (e.g. unit test) there is no window, guard for that to avoid build errors. -private val safeWebBluetooth: dynamic = - js("typeof(window) !== 'undefined' && window.navigator.bluetooth") - -public fun CoroutineScope.requestPeripheral( - options: Options, - builderAction: PeripheralBuilderAction = {}, -): Promise = bluetooth - .requestDevice(options.toRequestDeviceOptions()) - .then { device -> peripheral(device, builderAction) } - /** - * Convert public API type to external Web Bluetooth (JavaScript) type. - * - * According to the `requestDevice` - * [example](https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/requestDevice#example), the form of the - * JavaScript object should be similar to: - * ``` - * let options = { - * filters: [ - * {services: ['heart_rate']}, - * {services: [0x1802, 0x1803]}, - * {services: ['c48e6067-5295-48d3-8d5c-0395f61792b1']}, - * {name: 'ExampleName'}, - * {namePrefix: 'Prefix'} - * ], - * optionalServices: ['battery_service'] - * } - * ``` + * @throws IllegalStateException If bluetooth is [unavailable](https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth#browser_compatibility). */ -private fun Options.toRequestDeviceOptions(): RequestDeviceOptions = jso { - val jsFilters = this@toRequestDeviceOptions.filters.toBluetoothLEScanFilterInit() - if (jsFilters.isNotEmpty()) { - filters = jsFilters.toTypedArray() - } else { - acceptAllDevices = true +internal fun bluetoothOrThrow(): Bluetooth { + val navigator = try { + window.navigator + } catch (e: ReferenceError) { + // ReferenceError: window is not defined + throw IllegalStateException("Bluetooth unavailable", e) } - if (!this@toRequestDeviceOptions.optionalServices.isNullOrEmpty()) { - optionalServices = this@toRequestDeviceOptions.optionalServices - .map(Uuid::toBluetoothServiceUUID) - .toTypedArray() + val bluetooth = navigator.bluetooth + if (bluetooth === undefined) { + error("Bluetooth unavailable") } + return bluetooth } - -// Note: Web Bluetooth requires that UUIDs be provided as lowercase strings. -internal fun Uuid.toBluetoothServiceUUID(): BluetoothServiceUUID = toString().lowercase() diff --git a/kable-core/src/jsMain/kotlin/BluetoothAvailability.kt b/kable-core/src/jsMain/kotlin/BluetoothAvailability.kt new file mode 100644 index 000000000..853d71c89 --- /dev/null +++ b/kable-core/src/jsMain/kotlin/BluetoothAvailability.kt @@ -0,0 +1,42 @@ +package com.juul.kable + +import com.juul.kable.AvailabilityReason.BluetoothUndefined +import com.juul.kable.Bluetooth.Availability.Available +import com.juul.kable.Bluetooth.Availability.Unavailable +import com.juul.kable.external.BluetoothAvailabilityChanged +import kotlinx.coroutines.await +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.onStart +import org.w3c.dom.events.Event + +public actual enum class AvailabilityReason { + /** `window.navigator.bluetooth` is undefined. */ + BluetoothUndefined, +} + +private const val AVAILABILITY_CHANGED = "availabilitychanged" + +internal actual val bluetoothAvailability: Flow = + bluetoothOrNull()?.let { bluetooth -> + callbackFlow { + // https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/onavailabilitychanged + val listener: (Event) -> Unit = { event -> + val isAvailable = event.unsafeCast().value + trySend(if (isAvailable) Available else Unavailable(reason = null)) + } + + bluetooth.apply { + addEventListener(AVAILABILITY_CHANGED, listener) + awaitClose { + removeEventListener(AVAILABILITY_CHANGED, listener) + } + } + }.onStart { + val isAvailable = bluetooth.getAvailability().await() + val availability = if (isAvailable) Available else Unavailable(reason = null) + emit(availability) + } + } ?: flowOf(Unavailable(reason = BluetoothUndefined)) diff --git a/kable-core/src/jsMain/kotlin/BluetoothLEScanOptions.kt b/kable-core/src/jsMain/kotlin/BluetoothLEScanOptions.kt index 60968d518..935d35178 100644 --- a/kable-core/src/jsMain/kotlin/BluetoothLEScanOptions.kt +++ b/kable-core/src/jsMain/kotlin/BluetoothLEScanOptions.kt @@ -11,12 +11,13 @@ internal fun List.toBluetoothLEScanOptions(): BluetoothLEScanOp if (isEmpty()) { acceptAllAdvertisements = true } else { - filters = toBluetoothLEScanFilterInit().toTypedArray() + filters = toBluetoothLEScanFilterInit() } } -internal fun List.toBluetoothLEScanFilterInit(): List = +internal fun List.toBluetoothLEScanFilterInit(): Array = map(FilterPredicate::toBluetoothLEScanFilterInit) + .toTypedArray() private fun FilterPredicate.toBluetoothLEScanFilterInit(): BluetoothLEScanFilterInit = jso { filters diff --git a/kable-core/src/jsMain/kotlin/BluetoothWebBluetoothScanner.kt b/kable-core/src/jsMain/kotlin/BluetoothWebBluetoothScanner.kt index 43022fde8..e76eaa09f 100644 --- a/kable-core/src/jsMain/kotlin/BluetoothWebBluetoothScanner.kt +++ b/kable-core/src/jsMain/kotlin/BluetoothWebBluetoothScanner.kt @@ -1,15 +1,19 @@ package com.juul.kable -import com.juul.kable.external.Bluetooth import com.juul.kable.external.BluetoothAdvertisingEvent import com.juul.kable.logs.Logger import com.juul.kable.logs.Logging +import js.errors.JsError +import js.errors.TypeError import kotlinx.coroutines.await import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.getOrElse +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import org.w3c.dom.events.Event +import web.errors.DOMException +import web.errors.DOMException.Companion.SecurityError private const val ADVERTISEMENT_RECEIVED_EVENT = "advertisementreceived" @@ -18,9 +22,11 @@ private const val ADVERTISEMENT_RECEIVED_EVENT = "advertisementreceived" * chrome://flags/#enable-experimental-web-platform-features * * See also: [Chrome Platform Status: Web Bluetooth Scanning](https://www.chromestatus.com/feature/5346724402954240) + * + * @throws IllegalArgumentException If `filters` argument contains a filter of type [Filter.Address]. + * @throws IllegalStateException If bluetooth is not available. */ internal class BluetoothWebBluetoothScanner( - bluetooth: Bluetooth, filters: List, logging: Logging, ) : PlatformScanner { @@ -32,28 +38,70 @@ internal class BluetoothWebBluetoothScanner( } private val logger = Logger(logging, tag = "Kable/Scanner", identifier = null) - - // https://webbluetoothcg.github.io/web-bluetooth/scanning.html#scanning - private val supportsScanning = js("window.navigator.bluetooth.requestLEScan") != null - private val options = filters.toBluetoothLEScanOptions() + /** + * @throws IllegalStateException If bluetooth is [unavailable](https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth#browser_compatibility), + * or scanning is [not supported](https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/requestDevice#browser_compatibility) + * or operation is not permitted due to [security concerns](https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#security_considerations). + */ override val advertisements: Flow = callbackFlow { - check(supportsScanning) { "Scanning unavailable" } + logger.verbose { message = "Initializing scan" } + val bluetooth = bluetoothOrThrow() - logger.info { message = "Starting scan" } - val scan = bluetooth.requestLEScan(options).await() + val requestLEScan = try { + bluetooth.requestLEScan(options) + } catch (e: TypeError) { + // Example failure when executing `requestLEScan(..)` with Chrome's "Experimental Web Platform features" turned off: + // > TypeError: navigator.bluetooth.requestLEScan is not a function + throw IllegalStateException("Scanning not supported", e) + } catch (e: JsError) { + ensureActive() + throw InternalException("Failed to request scan", e) + } - val listener: (Event) -> Unit = { event -> - trySend(BluetoothAdvertisingEventWebBluetoothAdvertisement(event.unsafeCast())).getOrElse { + logger.verbose { message = "Adding scan listener" } + val listener: (Event) -> Unit = { + val event = it.unsafeCast() + val advertisement = BluetoothAdvertisingEventWebBluetoothAdvertisement(event) + trySend(advertisement).getOrElse { logger.warn { message = "Unable to deliver advertisement event due to failure in flow or premature closing." } } } bluetooth.addEventListener(ADVERTISEMENT_RECEIVED_EVENT, listener) + logger.info { message = "Starting scan" } + val scan = try { + requestLEScan.await() + } catch (e: JsError) { + logger.verbose { message = "Removing scan listener" } + bluetooth.removeEventListener(ADVERTISEMENT_RECEIVED_EVENT, listener) + ensureActive() + + // The Web Bluetooth API can only be used in a secure context. + // https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#security_considerations + if (e is DOMException && e.name == SecurityError) { + throw IllegalStateException("Operation is not permitted in this context due to security concerns", e) + } + + // Example failure when executing `requestLEScan(jso {})`: + // > TypeError: Failed to execute 'requestLEScan' on 'Bluetooth': Either 'filters' should be present or 'acceptAllAdvertisements' should be true, but not both. + // + // Based on the input `filters`, we expect valid `options` to be produced; if that isn't + // the case, then we log and throw `InternalError` (in hopes we'll get a bug report so + // that we can fix any issues). + logger.error { + detail("filters", filters.toString()) + detail("options", JSON.stringify(options)) + message = e.toString() + } + throw InternalException("Failed to start scan", e) + } + awaitClose { logger.info { message = "Stopping scan" } scan.stop() + logger.verbose { message = "Removing scan listener" } bluetooth.removeEventListener(ADVERTISEMENT_RECEIVED_EVENT, listener) } } diff --git a/kable-core/src/jsMain/kotlin/Options.kt b/kable-core/src/jsMain/kotlin/Options.kt index 31159a789..3a0cd454a 100644 --- a/kable-core/src/jsMain/kotlin/Options.kt +++ b/kable-core/src/jsMain/kotlin/Options.kt @@ -1,6 +1,8 @@ package com.juul.kable import com.benasher44.uuid.Uuid +import com.juul.kable.external.RequestDeviceOptions +import js.objects.jso /** https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/requestDevice */ public fun Options(builder: OptionsBuilder.() -> Unit): Options = @@ -8,5 +10,22 @@ public fun Options(builder: OptionsBuilder.() -> Unit): Options = public data class Options internal constructor( internal val filters: List, - internal val optionalServices: List?, + internal val optionalServices: List, ) + +internal fun Options.toRequestDeviceOptions(): RequestDeviceOptions { + val jsFilters = filters.toBluetoothLEScanFilterInit() + val jsOptionalServices = optionalServices.toBluetoothServiceUUID() + + return jso { + if (jsFilters.isEmpty()) { + acceptAllDevices = true + } else { + filters = jsFilters + } + + if (jsOptionalServices.isNotEmpty()) { + optionalServices = jsOptionalServices + } + } +} diff --git a/kable-core/src/jsMain/kotlin/OptionsBuilder.kt b/kable-core/src/jsMain/kotlin/OptionsBuilder.kt index 35245993c..507f35ef1 100644 --- a/kable-core/src/jsMain/kotlin/OptionsBuilder.kt +++ b/kable-core/src/jsMain/kotlin/OptionsBuilder.kt @@ -26,7 +26,7 @@ public class OptionsBuilder internal constructor() { * * https://webbluetoothcg.github.io/web-bluetooth/#device-discovery */ - public var optionalServices: List? = null + public var optionalServices: List = emptyList() internal fun build() = Options(filters, optionalServices) } diff --git a/kable-core/src/jsMain/kotlin/Peripheral.kt b/kable-core/src/jsMain/kotlin/Peripheral.kt index 33e225ba5..b43bc8ce5 100644 --- a/kable-core/src/jsMain/kotlin/Peripheral.kt +++ b/kable-core/src/jsMain/kotlin/Peripheral.kt @@ -3,6 +3,13 @@ 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( advertisement: Advertisement, builderAction: PeripheralBuilderAction, @@ -14,14 +21,9 @@ public actual fun CoroutineScope.peripheral( internal fun CoroutineScope.peripheral( bluetoothDevice: BluetoothDevice, builderAction: PeripheralBuilderAction = {}, -): WebBluetoothPeripheral { - val builder = PeripheralBuilder() - builder.builderAction() - return BluetoothDeviceWebBluetoothPeripheral( - coroutineContext, - bluetoothDevice, - builder.observationExceptionHandler, - builder.onServicesDiscovered, - builder.logging, - ) -} +): WebBluetoothPeripheral = peripheral(bluetoothDevice, PeripheralBuilder().apply(builderAction)) + +internal fun CoroutineScope.peripheral( + bluetoothDevice: BluetoothDevice, + builder: PeripheralBuilder, +): WebBluetoothPeripheral = builder.build(bluetoothDevice, this) diff --git a/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt b/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt index c0ff49807..75b591329 100644 --- a/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt +++ b/kable-core/src/jsMain/kotlin/PeripheralBuilder.kt @@ -1,7 +1,9 @@ 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 public actual class ServicesDiscoveredPeripheral internal constructor( private val peripheral: WebBluetoothPeripheral, @@ -49,4 +51,13 @@ public actual class PeripheralBuilder internal actual constructor() { public actual fun observationExceptionHandler(handler: ObservationExceptionHandler) { observationExceptionHandler = handler } + + internal fun build(bluetoothDevice: BluetoothDevice, scope: CoroutineScope) = + BluetoothDeviceWebBluetoothPeripheral( + scope.coroutineContext, + bluetoothDevice, + observationExceptionHandler, + onServicesDiscovered, + logging, + ) } diff --git a/kable-core/src/jsMain/kotlin/RequestPeripheral.deprecated.kt b/kable-core/src/jsMain/kotlin/RequestPeripheral.deprecated.kt new file mode 100644 index 000000000..c5ada608d --- /dev/null +++ b/kable-core/src/jsMain/kotlin/RequestPeripheral.deprecated.kt @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..0f6bb2fe9 --- /dev/null +++ b/kable-core/src/jsMain/kotlin/RequestPeripheral.kt @@ -0,0 +1,83 @@ +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 +import web.errors.DOMException.Companion.NotFoundError +import web.errors.DOMException.Companion.SecurityError +import kotlin.coroutines.coroutineContext + +/** + * Obtains a nearby [Peripheral] via device picker. Returns `null` if dialog is cancelled (e.g. user + * dismissed dialog by clicking outside of dialog or clicking cancel button). + * + * See [requestDevice](https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/requestDevice) for + * more details. + * + * Throws [IllegalStateException] under the following conditions: + * - Bluetooth is unavailable + * - Requesting a device is [not supported](https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/requestDevice#browser_compatibility) + * - Operation is not permitted due to [security concerns](https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#security_considerations) + * + * @throws IllegalStateException If requesting a device could not be fulfilled. + */ +public suspend fun requestPeripheral( + options: Options, + scope: CoroutineScope, + builderAction: PeripheralBuilderAction = {}, +): Peripheral? { + val bluetooth = bluetoothOrThrow() + val requestDeviceOptions = options.toRequestDeviceOptions() + val requestDevice = try { + bluetooth.requestDevice(requestDeviceOptions) + } catch (e: JsError) { + coroutineContext.ensureActive() + throw when (e) { + is TypeError -> IllegalStateException("Requesting a device is not supported", e) + else -> InternalException("Failed to invoke device request", e) + } + } + + // Acquire/configure `PeripheralBuilder` early (diverging from typical + // `PeripheralBuilder().apply(..).build(..)` pattern), so that `Logger` is available (if needed). + val builder = PeripheralBuilder().apply(builderAction) + + return try { + requestDevice.await() + } catch (e: JsError) { + coroutineContext.ensureActive() + when { + // User cancelled picker dialog by either clicking outside dialog, or clicking cancel button. + e is DOMException && e.name == NotFoundError -> null + + // The Web Bluetooth API can only be used in a secure context. + // https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#security_considerations + e is DOMException && e.name == SecurityError -> + throw IllegalStateException("Operation is not permitted in this context due to security concerns", e) + + e is TypeError -> { + // Example failure when executing `requestDevice(jso {})`: + // > TypeError: Failed to execute 'requestDevice' on 'Bluetooth': Either 'filters' + // > should be present or 'acceptAllAdvertisements' should be true, but not both. + // + // We expect valid `options` to be produced; if that isn't the case, then we log and + // throw `InternalError` (in hopes we get a bug report). + val logger = Logger(builder.logging, tag = "Kable/requestDevice", identifier = null) + logger.error { + detail("options", options.toString()) + detail("processed", JSON.stringify(requestDeviceOptions)) + message = e.toString() + } + throw InternalException("Type error when requesting device", e) + } + + else -> throw InternalException("Failed to request device", e) + } + }?.let { device -> + builder.build(device, scope) + } +} diff --git a/kable-core/src/jsMain/kotlin/ScannerBuilder.kt b/kable-core/src/jsMain/kotlin/ScannerBuilder.kt index c14add5b7..2b613876e 100644 --- a/kable-core/src/jsMain/kotlin/ScannerBuilder.kt +++ b/kable-core/src/jsMain/kotlin/ScannerBuilder.kt @@ -35,7 +35,6 @@ public actual class ScannerBuilder { } internal actual fun build(): PlatformScanner = BluetoothWebBluetoothScanner( - bluetooth = bluetooth, filters = filterPredicates, logging = logging, ) diff --git a/kable-core/src/jsMain/kotlin/UUID.kt b/kable-core/src/jsMain/kotlin/Uuid.kt similarity index 65% rename from kable-core/src/jsMain/kotlin/UUID.kt rename to kable-core/src/jsMain/kotlin/Uuid.kt index 86d66da7f..e9aa74568 100644 --- a/kable-core/src/jsMain/kotlin/UUID.kt +++ b/kable-core/src/jsMain/kotlin/Uuid.kt @@ -2,6 +2,7 @@ package com.juul.kable import com.benasher44.uuid.Uuid import com.benasher44.uuid.uuidFrom +import com.juul.kable.external.BluetoothServiceUUID import com.juul.kable.external.BluetoothUUID // Number of characters in a 16-bit UUID alias in string hex representation @@ -24,3 +25,11 @@ internal fun UUID.toUuid(): Uuid = else -> this }, ) + +internal fun List.toBluetoothServiceUUID(): Array = + map(Uuid::toBluetoothServiceUUID) + .toTypedArray() + +// Note: Web Bluetooth requires that UUIDs be provided as lowercase strings. +internal fun Uuid.toBluetoothServiceUUID(): BluetoothServiceUUID = + toString().lowercase() diff --git a/kable-core/src/jsMain/kotlin/external/Navigator.kt b/kable-core/src/jsMain/kotlin/external/Navigator.kt new file mode 100644 index 000000000..94ed13160 --- /dev/null +++ b/kable-core/src/jsMain/kotlin/external/Navigator.kt @@ -0,0 +1,7 @@ +package com.juul.kable.external + +import org.w3c.dom.Navigator + +/** Reference to [Bluetooth] instance or [undefined] if bluetooth is unavailable. */ +internal val Navigator.bluetooth: Bluetooth + get() = asDynamic().bluetooth.unsafeCast() diff --git a/kable-core/src/jsTest/kotlin/BluetoothJsTests.kt b/kable-core/src/jsTest/kotlin/BluetoothJsTests.kt new file mode 100644 index 000000000..f592688e4 --- /dev/null +++ b/kable-core/src/jsTest/kotlin/BluetoothJsTests.kt @@ -0,0 +1,27 @@ +package com.juul.kable + +import com.juul.kable.external.Bluetooth +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class BluetoothJsTests { + + @Test + fun bluetoothOrThrow_browserUnitTest_returnsBluetooth() = runTest { + if (isBrowser) { + assertIs(bluetoothOrThrow()) + } + } + + // In Node.js unit tests, bluetooth is unavailable. + @Test + fun bluetoothOrThrow_nodeJsUnitTest_throwsIllegalStateException() = runTest { + if (isNode) { + assertFailsWith { + bluetoothOrThrow() + } + } + } +} diff --git a/kable-core/src/jsTest/kotlin/Environment.kt b/kable-core/src/jsTest/kotlin/Environment.kt new file mode 100644 index 000000000..938859d58 --- /dev/null +++ b/kable-core/src/jsTest/kotlin/Environment.kt @@ -0,0 +1,9 @@ +package com.juul.kable + +val isBrowser: Boolean + get() = js("typeof window !== 'undefined'") + .unsafeCast() + +val isNode: Boolean + get() = js("typeof process !== 'undefined' && process.versions && process.versions.node") + .unsafeCast() diff --git a/kable-core/src/jsTest/kotlin/RequestPeripheralTests.kt b/kable-core/src/jsTest/kotlin/RequestPeripheralTests.kt new file mode 100644 index 000000000..080c3c525 --- /dev/null +++ b/kable-core/src/jsTest/kotlin/RequestPeripheralTests.kt @@ -0,0 +1,17 @@ +package com.juul.kable + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class RequestPeripheralTests { + + @Test + fun requestPeripheral_unitTest_throwsIllegalStateException() = runTest { + // In browser unit tests, bluetooth is not allowed per security restrictions. + // In Node.js unit tests, bluetooth is unavailable. + assertFailsWith { + requestPeripheral(Options {}, this) + } + } +} diff --git a/kable-core/src/jvmMain/kotlin/com/juul/kable/Bluetooth.kt b/kable-core/src/jvmMain/kotlin/com/juul/kable/Bluetooth.kt index eb09e935d..e0c306e78 100644 --- a/kable-core/src/jvmMain/kotlin/com/juul/kable/Bluetooth.kt +++ b/kable-core/src/jvmMain/kotlin/com/juul/kable/Bluetooth.kt @@ -2,7 +2,7 @@ package com.juul.kable import kotlinx.coroutines.flow.Flow -public actual enum class Reason { +public actual enum class AvailabilityReason { // Not implemented. } diff --git a/kable-exceptions/api/kable-exceptions.api b/kable-exceptions/api/kable-exceptions.api index bdfe73c84..87a87a668 100644 --- a/kable-exceptions/api/kable-exceptions.api +++ b/kable-exceptions/api/kable-exceptions.api @@ -28,6 +28,11 @@ public final class com/juul/kable/GattStatusException : java/io/IOException { public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } +public final class com/juul/kable/InternalException : java/lang/IllegalStateException { + 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/LocationManagerUnavailableException : com/juul/kable/BluetoothException { public fun ()V public fun (Ljava/lang/String;Ljava/lang/Throwable;)V @@ -46,3 +51,16 @@ public final class com/juul/kable/NotReadyException : com/juul/kable/NotConnecte 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 + public final fun getReason ()Lcom/juul/kable/UnmetRequirementReason; +} + +public final class com/juul/kable/UnmetRequirementReason : java/lang/Enum { + public static final field BluetoothDisabled Lcom/juul/kable/UnmetRequirementReason; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/UnmetRequirementReason; + public static fun values ()[Lcom/juul/kable/UnmetRequirementReason; +} + diff --git a/kable-exceptions/src/commonMain/kotlin/Exceptions.kt b/kable-exceptions/src/commonMain/kotlin/Exceptions.kt index 9c98bd47d..9e5f46e5d 100644 --- a/kable-exceptions/src/commonMain/kotlin/Exceptions.kt +++ b/kable-exceptions/src/commonMain/kotlin/Exceptions.kt @@ -16,15 +16,6 @@ public class BluetoothDisabledException( cause: Throwable? = null, ) : BluetoothException(message, cause) -public expect open class IOException( - message: String?, - cause: Throwable?, -) : Exception { - public constructor() - public constructor(message: String?) - public constructor(cause: Throwable?) -} - public open class NotConnectedException( message: String? = null, cause: Throwable? = null, diff --git a/kable-exceptions/src/commonMain/kotlin/IOException.kt b/kable-exceptions/src/commonMain/kotlin/IOException.kt new file mode 100644 index 000000000..ebb2f1ee7 --- /dev/null +++ b/kable-exceptions/src/commonMain/kotlin/IOException.kt @@ -0,0 +1,10 @@ +package com.juul.kable + +public expect open class IOException( + message: String?, + cause: Throwable?, +) : Exception { + public constructor() + public constructor(message: String?) + public constructor(cause: Throwable?) +} diff --git a/kable-exceptions/src/commonMain/kotlin/InternalException.kt b/kable-exceptions/src/commonMain/kotlin/InternalException.kt new file mode 100644 index 000000000..b3d9a89da --- /dev/null +++ b/kable-exceptions/src/commonMain/kotlin/InternalException.kt @@ -0,0 +1,6 @@ +package com.juul.kable + +public class InternalException( + message: String, + cause: Throwable? = null, +) : IllegalStateException("$message, please report issue to https://github.com/JuulLabs/kable/issues and provide logs", cause) diff --git a/kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt b/kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt new file mode 100644 index 000000000..bee37512b --- /dev/null +++ b/kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt @@ -0,0 +1,11 @@ +package com.juul.kable + +public enum class UnmetRequirementReason { + BluetoothDisabled, +} + +public open class UnmetRequirementException( + public val reason: UnmetRequirementReason, + message: String, + cause: Throwable? = null, +) : IOException(message, cause) From 510e7cac607ab527d1d5ca8a17c819c4afb5081b Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Thu, 15 Aug 2024 22:22:07 -0700 Subject: [PATCH 03/13] Remove periods from exception messages --- kable-core/src/androidMain/kotlin/BluetoothAdapter.kt | 2 +- .../src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt b/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt index a6e3c68dd..39c74f5e8 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt @@ -10,7 +10,7 @@ private fun getBluetoothManagerOrNull(): BluetoothManager? = /** @throws IllegalStateException If bluetooth is unavailable. */ private fun getBluetoothManager(): BluetoothManager = - getBluetoothManagerOrNull() ?: error("BluetoothManager is not a supported system service.") + getBluetoothManagerOrNull() ?: error("BluetoothManager is not a supported system service") /** * Per documentation, `BluetoothAdapter.getDefaultAdapter()` returns `null` when "Bluetooth is not diff --git a/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt b/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt index ca72aefd4..14809a0fa 100644 --- a/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt +++ b/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt @@ -85,7 +85,7 @@ private suspend fun CentralManager.awaitPoweredOn() { .onEach { state -> when (state) { CBManagerStateUnsupported -> error("This device doesn't support the Bluetooth low energy central or client role") - CBManagerStateUnauthorized -> error("Application isn't authorized to use the Bluetooth low energy role.") + CBManagerStateUnauthorized -> error("Application isn't authorized to use the Bluetooth low energy role") CBManagerStatePoweredOff -> throw UnmetRequirementException(BluetoothDisabled, "Bluetooth disabled") } } From cedbde58505751b8efcfb2e678c437f74f6fcb53 Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Thu, 15 Aug 2024 22:50:14 -0700 Subject: [PATCH 04/13] Check for location services and permission when scanning --- .../BluetoothLeScannerAndroidScanner.kt | 27 +++++++++---- .../kotlin/scan/requirements/BluetoothIsOn.kt | 33 +++++++++++++++ .../scan/requirements/BluetoothLeScanner.kt | 14 +++++++ .../requirements/LocationServicesEnabled.kt | 26 ++++++++++++ .../scan/requirements/ScanPermissions.kt | 40 +++++++++++++++++++ kable-core/src/commonMain/kotlin/Scanner.kt | 2 +- kable-exceptions/api/kable-exceptions.api | 1 + .../kotlin/UnmetRequirementException.kt | 7 ++++ 8 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 kable-core/src/androidMain/kotlin/scan/requirements/BluetoothIsOn.kt create mode 100644 kable-core/src/androidMain/kotlin/scan/requirements/BluetoothLeScanner.kt create mode 100644 kable-core/src/androidMain/kotlin/scan/requirements/LocationServicesEnabled.kt create mode 100644 kable-core/src/androidMain/kotlin/scan/requirements/ScanPermissions.kt diff --git a/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt b/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt index 38569291b..40bf8b603 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothLeScannerAndroidScanner.kt @@ -1,6 +1,5 @@ package com.juul.kable -import android.bluetooth.BluetoothAdapter.STATE_ON import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult @@ -10,11 +9,14 @@ 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.UnmetRequirementReason.BluetoothDisabled 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 import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.onFailure import kotlinx.coroutines.channels.trySendBlocking @@ -34,10 +36,13 @@ internal class BluetoothLeScannerAndroidScanner( private val scanFilters = filters.toNativeScanFilters() override val advertisements: Flow = callbackFlow { - logger.verbose { message = "Initializing scan" } - val bluetoothAdapter = getBluetoothAdapter() - val scanner = bluetoothAdapter.bluetoothLeScanner - ?: throw UnmetRequirementException(BluetoothDisabled, "Bluetooth disabled") + logger.debug { message = "Initializing scan" } + val scanner = requireBluetoothLeScanner() + + // Permissions are checked early (fail-fast), as they cannot be unexpectedly revoked prior + // to scanning (revoking permissions on Android restarts the app). + logger.verbose { message = "Checking permissions for scanning" } + checkScanPermissions() fun sendResult(scanResult: ScanResult) { val advertisement = ScanResultAndroidAdvertisement(scanResult) @@ -68,17 +73,23 @@ internal class BluetoothLeScannerAndroidScanner( } } + // These conditions could change prior to scanning, so we check them as close to + // initiating the scan as feasible. + logger.verbose { message = "Checking scanning requirements" } + checkLocationServicesEnabled() + checkBluetoothIsOn() + logger.info { message = logMessage("Starting", preConflate, scanFilters) } - checkBluetoothAdapterState(STATE_ON) scanner.startScan(scanFilters, scanSettings, callback) awaitClose { logger.info { message = logMessage("Stopping", preConflate, scanFilters) } - // Can't check BLE state here, only Bluetooth, but should assume `IllegalStateException` means BLE has been disabled. + // Can't check BLE state here, only Bluetooth, but should assume `IllegalStateException` + // means BLE has been disabled. try { scanner.stopScan(callback) } catch (e: IllegalStateException) { diff --git a/kable-core/src/androidMain/kotlin/scan/requirements/BluetoothIsOn.kt b/kable-core/src/androidMain/kotlin/scan/requirements/BluetoothIsOn.kt new file mode 100644 index 000000000..49a0cca20 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/scan/requirements/BluetoothIsOn.kt @@ -0,0 +1,33 @@ +package com.juul.kable.scan.requirements + +import android.bluetooth.BluetoothAdapter.STATE_OFF +import android.bluetooth.BluetoothAdapter.STATE_ON +import android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF +import android.bluetooth.BluetoothAdapter.STATE_TURNING_ON +import com.juul.kable.InternalException +import com.juul.kable.UnmetRequirementException +import com.juul.kable.UnmetRequirementReason.BluetoothDisabled +import com.juul.kable.getBluetoothAdapter + +/** + * @throws IllegalStateException If bluetooth is not supported. + * @throws UnmetRequirementException If bluetooth adapter state is not [STATE_ON]. + */ +internal fun checkBluetoothIsOn() { + val actual = getBluetoothAdapter().state + val expected = STATE_ON + if (actual != expected) { + throw UnmetRequirementException( + reason = BluetoothDisabled, + message = "Bluetooth was ${nameFor(actual)}, but ${nameFor(expected)} was required", + ) + } +} + +private fun nameFor(state: Int) = when (state) { + STATE_OFF -> "STATE_OFF" + STATE_ON -> "STATE_ON" + STATE_TURNING_OFF -> "STATE_TURNING_OFF" + STATE_TURNING_ON -> "STATE_TURNING_ON" + else -> throw InternalException("Unsupported bluetooth state: $state") +} diff --git a/kable-core/src/androidMain/kotlin/scan/requirements/BluetoothLeScanner.kt b/kable-core/src/androidMain/kotlin/scan/requirements/BluetoothLeScanner.kt new file mode 100644 index 000000000..01b8d5657 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/scan/requirements/BluetoothLeScanner.kt @@ -0,0 +1,14 @@ +package com.juul.kable.scan.requirements + +import android.bluetooth.le.BluetoothLeScanner +import com.juul.kable.UnmetRequirementException +import com.juul.kable.UnmetRequirementReason.BluetoothDisabled +import com.juul.kable.getBluetoothAdapter + +/** + * @throws IllegalStateException If bluetooth is not supported. + * @throws UnmetRequirementException If bluetooth is disabled. + */ +internal fun requireBluetoothLeScanner(): BluetoothLeScanner = + getBluetoothAdapter().bluetoothLeScanner + ?: throw UnmetRequirementException(BluetoothDisabled, "Bluetooth disabled") diff --git a/kable-core/src/androidMain/kotlin/scan/requirements/LocationServicesEnabled.kt b/kable-core/src/androidMain/kotlin/scan/requirements/LocationServicesEnabled.kt new file mode 100644 index 000000000..c98cdc266 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/scan/requirements/LocationServicesEnabled.kt @@ -0,0 +1,26 @@ +package com.juul.kable.scan.requirements + +import android.content.Context +import android.location.LocationManager +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.R +import androidx.core.content.ContextCompat +import androidx.core.location.LocationManagerCompat +import com.juul.kable.UnmetRequirementException +import com.juul.kable.UnmetRequirementReason.LocationServicesDisabled +import com.juul.kable.applicationContext + +internal fun checkLocationServicesEnabled() { + if (SDK_INT > R) return + val locationManager = applicationContext.getLocationManagerOrNull() + ?: error("Location manager unavailable") + if (!LocationManagerCompat.isLocationEnabled(locationManager)) { + throw UnmetRequirementException( + LocationServicesDisabled, + "Location services are required for scanning but are disabled", + ) + } +} + +private fun Context.getLocationManagerOrNull() = + ContextCompat.getSystemService(this, LocationManager::class.java) diff --git a/kable-core/src/androidMain/kotlin/scan/requirements/ScanPermissions.kt b/kable-core/src/androidMain/kotlin/scan/requirements/ScanPermissions.kt new file mode 100644 index 000000000..5aa304a46 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/scan/requirements/ScanPermissions.kt @@ -0,0 +1,40 @@ +package com.juul.kable.scan.requirements + +import android.Manifest.permission.ACCESS_COARSE_LOCATION +import android.Manifest.permission.ACCESS_FINE_LOCATION +import android.Manifest.permission.BLUETOOTH_SCAN +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.P +import android.os.Build.VERSION_CODES.R +import androidx.core.content.ContextCompat +import com.juul.kable.applicationContext + +private val requiredPermission = when { + // If your app targets Android 9 (API level 28) or lower, you can declare the ACCESS_COARSE_LOCATION permission + // instead of the ACCESS_FINE_LOCATION permission. + // https://developer.android.com/guide/topics/connectivity/bluetooth/permissions#declare-android11-or-lower + SDK_INT <= P -> ACCESS_COARSE_LOCATION + + // ACCESS_FINE_LOCATION is necessary because, on Android 11 (API level 30) and lower, a Bluetooth scan could + // potentially be used to gather information about the location of the user. + // https://developer.android.com/guide/topics/connectivity/bluetooth/permissions#declare-android11-or-lower + SDK_INT <= R -> ACCESS_FINE_LOCATION + + // If your app targets Android 12 (API level 31) or higher, declare the following permissions in your app's + // manifest file: + // + // 1. If your app looks for Bluetooth devices, such as BLE peripherals, declare the `BLUETOOTH_SCAN` permission. + // 2. If your app makes the current device discoverable to other Bluetooth devices, declare the + // `BLUETOOTH_ADVERTISE` permission. + // 3. If your app communicates with already-paired Bluetooth devices, declare the BLUETOOTH_CONNECT permission. + // https://developer.android.com/guide/topics/connectivity/bluetooth/permissions#declare-android12-or-higher + else /* SDK_INT >= S */ -> BLUETOOTH_SCAN +} + +/** @throws IllegalStateException If the required permissions for scanning have not been granted. */ +internal fun checkScanPermissions() { + if (ContextCompat.checkSelfPermission(applicationContext, requiredPermission) != PERMISSION_GRANTED) { + error("Missing required $requiredPermission for scanning") + } +} diff --git a/kable-core/src/commonMain/kotlin/Scanner.kt b/kable-core/src/commonMain/kotlin/Scanner.kt index ddbbb31bd..d0751bdc7 100644 --- a/kable-core/src/commonMain/kotlin/Scanner.kt +++ b/kable-core/src/commonMain/kotlin/Scanner.kt @@ -8,7 +8,7 @@ public interface Scanner { /** * [Bluetooth.availability] flow should emit [Available] before collecting from [advertisements] flow. * - * @throws IllegalStateException If scanning could not be initiated (e.g. bluetooth or scan feature unavailable). + * @throws IllegalStateException If scanning could not be initiated (e.g. feature unavailable or permission denied). * @throws UnmetRequirementException If a transient state was not satisfied (e.g. bluetooth disabled). */ public val advertisements: Flow diff --git a/kable-exceptions/api/kable-exceptions.api b/kable-exceptions/api/kable-exceptions.api index 87a87a668..0b71b4d42 100644 --- a/kable-exceptions/api/kable-exceptions.api +++ b/kable-exceptions/api/kable-exceptions.api @@ -59,6 +59,7 @@ public class com/juul/kable/UnmetRequirementException : java/io/IOException { public final class com/juul/kable/UnmetRequirementReason : java/lang/Enum { public static final field BluetoothDisabled Lcom/juul/kable/UnmetRequirementReason; + public static final field LocationServicesDisabled Lcom/juul/kable/UnmetRequirementReason; public static fun getEntries ()Lkotlin/enums/EnumEntries; public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/UnmetRequirementReason; public static fun values ()[Lcom/juul/kable/UnmetRequirementReason; diff --git a/kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt b/kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt index bee37512b..c7151226e 100644 --- a/kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt +++ b/kable-exceptions/src/commonMain/kotlin/UnmetRequirementException.kt @@ -1,6 +1,13 @@ package com.juul.kable public enum class UnmetRequirementReason { + + /** + * Only applicable on Android 11 (API 30) and lower, where location services are required to + * perform a scan. + */ + LocationServicesDisabled, + BluetoothDisabled, } From 6e1f86f6333df3c0338fa7b5cdc4f2e92e5bd17a Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Thu, 15 Aug 2024 22:55:44 -0700 Subject: [PATCH 05/13] Revert rename of `Reason` --- kable-core/api/android/kable-core.api | 32 +++++++++---------- kable-core/api/jvm/kable-core.api | 22 ++++++------- .../src/androidMain/kotlin/Bluetooth.kt | 12 +++---- kable-core/src/appleMain/kotlin/Bluetooth.kt | 12 +++---- kable-core/src/commonMain/kotlin/Bluetooth.kt | 10 ++---- .../jsMain/kotlin/BluetoothAvailability.kt | 4 +-- .../kotlin/com/juul/kable/Bluetooth.kt | 2 +- 7 files changed, 44 insertions(+), 50 deletions(-) diff --git a/kable-core/api/android/kable-core.api b/kable-core/api/android/kable-core.api index 67f73bed4..290778ab2 100644 --- a/kable-core/api/android/kable-core.api +++ b/kable-core/api/android/kable-core.api @@ -52,17 +52,6 @@ public final class com/juul/kable/AndroidPeripheral$WriteResult : java/lang/Enum public static fun values ()[Lcom/juul/kable/AndroidPeripheral$WriteResult; } -public final class com/juul/kable/AvailabilityReason : java/lang/Enum { - public static final field AdapterNotAvailable Lcom/juul/kable/AvailabilityReason; - public static final field LocationServicesDisabled Lcom/juul/kable/AvailabilityReason; - public static final field Off Lcom/juul/kable/AvailabilityReason; - public static final field TurningOff Lcom/juul/kable/AvailabilityReason; - public static final field TurningOn Lcom/juul/kable/AvailabilityReason; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/AvailabilityReason; - public static fun values ()[Lcom/juul/kable/AvailabilityReason; -} - public final class com/juul/kable/Bluetooth { public static final field INSTANCE Lcom/juul/kable/Bluetooth; public final fun getAvailability ()Lkotlinx/coroutines/flow/Flow; @@ -79,12 +68,12 @@ public final class com/juul/kable/Bluetooth$Availability$Available : com/juul/ka } public final class com/juul/kable/Bluetooth$Availability$Unavailable : com/juul/kable/Bluetooth$Availability { - public fun (Lcom/juul/kable/AvailabilityReason;)V - public final fun component1 ()Lcom/juul/kable/AvailabilityReason; - public final fun copy (Lcom/juul/kable/AvailabilityReason;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; - public static synthetic fun copy$default (Lcom/juul/kable/Bluetooth$Availability$Unavailable;Lcom/juul/kable/AvailabilityReason;ILjava/lang/Object;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; + public fun (Lcom/juul/kable/Reason;)V + public final fun component1 ()Lcom/juul/kable/Reason; + public final fun copy (Lcom/juul/kable/Reason;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; + public static synthetic fun copy$default (Lcom/juul/kable/Bluetooth$Availability$Unavailable;Lcom/juul/kable/Reason;ILjava/lang/Object;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; public fun equals (Ljava/lang/Object;)Z - public final fun getReason ()Lcom/juul/kable/AvailabilityReason; + public final fun getReason ()Lcom/juul/kable/Reason; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -375,6 +364,17 @@ public final class com/juul/kable/ProfileKt { public static final fun getWriteWithoutResponse-G25LNqA (I)Z } +public final class com/juul/kable/Reason : java/lang/Enum { + public static final field AdapterNotAvailable Lcom/juul/kable/Reason; + public static final field LocationServicesDisabled Lcom/juul/kable/Reason; + public static final field Off Lcom/juul/kable/Reason; + public static final field TurningOff Lcom/juul/kable/Reason; + public static final field TurningOn Lcom/juul/kable/Reason; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/Reason; + public static fun values ()[Lcom/juul/kable/Reason; +} + public final class com/juul/kable/ScanResultAndroidAdvertisement$Creator : android/os/Parcelable$Creator { public fun ()V public final fun createFromParcel (Landroid/os/Parcel;)Lcom/juul/kable/ScanResultAndroidAdvertisement; diff --git a/kable-core/api/jvm/kable-core.api b/kable-core/api/jvm/kable-core.api index fbcc88375..87b612c47 100644 --- a/kable-core/api/jvm/kable-core.api +++ b/kable-core/api/jvm/kable-core.api @@ -11,12 +11,6 @@ public abstract interface class com/juul/kable/Advertisement { public abstract fun serviceData (Ljava/util/UUID;)[B } -public final class com/juul/kable/AvailabilityReason : java/lang/Enum { - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/AvailabilityReason; - public static fun values ()[Lcom/juul/kable/AvailabilityReason; -} - public final class com/juul/kable/Bluetooth { public static final field INSTANCE Lcom/juul/kable/Bluetooth; public final fun getAvailability ()Lkotlinx/coroutines/flow/Flow; @@ -33,12 +27,12 @@ public final class com/juul/kable/Bluetooth$Availability$Available : com/juul/ka } public final class com/juul/kable/Bluetooth$Availability$Unavailable : com/juul/kable/Bluetooth$Availability { - public fun (Lcom/juul/kable/AvailabilityReason;)V - public final fun component1 ()Lcom/juul/kable/AvailabilityReason; - public final fun copy (Lcom/juul/kable/AvailabilityReason;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; - public static synthetic fun copy$default (Lcom/juul/kable/Bluetooth$Availability$Unavailable;Lcom/juul/kable/AvailabilityReason;ILjava/lang/Object;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; + public fun (Lcom/juul/kable/Reason;)V + public final fun component1 ()Lcom/juul/kable/Reason; + public final fun copy (Lcom/juul/kable/Reason;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; + public static synthetic fun copy$default (Lcom/juul/kable/Bluetooth$Availability$Unavailable;Lcom/juul/kable/Reason;ILjava/lang/Object;)Lcom/juul/kable/Bluetooth$Availability$Unavailable; public fun equals (Ljava/lang/Object;)Z - public final fun getReason ()Lcom/juul/kable/AvailabilityReason; + public final fun getReason ()Lcom/juul/kable/Reason; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -262,6 +256,12 @@ public final class com/juul/kable/ProfileKt { public static final fun getWriteWithoutResponse-G25LNqA (I)Z } +public final class com/juul/kable/Reason : java/lang/Enum { + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/juul/kable/Reason; + public static fun values ()[Lcom/juul/kable/Reason; +} + public abstract interface class com/juul/kable/Scanner { public abstract fun getAdvertisements ()Lkotlinx/coroutines/flow/Flow; } diff --git a/kable-core/src/androidMain/kotlin/Bluetooth.kt b/kable-core/src/androidMain/kotlin/Bluetooth.kt index b3d3530d3..6e7a0cbda 100644 --- a/kable-core/src/androidMain/kotlin/Bluetooth.kt +++ b/kable-core/src/androidMain/kotlin/Bluetooth.kt @@ -16,13 +16,13 @@ import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.R import androidx.core.content.ContextCompat import androidx.core.location.LocationManagerCompat -import com.juul.kable.AvailabilityReason.AdapterNotAvailable -import com.juul.kable.AvailabilityReason.LocationServicesDisabled -import com.juul.kable.AvailabilityReason.Off -import com.juul.kable.AvailabilityReason.TurningOff -import com.juul.kable.AvailabilityReason.TurningOn import com.juul.kable.Bluetooth.Availability.Available import com.juul.kable.Bluetooth.Availability.Unavailable +import com.juul.kable.Reason.AdapterNotAvailable +import com.juul.kable.Reason.LocationServicesDisabled +import com.juul.kable.Reason.Off +import com.juul.kable.Reason.TurningOff +import com.juul.kable.Reason.TurningOn import com.juul.tuulbox.coroutines.flow.broadcastReceiverFlow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -34,7 +34,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED as BLUETOOTH_STATE_CHANGED -public actual enum class AvailabilityReason { +public actual enum class Reason { Off, // BluetoothAdapter.STATE_OFF TurningOff, // BluetoothAdapter.STATE_TURNING_OFF or BluetoothAdapter.STATE_BLE_TURNING_OFF TurningOn, // BluetoothAdapter.STATE_TURNING_ON or BluetoothAdapter.STATE_BLE_TURNING_ON diff --git a/kable-core/src/appleMain/kotlin/Bluetooth.kt b/kable-core/src/appleMain/kotlin/Bluetooth.kt index d30e83fb9..ae34c83cf 100644 --- a/kable-core/src/appleMain/kotlin/Bluetooth.kt +++ b/kable-core/src/appleMain/kotlin/Bluetooth.kt @@ -1,12 +1,12 @@ package com.juul.kable -import com.juul.kable.AvailabilityReason.Off -import com.juul.kable.AvailabilityReason.Resetting -import com.juul.kable.AvailabilityReason.Unauthorized -import com.juul.kable.AvailabilityReason.Unknown -import com.juul.kable.AvailabilityReason.Unsupported import com.juul.kable.Bluetooth.Availability.Available import com.juul.kable.Bluetooth.Availability.Unavailable +import com.juul.kable.Reason.Off +import com.juul.kable.Reason.Resetting +import com.juul.kable.Reason.Unauthorized +import com.juul.kable.Reason.Unknown +import com.juul.kable.Reason.Unsupported import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow @@ -18,7 +18,7 @@ import platform.CoreBluetooth.CBCentralManagerStateUnauthorized import platform.CoreBluetooth.CBCentralManagerStateUnsupported /** https://developer.apple.com/documentation/corebluetooth/cbmanagerstate */ -public actual enum class AvailabilityReason { +public actual enum class Reason { Off, // CBManagerState.poweredOff Resetting, // CBManagerState.resetting Unauthorized, // CBManagerState.unauthorized diff --git a/kable-core/src/commonMain/kotlin/Bluetooth.kt b/kable-core/src/commonMain/kotlin/Bluetooth.kt index 22848d422..765c2ba11 100644 --- a/kable-core/src/commonMain/kotlin/Bluetooth.kt +++ b/kable-core/src/commonMain/kotlin/Bluetooth.kt @@ -6,13 +6,7 @@ import com.benasher44.uuid.Uuid import kotlinx.coroutines.flow.Flow import kotlin.jvm.JvmName -@Deprecated( - message = "Renamed to AvailabilityReason.", - replaceWith = ReplaceWith("AvailabilityReason"), -) -public typealias Reason = AvailabilityReason - -public expect enum class AvailabilityReason +public expect enum class Reason public object Bluetooth { @@ -37,7 +31,7 @@ public object Bluetooth { public sealed class Availability { public data object Available : Availability() - public data class Unavailable(val reason: AvailabilityReason?) : Availability() + public data class Unavailable(val reason: Reason?) : Availability() } public val availability: Flow = bluetoothAvailability diff --git a/kable-core/src/jsMain/kotlin/BluetoothAvailability.kt b/kable-core/src/jsMain/kotlin/BluetoothAvailability.kt index 853d71c89..4d76b72ec 100644 --- a/kable-core/src/jsMain/kotlin/BluetoothAvailability.kt +++ b/kable-core/src/jsMain/kotlin/BluetoothAvailability.kt @@ -1,8 +1,8 @@ package com.juul.kable -import com.juul.kable.AvailabilityReason.BluetoothUndefined import com.juul.kable.Bluetooth.Availability.Available import com.juul.kable.Bluetooth.Availability.Unavailable +import com.juul.kable.Reason.BluetoothUndefined import com.juul.kable.external.BluetoothAvailabilityChanged import kotlinx.coroutines.await import kotlinx.coroutines.channels.awaitClose @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onStart import org.w3c.dom.events.Event -public actual enum class AvailabilityReason { +public actual enum class Reason { /** `window.navigator.bluetooth` is undefined. */ BluetoothUndefined, } diff --git a/kable-core/src/jvmMain/kotlin/com/juul/kable/Bluetooth.kt b/kable-core/src/jvmMain/kotlin/com/juul/kable/Bluetooth.kt index e0c306e78..eb09e935d 100644 --- a/kable-core/src/jvmMain/kotlin/com/juul/kable/Bluetooth.kt +++ b/kable-core/src/jvmMain/kotlin/com/juul/kable/Bluetooth.kt @@ -2,7 +2,7 @@ package com.juul.kable import kotlinx.coroutines.flow.Flow -public actual enum class AvailabilityReason { +public actual enum class Reason { // Not implemented. } From da62e658256650a631d92a017418cac05b13f0ec Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Thu, 15 Aug 2024 23:00:04 -0700 Subject: [PATCH 06/13] Disable code comment ktlint rule --- .editorconfig | 1 + 1 file changed, 1 insertion(+) diff --git a/.editorconfig b/.editorconfig index e359bf4bb..ed5451773 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,6 +6,7 @@ ktlint_standard_backing-property-naming = disabled ktlint_standard_blank-line-before-declaration = disabled ktlint_standard_chain-method-continuation = disabled ktlint_standard_class-signature = disabled +ktlint_standard_comment-wrapping = disabled ktlint_standard_filename = disabled ktlint_standard_function-naming = disabled ktlint_standard_function-signature = disabled From baf842a797bb0ba4cfa9dae5ae22a5e9947b89c3 Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Thu, 15 Aug 2024 23:04:03 -0700 Subject: [PATCH 07/13] Revert change to `checkBluetoothAdapterState` It was not needed for scan exception changes. --- .../androidMain/kotlin/BluetoothAdapter.kt | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt b/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt index 39c74f5e8..33fbb25e7 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothAdapter.kt @@ -3,7 +3,6 @@ package com.juul.kable import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager import androidx.core.content.ContextCompat -import com.juul.kable.UnmetRequirementReason.BluetoothDisabled private fun getBluetoothManagerOrNull(): BluetoothManager? = ContextCompat.getSystemService(applicationContext, BluetoothManager::class.java) @@ -29,26 +28,21 @@ internal fun getBluetoothAdapter(): BluetoothAdapter = * 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. - * - * @throws IllegalStateException If bluetooth is not supported. - * @throws UnmetRequirementException In bluetooth adapter is in an unexpected state. */ -internal fun checkBluetoothAdapterState(expected: Int) { +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 UnmetRequirementException( - reason = BluetoothDisabled, - message = "Bluetooth adapter state is $actualName ($actual), but $expectedName ($expected) was required.", - ) + throw BluetoothDisabledException("Bluetooth adapter state is $actualName ($actual), but $expectedName ($expected) was required.") } } - -private fun nameFor(state: Int) = when (state) { - BluetoothAdapter.STATE_OFF -> "Off" - BluetoothAdapter.STATE_ON -> "On" - BluetoothAdapter.STATE_TURNING_OFF -> "TurningOff" - BluetoothAdapter.STATE_TURNING_ON -> "TurningOn" - else -> "Unknown" -} From d968e4018f3a20a1f91865bfedb0b42bb234d2e9 Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Thu, 15 Aug 2024 23:24:24 -0700 Subject: [PATCH 08/13] Use string interpolation for `Filter.toString` --- kable-core/src/commonMain/kotlin/Filter.kt | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/kable-core/src/commonMain/kotlin/Filter.kt b/kable-core/src/commonMain/kotlin/Filter.kt index fdc0b6424..34909be45 100644 --- a/kable-core/src/commonMain/kotlin/Filter.kt +++ b/kable-core/src/commonMain/kotlin/Filter.kt @@ -120,15 +120,8 @@ public sealed class Filter { if (dataMask != null) requireDataAndMaskHaveSameLength(data, dataMask) } - override fun toString(): String = buildString { - append("ManufacturerData(id=") - append(id) - append(", data=") - append(data.toHexString()) - append(", dataMask=") - append(dataMask?.toHexString()) - append(')') - } + override fun toString(): String = + "ManufacturerData(id=$id, data=${data.toHexString()}, dataMask=${dataMask?.toHexString()})" } } From e2f39ffbe41e584dbd9e09bd96799a0b573cdb23 Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Fri, 16 Aug 2024 00:31:11 -0700 Subject: [PATCH 09/13] Add `Bluetooth.isSupported()` function --- kable-core/api/android/kable-core.api | 1 + kable-core/api/jvm/kable-core.api | 1 + .../kotlin/bluetooth/IsSupported.kt | 9 ++++ .../appleMain/kotlin/bluetooth/IsSupported.kt | 49 +++++++++++++++++++ kable-core/src/commonMain/kotlin/Bluetooth.kt | 10 ++++ .../kotlin/bluetooth/IsSupported.kt | 3 ++ .../jsMain/kotlin/bluetooth/IsSupported.kt | 22 +++++++++ .../com/juul/kable/bluetooth/IsSupported.kt | 5 ++ 8 files changed, 100 insertions(+) create mode 100644 kable-core/src/androidMain/kotlin/bluetooth/IsSupported.kt create mode 100644 kable-core/src/appleMain/kotlin/bluetooth/IsSupported.kt create mode 100644 kable-core/src/commonMain/kotlin/bluetooth/IsSupported.kt create mode 100644 kable-core/src/jsMain/kotlin/bluetooth/IsSupported.kt create mode 100644 kable-core/src/jvmMain/kotlin/com/juul/kable/bluetooth/IsSupported.kt diff --git a/kable-core/api/android/kable-core.api b/kable-core/api/android/kable-core.api index 290778ab2..a8839f7a3 100644 --- a/kable-core/api/android/kable-core.api +++ b/kable-core/api/android/kable-core.api @@ -55,6 +55,7 @@ public final class com/juul/kable/AndroidPeripheral$WriteResult : java/lang/Enum public final class com/juul/kable/Bluetooth { public static final field INSTANCE Lcom/juul/kable/Bluetooth; public final fun getAvailability ()Lkotlinx/coroutines/flow/Flow; + public final fun isSupported (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract class com/juul/kable/Bluetooth$Availability { diff --git a/kable-core/api/jvm/kable-core.api b/kable-core/api/jvm/kable-core.api index 87b612c47..eac9080aa 100644 --- a/kable-core/api/jvm/kable-core.api +++ b/kable-core/api/jvm/kable-core.api @@ -14,6 +14,7 @@ public abstract interface class com/juul/kable/Advertisement { public final class com/juul/kable/Bluetooth { public static final field INSTANCE Lcom/juul/kable/Bluetooth; public final fun getAvailability ()Lkotlinx/coroutines/flow/Flow; + public final fun isSupported (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract class com/juul/kable/Bluetooth$Availability { diff --git a/kable-core/src/androidMain/kotlin/bluetooth/IsSupported.kt b/kable-core/src/androidMain/kotlin/bluetooth/IsSupported.kt new file mode 100644 index 000000000..f6f1dd3ec --- /dev/null +++ b/kable-core/src/androidMain/kotlin/bluetooth/IsSupported.kt @@ -0,0 +1,9 @@ +package com.juul.kable.bluetooth + +import android.content.pm.PackageManager.FEATURE_BLUETOOTH_LE +import com.juul.kable.applicationContext +import com.juul.kable.getBluetoothAdapterOrNull + +internal actual suspend fun isSupported(): Boolean = + applicationContext.packageManager.hasSystemFeature(FEATURE_BLUETOOTH_LE) && + getBluetoothAdapterOrNull() != null diff --git a/kable-core/src/appleMain/kotlin/bluetooth/IsSupported.kt b/kable-core/src/appleMain/kotlin/bluetooth/IsSupported.kt new file mode 100644 index 000000000..879ef6209 --- /dev/null +++ b/kable-core/src/appleMain/kotlin/bluetooth/IsSupported.kt @@ -0,0 +1,49 @@ +package com.juul.kable.bluetooth + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.onFailure +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import platform.CoreBluetooth.CBCentralManager +import platform.CoreBluetooth.CBCentralManagerDelegateProtocol +import platform.CoreBluetooth.CBCentralManagerOptionShowPowerAlertKey +import platform.CoreBluetooth.CBManagerState +import platform.CoreBluetooth.CBManagerStateResetting +import platform.CoreBluetooth.CBManagerStateUnknown +import platform.CoreBluetooth.CBManagerStateUnsupported +import platform.darwin.NSObject + +// Prevent triggering permissions dialog. +// https://chrismaddern.com/determine-whether-bluetooth-is-enabled-on-ios-passively/ +private val options = mapOf(CBCentralManagerOptionShowPowerAlertKey to false) as Map? + +private var cachedState: CBManagerState? = null +private val mutex = Mutex() + +internal actual suspend fun isSupported() = mutex.withLock { + cachedState ?: awaitState().also { cachedState = it } +} != CBManagerStateUnsupported + +// Need to hold strong-reference to CBCentralManager and its delegate while in use. +private var managerRef: NSObject? = null +private var delegateRef: NSObject? = null + +private suspend fun awaitState() = callbackFlow { + val delegate = object : NSObject(), CBCentralManagerDelegateProtocol { + override fun centralManagerDidUpdateState(central: CBCentralManager) { + trySend(central.state).onFailure { + // Silently ignore. + } + } + }.also { delegateRef = it } + CBCentralManager(delegate, null, options).also { managerRef = it } + awaitClose { + managerRef = null + delegateRef = null + } +}.first { it.isDetermined } + +private val CBManagerState.isDetermined: Boolean + get() = this != CBManagerStateUnknown && this != CBManagerStateResetting diff --git a/kable-core/src/commonMain/kotlin/Bluetooth.kt b/kable-core/src/commonMain/kotlin/Bluetooth.kt index 765c2ba11..e3fc7e751 100644 --- a/kable-core/src/commonMain/kotlin/Bluetooth.kt +++ b/kable-core/src/commonMain/kotlin/Bluetooth.kt @@ -5,6 +5,7 @@ package com.juul.kable import com.benasher44.uuid.Uuid import kotlinx.coroutines.flow.Flow import kotlin.jvm.JvmName +import com.juul.kable.bluetooth.isSupported as isBluetoothSupported public expect enum class Reason @@ -34,6 +35,15 @@ public object Bluetooth { public data class Unavailable(val reason: Reason?) : Availability() } + /** + * Checks if Bluetooth Low Energy is supported on the system. Being supported (a return of + * `true`) does not necessarily mean that bluetooth operations will work. The radio could be off + * of permissions may be denied. + * + * This function is idempotent. + */ + public suspend fun isSupported(): Boolean = isBluetoothSupported() + public val availability: Flow = bluetoothAvailability } diff --git a/kable-core/src/commonMain/kotlin/bluetooth/IsSupported.kt b/kable-core/src/commonMain/kotlin/bluetooth/IsSupported.kt new file mode 100644 index 000000000..4a41533dd --- /dev/null +++ b/kable-core/src/commonMain/kotlin/bluetooth/IsSupported.kt @@ -0,0 +1,3 @@ +package com.juul.kable.bluetooth + +internal expect suspend fun isSupported(): Boolean diff --git a/kable-core/src/jsMain/kotlin/bluetooth/IsSupported.kt b/kable-core/src/jsMain/kotlin/bluetooth/IsSupported.kt new file mode 100644 index 000000000..96dd92ea7 --- /dev/null +++ b/kable-core/src/jsMain/kotlin/bluetooth/IsSupported.kt @@ -0,0 +1,22 @@ +package com.juul.kable.bluetooth + +import com.juul.kable.InternalException +import com.juul.kable.bluetoothOrNull +import js.errors.JsError +import js.errors.TypeError +import kotlinx.coroutines.await + +internal actual suspend fun isSupported(): Boolean { + val bluetooth = bluetoothOrNull() ?: return false + val promise = try { + bluetooth.getAvailability() + } catch (e: TypeError) { + // > TypeError: navigator.bluetooth.getAvailability is not a function + return false + } + return try { + promise.await() + } catch (e: JsError) { + throw InternalException("Failed to get bluetooth availability", e) + } +} diff --git a/kable-core/src/jvmMain/kotlin/com/juul/kable/bluetooth/IsSupported.kt b/kable-core/src/jvmMain/kotlin/com/juul/kable/bluetooth/IsSupported.kt new file mode 100644 index 000000000..a3a336b2b --- /dev/null +++ b/kable-core/src/jvmMain/kotlin/com/juul/kable/bluetooth/IsSupported.kt @@ -0,0 +1,5 @@ +package com.juul.kable.bluetooth + +import com.juul.kable.jvmNotImplementedException + +internal actual suspend fun isSupported(): Boolean = jvmNotImplementedException() From e2a54aed29881aa5e36344a3c794963cdc0b2193 Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Fri, 16 Aug 2024 10:21:22 -0700 Subject: [PATCH 10/13] =?UTF-8?q?Fixed=20typo:=20"of"=20=E2=86=92=20"or"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kable-core/src/commonMain/kotlin/Bluetooth.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kable-core/src/commonMain/kotlin/Bluetooth.kt b/kable-core/src/commonMain/kotlin/Bluetooth.kt index e3fc7e751..e13946842 100644 --- a/kable-core/src/commonMain/kotlin/Bluetooth.kt +++ b/kable-core/src/commonMain/kotlin/Bluetooth.kt @@ -38,7 +38,7 @@ public object Bluetooth { /** * Checks if Bluetooth Low Energy is supported on the system. Being supported (a return of * `true`) does not necessarily mean that bluetooth operations will work. The radio could be off - * of permissions may be denied. + * or permissions may be denied. * * This function is idempotent. */ From ea0acd46aab8a0eab31b4fc9fae888db8b939ee7 Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Thu, 1 Aug 2024 12:51:05 -0700 Subject: [PATCH 11/13] Add basic threading strategy support --- gradle/libs.versions.toml | 1 + kable-core/api/android/kable-core.api | 30 ++++++ kable-core/build.gradle.kts | 1 + .../src/androidMain/kotlin/BluetoothDevice.kt | 59 ++---------- .../BluetoothDeviceAndroidPeripheral.kt | 15 ++- .../src/androidMain/kotlin/Connection.kt | 4 +- .../src/androidMain/kotlin/Peripheral.kt | 1 + .../androidMain/kotlin/PeripheralBuilder.kt | 2 + .../src/androidMain/kotlin/Threading.kt | 57 ++++++++++++ .../androidMain/kotlin/ThreadingStrategy.kt | 92 +++++++++++++++++++ kotlin-js-store/yarn.lock | 5 + 11 files changed, 211 insertions(+), 56 deletions(-) create mode 100644 kable-core/src/androidMain/kotlin/Threading.kt create mode 100644 kable-core/src/androidMain/kotlin/ThreadingStrategy.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 795ef5a2e..0d3662bca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ tuulbox = "8.0.0" androidx-core = { module = "androidx.core:core-ktx", version = "1.13.1" } androidx-startup = { module = "androidx.startup:startup-runtime", version = "1.1.1" } atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } +datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.6.1" } khronicle = { module = "com.juul.khronicle:khronicle-core", version = "0.3.0" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } diff --git a/kable-core/api/android/kable-core.api b/kable-core/api/android/kable-core.api index a8839f7a3..61662a403 100644 --- a/kable-core/api/android/kable-core.api +++ b/kable-core/api/android/kable-core.api @@ -284,6 +284,12 @@ public final class com/juul/kable/ObservationExceptionPeripheral { public abstract interface annotation class com/juul/kable/ObsoleteKableApi : java/lang/annotation/Annotation { } +public final class com/juul/kable/OnDemandThreadingStrategy : com/juul/kable/ThreadingStrategy { + public static final field INSTANCE Lcom/juul/kable/OnDemandThreadingStrategy; + public fun acquire ()Lcom/juul/kable/Threading; + public fun release (Lcom/juul/kable/Threading;)V +} + public final class com/juul/kable/OutOfOrderGattCallbackException : java/lang/IllegalStateException { } @@ -310,11 +316,13 @@ 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 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 setPhy (Lcom/juul/kable/Phy;)V + public final fun setThreadingStrategy (Lcom/juul/kable/ThreadingStrategy;)V public final fun setTransport (Lcom/juul/kable/Transport;)V } @@ -352,6 +360,14 @@ public final class com/juul/kable/PlatformAdvertisement$BondState : java/lang/En public static fun values ()[Lcom/juul/kable/PlatformAdvertisement$BondState; } +public final class com/juul/kable/PooledThreadingStrategy : com/juul/kable/ThreadingStrategy { + public synthetic fun (Lkotlinx/coroutines/CoroutineScope;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lkotlinx/coroutines/CoroutineScope;JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun acquire ()Lcom/juul/kable/Threading; + public final fun cancel ()V + public fun release (Lcom/juul/kable/Threading;)V +} + public final class com/juul/kable/ProfileKt { public static final fun characteristicOf (Ljava/lang/String;Ljava/lang/String;)Lcom/juul/kable/Characteristic; public static final fun descriptorOf (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/juul/kable/Descriptor; @@ -513,6 +529,20 @@ public final class com/juul/kable/State$Disconnecting : com/juul/kable/State { public static final field INSTANCE Lcom/juul/kable/State$Disconnecting; } +public abstract class com/juul/kable/Threading { +} + +public final class com/juul/kable/ThreadingKt { + public static final fun Threading (Ljava/lang/String;)Lcom/juul/kable/Threading; + public static final fun getName (Lcom/juul/kable/Threading;)Ljava/lang/String; + public static final fun shutdown (Lcom/juul/kable/Threading;)V +} + +public abstract interface class com/juul/kable/ThreadingStrategy { + public abstract fun acquire ()Lcom/juul/kable/Threading; + public abstract fun release (Lcom/juul/kable/Threading;)V +} + public final class com/juul/kable/Transport : java/lang/Enum { public static final field Auto Lcom/juul/kable/Transport; public static final field BrEdr Lcom/juul/kable/Transport; diff --git a/kable-core/build.gradle.kts b/kable-core/build.gradle.kts index 9858c00e4..9df380b50 100644 --- a/kable-core/build.gradle.kts +++ b/kable-core/build.gradle.kts @@ -27,6 +27,7 @@ kotlin { api(libs.kotlinx.coroutines.core) api(libs.uuid) api(project(":kable-exceptions")) + implementation(libs.datetime) implementation(libs.tuulbox.collections) } diff --git a/kable-core/src/androidMain/kotlin/BluetoothDevice.kt b/kable-core/src/androidMain/kotlin/BluetoothDevice.kt index 7624f7fc1..f651fad8b 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDevice.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDevice.kt @@ -12,55 +12,11 @@ import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback import android.content.Context import android.os.Build -import android.os.Handler -import android.os.HandlerThread import com.juul.kable.gatt.Callback import com.juul.kable.logs.Logging -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExecutorCoroutineDispatcher -import kotlinx.coroutines.android.asCoroutineDispatcher import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.newSingleThreadContext - -internal sealed class Threading { - - abstract val dispatcher: CoroutineDispatcher - - /** Available on Android O (API 26) and above. */ - data class Handler( - val thread: HandlerThread, - val handler: android.os.Handler, - override val dispatcher: CoroutineDispatcher, - ) : Threading() - - /** Used on Android versions **lower** than Android O (API 26). */ - data class SingleThreadContext( - override val dispatcher: ExecutorCoroutineDispatcher, - ) : Threading() -} - -internal fun Threading.close() { - when (this) { - is Threading.Handler -> thread.quit() - is Threading.SingleThreadContext -> dispatcher.close() - } -} - -/** - * Creates the [Threading] that will be used for Bluetooth communication. The returned [Threading] is returned in a - * started state and must be shutdown when no longer needed. - */ -internal fun BluetoothDevice.threading(): Threading = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val thread = HandlerThread(threadName).apply { start() } - val handler = Handler(thread.looper) - val dispatcher = handler.asCoroutineDispatcher() - Threading.Handler(thread, handler, dispatcher) - } else { - Threading.SingleThreadContext(newSingleThreadContext(threadName)) - } /** * @param transport is only used on API level >= 23. @@ -76,9 +32,10 @@ internal fun BluetoothDevice.connect( mtu: MutableStateFlow, onCharacteristicChanged: MutableSharedFlow>, logging: Logging, - threading: Threading, + threadingStrategy: ThreadingStrategy, ): Connection? { val callback = Callback(state, mtu, onCharacteristicChanged, logging, address) + val threading = threadingStrategy.acquire() val bluetoothGatt = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { @@ -91,9 +48,14 @@ internal fun BluetoothDevice.connect( ?: connectGattCompat(context, true, callback, transport.intValue) else -> connectGattCompat(context, autoConnect, callback, transport.intValue) - } ?: return null + } + + if (bluetoothGatt == null) { + threadingStrategy.release(threading) + return null + } - return Connection(scope, bluetoothGatt, threading.dispatcher, callback, logging) + return Connection(scope, bluetoothGatt, threading, callback, logging) } private fun BluetoothDevice.connectGattCompat( @@ -121,6 +83,3 @@ private val Phy.intValue: Int Phy.Le2M -> PHY_LE_2M_MASK Phy.LeCoded -> PHY_LE_CODED_MASK } - -private val BluetoothDevice.threadName: String - get() = "Gatt@$this" diff --git a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt index a220a49dd..5e06930d1 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt @@ -64,6 +64,7 @@ internal class BluetoothDeviceAndroidPeripheral( private val autoConnectPredicate: () -> Boolean, private val transport: Transport, private val phy: Phy, + private val threadingStrategy: ThreadingStrategy, observationExceptionHandler: ObservationExceptionHandler, private val onServicesDiscovered: ServicesDiscoveredAction, private val logging: Logging, @@ -76,9 +77,6 @@ internal class BluetoothDeviceAndroidPeripheral( override val identifier: String = bluetoothDevice.address - // todo: Spin up/down w/ connection, rather than matching lifecycle of peripheral. - private val threading = bluetoothDevice.threading() - private val _mtu = MutableStateFlow(null) override val mtu: StateFlow = _mtu.asStateFlow() @@ -131,7 +129,7 @@ internal class BluetoothDeviceAndroidPeripheral( _mtu, observers.characteristicChanges, logging, - threading, + threadingStrategy, ) ?: throw ConnectionRejectedException() suspendUntilOrThrow() @@ -197,17 +195,24 @@ internal class BluetoothDeviceAndroidPeripheral( connectAction.cancelAndJoin(CancellationException(NotConnectedException())) } suspendUntil() + releaseThread() logger.info { message = "Disconnected" } } + private fun releaseThread() { + _connection?.threading?.let { + threadingStrategy.release(it) + } + } + private fun dispose(cause: Throwable?) { closeConnection() - threading.close() logger.info(cause) { message = "Disposed" } } private fun closeConnection() { _connection?.bluetoothGatt?.close() + releaseThread() setDisconnected() } diff --git a/kable-core/src/androidMain/kotlin/Connection.kt b/kable-core/src/androidMain/kotlin/Connection.kt index 32242c199..5e0709fb9 100644 --- a/kable-core/src/androidMain/kotlin/Connection.kt +++ b/kable-core/src/androidMain/kotlin/Connection.kt @@ -24,7 +24,7 @@ private val GattSuccess = GattStatus(GATT_SUCCESS) internal class Connection( private val scope: CoroutineScope, internal val bluetoothGatt: BluetoothGatt, - internal val dispatcher: CoroutineDispatcher, + internal val threading: Threading, private val callback: Callback, logging: Logging, ) { @@ -34,6 +34,8 @@ internal class Connection( private val lock = Mutex() private var deferredResponse: Deferred? = null + internal val dispatcher = threading.dispatcher + /** * Executes specified [BluetoothGatt] [action]. * diff --git a/kable-core/src/androidMain/kotlin/Peripheral.kt b/kable-core/src/androidMain/kotlin/Peripheral.kt index a396b553e..5eb26efd4 100644 --- a/kable-core/src/androidMain/kotlin/Peripheral.kt +++ b/kable-core/src/androidMain/kotlin/Peripheral.kt @@ -33,6 +33,7 @@ public fun CoroutineScope.peripheral( builder.autoConnectPredicate, builder.transport, builder.phy, + builder.threadingStrategy, builder.observationExceptionHandler, builder.onServicesDiscovered, builder.logging, diff --git a/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt b/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt index 0b2ea06c8..6ae4ea0af 100644 --- a/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt +++ b/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt @@ -112,4 +112,6 @@ public actual class PeripheralBuilder internal actual constructor() { /** Preferred PHY for connections to remote LE device. */ public var phy: Phy = Phy.Le1M + + public var threadingStrategy: ThreadingStrategy = OnDemandThreadingStrategy } diff --git a/kable-core/src/androidMain/kotlin/Threading.kt b/kable-core/src/androidMain/kotlin/Threading.kt new file mode 100644 index 000000000..391c90d53 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/Threading.kt @@ -0,0 +1,57 @@ +package com.juul.kable + +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.android.asCoroutineDispatcher +import kotlinx.coroutines.newSingleThreadContext + +public sealed class Threading { + + internal abstract val dispatcher: CoroutineDispatcher + + /** Used on Android O (API 26) and above. */ + internal data class Handler( + val thread: HandlerThread, + val handler: android.os.Handler, + override val dispatcher: CoroutineDispatcher, + ) : Threading() + + /** Used on Android versions **lower** than Android O (API 26). */ + internal data class SingleThreadContext( + val name: String, + override val dispatcher: ExecutorCoroutineDispatcher, + ) : Threading() +} + +public val Threading.name: String + get() = when (this) { + is Threading.Handler -> thread.name + is Threading.SingleThreadContext -> name + } + +public fun Threading.shutdown() { + when (this) { + is Threading.Handler -> thread.quit() + is Threading.SingleThreadContext -> dispatcher.close() + } +} + +/** + * Creates [Threading] that can be used for Bluetooth communication. The returned [Threading] is + * returned in a started state and must be [shutdown] when no longer needed. + */ +public fun Threading(name: String): Threading = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val thread = HandlerThread(name).apply { start() } + val handler = Handler(thread.looper) + val dispatcher = handler.asCoroutineDispatcher() + Threading.Handler(thread, handler, dispatcher) + } else { // Build.VERSION.SDK_INT < Build.VERSION_CODES.O + @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) + Threading.SingleThreadContext(name, newSingleThreadContext(name)) + } diff --git a/kable-core/src/androidMain/kotlin/ThreadingStrategy.kt b/kable-core/src/androidMain/kotlin/ThreadingStrategy.kt new file mode 100644 index 000000000..e62c438c5 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/ThreadingStrategy.kt @@ -0,0 +1,92 @@ +package com.juul.kable + +import com.juul.kable.OnDemandThreadingStrategy.acquire +import com.juul.kable.OnDemandThreadingStrategy.release +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.locks.reentrantLock +import kotlinx.atomicfu.locks.withLock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +private val threadNumber = atomic(0) +private fun generateThreadName() = "Kable#${threadNumber.incrementAndGet()}" + +public interface ThreadingStrategy { + public fun acquire(): Threading + public fun release(threading: Threading) +} + +/** + * A [ThreadingStrategy] that creates ["threads"][Threading] when [acquired][acquire] and + * immediately shuts down when [released][release]. + */ +public object OnDemandThreadingStrategy : ThreadingStrategy { + + override fun acquire(): Threading = Threading(generateThreadName()) + + override fun release(threading: Threading) { + threading.shutdown() + } +} + +/** + * A [ThreadingStrategy] that pools unused ["threads"][Threading] until [evictAfter] time has + * elapsed. + * + * In most circumstances, only a a single [PooledThreadingStrategy] instance should be created per + * application run, as it holds the "shared" pool of unused ["threads"][Threading]. + * + * Useful for when [Peripheral] connections are quickly being spun down and up again — as they can + * re-use existing ["threads"][Threading] ([acquire] their ["threads"][Threading] from the unused + * pool). + * + * If [Peripheral] connections are expected to be long running, or for there to be long down times + * between connections, [OnDemandThreadingStrategy] may be a better choice. + */ +public class PooledThreadingStrategy( + scope: CoroutineScope = GlobalScope, + private val evictAfter: Duration = 1.minutes, +) : ThreadingStrategy { + + private val pool = mutableListOf>() + private val guard = reentrantLock() + + private val job = scope.launch { + try { + while (true) { + guard.withLock { + pool.removeAll { (timeMark) -> timeMark.hasPassedNow() } + } + delay(evictAfter / 2) + } + } catch (e: CancellationException) { + guard.withLock { + check(pool.isEmpty()) { + "PooledThreadStrategy must complete with an empty pool, but had ${pool.count()} threads in pool" + } + } + throw CancellationException(e) + } + } + + public fun cancel(): Unit = job.cancel() + + override fun acquire(): Threading = guard.withLock { + pool.removeFirstOrNull() + ?.let { (_, threading) -> threading } + } ?: Threading(generateThreadName()) + + override fun release(threading: Threading) { + guard.withLock { + val evictAt = TimeSource.Monotonic.markNow() + evictAfter + pool.add(evictAt to threading) + } + } +} diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index 8e70e9a2a..bcd04bfc1 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -70,6 +70,11 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@js-joda/core@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" + integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== + "@socket.io/component-emitter@~3.1.0": version "3.1.2" resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" From 43bf667dbe5a6aca6688ac96d225e27391c2ad0f Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Wed, 11 Sep 2024 10:36:04 -0700 Subject: [PATCH 12/13] Simplify `Threading` release --- kable-core/api/android/kable-core.api | 2 +- .../src/androidMain/kotlin/BluetoothDevice.kt | 25 +++++++++++-------- .../src/androidMain/kotlin/Threading.kt | 13 +++++++--- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/kable-core/api/android/kable-core.api b/kable-core/api/android/kable-core.api index 61662a403..da053bac3 100644 --- a/kable-core/api/android/kable-core.api +++ b/kable-core/api/android/kable-core.api @@ -533,7 +533,7 @@ public abstract class com/juul/kable/Threading { } public final class com/juul/kable/ThreadingKt { - public static final fun Threading (Ljava/lang/String;)Lcom/juul/kable/Threading; + public static final fun Threading (Lcom/juul/kable/ThreadingStrategy;Ljava/lang/String;)Lcom/juul/kable/Threading; public static final fun getName (Lcom/juul/kable/Threading;)Ljava/lang/String; public static final fun shutdown (Lcom/juul/kable/Threading;)V } diff --git a/kable-core/src/androidMain/kotlin/BluetoothDevice.kt b/kable-core/src/androidMain/kotlin/BluetoothDevice.kt index f651fad8b..3b41a1227 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDevice.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDevice.kt @@ -37,21 +37,26 @@ internal fun BluetoothDevice.connect( val callback = Callback(state, mtu, onCharacteristicChanged, logging, address) val threading = threadingStrategy.acquire() - val bluetoothGatt = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { - val handler = (threading as Threading.Handler).handler - connectGatt(context, autoConnect, callback, transport.intValue, phy.intValue, handler) - } + val bluetoothGatt = try { + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { + val handler = (threading as Threading.Handler).handler + connectGatt(context, autoConnect, callback, transport.intValue, phy.intValue, handler) + } - Build.VERSION.SDK_INT <= Build.VERSION_CODES.M && autoConnect -> - connectGattWithReflection(context, true, callback, transport.intValue) - ?: connectGattCompat(context, true, callback, transport.intValue) + Build.VERSION.SDK_INT <= Build.VERSION_CODES.M && autoConnect -> + connectGattWithReflection(context, true, callback, transport.intValue) + ?: connectGattCompat(context, true, callback, transport.intValue) - else -> connectGattCompat(context, autoConnect, callback, transport.intValue) + else -> connectGattCompat(context, autoConnect, callback, transport.intValue) + } + } catch (t: Throwable) { + threading.release() + throw t } if (bluetoothGatt == null) { - threadingStrategy.release(threading) + threading.release() return null } diff --git a/kable-core/src/androidMain/kotlin/Threading.kt b/kable-core/src/androidMain/kotlin/Threading.kt index 391c90d53..3ba7c3cb6 100644 --- a/kable-core/src/androidMain/kotlin/Threading.kt +++ b/kable-core/src/androidMain/kotlin/Threading.kt @@ -13,21 +13,28 @@ import kotlinx.coroutines.newSingleThreadContext public sealed class Threading { internal abstract val dispatcher: CoroutineDispatcher + internal abstract val strategy: ThreadingStrategy /** Used on Android O (API 26) and above. */ internal data class Handler( val thread: HandlerThread, val handler: android.os.Handler, override val dispatcher: CoroutineDispatcher, + override val strategy: ThreadingStrategy, ) : Threading() /** Used on Android versions **lower** than Android O (API 26). */ internal data class SingleThreadContext( val name: String, override val dispatcher: ExecutorCoroutineDispatcher, + override val strategy: ThreadingStrategy, ) : Threading() } +internal fun Threading.release() { + strategy.release(this) +} + public val Threading.name: String get() = when (this) { is Threading.Handler -> thread.name @@ -45,13 +52,13 @@ public fun Threading.shutdown() { * Creates [Threading] that can be used for Bluetooth communication. The returned [Threading] is * returned in a started state and must be [shutdown] when no longer needed. */ -public fun Threading(name: String): Threading = +public fun ThreadingStrategy.Threading(name: String): Threading = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val thread = HandlerThread(name).apply { start() } val handler = Handler(thread.looper) val dispatcher = handler.asCoroutineDispatcher() - Threading.Handler(thread, handler, dispatcher) + Threading.Handler(thread, handler, dispatcher, this) } else { // Build.VERSION.SDK_INT < Build.VERSION_CODES.O @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) - Threading.SingleThreadContext(name, newSingleThreadContext(name)) + Threading.SingleThreadContext(name, newSingleThreadContext(name), this) } From 8eafe25af8093d71289f777b4b55ad58bc47aba7 Mon Sep 17 00:00:00 2001 From: Travis Wyatt Date: Wed, 11 Sep 2024 10:51:54 -0700 Subject: [PATCH 13/13] Further simplification --- .../androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt index 5e06930d1..b3897cd80 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt @@ -200,9 +200,7 @@ internal class BluetoothDeviceAndroidPeripheral( } private fun releaseThread() { - _connection?.threading?.let { - threadingStrategy.release(it) - } + _connection?.threading?.release() } private fun dispose(cause: Throwable?) {