Skip to content

Commit 90a9e79

Browse files
Breaking: Move to a dynamic model of permission checks (#73)
Remove them from the connection status. Check them before connect but also provide pre-flight API for UX flows. This better matches the iOS/Android permission model and is easier for client code to manage.
1 parent d366e59 commit 90a9e79

File tree

8 files changed

+184
-199
lines changed

8 files changed

+184
-199
lines changed

lib/availability.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* (c) 2021, Micro:bit Educational Foundation and contributors
3+
*
4+
* SPDX-License-Identifier: MIT
5+
*/
6+
import { ConnectionAvailabilityStatus, DeviceError } from "./device.js";
7+
8+
/**
9+
* Throws a DeviceError if the availability status indicates the connection is unavailable.
10+
* The error codes align with ConnectionAvailabilityStatus values.
11+
*
12+
* @param status - The availability status to check.
13+
* @throws {DeviceError} If status is not "available".
14+
*/
15+
export const throwIfUnavailable = (
16+
status: ConnectionAvailabilityStatus,
17+
): void => {
18+
switch (status) {
19+
case "available":
20+
return;
21+
case "unsupported":
22+
throw new DeviceError({
23+
code: "unsupported",
24+
message: "Connection type not supported",
25+
});
26+
case "disabled":
27+
throw new DeviceError({
28+
code: "disabled",
29+
message: "Connection is disabled",
30+
});
31+
case "permission-denied":
32+
throw new DeviceError({
33+
code: "permission-denied",
34+
message: "Permission denied",
35+
});
36+
case "location-disabled":
37+
throw new DeviceError({
38+
code: "location-disabled",
39+
message: "Location services disabled",
40+
});
41+
}
42+
};

lib/bluetooth.ts

Lines changed: 72 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
BeforeRequestDevice,
1919
BoardVersion,
2020
ConnectOptions,
21+
ConnectionAvailabilityStatus,
2122
ConnectionStatus,
2223
ConnectionStatusEvent,
2324
DeviceConnection,
@@ -42,6 +43,8 @@ import {
4243
TypedServiceEvent,
4344
} from "./service-events.js";
4445

46+
import { throwIfUnavailable } from "./availability.js";
47+
4548
type BleClientError = { message: string; errorMessage: string };
4649

4750
let bleClientInitialized = false;
@@ -181,7 +184,7 @@ class MicrobitWebBluetoothConnectionImpl
181184
extends TypedEventTarget<DeviceConnectionEventMap & ServiceConnectionEventMap>
182185
implements MicrobitWebBluetoothConnection
183186
{
184-
status: ConnectionStatus = ConnectionStatus.SUPPORT_NOT_KNOWN;
187+
status: ConnectionStatus = ConnectionStatus.NO_AUTHORIZED_DEVICE;
185188

186189
/**
187190
* The USB device we last connected to.
@@ -192,12 +195,6 @@ class MicrobitWebBluetoothConnectionImpl
192195
private logging: Logging;
193196
private connection: BluetoothDeviceWrapper | undefined;
194197

195-
private availabilityListener = (e: Event) => {
196-
// TODO: is this called? is `value` correct?
197-
const value = (e as any).value as boolean;
198-
this.availability = value;
199-
};
200-
private availability: boolean | undefined;
201198
private nameFilter: string | undefined;
202199
private deferStatusUpdates: boolean = false;
203200

@@ -222,24 +219,73 @@ class MicrobitWebBluetoothConnectionImpl
222219
this.logging.error(message, e);
223220
}
224221

225-
async initialize(): Promise<void> {
226-
navigator.bluetooth?.addEventListener(
227-
"availabilitychanged",
228-
this.availabilityListener,
229-
);
230-
this.availability = await navigator.bluetooth?.getAvailability();
231-
this.setStatus(
232-
this.availability
233-
? ConnectionStatus.NO_AUTHORIZED_DEVICE
234-
: ConnectionStatus.NOT_SUPPORTED,
235-
);
222+
async initialize(): Promise<void> {}
223+
224+
dispose() {}
225+
226+
async checkAvailability(): Promise<ConnectionAvailabilityStatus> {
227+
if (Capacitor.isNativePlatform()) {
228+
return this.checkNativeBluetoothAvailability();
229+
}
230+
return this.checkWebBluetoothAvailability();
236231
}
237232

238-
dispose() {
239-
navigator.bluetooth?.removeEventListener(
240-
"availabilitychanged",
241-
this.availabilityListener,
242-
);
233+
private async checkWebBluetoothAvailability(): Promise<ConnectionAvailabilityStatus> {
234+
if (!navigator.bluetooth) {
235+
return "unsupported";
236+
}
237+
238+
try {
239+
const available = await navigator.bluetooth.getAvailability();
240+
return available ? "available" : "disabled";
241+
} catch {
242+
return "disabled";
243+
}
244+
}
245+
246+
private async checkNativeBluetoothAvailability(): Promise<ConnectionAvailabilityStatus> {
247+
try {
248+
// On Android, check if location services are enabled. This is only
249+
// required on Android < 12 (API < 31), but isLocationEnabled() returns
250+
// true on newer Android, so we can always check it.
251+
if (isAndroid()) {
252+
const isLocationEnabled = await BleClient.isLocationEnabled();
253+
if (!isLocationEnabled) {
254+
return "location-disabled";
255+
}
256+
}
257+
258+
// Initialize BLE (requests permissions automatically on first call).
259+
if (!bleClientInitialized) {
260+
await BleClient.initialize({ androidNeverForLocation: true });
261+
bleClientInitialized = true;
262+
}
263+
264+
// Check if Bluetooth is enabled.
265+
const isBluetoothEnabled = await BleClient.isEnabled();
266+
if (!isBluetoothEnabled) {
267+
return "disabled";
268+
}
269+
270+
return "available";
271+
} catch (e) {
272+
// Handle errors from BleClient.initialize() which rejects for permission
273+
// or unsupported states. Error messages are hardcoded in the plugin:
274+
// https://github.com/capacitor-community/bluetooth-le/blob/main/ios/Plugin/DeviceManager.swift
275+
const errorMessage =
276+
e instanceof Error ? e.message : (e as BleClientError)?.message ?? "";
277+
this.log(`Bluetooth availability check failed: "${errorMessage}"`);
278+
279+
if (errorMessage === "BLE permission denied") {
280+
return "permission-denied";
281+
}
282+
if (errorMessage === "BLE unsupported") {
283+
return "unsupported";
284+
}
285+
286+
// Unknown error - default to permission-denied
287+
return "permission-denied";
288+
}
243289
}
244290

245291
getBoardVersion(): BoardVersion | undefined {
@@ -249,8 +295,10 @@ class MicrobitWebBluetoothConnectionImpl
249295
async connect(options?: ConnectOptions): Promise<void> {
250296
const progress = options?.progress ?? (() => {});
251297

298+
// Check availability before connecting. Done here rather than at initialize()
299+
// because on Android/iOS that's the appropriate time to ask for permissions.
252300
progress(ProgressStage.Initializing);
253-
await this.preConnectInitialization();
301+
throwIfUnavailable(await this.checkAvailability());
254302

255303
if (!this.connection) {
256304
progress(ProgressStage.FindingDevice);
@@ -276,53 +324,6 @@ class MicrobitWebBluetoothConnectionImpl
276324
await this.connection.connect();
277325
}
278326

279-
/**
280-
* Initializes BLE.
281-
*
282-
* This must happen before requesting a device or connecting.
283-
*
284-
* We do this just before use not at connection initialize because on Android/iOS
285-
* that's the appropriate time to ask for permissions.
286-
*/
287-
private async preConnectInitialization(): Promise<void> {
288-
try {
289-
if (isAndroid()) {
290-
const isLocationEnabled = await BleClient.isLocationEnabled();
291-
if (!isLocationEnabled) {
292-
throw new DeviceError({
293-
code: "bluetooth-missing-permissions",
294-
message: "Location services is disabled",
295-
});
296-
}
297-
}
298-
if (!bleClientInitialized) {
299-
await BleClient.initialize({ androidNeverForLocation: true });
300-
bleClientInitialized = true;
301-
}
302-
const isBluetoothEnabled = await BleClient.isEnabled();
303-
if (!isBluetoothEnabled) {
304-
throw new DeviceError({
305-
code: "bluetooth-disabled",
306-
message: "Bluetooth is disabled",
307-
});
308-
}
309-
} catch (e: unknown) {
310-
this.error("Error initializing Bluetooth", e);
311-
const error = e as BleClientError;
312-
if (error.message === "BLE permission denied") {
313-
// Error thrown for iOS platform.
314-
throw new DeviceError({
315-
code: "bluetooth-disabled",
316-
message: "Bluetooth is disabled",
317-
});
318-
}
319-
throw new DeviceError({
320-
code: "bluetooth-missing-permissions",
321-
message: "Missing permissions",
322-
});
323-
}
324-
}
325-
326327
async disconnect(): Promise<void> {
327328
try {
328329
if (this.connection) {

lib/device.ts

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@
55
*/
66
import { TypedEventTarget, ValueIsEvent } from "./events.js";
77

8+
/**
9+
* Connection availability status returned by checkAvailability().
10+
* Used for pre-flight UX decisions before attempting to connect.
11+
*/
12+
export type ConnectionAvailabilityStatus =
13+
| "available"
14+
| "unsupported"
15+
| "disabled"
16+
| "permission-denied"
17+
| "location-disabled";
18+
819
/**
920
* Specific identified error types.
1021
*
@@ -47,14 +58,6 @@ export type DeviceErrorCode =
4758
* Failed to establish Bluetooth connection.
4859
*/
4960
| "bluetooth-connection-failed"
50-
/**
51-
* Bluetooth is disabled on the device.
52-
*/
53-
| "bluetooth-disabled"
54-
/**
55-
* Missing required Bluetooth permissions.
56-
*/
57-
| "bluetooth-missing-permissions"
5861
/**
5962
* Partial flash operation failed.
6063
*/
@@ -66,7 +69,27 @@ export type DeviceErrorCode =
6669
/**
6770
* Flash operation was cancelled.
6871
*/
69-
| "flash-cancelled";
72+
| "flash-cancelled"
73+
/**
74+
* Connection type is not supported on this platform/browser.
75+
* Aligns with ConnectionAvailabilityStatus "unsupported".
76+
*/
77+
| "unsupported"
78+
/**
79+
* Connection is disabled (e.g., Bluetooth turned off).
80+
* Aligns with ConnectionAvailabilityStatus "disabled".
81+
*/
82+
| "disabled"
83+
/**
84+
* Required permissions were denied.
85+
* Aligns with ConnectionAvailabilityStatus "permission-denied".
86+
*/
87+
| "permission-denied"
88+
/**
89+
* Location services are disabled (Android < 12 only).
90+
* Aligns with ConnectionAvailabilityStatus "location-disabled".
91+
*/
92+
| "location-disabled";
7093

7194
export enum ProgressStage {
7295
Initializing = "Initializing",
@@ -116,23 +139,17 @@ export class DeviceError extends Error {
116139
}
117140

118141
/**
119-
* Tracks connection status,
142+
* Tracks connection status.
120143
*/
121144
export enum ConnectionStatus {
122145
/**
123-
* Determining whether the connection type is supported requires
124-
* initialize() to complete.
125-
*/
126-
SUPPORT_NOT_KNOWN = "SUPPORT_NOT_KNOWN",
127-
/**
128-
* Not supported.
129-
*/
130-
NOT_SUPPORTED = "NOT_SUPPORTED",
131-
/**
132-
* Supported but no device available.
146+
* No device available.
147+
*
148+
* This is the initial status and will be the case even when a device is
149+
* physically connected but has not been connected via the browser security UI.
133150
*
134-
* This will be the case even when a device is physically connected
135-
* but has not been connected via the browser security UI.
151+
* Use checkAvailability() to determine whether the connection type is
152+
* supported before attempting to connect.
136153
*/
137154
NO_AUTHORIZED_DEVICE = "NO_AUTHORIZED_DEVICE",
138155
/**
@@ -237,6 +254,17 @@ export interface DeviceConnection<M extends ValueIsEvent<M>>
237254
* Initializes the device.
238255
*/
239256
initialize(): Promise<void>;
257+
258+
/**
259+
* Checks if this connection type is currently available.
260+
*
261+
* Use this for pre-flight UX decisions (e.g., showing "enable Bluetooth" dialog).
262+
* Note: Even if this returns "available", connect() can still fail.
263+
*
264+
* @returns A promise resolving to the current availability status.
265+
*/
266+
checkAvailability(): Promise<ConnectionAvailabilityStatus>;
267+
240268
/**
241269
* Removes all listeners.
242270
*/

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
BeforeRequestDevice,
1313
BoardVersion,
1414
ConnectOptions,
15+
ConnectionAvailabilityStatus,
1516
ConnectionStatus,
1617
ConnectionStatusEvent,
1718
DeviceConnection,
@@ -80,6 +81,7 @@ export type {
8081
AccelerometerData,
8182
AccelerometerDataEvent,
8283
BoardVersion,
84+
ConnectionAvailabilityStatus,
8385
ButtonEvent,
8486
ButtonEventType,
8587
ButtonState,

lib/usb-radio-bridge.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AccelerometerDataEvent } from "./accelerometer.js";
88
import { ButtonEvent, ButtonState } from "./buttons.js";
99
import {
1010
BoardVersion,
11+
ConnectionAvailabilityStatus,
1112
ConnectionStatus,
1213
ConnectionStatusEvent,
1314
DeviceConnection,
@@ -130,6 +131,10 @@ class MicrobitRadioBridgeConnectionImpl
130131
this.delegate.addEventListener("status", this.delegateStatusListener);
131132
}
132133

134+
async checkAvailability(): Promise<ConnectionAvailabilityStatus> {
135+
return this.delegate.checkAvailability();
136+
}
137+
133138
dispose(): void {
134139
this.delegate.removeEventListener("status", this.delegateStatusListener);
135140
this.delegate.dispose();

lib/usb.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ const describeDeviceOnly = process.env.TEST_MODE_DEVICE
2727
: describe.skip;
2828

2929
describe("MicrobitWebUSBConnection (WebUSB unsupported)", () => {
30-
it("notices if WebUSB isn't supported", () => {
30+
it("checkAvailability returns unsupported when WebUSB isn't available", async () => {
3131
vi.stubGlobal("navigator", {});
3232
const microbit = createWebUSBConnection();
33-
expect(microbit.status).toBe(ConnectionStatus.NOT_SUPPORTED);
33+
expect(await microbit.checkAvailability()).toBe("unsupported");
3434
vi.unstubAllGlobals();
3535
});
3636
it("still triggers afterrequestdevice if requestDevice throws", async () => {

0 commit comments

Comments
 (0)