@onekeyfe/hd-transport-react-native 1.2.3-alpha.3 → 1.2.3-alpha.5
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/README.md +1 -1
- package/dist/BleManager.d.ts +1 -1
- package/dist/BleManager.d.ts.map +1 -1
- package/dist/bleNativeDisconnect.d.ts +3 -0
- package/dist/bleNativeDisconnect.d.ts.map +1 -0
- package/dist/index.d.ts +35 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +634 -152
- package/dist/subscribeBleOn.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/BleManager.ts +73 -5
- package/src/__tests__/bleNativeDisconnect.test.ts +54 -0
- package/src/__tests__/connectTimeout.test.ts +465 -3
- package/src/__tests__/enumerate.test.ts +36 -1
- package/src/__tests__/protocolV2Link.test.ts +976 -62
- package/src/__tests__/staleCallTimeout.test.ts +34 -5
- package/src/__tests__/subscribeBleOn.test.ts +111 -0
- package/src/bleNativeDisconnect.ts +41 -0
- package/src/index.ts +647 -159
- package/src/subscribeBleOn.ts +26 -10
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { EventEmitter } from 'events';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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, {
|
|
9
|
+
ANDROID_LINK_DROP_QUIET_MS,
|
|
10
|
+
ANDROID_MTU_EXCHANGE_TIMEOUT_MS,
|
|
6
11
|
BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD,
|
|
7
12
|
BLE_CONNECT_TIMEOUT_MS,
|
|
8
13
|
BLE_GATT_SETUP_TIMEOUT_MS,
|
|
@@ -10,6 +15,8 @@ import ReactNativeBleTransport, {
|
|
|
10
15
|
} from '../index';
|
|
11
16
|
import protocolV1Schema from './protocolV1SchemaFixture';
|
|
12
17
|
|
|
18
|
+
import type { Peripheral } from '@onekeyfe/react-native-ble-utils';
|
|
19
|
+
|
|
13
20
|
jest.mock(
|
|
14
21
|
'react-native',
|
|
15
22
|
() => ({
|
|
@@ -44,12 +51,131 @@ jest.mock('@onekeyfe/react-native-ble-utils', () => ({
|
|
|
44
51
|
default: {
|
|
45
52
|
getConnectedPeripherals: jest.fn(() => Promise.resolve([])),
|
|
46
53
|
getBondedPeripherals: jest.fn(() => Promise.resolve([])),
|
|
47
|
-
pairDevice: jest.fn(() => Promise.resolve()),
|
|
54
|
+
pairDevice: jest.fn(() => Promise.resolve({ bonded: true, bonding: false })),
|
|
55
|
+
onDeviceBondState: jest.fn(),
|
|
48
56
|
},
|
|
49
57
|
}));
|
|
50
58
|
|
|
51
59
|
const UUID = 'stalled-connect-device';
|
|
52
60
|
|
|
61
|
+
describe('Android bond failure reasons', () => {
|
|
62
|
+
let emitBondState: (peripheral: Peripheral) => void;
|
|
63
|
+
const cleanup = jest.fn();
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
jest.useFakeTimers({ doNotFake: ['performance'] });
|
|
67
|
+
cleanup.mockClear();
|
|
68
|
+
jest.spyOn(BleUtils, 'onDeviceBondState').mockImplementation(listener => {
|
|
69
|
+
emitBondState = listener;
|
|
70
|
+
return cleanup;
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
jest.restoreAllMocks();
|
|
76
|
+
jest.useRealTimers();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test.each([
|
|
80
|
+
[6, 'timeout', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing timed out'],
|
|
81
|
+
[3, 'canceled', HardwareErrorCode.BleDeviceBondedCanceled, 'Bluetooth pairing canceled'],
|
|
82
|
+
[1, 'authentication_failed', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
|
|
83
|
+
[2, 'rejected', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
|
|
84
|
+
[4, 'device_unreachable', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
|
|
85
|
+
[5, 'discovery_in_progress', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
|
|
86
|
+
[7, 'repeated_attempts', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
|
|
87
|
+
[8, 'remote_canceled', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
|
|
88
|
+
[9, 'removed', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
|
|
89
|
+
[undefined, 'unknown', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
|
|
90
|
+
[999, 'unknown', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'],
|
|
91
|
+
] as const)(
|
|
92
|
+
'preserves Android reason %s as %s across serialization',
|
|
93
|
+
async (nativeReason, reason, code, message) => {
|
|
94
|
+
const result = onDeviceBondState(UUID).catch(error =>
|
|
95
|
+
JSON.parse(JSON.stringify(ERRORS.serializeError({ error })))
|
|
96
|
+
);
|
|
97
|
+
emitBondState({
|
|
98
|
+
id: UUID,
|
|
99
|
+
advertising: {},
|
|
100
|
+
bondState: {
|
|
101
|
+
preState: 'BOND_BONDING',
|
|
102
|
+
state: 'BOND_NONE',
|
|
103
|
+
...(nativeReason === undefined ? {} : { reason: nativeReason }),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await expect(result).resolves.toEqual({
|
|
108
|
+
code,
|
|
109
|
+
error: message,
|
|
110
|
+
params: {
|
|
111
|
+
phase: 'bond',
|
|
112
|
+
reason,
|
|
113
|
+
...(nativeReason === undefined ? {} : { nativeReason }),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
117
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
test('reports a timeout if no bond event arrives within the SDK deadline', async () => {
|
|
122
|
+
const result = onDeviceBondState(UUID);
|
|
123
|
+
const rejection = expect(result).rejects.toMatchObject({
|
|
124
|
+
errorCode: HardwareErrorCode.BleDeviceNotBonded,
|
|
125
|
+
params: { phase: 'bond', reason: 'timeout' },
|
|
126
|
+
});
|
|
127
|
+
jest.advanceTimersByTime(60_000);
|
|
128
|
+
await rejection;
|
|
129
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('aborts the bond wait and removes its listener and deadline', async () => {
|
|
133
|
+
const controller = new AbortController();
|
|
134
|
+
const result = onDeviceBondState(UUID, controller.signal);
|
|
135
|
+
const rejection = expect(result).rejects.toMatchObject({
|
|
136
|
+
errorCode: HardwareErrorCode.BleDeviceDisconnected,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
controller.abort();
|
|
140
|
+
|
|
141
|
+
await rejection;
|
|
142
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
143
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('does not subscribe when the transport stopped before the bond wait starts', async () => {
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
controller.abort();
|
|
149
|
+
const subscribe = jest.spyOn(BleUtils, 'onDeviceBondState').mockClear();
|
|
150
|
+
|
|
151
|
+
await expect(onDeviceBondState(UUID, controller.signal)).rejects.toMatchObject({
|
|
152
|
+
errorCode: HardwareErrorCode.BleDeviceDisconnected,
|
|
153
|
+
});
|
|
154
|
+
expect(subscribe).not.toHaveBeenCalled();
|
|
155
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('ignores other devices and clears the deadline after bonding succeeds', async () => {
|
|
159
|
+
const result = onDeviceBondState(UUID);
|
|
160
|
+
const otherPeripheral = {
|
|
161
|
+
id: 'another-device',
|
|
162
|
+
advertising: {},
|
|
163
|
+
bondState: { preState: 'BOND_BONDING', state: 'BOND_NONE', reason: 6 },
|
|
164
|
+
};
|
|
165
|
+
emitBondState(otherPeripheral);
|
|
166
|
+
expect(cleanup).not.toHaveBeenCalled();
|
|
167
|
+
const peripheral: Peripheral = {
|
|
168
|
+
id: UUID.toUpperCase(),
|
|
169
|
+
advertising: {},
|
|
170
|
+
bondState: { preState: 'BOND_BONDING', state: 'BOND_BONDED' },
|
|
171
|
+
};
|
|
172
|
+
emitBondState(peripheral);
|
|
173
|
+
await expect(result).resolves.toBe(peripheral);
|
|
174
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
175
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
53
179
|
const flush = () =>
|
|
54
180
|
new Promise(resolve => {
|
|
55
181
|
setImmediate(resolve);
|
|
@@ -129,6 +255,60 @@ describe('BLE connect timeout', () => {
|
|
|
129
255
|
afterEach(() => {
|
|
130
256
|
jest.clearAllTimers();
|
|
131
257
|
jest.restoreAllMocks();
|
|
258
|
+
Object.assign(Platform, { OS: 'ios' });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('waits for singleton destruction before creating the next manager', async () => {
|
|
262
|
+
const { transport, bleManager } = createHarness(() => Promise.resolve());
|
|
263
|
+
const destruction = createDeferred<void>();
|
|
264
|
+
Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) });
|
|
265
|
+
const createManager = jest.mocked(BleManager);
|
|
266
|
+
createManager.mockClear();
|
|
267
|
+
(transport as unknown as { resetPlxManager(): void }).resetPlxManager();
|
|
268
|
+
const first = transport.getPlxManager();
|
|
269
|
+
const second = transport.getPlxManager();
|
|
270
|
+
await flush();
|
|
271
|
+
expect(createManager).not.toHaveBeenCalled();
|
|
272
|
+
destruction.resolve();
|
|
273
|
+
expect(await first).toBe(await second);
|
|
274
|
+
expect(createManager).toHaveBeenCalledTimes(1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('does not reuse a manager after asynchronous destruction fails', async () => {
|
|
278
|
+
const { bleManager } = createHarness(() => Promise.resolve());
|
|
279
|
+
let transport!: ReactNativeBleTransport;
|
|
280
|
+
jest.isolateModules(() => {
|
|
281
|
+
const { default: Transport } = jest.requireActual<typeof import('../index')>('../index');
|
|
282
|
+
transport = new Transport({});
|
|
283
|
+
});
|
|
284
|
+
transport.blePlxManager = bleManager as never;
|
|
285
|
+
const destruction = createDeferred<void>();
|
|
286
|
+
Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) });
|
|
287
|
+
(transport as unknown as { resetPlxManager(): void }).resetPlxManager();
|
|
288
|
+
const result = transport.getPlxManager();
|
|
289
|
+
const failure = expect(result).rejects.toMatchObject({
|
|
290
|
+
errorCode: HardwareErrorCode.PollingTimeout,
|
|
291
|
+
});
|
|
292
|
+
destruction.reject(new Error('Native destruction failed'));
|
|
293
|
+
await failure;
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('bounds reset waiting without letting a new transport bypass unfinished destruction', async () => {
|
|
297
|
+
const { transport, bleManager } = createHarness(() => Promise.resolve());
|
|
298
|
+
const destruction = createDeferred<void>();
|
|
299
|
+
Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) });
|
|
300
|
+
const createManager = jest.mocked(BleManager);
|
|
301
|
+
createManager.mockClear();
|
|
302
|
+
(transport as unknown as { resetPlxManager(): void }).resetPlxManager();
|
|
303
|
+
const nextTransport = new ReactNativeBleTransport({});
|
|
304
|
+
const result = nextTransport.getPlxManager().catch(error => error);
|
|
305
|
+
await flush();
|
|
306
|
+
jest.advanceTimersByTime(BLE_CONNECT_TIMEOUT_MS);
|
|
307
|
+
await expect(result).resolves.toMatchObject({ errorCode: HardwareErrorCode.PollingTimeout });
|
|
308
|
+
expect(createManager).not.toHaveBeenCalled();
|
|
309
|
+
destruction.resolve();
|
|
310
|
+
await nextTransport.getPlxManager();
|
|
311
|
+
expect(createManager).toHaveBeenCalledTimes(1);
|
|
132
312
|
});
|
|
133
313
|
|
|
134
314
|
test('a native connect that never settles is bounded instead of blocking forever', async () => {
|
|
@@ -155,6 +335,96 @@ describe('BLE connect timeout', () => {
|
|
|
155
335
|
expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleConnectedError);
|
|
156
336
|
});
|
|
157
337
|
|
|
338
|
+
test('stop drains its scan and removes the timer before another transport scans', async () => {
|
|
339
|
+
const { transport, bleManager } = createHarness(() => Promise.resolve());
|
|
340
|
+
const nativeStop = createDeferred<void>();
|
|
341
|
+
bleManager.stopDeviceScan.mockImplementation(() => nativeStop.promise);
|
|
342
|
+
const scanned = transport.enumerate().catch(error => error);
|
|
343
|
+
await flush();
|
|
344
|
+
await flush();
|
|
345
|
+
expect(bleManager.startDeviceScan).toHaveBeenCalledTimes(1);
|
|
346
|
+
const stopping = transport.stop();
|
|
347
|
+
expect(transport.stop()).toBe(stopping);
|
|
348
|
+
let stopped = false;
|
|
349
|
+
stopping.then(() => {
|
|
350
|
+
stopped = true;
|
|
351
|
+
});
|
|
352
|
+
await flush();
|
|
353
|
+
expect(stopped).toBe(false);
|
|
354
|
+
nativeStop.resolve();
|
|
355
|
+
await stopping;
|
|
356
|
+
await expect(scanned).resolves.toMatchObject({
|
|
357
|
+
errorCode: HardwareErrorCode.BleDeviceDisconnected,
|
|
358
|
+
});
|
|
359
|
+
jest.advanceTimersByTime(transport.scanTimeout);
|
|
360
|
+
await flush();
|
|
361
|
+
expect(bleManager.stopDeviceScan).toHaveBeenCalledTimes(1);
|
|
362
|
+
await expect(transport.getPlxManager()).rejects.toMatchObject({
|
|
363
|
+
errorCode: HardwareErrorCode.BleDeviceDisconnected,
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test.each(['V1', 'V2'] as const)(
|
|
368
|
+
'stop completes while Android %s bonding remains pending',
|
|
369
|
+
async expectedProtocol => {
|
|
370
|
+
Object.assign(Platform, { OS: 'android' });
|
|
371
|
+
const { transport, bleManager, connect } = createHarness(() => Promise.resolve());
|
|
372
|
+
jest.spyOn(BleUtils, 'pairDevice').mockResolvedValueOnce({ bonded: false, bonding: true });
|
|
373
|
+
const listening = createDeferred<void>();
|
|
374
|
+
const cleanup = jest.fn();
|
|
375
|
+
jest.spyOn(BleUtils, 'onDeviceBondState').mockImplementationOnce(() => {
|
|
376
|
+
listening.resolve();
|
|
377
|
+
return cleanup;
|
|
378
|
+
});
|
|
379
|
+
const acquiring = transport.acquire({ uuid: UUID, expectedProtocol });
|
|
380
|
+
const rejection = expect(acquiring).rejects.toMatchObject({
|
|
381
|
+
errorCode: HardwareErrorCode.BleDeviceDisconnected,
|
|
382
|
+
});
|
|
383
|
+
await listening.promise;
|
|
384
|
+
|
|
385
|
+
// Advance only the existing 100ms disconnect drain, not the bond deadline.
|
|
386
|
+
let stopped = false;
|
|
387
|
+
const stopping = transport.stop().then(() => {
|
|
388
|
+
stopped = true;
|
|
389
|
+
});
|
|
390
|
+
await advanceUntil(() => stopped, 1000);
|
|
391
|
+
expect(stopped).toBe(true);
|
|
392
|
+
await stopping;
|
|
393
|
+
|
|
394
|
+
await rejection;
|
|
395
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
396
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
397
|
+
expect(bleManager.devices).not.toHaveBeenCalled();
|
|
398
|
+
expect(connect).not.toHaveBeenCalled();
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
test('stop rejects a pending read and waits for native disconnection without destroying the shared manager', async () => {
|
|
403
|
+
const { transport, bleManager } = createHarness(() => Promise.resolve());
|
|
404
|
+
const nativeDisconnect = createDeferred<void>();
|
|
405
|
+
bleManager.cancelDeviceConnection.mockImplementation(() => nativeDisconnect.promise);
|
|
406
|
+
const destroy = jest.fn();
|
|
407
|
+
Object.assign(bleManager, { destroy });
|
|
408
|
+
const read = createDeferred<void>();
|
|
409
|
+
transport.runPromise = read;
|
|
410
|
+
Object.assign(transport, { runPromiseDeviceId: UUID });
|
|
411
|
+
const readResult = read.promise.catch(error => error);
|
|
412
|
+
let stopped = false;
|
|
413
|
+
const stopping = transport.stop().then(() => {
|
|
414
|
+
stopped = true;
|
|
415
|
+
});
|
|
416
|
+
await flush();
|
|
417
|
+
await expect(readResult).resolves.toMatchObject({
|
|
418
|
+
errorCode: HardwareErrorCode.BleDeviceDisconnected,
|
|
419
|
+
});
|
|
420
|
+
expect(stopped).toBe(false);
|
|
421
|
+
nativeDisconnect.resolve();
|
|
422
|
+
await advanceUntil(() => stopped, 1000);
|
|
423
|
+
await stopping;
|
|
424
|
+
expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID);
|
|
425
|
+
expect(destroy).not.toHaveBeenCalled();
|
|
426
|
+
});
|
|
427
|
+
|
|
158
428
|
test('the connect budget leaves generous headroom over a healthy connect', () => {
|
|
159
429
|
// Healthy connects finish in ~2-3s (the native budget is 3s); this backstop only
|
|
160
430
|
// fires when the native timeout itself fails to.
|
|
@@ -162,6 +432,78 @@ describe('BLE connect timeout', () => {
|
|
|
162
432
|
expect(BLE_CONNECT_TIMEOUT_MS).toBeLessThanOrEqual(12000);
|
|
163
433
|
});
|
|
164
434
|
|
|
435
|
+
test.each(
|
|
436
|
+
(['ios', 'android'] as const).flatMap(platform =>
|
|
437
|
+
(['connect', 'mtu', 'gatt'] as const).flatMap(stage =>
|
|
438
|
+
(['reject', 'resolve'] as const).map(completion => ({ platform, stage, completion }))
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
)(
|
|
442
|
+
'stop cancels $platform $stage before draining a late $completion',
|
|
443
|
+
async ({ platform, stage, completion }) => {
|
|
444
|
+
Object.assign(Platform, { OS: platform });
|
|
445
|
+
const { transport, device, bleManager, connect } = createHarness(
|
|
446
|
+
() => nativeOperation.promise
|
|
447
|
+
);
|
|
448
|
+
const nativeOperation = createDeferred<typeof device>();
|
|
449
|
+
const nativeDisconnect = createDeferred<void>();
|
|
450
|
+
const requestMtu = jest.fn(() => Promise.resolve(device));
|
|
451
|
+
Object.assign(device, { mtu: stage === 'mtu' ? 23 : 247, requestMTU: requestMtu });
|
|
452
|
+
device.isConnected.mockResolvedValue(true);
|
|
453
|
+
if (stage === 'connect') device.isConnected.mockResolvedValueOnce(false);
|
|
454
|
+
if (stage === 'mtu') requestMtu.mockImplementationOnce(() => nativeOperation.promise);
|
|
455
|
+
if (stage === 'gatt') {
|
|
456
|
+
device.discoverAllServicesAndCharacteristics.mockImplementationOnce(() =>
|
|
457
|
+
nativeOperation.promise.then(() => undefined)
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
const destroy = jest.fn();
|
|
461
|
+
Object.assign(bleManager, { destroy });
|
|
462
|
+
const monitor = jest.spyOn(transport, '_monitorCharacteristic');
|
|
463
|
+
const acquire = transport.acquire({ uuid: UUID }).catch(error => error);
|
|
464
|
+
await flush();
|
|
465
|
+
await flush();
|
|
466
|
+
const pending = {
|
|
467
|
+
connect,
|
|
468
|
+
mtu: requestMtu,
|
|
469
|
+
gatt: device.discoverAllServicesAndCharacteristics,
|
|
470
|
+
}[stage];
|
|
471
|
+
expect(pending).toHaveBeenCalledTimes(1);
|
|
472
|
+
|
|
473
|
+
bleManager.cancelDeviceConnection.mockImplementation(() => nativeDisconnect.promise);
|
|
474
|
+
let stopped = false;
|
|
475
|
+
const stopping = transport.stop().then(() => {
|
|
476
|
+
stopped = true;
|
|
477
|
+
});
|
|
478
|
+
try {
|
|
479
|
+
await flush();
|
|
480
|
+
expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID);
|
|
481
|
+
expect(stopped).toBe(false);
|
|
482
|
+
} finally {
|
|
483
|
+
// Native completion can race cancellation; neither outcome may restart setup.
|
|
484
|
+
if (completion === 'resolve') nativeOperation.resolve(device);
|
|
485
|
+
else
|
|
486
|
+
nativeOperation.reject(
|
|
487
|
+
Object.assign(new Error('Operation was cancelled'), {
|
|
488
|
+
errorCode: BleErrorCode.OperationCancelled,
|
|
489
|
+
})
|
|
490
|
+
);
|
|
491
|
+
nativeDisconnect.resolve();
|
|
492
|
+
await advanceUntil(() => stopped, BLE_CONNECT_TIMEOUT_MS + BLE_GATT_SETUP_TIMEOUT_MS);
|
|
493
|
+
await stopping;
|
|
494
|
+
}
|
|
495
|
+
await expect(acquire).resolves.toBeInstanceOf(Error);
|
|
496
|
+
expect(connect).toHaveBeenCalledTimes(stage === 'connect' ? 1 : 0);
|
|
497
|
+
expect(monitor).not.toHaveBeenCalled();
|
|
498
|
+
if (stage === 'mtu')
|
|
499
|
+
expect(device.discoverAllServicesAndCharacteristics).not.toHaveBeenCalled();
|
|
500
|
+
expect(bleManager.cancelDeviceConnection.mock.calls).toEqual(
|
|
501
|
+
Array.from({ length: bleManager.cancelDeviceConnection.mock.calls.length }, () => [UUID])
|
|
502
|
+
);
|
|
503
|
+
expect(destroy).not.toHaveBeenCalled();
|
|
504
|
+
}
|
|
505
|
+
);
|
|
506
|
+
|
|
165
507
|
test('a stalled connect is abandoned natively so the next attempt is not cancelled by it', async () => {
|
|
166
508
|
const { transport, bleManager } = createHarness(
|
|
167
509
|
() =>
|
|
@@ -268,6 +610,57 @@ describe('BLE connect timeout', () => {
|
|
|
268
610
|
expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID);
|
|
269
611
|
});
|
|
270
612
|
|
|
613
|
+
test('a native GATT timeout abandons the native connection', async () => {
|
|
614
|
+
const { transport, device, bleManager } = createHarness(() => Promise.resolve());
|
|
615
|
+
const nativeTimeout = Object.assign(new Error('Operation timed out'), {
|
|
616
|
+
errorCode: BleErrorCode.OperationTimedOut,
|
|
617
|
+
});
|
|
618
|
+
device.discoverAllServicesAndCharacteristics.mockRejectedValueOnce(nativeTimeout);
|
|
619
|
+
|
|
620
|
+
await expect((transport as any).resolveCharacteristicsWithTimeout(UUID, device)).rejects.toBe(
|
|
621
|
+
nativeTimeout
|
|
622
|
+
);
|
|
623
|
+
expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test.each([
|
|
627
|
+
{
|
|
628
|
+
nativeFields: { errorCode: BleErrorCode.DeviceDisconnected, iosErrorCode: 14 },
|
|
629
|
+
reason: 'Peer removed pairing information',
|
|
630
|
+
errorCode: HardwareErrorCode.BlePeerRemovedPairingInformation,
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
nativeFields: { errorCode: 0, attErrorCode: 5 },
|
|
634
|
+
reason: 'GATT_INSUF_AUTHENTICATION',
|
|
635
|
+
errorCode: HardwareErrorCode.BleDeviceBondError,
|
|
636
|
+
},
|
|
637
|
+
])(
|
|
638
|
+
'maps a stale bond during GATT discovery to canonical error $errorCode',
|
|
639
|
+
async ({ nativeFields, reason, errorCode }) => {
|
|
640
|
+
const { transport, device } = createHarness(() => Promise.resolve());
|
|
641
|
+
const nativeError = Object.assign(new Error(reason), nativeFields, { reason });
|
|
642
|
+
device.discoverAllServicesAndCharacteristics.mockRejectedValueOnce(nativeError);
|
|
643
|
+
|
|
644
|
+
await expect(
|
|
645
|
+
(transport as any).resolveCharacteristicsWithTimeout(UUID, device)
|
|
646
|
+
).rejects.toMatchObject({ errorCode });
|
|
647
|
+
}
|
|
648
|
+
);
|
|
649
|
+
|
|
650
|
+
test('normalizes an unstructured GATT disconnect instead of leaking an unknown error', async () => {
|
|
651
|
+
const { transport, device } = createHarness(() => Promise.resolve());
|
|
652
|
+
const nativeError = Object.assign(new Error(`BleError: Device ${UUID} was disconnected`), {
|
|
653
|
+
errorCode: 0,
|
|
654
|
+
});
|
|
655
|
+
device.discoverAllServicesAndCharacteristics.mockRejectedValueOnce(nativeError);
|
|
656
|
+
|
|
657
|
+
await expect(
|
|
658
|
+
(transport as any).resolveCharacteristicsWithTimeout(UUID, device)
|
|
659
|
+
).rejects.toMatchObject({
|
|
660
|
+
errorCode: HardwareErrorCode.BleDeviceDisconnected,
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
271
664
|
test('a successful GATT retry clears the timeout budget before an abandoned call settles', async () => {
|
|
272
665
|
const { transport, device, bleManager } = createHarness(() => Promise.resolve());
|
|
273
666
|
let resolveAbandonedDiscovery: (() => void) | undefined;
|
|
@@ -300,4 +693,73 @@ describe('BLE connect timeout', () => {
|
|
|
300
693
|
expect(bleManager.cancelDeviceConnection).toHaveBeenCalledTimes(1);
|
|
301
694
|
expect((transport as any).connectionSetupTimeoutCounts.has(UUID)).toBe(false);
|
|
302
695
|
});
|
|
696
|
+
|
|
697
|
+
test('holds an unusable link past the idle timer', async () => {
|
|
698
|
+
Object.assign(Platform, { OS: 'android' });
|
|
699
|
+
const { transport, device, bleManager } = createHarness(() => Promise.resolve(device));
|
|
700
|
+
device.isConnected.mockResolvedValue(true);
|
|
701
|
+
Object.assign(device, { mtu: 23, requestMTU: jest.fn(() => new Promise(() => {})) });
|
|
702
|
+
let settled: Error | undefined;
|
|
703
|
+
const acquire = transport.acquire({ uuid: UUID, expectedProtocol: 'V1' }).catch(error => {
|
|
704
|
+
settled = error;
|
|
705
|
+
});
|
|
706
|
+
await flush();
|
|
707
|
+
await flush();
|
|
708
|
+
expect(device.requestMTU).toHaveBeenCalledTimes(1);
|
|
709
|
+
|
|
710
|
+
jest.advanceTimersByTime(ANDROID_MTU_EXCHANGE_TIMEOUT_MS);
|
|
711
|
+
await flush();
|
|
712
|
+
await flush();
|
|
713
|
+
expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID);
|
|
714
|
+
|
|
715
|
+
jest.advanceTimersByTime(4000);
|
|
716
|
+
await flush();
|
|
717
|
+
await flush();
|
|
718
|
+
expect(settled).toBeUndefined();
|
|
719
|
+
|
|
720
|
+
await advanceUntil(() => !!settled, ANDROID_LINK_DROP_QUIET_MS);
|
|
721
|
+
await acquire;
|
|
722
|
+
expect(settled).toMatchObject({ errorCode: HardwareErrorCode.BleConnectedError });
|
|
723
|
+
expect(device.discoverAllServicesAndCharacteristics).not.toHaveBeenCalled();
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test('a slow but successful MTU exchange completes inside the bound', async () => {
|
|
727
|
+
Object.assign(Platform, { OS: 'android' });
|
|
728
|
+
const { transport, device } = createHarness(() => Promise.resolve(device));
|
|
729
|
+
device.isConnected.mockResolvedValue(true);
|
|
730
|
+
const negotiated = { ...device, mtu: 247 };
|
|
731
|
+
Object.assign(device, {
|
|
732
|
+
mtu: 23,
|
|
733
|
+
// A cold cache makes the stack discover first; the exchange then completes late.
|
|
734
|
+
requestMTU: jest.fn(
|
|
735
|
+
() =>
|
|
736
|
+
new Promise(resolve => {
|
|
737
|
+
setTimeout(() => resolve(negotiated), 10_000);
|
|
738
|
+
})
|
|
739
|
+
),
|
|
740
|
+
});
|
|
741
|
+
const [, notifyCharacteristic] = await device.characteristicsForService();
|
|
742
|
+
Object.assign(notifyCharacteristic, { monitor: jest.fn(() => ({ remove: jest.fn() })) });
|
|
743
|
+
let result: unknown;
|
|
744
|
+
let failure: Error | undefined;
|
|
745
|
+
const acquire = transport.acquire({ uuid: UUID, expectedProtocol: 'V1' }).then(
|
|
746
|
+
value => {
|
|
747
|
+
result = value;
|
|
748
|
+
},
|
|
749
|
+
error => {
|
|
750
|
+
failure = error;
|
|
751
|
+
}
|
|
752
|
+
);
|
|
753
|
+
await flush();
|
|
754
|
+
await flush();
|
|
755
|
+
expect(device.discoverAllServicesAndCharacteristics).not.toHaveBeenCalled();
|
|
756
|
+
|
|
757
|
+
jest.advanceTimersByTime(10_000);
|
|
758
|
+
await advanceUntil(() => result !== undefined || failure !== undefined, 5000);
|
|
759
|
+
await acquire;
|
|
760
|
+
expect(failure).toBeUndefined();
|
|
761
|
+
expect(result).toEqual({ uuid: UUID, protocolType: 'V1' });
|
|
762
|
+
expect(device.discoverAllServicesAndCharacteristics).toHaveBeenCalledTimes(1);
|
|
763
|
+
await transport.release(UUID, true);
|
|
764
|
+
});
|
|
303
765
|
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { EventEmitter } from 'events';
|
|
2
2
|
|
|
3
|
+
import { BleErrorCode } from 'react-native-ble-plx';
|
|
4
|
+
import { HardwareErrorCode } from '@onekeyfe/hd-shared';
|
|
5
|
+
|
|
3
6
|
import { getConnectedDeviceIds } from '../BleManager';
|
|
4
7
|
import ReactNativeBleTransport from '../index';
|
|
5
8
|
|
|
@@ -14,7 +17,14 @@ jest.mock(
|
|
|
14
17
|
|
|
15
18
|
jest.mock('react-native-ble-plx', () => ({
|
|
16
19
|
BleError: class BleError extends Error {},
|
|
17
|
-
BleErrorCode: {
|
|
20
|
+
BleErrorCode: {
|
|
21
|
+
BluetoothUnsupported: 100,
|
|
22
|
+
BluetoothUnauthorized: 101,
|
|
23
|
+
BluetoothPoweredOff: 102,
|
|
24
|
+
BluetoothInUnknownState: 103,
|
|
25
|
+
ScanStartFailed: 600,
|
|
26
|
+
LocationServicesDisabled: 601,
|
|
27
|
+
},
|
|
18
28
|
BleManager: jest.fn(),
|
|
19
29
|
ScanMode: { LowLatency: 2 },
|
|
20
30
|
}));
|
|
@@ -31,6 +41,31 @@ jest.mock('../subscribeBleOn', () => ({
|
|
|
31
41
|
|
|
32
42
|
const ONEKEY_SERVICE_UUID = '00000001-0000-1000-8000-00805f9b34fb';
|
|
33
43
|
|
|
44
|
+
describe('ReactNativeBleTransport scan error mapping', () => {
|
|
45
|
+
test.each([
|
|
46
|
+
[BleErrorCode.BluetoothPoweredOff, HardwareErrorCode.BlePoweredOff],
|
|
47
|
+
[BleErrorCode.BluetoothUnsupported, HardwareErrorCode.BleUnsupported],
|
|
48
|
+
[BleErrorCode.BluetoothInUnknownState, HardwareErrorCode.BleScanError],
|
|
49
|
+
[BleErrorCode.BluetoothUnauthorized, HardwareErrorCode.BleLocationError],
|
|
50
|
+
])('maps native BLE error %s to hardware error %s', async (nativeCode, errorCode) => {
|
|
51
|
+
jest.mocked(getConnectedDeviceIds).mockResolvedValueOnce([]);
|
|
52
|
+
const blePlxManager = {
|
|
53
|
+
startDeviceScan: jest.fn((_serviceUUIDs, _options, listener) => {
|
|
54
|
+
queueMicrotask(() => {
|
|
55
|
+
listener({ errorCode: nativeCode, reason: 'native scan failure' }, null);
|
|
56
|
+
});
|
|
57
|
+
}),
|
|
58
|
+
stopDeviceScan: jest.fn(() => Promise.resolve()),
|
|
59
|
+
};
|
|
60
|
+
const transport = new ReactNativeBleTransport({ scanTimeout: 10_000 });
|
|
61
|
+
transport.blePlxManager = blePlxManager as never;
|
|
62
|
+
transport.init({ debug: jest.fn(), error: jest.fn() }, new EventEmitter());
|
|
63
|
+
|
|
64
|
+
await expect(transport.enumerate()).rejects.toMatchObject({ errorCode });
|
|
65
|
+
expect(blePlxManager.stopDeviceScan).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
34
69
|
describe('ReactNativeBleTransport iOS discovery', () => {
|
|
35
70
|
test('keeps a bonded Pro2 communication peripheral after Find My changes its name', async () => {
|
|
36
71
|
jest.mocked(getConnectedDeviceIds).mockResolvedValueOnce([
|