kable
kable copied to clipboard
Remove `Bluetooth.availability`
The Bluetooth.availability is problematic because it provides Available or Unavailable but it is inconsistent across platforms.
This comes down to the major differences in how Android vs. Core Bluetooth (e.g. iOS) report their bluetooth radio state and bluetooth permissions.
Core Bluetooth
Core Bluetooth combines BLE supported status, radio state and permissions via CBManagerState:
poweredOff(radio state)poweredOn(radio state)resettingunauthorized(permission)unknownunsupported(supported status)
Android
Android separates BLE supported status, radio state and permissions.
BLE supported status can be checked via the existence (non-null) value for the BluetoothAdapter. Radio state is provided via BluetoothAdapter states:
Permissions are determined via the Android permissions API.
JavaScript
On JavaScript, the scanner APIs are behind a feature flag, and the requestDevice function does not require permissions request from the user. Additionally, if BLE radio is off, the device picker dialog (shown via requestDevice) will tell the user that BLE is off and needs to be turned on.
Differences
On Android, there is no way of getting real-time permission status (it is checked on-demand) while having permission on iOS comes in via the CBCentralManager delegate (a non-unsupported) state.
Requesting permission on Android is explicit, while on Apple: permission request is presented to the user on construction of CBCentralManager.
Deprecation of Bluetooth.availability
Because of the major platform differences, there is no way to provide a consistent flow of when BLE is "ready to use" (i.e. available). As such, detecting the availability of BLE is better left to the consumer.
A possible approach will be demonstrated in the SensorTag sample app.
Bluetooth.state
It was considered if Kable should/could provide a consistent "radio state" flow (via Bluetooth.state), unfortunately: the CBManagerState.unauthorized state throws a wrench in this — as it would force Kable to either ignore that state (so that Bluetooth.state can represent solely the radio state) or for it to represent both "radio state" and "BLE permissions" (which brings us back to the original "platform inconsistency" issue). Ultimately, there doesn't seem to be a way to provide an intuitive/consistent API, and it is better left to the consumer.
Bluetooth.isSupported
Detecting if "BLE is supported" would almost be possible / consistent across platforms, except Core Bluetooth (on Apple platform) shows a permission dialog when querying BLE support, with no known way to suppress the permission dialog.
Per https://github.com/JuulLabs/kable/pull/772#issuecomment-2397414837:
Would be nice to see this come back in platform specific APIs eventually
...and despite not being in the spirit of Kable (unified API) this would be entirely possible.
I understand why this was closed. I'm wondering if it makes sense for Kable to somehow expose the underlying CBCentralManager though. Because unlike Android where BluetoothManager is a singleton, on iOS you can have multiple CBCentralManager instances.
It is unclear to me what the implications are of instantiating multiple of them. For example if I wanted to handle Bluetooth state availability on my own, I could simply write CBCentralManager().state. Is this valid? Or does this interfere with for example the unauthorized state (as in, does CoreBluetooth associate authorization status with a particular instance of CBCentralManager)?
Edited to add
I see the SensorTag sample creates its own instance of CBCentralManager. So I'm guessing that means that this is a valid approach?
To be perfectly honest (disclaimer), much of the iOS side of things remains a mystery because Core Bluetooth is closed source and the documentation is often severely lacking. Things are often just determined via trial-and-error.
Specifically w/ regard to instantiating your own CBCentralManager: it has thus far been my experience that that should be fine (and as you discovered, that's what I did in SensorTag).
I'm happy to evaluate if exposing the instance that Kable uses is necessary, but my understanding at this point is that it shouldn't be necessary. 🤷
I see issues in my app when using a separate instance of CBCentralManager just for bluetooth state availability. In particular, it looks like takes a while for CBCentralManager to "settle". Calling cbCentralManager.state immediately on instantiation does not always report the correct state.
I also tried using AsyncBluetooth library just for this purpose (since it offers a waitUntilReady() to handle the setting part). But I still see some of the same flakiness with respect to detecting the CBCentralManager state.
I'm just guessing that this has to to with having multiple instance of CBCentralManager in the app. Is it an idea for Kable to offer a way to get the CBCentralManager state only on iOS? I think exposing the underlying CBCentralManager instance is too risky (the client can re-assign the delegates and that messes up everything). However, just re-hashing the existing (soon-to-be-deprecated) Bluetooth.availability code and exposing the state just on iOS might make be useful.
What do you think about such an API?
immediately on instantiation does not always report the correct state.
Oof. Core Bluetooth has so many rough edges.
What do you think about such an API?
I'm not excited about having to expose it, but Core Bluetooth is forcing our hand if multiple CBCentralManagers don't behave correctly.
Would it be possible for you to provide a MCVE?
I will try to put together an MCVE. Before that, I want to clarify a couple of things. Maybe it helps you if I start with my motivation for this feature
Ensuring Bluetooth is ON before connecting to a peripheral
It looks to me like this is already possible with Kable.
scanner.advertisementsflow throwsUnmetRequirementExceptionwith reason =BluetoothDisabledif you try to scan when Bluetooth is OFF. This can be used to nudge the user to turn on Bluetooth.- However, not all connections go through a scan. For example, you can get a
Peripheralfrom a saved Bluetooth address and you can callconnect()on it. The API docs forconnect()don't mentionBluetoothDisabledbut I see at least iOS and Android potentially throw the sameUnmetRequirementExceptionwith reason =BluetoothDisabledwhile trying to establish a connection.
I'm assuming that existing Kable facilities should be sufficient if the use case is to inform the user to turn on Bluetooth when attempting to connect to a peripheral
Changing the UI based on Bluetooth being powered ON
This seems more challenging with the deprecation of Bluetooth.availability. On Android it is fairly trivial. On iOS, it should have been trivial but I see some flakiness around the timing, which I'm guessing is because of instantiation of a CBCentralManager to check the bluetooth state.
Do you think these use-cases are valid? Is there some way using Kable (or without) to achieve these use-cases that I'm missing?
Alright I could not repro the flakiness in a standalone project. Looks like there's some other mistake in the way I'm using the APIs.
For completeness, here's the code I used for my stand-alone repro and it works 100% of the time.
private val options = mapOf<Any?, Any>(CBCentralManagerOptionShowPowerAlertKey to false)
actual suspend fun checkBluetoothIsOn(): Boolean {
val cbStateFlow = callbackFlow {
val delegate = object : NSObject(), CBCentralManagerDelegateProtocol {
override fun centralManagerDidUpdateState(central: CBCentralManager) {
trySend(central.state)
}
}
val centralManager = CBCentralManager(delegate, null, options)
awaitClose {
// What cleanup do we need?
}
}
return withTimeoutOrNull(5.seconds) {
cbStateFlow
.firstOrNull { cbState ->
cbState !in arrayOf(CBManagerStateUnknown, CBManagerStateResetting)
} == CBManagerStatePoweredOn
} ?: false
}
I cannot get this code to break (on iOS 18.5). It reports the correct status every time.