@onekeyfe/hd-transport-react-native 1.2.2-alpha.11 → 1.2.2-alpha.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-react-native",
3
- "version": "1.2.2-alpha.11",
3
+ "version": "1.2.2-alpha.111",
4
4
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -20,11 +20,11 @@
20
20
  "lint:fix": "eslint . --fix"
21
21
  },
22
22
  "dependencies": {
23
- "@onekeyfe/hd-core": "1.2.2-alpha.11",
24
- "@onekeyfe/hd-shared": "1.2.2-alpha.11",
25
- "@onekeyfe/hd-transport": "1.2.2-alpha.11",
26
- "@onekeyfe/react-native-ble-utils": "^0.1.6",
23
+ "@onekeyfe/hd-core": "1.2.2-alpha.111",
24
+ "@onekeyfe/hd-shared": "1.2.2-alpha.111",
25
+ "@onekeyfe/hd-transport": "1.2.2-alpha.111",
26
+ "@onekeyfe/react-native-ble-utils": "0.1.6",
27
27
  "react-native-ble-plx": "3.5.1"
28
28
  },
29
- "gitHead": "8928100acb893d72361eb74422d5e2a0f7af77d8"
29
+ "gitHead": "a87be5a4ae4b59f91f853a9873a9e8e924bbdfd6"
30
30
  }
package/src/BleManager.ts CHANGED
@@ -7,6 +7,19 @@ import type { Peripheral } from '@onekeyfe/react-native-ble-utils';
7
7
 
8
8
  const Logger = bleLogger;
9
9
 
10
+ // Android BluetoothDevice.EXTRA_UNBOND_REASON values.
11
+ const bondFailureReasons: Record<number, string> = {
12
+ 1: 'authentication_failed',
13
+ 2: 'rejected',
14
+ 3: 'canceled',
15
+ 4: 'device_unreachable',
16
+ 5: 'discovery_in_progress',
17
+ 6: 'timeout',
18
+ 7: 'repeated_attempts',
19
+ 8: 'remote_canceled',
20
+ 9: 'removed',
21
+ };
22
+
10
23
  /**
11
24
  * get the device basic info of connected devices
12
25
  * @param serviceUuids
@@ -19,18 +32,37 @@ export const getBondedDevices = () => BleUtils.getBondedPeripherals();
19
32
 
20
33
  export const pairDevice = (macAddress: string) => BleUtils.pairDevice(macAddress);
21
34
 
22
- export const onDeviceBondState = (bleMacAddress: string): Promise<Peripheral | undefined> =>
35
+ export const onDeviceBondState = (
36
+ bleMacAddress: string,
37
+ signal?: AbortSignal
38
+ ): Promise<Peripheral | undefined> =>
23
39
  new Promise((resolve, reject) => {
40
+ if (signal?.aborted) {
41
+ reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
42
+ return;
43
+ }
24
44
  const cleanup = () => {
25
45
  if (timeout) {
26
46
  clearTimeout(timeout);
27
47
  }
28
48
  if (cleanupListener) cleanupListener();
49
+ signal?.removeEventListener('abort', onAbort);
50
+ };
51
+ const onAbort = () => {
52
+ cleanup();
53
+ reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
29
54
  };
30
55
 
56
+ signal?.addEventListener('abort', onAbort, { once: true });
57
+
31
58
  const timeout = setTimeout(() => {
32
59
  cleanup();
33
- reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded'));
60
+ reject(
61
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing timed out', {
62
+ phase: 'bond',
63
+ reason: 'timeout',
64
+ })
65
+ );
34
66
  }, 60 * 1000);
35
67
 
36
68
  const cleanupListener = BleUtils.onDeviceBondState(peripheral => {
@@ -40,14 +72,50 @@ export const onDeviceBondState = (bleMacAddress: string): Promise<Peripheral | u
40
72
  const { bondState } = peripheral;
41
73
 
42
74
  const hasBonded = bondState.preState === 'BOND_BONDING' && bondState.state === 'BOND_BONDED';
43
- const hasCanceled = bondState.preState === 'BOND_BONDING' && bondState.state === 'BOND_NONE';
75
+ const hasFailed = bondState.preState === 'BOND_BONDING' && bondState.state === 'BOND_NONE';
44
76
  Logger.debug('onDeviceBondState bondState:', bondState);
45
77
  if (hasBonded) {
46
78
  cleanup();
47
79
  resolve(peripheral);
48
- } else if (hasCanceled) {
80
+ } else if (hasFailed) {
49
81
  cleanup();
50
- reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceBondedCanceled, 'bonding canceled'));
82
+ const nativeReason =
83
+ 'reason' in bondState && typeof bondState.reason === 'number'
84
+ ? bondState.reason
85
+ : undefined;
86
+ const reason =
87
+ nativeReason === undefined ? 'unknown' : bondFailureReasons[nativeReason] ?? 'unknown';
88
+ const params = {
89
+ phase: 'bond',
90
+ reason,
91
+ ...(nativeReason === undefined ? {} : { nativeReason }),
92
+ };
93
+ if (reason === 'timeout') {
94
+ // Connection timeouts are retried by Core; pairing requires a new user attempt.
95
+ reject(
96
+ ERRORS.TypedError(
97
+ HardwareErrorCode.BleDeviceNotBonded,
98
+ 'Bluetooth pairing timed out',
99
+ params
100
+ )
101
+ );
102
+ } else if (reason === 'canceled') {
103
+ reject(
104
+ ERRORS.TypedError(
105
+ HardwareErrorCode.BleDeviceBondedCanceled,
106
+ 'Bluetooth pairing canceled',
107
+ params
108
+ )
109
+ );
110
+ } else {
111
+ reject(
112
+ ERRORS.TypedError(
113
+ HardwareErrorCode.BleDeviceNotBonded,
114
+ 'Bluetooth pairing failed',
115
+ params
116
+ )
117
+ );
118
+ }
51
119
  }
52
120
  });
53
121
  });
@@ -0,0 +1,33 @@
1
+ import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+
3
+ import { isNativeBleDisconnectError, toBleDisconnectHardwareError } from '../bleNativeDisconnect';
4
+
5
+ describe('native BLE disconnect mapping', () => {
6
+ test.each([
7
+ [{ errorCode: 201, reason: 'The specified device has disconnected from us.' }],
8
+ [{ errorCode: 201, iosErrorCode: 7, reason: 'The specified device has disconnected from us.' }],
9
+ [{ iosErrorCode: 7, reason: 'Peripheral disconnected' }],
10
+ ])('recognizes %j as a native disconnect', nativeError => {
11
+ expect(isNativeBleDisconnectError(nativeError)).toBe(true);
12
+ expect(toBleDisconnectHardwareError(nativeError)).toMatchObject({
13
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
14
+ });
15
+ });
16
+
17
+ test('does not treat a stale-bond iOS code as a disconnect', () => {
18
+ expect(
19
+ isNativeBleDisconnectError({
20
+ iosErrorCode: 14,
21
+ reason: 'Peer removed pairing information',
22
+ })
23
+ ).toBe(false);
24
+ });
25
+
26
+ test('does not treat an already-canonical unpaired error as a native disconnect', () => {
27
+ expect(
28
+ isNativeBleDisconnectError({
29
+ errorCode: HardwareErrorCode.BleDeviceNotBonded,
30
+ })
31
+ ).toBe(false);
32
+ });
33
+ });
@@ -12,6 +12,14 @@ describe('native BLE stale bond mapping', () => {
12
12
  { attErrorCode: 5, reason: 'GATT_INSUF_AUTHENTICATION' },
13
13
  HardwareErrorCode.BleDeviceBondError,
14
14
  ],
15
+ [
16
+ { androidErrorCode: 5, reason: 'Connection state changed with status 5' },
17
+ HardwareErrorCode.BleDeviceBondError,
18
+ ],
19
+ [
20
+ { androidErrorCode: 15, reason: 'Connection state changed with status 15' },
21
+ HardwareErrorCode.BleDeviceBondError,
22
+ ],
15
23
  [
16
24
  { iosErrorCode: 14, reason: 'Peer removed pairing information' },
17
25
  HardwareErrorCode.BlePeerRemovedPairingInformation,
@@ -1,7 +1,10 @@
1
1
  import { EventEmitter } from 'events';
2
- import { BleErrorCode } from 'react-native-ble-plx';
3
- import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+ import { Platform } from 'react-native';
3
+ import { BleErrorCode, BleManager } from 'react-native-ble-plx';
4
+ import BleUtils from '@onekeyfe/react-native-ble-utils';
5
+ import { ERRORS, HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
4
6
 
7
+ import { onDeviceBondState } from '../BleManager';
5
8
  import ReactNativeBleTransport, {
6
9
  BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD,
7
10
  BLE_CONNECT_TIMEOUT_MS,
@@ -10,6 +13,8 @@ import ReactNativeBleTransport, {
10
13
  } from '../index';
11
14
  import protocolV1Schema from './protocolV1SchemaFixture';
12
15
 
16
+ import type { Peripheral } from '@onekeyfe/react-native-ble-utils';
17
+
13
18
  jest.mock(
14
19
  'react-native',
15
20
  () => ({
@@ -44,12 +49,131 @@ jest.mock('@onekeyfe/react-native-ble-utils', () => ({
44
49
  default: {
45
50
  getConnectedPeripherals: jest.fn(() => Promise.resolve([])),
46
51
  getBondedPeripherals: jest.fn(() => Promise.resolve([])),
47
- pairDevice: jest.fn(() => Promise.resolve()),
52
+ pairDevice: jest.fn(() => Promise.resolve({ bonded: true, bonding: false })),
53
+ onDeviceBondState: jest.fn(),
48
54
  },
49
55
  }));
50
56
 
51
57
  const UUID = 'stalled-connect-device';
52
58
 
59
+ describe('Android bond failure reasons', () => {
60
+ let emitBondState: (peripheral: Peripheral) => void;
61
+ const cleanup = jest.fn();
62
+
63
+ beforeEach(() => {
64
+ jest.useFakeTimers({ doNotFake: ['performance'] });
65
+ cleanup.mockClear();
66
+ jest.spyOn(BleUtils, 'onDeviceBondState').mockImplementation(listener => {
67
+ emitBondState = listener;
68
+ return cleanup;
69
+ });
70
+ });
71
+
72
+ afterEach(() => {
73
+ jest.restoreAllMocks();
74
+ jest.useRealTimers();
75
+ });
76
+
77
+ test.each([
78
+ [6, 'timeout', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing timed out'],
79
+ [3, 'canceled', HardwareErrorCode.BleDeviceBondedCanceled, 'Bluetooth pairing canceled'],
80
+ [1, 'authentication_failed', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
81
+ [2, 'rejected', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
82
+ [4, 'device_unreachable', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
83
+ [5, 'discovery_in_progress', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
84
+ [7, 'repeated_attempts', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
85
+ [8, 'remote_canceled', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
86
+ [9, 'removed', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
87
+ [undefined, 'unknown', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
88
+ [999, 'unknown', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
89
+ ] as const)(
90
+ 'preserves Android reason %s as %s across serialization',
91
+ async (nativeReason, reason, code, message) => {
92
+ const result = onDeviceBondState(UUID).catch(error =>
93
+ JSON.parse(JSON.stringify(ERRORS.serializeError({ error })))
94
+ );
95
+ emitBondState({
96
+ id: UUID,
97
+ advertising: {},
98
+ bondState: {
99
+ preState: 'BOND_BONDING',
100
+ state: 'BOND_NONE',
101
+ ...(nativeReason === undefined ? {} : { reason: nativeReason }),
102
+ },
103
+ });
104
+
105
+ await expect(result).resolves.toEqual({
106
+ code,
107
+ error: message,
108
+ params: {
109
+ phase: 'bond',
110
+ reason,
111
+ ...(nativeReason === undefined ? {} : { nativeReason }),
112
+ },
113
+ });
114
+ expect(cleanup).toHaveBeenCalledTimes(1);
115
+ expect(jest.getTimerCount()).toBe(0);
116
+ }
117
+ );
118
+
119
+ test('reports a timeout if no bond event arrives within the SDK deadline', async () => {
120
+ const result = onDeviceBondState(UUID);
121
+ const rejection = expect(result).rejects.toMatchObject({
122
+ errorCode: HardwareErrorCode.BleDeviceNotBonded,
123
+ params: { phase: 'bond', reason: 'timeout' },
124
+ });
125
+ jest.advanceTimersByTime(60_000);
126
+ await rejection;
127
+ expect(cleanup).toHaveBeenCalledTimes(1);
128
+ });
129
+
130
+ test('aborts the bond wait and removes its listener and deadline', async () => {
131
+ const controller = new AbortController();
132
+ const result = onDeviceBondState(UUID, controller.signal);
133
+ const rejection = expect(result).rejects.toMatchObject({
134
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
135
+ });
136
+
137
+ controller.abort();
138
+
139
+ await rejection;
140
+ expect(cleanup).toHaveBeenCalledTimes(1);
141
+ expect(jest.getTimerCount()).toBe(0);
142
+ });
143
+
144
+ test('does not subscribe when the transport stopped before the bond wait starts', async () => {
145
+ const controller = new AbortController();
146
+ controller.abort();
147
+ const subscribe = jest.spyOn(BleUtils, 'onDeviceBondState').mockClear();
148
+
149
+ await expect(onDeviceBondState(UUID, controller.signal)).rejects.toMatchObject({
150
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
151
+ });
152
+ expect(subscribe).not.toHaveBeenCalled();
153
+ expect(jest.getTimerCount()).toBe(0);
154
+ });
155
+
156
+ test('ignores other devices and clears the deadline after bonding succeeds', async () => {
157
+ const result = onDeviceBondState(UUID);
158
+ const otherPeripheral = {
159
+ id: 'another-device',
160
+ advertising: {},
161
+ bondState: { preState: 'BOND_BONDING', state: 'BOND_NONE', reason: 6 },
162
+ };
163
+ emitBondState(otherPeripheral);
164
+ expect(cleanup).not.toHaveBeenCalled();
165
+ const peripheral: Peripheral = {
166
+ id: UUID.toUpperCase(),
167
+ advertising: {},
168
+ bondState: { preState: 'BOND_BONDING', state: 'BOND_BONDED' },
169
+ };
170
+ emitBondState(peripheral);
171
+ await expect(result).resolves.toBe(peripheral);
172
+ expect(cleanup).toHaveBeenCalledTimes(1);
173
+ expect(jest.getTimerCount()).toBe(0);
174
+ });
175
+ });
176
+
53
177
  const flush = () =>
54
178
  new Promise(resolve => {
55
179
  setImmediate(resolve);
@@ -129,6 +253,60 @@ describe('BLE connect timeout', () => {
129
253
  afterEach(() => {
130
254
  jest.clearAllTimers();
131
255
  jest.restoreAllMocks();
256
+ Object.assign(Platform, { OS: 'ios' });
257
+ });
258
+
259
+ test('waits for singleton destruction before creating the next manager', async () => {
260
+ const { transport, bleManager } = createHarness(() => Promise.resolve());
261
+ const destruction = createDeferred<void>();
262
+ Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) });
263
+ const createManager = jest.mocked(BleManager);
264
+ createManager.mockClear();
265
+ (transport as unknown as { resetPlxManager(): void }).resetPlxManager();
266
+ const first = transport.getPlxManager();
267
+ const second = transport.getPlxManager();
268
+ await flush();
269
+ expect(createManager).not.toHaveBeenCalled();
270
+ destruction.resolve();
271
+ expect(await first).toBe(await second);
272
+ expect(createManager).toHaveBeenCalledTimes(1);
273
+ });
274
+
275
+ test('does not reuse a manager after asynchronous destruction fails', async () => {
276
+ const { bleManager } = createHarness(() => Promise.resolve());
277
+ let transport!: ReactNativeBleTransport;
278
+ jest.isolateModules(() => {
279
+ const { default: Transport } = jest.requireActual<typeof import('../index')>('../index');
280
+ transport = new Transport({});
281
+ });
282
+ transport.blePlxManager = bleManager as never;
283
+ const destruction = createDeferred<void>();
284
+ Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) });
285
+ (transport as unknown as { resetPlxManager(): void }).resetPlxManager();
286
+ const result = transport.getPlxManager();
287
+ const failure = expect(result).rejects.toMatchObject({
288
+ errorCode: HardwareErrorCode.PollingTimeout,
289
+ });
290
+ destruction.reject(new Error('Native destruction failed'));
291
+ await failure;
292
+ });
293
+
294
+ test('bounds reset waiting without letting a new transport bypass unfinished destruction', async () => {
295
+ const { transport, bleManager } = createHarness(() => Promise.resolve());
296
+ const destruction = createDeferred<void>();
297
+ Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) });
298
+ const createManager = jest.mocked(BleManager);
299
+ createManager.mockClear();
300
+ (transport as unknown as { resetPlxManager(): void }).resetPlxManager();
301
+ const nextTransport = new ReactNativeBleTransport({});
302
+ const result = nextTransport.getPlxManager().catch(error => error);
303
+ await flush();
304
+ jest.advanceTimersByTime(BLE_CONNECT_TIMEOUT_MS);
305
+ await expect(result).resolves.toMatchObject({ errorCode: HardwareErrorCode.PollingTimeout });
306
+ expect(createManager).not.toHaveBeenCalled();
307
+ destruction.resolve();
308
+ await nextTransport.getPlxManager();
309
+ expect(createManager).toHaveBeenCalledTimes(1);
132
310
  });
133
311
 
134
312
  test('a native connect that never settles is bounded instead of blocking forever', async () => {
@@ -155,6 +333,96 @@ describe('BLE connect timeout', () => {
155
333
  expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleConnectedError);
156
334
  });
157
335
 
336
+ test('stop drains its scan and removes the timer before another transport scans', async () => {
337
+ const { transport, bleManager } = createHarness(() => Promise.resolve());
338
+ const nativeStop = createDeferred<void>();
339
+ bleManager.stopDeviceScan.mockImplementation(() => nativeStop.promise);
340
+ const scanned = transport.enumerate().catch(error => error);
341
+ await flush();
342
+ await flush();
343
+ expect(bleManager.startDeviceScan).toHaveBeenCalledTimes(1);
344
+ const stopping = transport.stop();
345
+ expect(transport.stop()).toBe(stopping);
346
+ let stopped = false;
347
+ stopping.then(() => {
348
+ stopped = true;
349
+ });
350
+ await flush();
351
+ expect(stopped).toBe(false);
352
+ nativeStop.resolve();
353
+ await stopping;
354
+ await expect(scanned).resolves.toMatchObject({
355
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
356
+ });
357
+ jest.advanceTimersByTime(transport.scanTimeout);
358
+ await flush();
359
+ expect(bleManager.stopDeviceScan).toHaveBeenCalledTimes(1);
360
+ await expect(transport.getPlxManager()).rejects.toMatchObject({
361
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
362
+ });
363
+ });
364
+
365
+ test.each(['V1', 'V2'] as const)(
366
+ 'stop completes while Android %s bonding remains pending',
367
+ async expectedProtocol => {
368
+ Object.assign(Platform, { OS: 'android' });
369
+ const { transport, bleManager, connect } = createHarness(() => Promise.resolve());
370
+ jest.spyOn(BleUtils, 'pairDevice').mockResolvedValueOnce({ bonded: false, bonding: true });
371
+ const listening = createDeferred<void>();
372
+ const cleanup = jest.fn();
373
+ jest.spyOn(BleUtils, 'onDeviceBondState').mockImplementationOnce(() => {
374
+ listening.resolve();
375
+ return cleanup;
376
+ });
377
+ const acquiring = transport.acquire({ uuid: UUID, expectedProtocol });
378
+ const rejection = expect(acquiring).rejects.toMatchObject({
379
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
380
+ });
381
+ await listening.promise;
382
+
383
+ // Advance only the existing 100ms disconnect drain, not the bond deadline.
384
+ let stopped = false;
385
+ const stopping = transport.stop().then(() => {
386
+ stopped = true;
387
+ });
388
+ await advanceUntil(() => stopped, 1000);
389
+ expect(stopped).toBe(true);
390
+ await stopping;
391
+
392
+ await rejection;
393
+ expect(cleanup).toHaveBeenCalledTimes(1);
394
+ expect(jest.getTimerCount()).toBe(0);
395
+ expect(bleManager.devices).not.toHaveBeenCalled();
396
+ expect(connect).not.toHaveBeenCalled();
397
+ }
398
+ );
399
+
400
+ test('stop rejects a pending read and waits for native disconnection without destroying the shared manager', async () => {
401
+ const { transport, bleManager } = createHarness(() => Promise.resolve());
402
+ const nativeDisconnect = createDeferred<void>();
403
+ bleManager.cancelDeviceConnection.mockImplementation(() => nativeDisconnect.promise);
404
+ const destroy = jest.fn();
405
+ Object.assign(bleManager, { destroy });
406
+ const read = createDeferred<void>();
407
+ transport.runPromise = read;
408
+ Object.assign(transport, { runPromiseDeviceId: UUID });
409
+ const readResult = read.promise.catch(error => error);
410
+ let stopped = false;
411
+ const stopping = transport.stop().then(() => {
412
+ stopped = true;
413
+ });
414
+ await flush();
415
+ await expect(readResult).resolves.toMatchObject({
416
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
417
+ });
418
+ expect(stopped).toBe(false);
419
+ nativeDisconnect.resolve();
420
+ await advanceUntil(() => stopped, 1000);
421
+ await stopping;
422
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID);
423
+ expect(destroy).not.toHaveBeenCalled();
424
+ });
425
+
158
426
  test('the connect budget leaves generous headroom over a healthy connect', () => {
159
427
  // Healthy connects finish in ~2-3s (the native budget is 3s); this backstop only
160
428
  // fires when the native timeout itself fails to.
@@ -162,6 +430,78 @@ describe('BLE connect timeout', () => {
162
430
  expect(BLE_CONNECT_TIMEOUT_MS).toBeLessThanOrEqual(12000);
163
431
  });
164
432
 
433
+ test.each(
434
+ (['ios', 'android'] as const).flatMap(platform =>
435
+ (['connect', 'mtu', 'gatt'] as const).flatMap(stage =>
436
+ (['reject', 'resolve'] as const).map(completion => ({ platform, stage, completion }))
437
+ )
438
+ )
439
+ )(
440
+ 'stop cancels $platform $stage before draining a late $completion',
441
+ async ({ platform, stage, completion }) => {
442
+ Object.assign(Platform, { OS: platform });
443
+ const { transport, device, bleManager, connect } = createHarness(
444
+ () => nativeOperation.promise
445
+ );
446
+ const nativeOperation = createDeferred<typeof device>();
447
+ const nativeDisconnect = createDeferred<void>();
448
+ const requestMtu = jest.fn(() => Promise.resolve(device));
449
+ Object.assign(device, { mtu: 247, requestMTU: requestMtu });
450
+ device.isConnected.mockResolvedValue(true);
451
+ if (stage === 'connect') device.isConnected.mockResolvedValueOnce(false);
452
+ if (stage === 'mtu') requestMtu.mockImplementationOnce(() => nativeOperation.promise);
453
+ if (stage === 'gatt') {
454
+ device.discoverAllServicesAndCharacteristics.mockImplementationOnce(() =>
455
+ nativeOperation.promise.then(() => undefined)
456
+ );
457
+ }
458
+ const destroy = jest.fn();
459
+ Object.assign(bleManager, { destroy });
460
+ const monitor = jest.spyOn(transport, '_monitorCharacteristic');
461
+ const acquire = transport.acquire({ uuid: UUID }).catch(error => error);
462
+ await flush();
463
+ await flush();
464
+ const pending = {
465
+ connect,
466
+ mtu: requestMtu,
467
+ gatt: device.discoverAllServicesAndCharacteristics,
468
+ }[stage];
469
+ expect(pending).toHaveBeenCalledTimes(1);
470
+
471
+ bleManager.cancelDeviceConnection.mockImplementation(() => nativeDisconnect.promise);
472
+ let stopped = false;
473
+ const stopping = transport.stop().then(() => {
474
+ stopped = true;
475
+ });
476
+ try {
477
+ await flush();
478
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID);
479
+ expect(stopped).toBe(false);
480
+ } finally {
481
+ // Native completion can race cancellation; neither outcome may restart setup.
482
+ if (completion === 'resolve') nativeOperation.resolve(device);
483
+ else
484
+ nativeOperation.reject(
485
+ Object.assign(new Error('Operation was cancelled'), {
486
+ errorCode: BleErrorCode.OperationCancelled,
487
+ })
488
+ );
489
+ nativeDisconnect.resolve();
490
+ await advanceUntil(() => stopped, BLE_CONNECT_TIMEOUT_MS + BLE_GATT_SETUP_TIMEOUT_MS);
491
+ await stopping;
492
+ }
493
+ await expect(acquire).resolves.toBeInstanceOf(Error);
494
+ expect(connect).toHaveBeenCalledTimes(stage === 'connect' ? 1 : 0);
495
+ expect(monitor).not.toHaveBeenCalled();
496
+ if (stage === 'mtu')
497
+ expect(device.discoverAllServicesAndCharacteristics).not.toHaveBeenCalled();
498
+ expect(bleManager.cancelDeviceConnection.mock.calls).toEqual(
499
+ Array.from({ length: bleManager.cancelDeviceConnection.mock.calls.length }, () => [UUID])
500
+ );
501
+ expect(destroy).not.toHaveBeenCalled();
502
+ }
503
+ );
504
+
165
505
  test('a stalled connect is abandoned natively so the next attempt is not cancelled by it', async () => {
166
506
  const { transport, bleManager } = createHarness(
167
507
  () =>