@onekeyfe/hd-transport-react-native 1.2.3-alpha.1 → 1.2.3-alpha.10

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.3-alpha.1",
3
+ "version": "1.2.3-alpha.10",
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.3-alpha.1",
24
- "@onekeyfe/hd-shared": "1.2.3-alpha.1",
25
- "@onekeyfe/hd-transport": "1.2.3-alpha.1",
26
- "@onekeyfe/react-native-ble-utils": "^0.1.6",
23
+ "@onekeyfe/hd-core": "1.2.3-alpha.10",
24
+ "@onekeyfe/hd-shared": "1.2.3-alpha.10",
25
+ "@onekeyfe/hd-transport": "1.2.3-alpha.10",
26
+ "@onekeyfe/react-native-ble-utils": "0.1.9",
27
27
  "react-native-ble-plx": "3.5.1"
28
28
  },
29
- "gitHead": "fd4221863c00240fc4870fc50f8c517d97426de2"
29
+ "gitHead": "2295285a846281941c21955684281446f587d993"
30
30
  }
package/src/BleManager.ts CHANGED
@@ -1,12 +1,26 @@
1
1
  import BleUtils from '@onekeyfe/react-native-ble-utils';
2
2
  import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
3
3
 
4
+ import { onBleKeyMissing, startBleKeyMissingTracking } from './bleKeyMissing';
4
5
  import { bleLogger } from './logger';
5
6
 
6
7
  import type { Peripheral } from '@onekeyfe/react-native-ble-utils';
7
8
 
8
9
  const Logger = bleLogger;
9
10
 
11
+ // Android BluetoothDevice.EXTRA_UNBOND_REASON values.
12
+ const bondFailureReasons: Record<number, string> = {
13
+ 1: 'authentication_failed',
14
+ 2: 'rejected',
15
+ 3: 'canceled',
16
+ 4: 'device_unreachable',
17
+ 5: 'discovery_in_progress',
18
+ 6: 'timeout',
19
+ 7: 'repeated_attempts',
20
+ 8: 'remote_canceled',
21
+ 9: 'removed',
22
+ };
23
+
10
24
  /**
11
25
  * get the device basic info of connected devices
12
26
  * @param serviceUuids
@@ -19,20 +33,104 @@ export const getBondedDevices = () => BleUtils.getBondedPeripherals();
19
33
 
20
34
  export const pairDevice = (macAddress: string) => BleUtils.pairDevice(macAddress);
21
35
 
22
- export const onDeviceBondState = (bleMacAddress: string): Promise<Peripheral | undefined> =>
36
+ /**
37
+ * Android replaces a bond as BONDING -> NONE, then NONE -> BONDING within milliseconds.
38
+ * The window covers slow broadcast delivery before the first half counts as a failure.
39
+ */
40
+ export const SYSTEM_BONDING_RESTART_WINDOW_MS = 1500;
41
+
42
+ export type DeviceBondStateOptions = {
43
+ /**
44
+ * The system, not this transport, started the bonding in progress. Android 16+ re-pairs
45
+ * by itself after it detects a lost bond, and a successful re-pair replaces the old bond
46
+ * instead of going straight to BONDED.
47
+ */
48
+ systemInitiated?: boolean;
49
+ };
50
+
51
+ const createBondFailureError = (bondState: Peripheral['bondState']) => {
52
+ const nativeReason =
53
+ 'reason' in bondState && typeof bondState.reason === 'number' ? bondState.reason : undefined;
54
+ const reason =
55
+ nativeReason === undefined ? 'unknown' : bondFailureReasons[nativeReason] ?? 'unknown';
56
+ const params = {
57
+ phase: 'bond',
58
+ reason,
59
+ ...(nativeReason === undefined ? {} : { nativeReason }),
60
+ };
61
+ if (reason === 'timeout') {
62
+ // Connection timeouts are retried by Core; pairing requires a new user attempt.
63
+ return ERRORS.TypedError(
64
+ HardwareErrorCode.BleDeviceNotBonded,
65
+ 'Bluetooth pairing timed out',
66
+ params
67
+ );
68
+ }
69
+ if (reason === 'canceled') {
70
+ return ERRORS.TypedError(
71
+ HardwareErrorCode.BleDeviceBondedCanceled,
72
+ 'Bluetooth pairing canceled',
73
+ params
74
+ );
75
+ }
76
+ return ERRORS.TypedError(
77
+ HardwareErrorCode.BleDeviceNotBonded,
78
+ 'Bluetooth pairing failed',
79
+ params
80
+ );
81
+ };
82
+
83
+ export const onDeviceBondState = (
84
+ bleMacAddress: string,
85
+ signal?: AbortSignal,
86
+ options?: DeviceBondStateOptions
87
+ ): Promise<Peripheral | undefined> =>
23
88
  new Promise((resolve, reject) => {
89
+ if (signal?.aborted) {
90
+ reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
91
+ return;
92
+ }
93
+ let pendingFailure: ReturnType<typeof setTimeout> | undefined;
24
94
  const cleanup = () => {
25
95
  if (timeout) {
26
96
  clearTimeout(timeout);
27
97
  }
98
+ if (pendingFailure) clearTimeout(pendingFailure);
28
99
  if (cleanupListener) cleanupListener();
100
+ cleanupKeyMissing?.();
101
+ signal?.removeEventListener('abort', onAbort);
102
+ };
103
+ const onAbort = () => {
104
+ cleanup();
105
+ reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
29
106
  };
30
107
 
108
+ signal?.addEventListener('abort', onAbort, { once: true });
109
+
31
110
  const timeout = setTimeout(() => {
32
111
  cleanup();
33
- reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded'));
112
+ reject(
113
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing timed out', {
114
+ phase: 'bond',
115
+ reason: 'timeout',
116
+ })
117
+ );
34
118
  }, 60 * 1000);
35
119
 
120
+ // A failed system re-pair restores the old bond, so it also ends in BONDED. Key
121
+ // missing is what tells it apart from a bond that now works.
122
+ const cleanupKeyMissing = startBleKeyMissingTracking()
123
+ ? onBleKeyMissing(bleMacAddress, () => {
124
+ cleanup();
125
+ reject(
126
+ ERRORS.TypedError(HardwareErrorCode.BleBondInvalid, undefined, {
127
+ phase: 'bond',
128
+ reason: 'key_missing',
129
+ })
130
+ );
131
+ })
132
+ : undefined;
133
+
36
134
  const cleanupListener = BleUtils.onDeviceBondState(peripheral => {
37
135
  if (peripheral.id?.toLowerCase() !== bleMacAddress.toLowerCase()) {
38
136
  return;
@@ -40,14 +138,24 @@ export const onDeviceBondState = (bleMacAddress: string): Promise<Peripheral | u
40
138
  const { bondState } = peripheral;
41
139
 
42
140
  const hasBonded = bondState.preState === 'BOND_BONDING' && bondState.state === 'BOND_BONDED';
43
- const hasCanceled = bondState.preState === 'BOND_BONDING' && bondState.state === 'BOND_NONE';
141
+ const hasFailed = bondState.preState === 'BOND_BONDING' && bondState.state === 'BOND_NONE';
142
+ const hasRestarted = bondState.preState === 'BOND_NONE' && bondState.state === 'BOND_BONDING';
44
143
  Logger.debug('onDeviceBondState bondState:', bondState);
45
144
  if (hasBonded) {
46
145
  cleanup();
47
146
  resolve(peripheral);
48
- } else if (hasCanceled) {
147
+ } else if (hasRestarted && pendingFailure) {
148
+ clearTimeout(pendingFailure);
149
+ pendingFailure = undefined;
150
+ } else if (hasFailed && options?.systemInitiated) {
151
+ if (pendingFailure) clearTimeout(pendingFailure);
152
+ pendingFailure = setTimeout(() => {
153
+ cleanup();
154
+ reject(createBondFailureError(bondState));
155
+ }, SYSTEM_BONDING_RESTART_WINDOW_MS);
156
+ } else if (hasFailed) {
49
157
  cleanup();
50
- reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceBondedCanceled, 'bonding canceled'));
158
+ reject(createBondFailureError(bondState));
51
159
  }
52
160
  });
53
161
  });
@@ -0,0 +1,482 @@
1
+ import EventEmitter from 'events';
2
+ import { HardwareErrorCode } from '@onekeyfe/hd-shared';
3
+
4
+ import ReactNativeBleTransport, {
5
+ ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS,
6
+ ANDROID_KEY_MISSING_GRACE_MS,
7
+ ANDROID_SYSTEM_REPAIR_TIMEOUT_MS,
8
+ } from '../index';
9
+ import {
10
+ HCI_PIN_OR_KEY_MISSING,
11
+ stopBleEncryptionTracking,
12
+ waitForAndroidLinkEncryption,
13
+ } from '../bleEncryption';
14
+ import { stopBleKeyMissingTracking } from '../bleKeyMissing';
15
+ import protocolV1Schema from './protocolV1SchemaFixture';
16
+
17
+ type EncryptionEvent = { id: string; status: number; enabled: boolean };
18
+
19
+ let mockSupported = true;
20
+ let mockPairResult: { bonded: boolean; bonding: boolean; initiated?: boolean } = {
21
+ bonded: true,
22
+ bonding: false,
23
+ initiated: false,
24
+ };
25
+ let mockEmitEncryption: ((event: EncryptionEvent) => void) | undefined;
26
+ let mockEmitAclDisconnected: ((event: { id: string }) => void) | undefined;
27
+ let mockEmitKeyMissing: ((event: { id: string }) => void) | undefined;
28
+ let mockEmitBondState: ((peripheral: unknown) => void) | undefined;
29
+
30
+ jest.mock(
31
+ 'react-native',
32
+ () => ({
33
+ Platform: { OS: 'android', select: (spec: Record<string, unknown>) => spec.android },
34
+ PermissionsAndroid: {
35
+ PERMISSIONS: {},
36
+ RESULTS: {},
37
+ request: jest.fn(),
38
+ requestMultiple: jest.fn(),
39
+ },
40
+ }),
41
+ { virtual: true }
42
+ );
43
+
44
+ jest.mock('react-native-ble-plx', () => ({
45
+ BleATTErrorCode: { InvalidHandle: 1 },
46
+ BleError: Error,
47
+ BleErrorCode: { DeviceDisconnected: 201, OperationStartFailed: 601 },
48
+ BleManager: jest.fn(),
49
+ ScanMode: { LowLatency: 2 },
50
+ }));
51
+
52
+ jest.mock('@onekeyfe/react-native-ble-utils', () => ({
53
+ __esModule: true,
54
+ default: {
55
+ getConnectedPeripherals: jest.fn(() => Promise.resolve([])),
56
+ getBondedPeripherals: jest.fn(() => Promise.resolve([])),
57
+ pairDevice: jest.fn(() => Promise.resolve(mockPairResult)),
58
+ supportsDeviceKeyMissing: jest.fn(() => mockSupported),
59
+ onDeviceKeyMissing: jest.fn((callback: (event: { id: string }) => void) => {
60
+ mockEmitKeyMissing = callback;
61
+ return () => {
62
+ mockEmitKeyMissing = undefined;
63
+ };
64
+ }),
65
+ supportsDeviceEncryptionChange: jest.fn(() => mockSupported),
66
+ onDeviceEncryptionChange: jest.fn((callback: (event: EncryptionEvent) => void) => {
67
+ mockEmitEncryption = callback;
68
+ return () => {
69
+ mockEmitEncryption = undefined;
70
+ };
71
+ }),
72
+ onDeviceAclDisconnected: jest.fn((callback: (event: { id: string }) => void) => {
73
+ mockEmitAclDisconnected = callback;
74
+ return () => {
75
+ mockEmitAclDisconnected = undefined;
76
+ };
77
+ }),
78
+ onDeviceBondState: jest.fn((callback: (peripheral: unknown) => void) => {
79
+ mockEmitBondState = callback;
80
+ return () => {
81
+ mockEmitBondState = undefined;
82
+ };
83
+ }),
84
+ },
85
+ }));
86
+
87
+ const UUID = 'CC:06:1C:FD:48:E9';
88
+ const OTHER_UUID = 'E9:B9:F4:DD:DE:CD';
89
+
90
+ const flush = () =>
91
+ new Promise(resolve => {
92
+ setImmediate(resolve);
93
+ });
94
+
95
+ const advance = async (ms: number) => {
96
+ jest.advanceTimersByTime(ms);
97
+ await flush();
98
+ };
99
+
100
+ const encrypted = (id = UUID) => mockEmitEncryption?.({ id, status: 0, enabled: true });
101
+ const keyMissing = (id = UUID) =>
102
+ mockEmitEncryption?.({ id, status: HCI_PIN_OR_KEY_MISSING, enabled: false });
103
+
104
+ const settle = <T>(promise: Promise<T>) => {
105
+ const outcome: { value?: T; error?: { errorCode?: unknown }; done: boolean } = { done: false };
106
+ promise.then(
107
+ value => {
108
+ outcome.value = value;
109
+ outcome.done = true;
110
+ },
111
+ error => {
112
+ outcome.error = error;
113
+ outcome.done = true;
114
+ }
115
+ );
116
+ return outcome;
117
+ };
118
+
119
+ const waitFor = ({
120
+ linkStartedAt = Date.now(),
121
+ signal,
122
+ }: { linkStartedAt?: number; signal?: AbortSignal } = {}) =>
123
+ settle(
124
+ waitForAndroidLinkEncryption({
125
+ deviceId: UUID,
126
+ linkStartedAt,
127
+ resultTimeoutMs: ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS,
128
+ repairTimeoutMs: ANDROID_SYSTEM_REPAIR_TIMEOUT_MS,
129
+ keyMissingGraceMs: ANDROID_KEY_MISSING_GRACE_MS,
130
+ signal,
131
+ })
132
+ );
133
+
134
+ describe('Android link encryption', () => {
135
+ beforeEach(() => {
136
+ jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] });
137
+ mockSupported = true;
138
+ mockPairResult = { bonded: true, bonding: false, initiated: false };
139
+ });
140
+
141
+ afterEach(() => {
142
+ stopBleEncryptionTracking();
143
+ stopBleKeyMissingTracking();
144
+ jest.clearAllTimers();
145
+ jest.useRealTimers();
146
+ });
147
+
148
+ describe('first result of a new link', () => {
149
+ test('resolves when the stored bond encrypts the link', async () => {
150
+ const outcome = waitFor();
151
+ await flush();
152
+ expect(outcome.done).toBe(false);
153
+
154
+ encrypted();
155
+ await flush();
156
+
157
+ expect(outcome.value).toBe('encrypted');
158
+ });
159
+
160
+ test('uses a result that arrived before the wait started', async () => {
161
+ const linkStartedAt = Date.now();
162
+ waitFor({ linkStartedAt });
163
+ await flush();
164
+ encrypted();
165
+ await flush();
166
+
167
+ const outcome = waitFor({ linkStartedAt });
168
+ await flush();
169
+
170
+ expect(outcome.value).toBe('encrypted');
171
+ });
172
+
173
+ test('proceeds as before when no result arrives in time', async () => {
174
+ const outcome = waitFor();
175
+ await advance(ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS - 1);
176
+ expect(outcome.done).toBe(false);
177
+
178
+ await advance(1);
179
+
180
+ expect(outcome.value).toBe('unresolved');
181
+ });
182
+
183
+ test('does not wait when the platform cannot report the result', async () => {
184
+ mockSupported = false;
185
+ const outcome = waitFor();
186
+ await flush();
187
+
188
+ expect(outcome.value).toBe('unresolved');
189
+ });
190
+
191
+ test('proceeds when encryption fails for a reason other than a lost bond', async () => {
192
+ const outcome = waitFor();
193
+ await flush();
194
+ mockEmitEncryption?.({ id: UUID, status: 0x08, enabled: false });
195
+ await flush();
196
+
197
+ expect(outcome.value).toBe('unresolved');
198
+ });
199
+
200
+ test('proceeds when the link drops before any result', async () => {
201
+ const outcome = waitFor();
202
+ await flush();
203
+ mockEmitAclDisconnected?.({ id: UUID });
204
+ await flush();
205
+
206
+ expect(outcome.value).toBe('unresolved');
207
+ });
208
+
209
+ test('ignores results for another device', async () => {
210
+ const outcome = waitFor();
211
+ await flush();
212
+ encrypted(OTHER_UUID);
213
+ await flush();
214
+ expect(outcome.done).toBe(false);
215
+
216
+ encrypted(UUID.toLowerCase());
217
+ await flush();
218
+
219
+ expect(outcome.value).toBe('encrypted');
220
+ });
221
+ });
222
+
223
+ describe('reused link', () => {
224
+ test('does not wait on a link that is still up and was encrypted earlier', async () => {
225
+ waitFor();
226
+ await flush();
227
+ encrypted();
228
+ await advance(1000);
229
+
230
+ const outcome = waitFor();
231
+ await flush();
232
+
233
+ expect(outcome.value).toBe('already-encrypted');
234
+ });
235
+
236
+ test('waits again once the earlier link has dropped', async () => {
237
+ waitFor();
238
+ await flush();
239
+ encrypted();
240
+ await advance(1000);
241
+ mockEmitAclDisconnected?.({ id: UUID });
242
+ await advance(1000);
243
+
244
+ const outcome = waitFor();
245
+ await flush();
246
+ expect(outcome.done).toBe(false);
247
+
248
+ keyMissing();
249
+ await flush();
250
+
251
+ expect(outcome.done).toBe(false);
252
+ });
253
+ });
254
+
255
+ describe('lost bond', () => {
256
+ test('holds until the system re-pairs on the same link', async () => {
257
+ const outcome = waitFor();
258
+ await flush();
259
+ keyMissing();
260
+ await advance(10_000);
261
+ expect(outcome.done).toBe(false);
262
+
263
+ encrypted();
264
+ await flush();
265
+
266
+ expect(outcome.value).toBe('re-paired');
267
+ });
268
+
269
+ test('reports an invalid bond when the system says re-pairing failed', async () => {
270
+ const outcome = waitFor();
271
+ await flush();
272
+ keyMissing();
273
+ await advance(5000);
274
+ mockEmitKeyMissing?.({ id: UUID });
275
+ await flush();
276
+
277
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleBondInvalid);
278
+ });
279
+
280
+ test('reports an invalid bond when the drop is followed by key missing', async () => {
281
+ const outcome = waitFor();
282
+ await flush();
283
+ keyMissing();
284
+ await advance(5000);
285
+ mockEmitAclDisconnected?.({ id: UUID });
286
+ await advance(ANDROID_KEY_MISSING_GRACE_MS - 100);
287
+ mockEmitKeyMissing?.({ id: UUID });
288
+ await flush();
289
+
290
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleBondInvalid);
291
+ });
292
+
293
+ test('reports a disconnect when the link drops without key missing', async () => {
294
+ const outcome = waitFor();
295
+ await flush();
296
+ keyMissing();
297
+ await advance(5000);
298
+ mockEmitAclDisconnected?.({ id: UUID });
299
+ await advance(ANDROID_KEY_MISSING_GRACE_MS);
300
+
301
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleDeviceDisconnected);
302
+ });
303
+
304
+ test('gives up when re-pairing never finishes', async () => {
305
+ const outcome = waitFor();
306
+ await flush();
307
+ keyMissing();
308
+ await advance(ANDROID_SYSTEM_REPAIR_TIMEOUT_MS);
309
+
310
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleDeviceNotBonded);
311
+ });
312
+
313
+ test('stops waiting when the transport is stopped', async () => {
314
+ const controller = new AbortController();
315
+ const outcome = waitFor({ signal: controller.signal });
316
+ await flush();
317
+ keyMissing();
318
+ await flush();
319
+
320
+ controller.abort();
321
+ await flush();
322
+
323
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleDeviceDisconnected);
324
+ expect(jest.getTimerCount()).toBe(0);
325
+ });
326
+ });
327
+
328
+ describe('acquire', () => {
329
+ function createHarness() {
330
+ const monitor = jest.fn(() => ({ remove: jest.fn() }));
331
+ const writeCharacteristic = {
332
+ uuid: '00000002-0000-1000-8000-00805f9b34fb',
333
+ isWritableWithoutResponse: true,
334
+ writeWithoutResponse: jest.fn(() => Promise.resolve()),
335
+ };
336
+ const notifyCharacteristic = {
337
+ uuid: '00000003-0000-1000-8000-00805f9b34fb',
338
+ isNotifiable: true,
339
+ monitor,
340
+ };
341
+ const device = {
342
+ id: UUID,
343
+ name: 'Pro 2 48E9',
344
+ localName: 'Pro 2 48E9',
345
+ mtu: 247,
346
+ serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
347
+ isConnected: jest.fn(() => Promise.resolve(true)),
348
+ cancelConnection: jest.fn(() => Promise.resolve()),
349
+ discoverAllServicesAndCharacteristics: jest.fn(() => Promise.resolve()),
350
+ characteristicsForService: jest.fn(() =>
351
+ Promise.resolve([writeCharacteristic, notifyCharacteristic])
352
+ ),
353
+ services: jest.fn(() => Promise.resolve([])),
354
+ onDisconnected: jest.fn(() => ({ remove: jest.fn() })),
355
+ };
356
+ const transport = new ReactNativeBleTransport({});
357
+ (transport as any).blePlxManager = {
358
+ devices: jest.fn(() => Promise.resolve([device])),
359
+ connectedDevices: jest.fn(() => Promise.resolve([])),
360
+ cancelTransaction: jest.fn(() => Promise.resolve()),
361
+ cancelDeviceConnection: jest.fn(() => Promise.resolve()),
362
+ onDeviceDisconnected: jest.fn(() => ({ remove: jest.fn() })),
363
+ onStateChange: jest.fn((listener: (state: string) => void) => {
364
+ setImmediate(() => listener('PoweredOn'));
365
+ return { remove: jest.fn() };
366
+ }),
367
+ state: jest.fn(() => Promise.resolve('PoweredOn')),
368
+ };
369
+ transport.init(
370
+ { debug: jest.fn(), error: jest.fn(), warn: jest.fn() } as any,
371
+ new EventEmitter()
372
+ );
373
+ transport.configure(protocolV1Schema);
374
+ return { transport, monitor, device };
375
+ }
376
+
377
+ /** Drives acquire up to the point where it waits for the link's encryption result. */
378
+ async function reachGate(device: { characteristicsForService: jest.Mock }) {
379
+ for (let i = 0; i < 100 && device.characteristicsForService.mock.calls.length === 0; i += 1) {
380
+ // eslint-disable-next-line no-await-in-loop
381
+ await advance(10);
382
+ }
383
+ expect(device.characteristicsForService).toHaveBeenCalled();
384
+ await advance(10);
385
+ }
386
+
387
+ async function runUntil(done: () => boolean, maxMs = 2000) {
388
+ for (let elapsed = 0; elapsed < maxMs && !done(); elapsed += 50) {
389
+ // eslint-disable-next-line no-await-in-loop
390
+ await advance(50);
391
+ }
392
+ }
393
+
394
+ test('subscribes to notifications only after a bonded link is encrypted', async () => {
395
+ const { transport, monitor, device } = createHarness();
396
+ const outcome = settle(transport.acquire({ uuid: UUID, expectedProtocol: 'V1' }));
397
+ await reachGate(device);
398
+ await advance(ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS / 2);
399
+ expect(monitor).not.toHaveBeenCalled();
400
+
401
+ encrypted();
402
+ await runUntil(() => outcome.done);
403
+
404
+ expect(outcome.error).toBeUndefined();
405
+ expect(outcome.value).toEqual({ uuid: UUID, protocolType: 'V1' });
406
+ expect(monitor).toHaveBeenCalledTimes(1);
407
+ await transport.release(UUID, true);
408
+ });
409
+
410
+ test('keeps GATT idle while the system re-pairs a lost bond', async () => {
411
+ const { transport, monitor, device } = createHarness();
412
+ const outcome = settle(transport.acquire({ uuid: UUID, expectedProtocol: 'V1' }));
413
+ await reachGate(device);
414
+ keyMissing();
415
+ await advance(8000);
416
+ expect(monitor).not.toHaveBeenCalled();
417
+ expect(outcome.done).toBe(false);
418
+
419
+ encrypted();
420
+ await runUntil(() => outcome.done);
421
+
422
+ expect(outcome.value).toEqual({ uuid: UUID, protocolType: 'V1' });
423
+ expect(monitor).toHaveBeenCalledTimes(1);
424
+ await transport.release(UUID, true);
425
+ });
426
+
427
+ test('fails as an invalid bond when the system re-pairing fails', async () => {
428
+ const { transport, monitor, device } = createHarness();
429
+ const outcome = settle(transport.acquire({ uuid: UUID, expectedProtocol: 'V1' }));
430
+ await reachGate(device);
431
+ keyMissing();
432
+ await advance(5000);
433
+ mockEmitKeyMissing?.({ id: UUID });
434
+ await runUntil(() => outcome.done);
435
+
436
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleBondInvalid);
437
+ expect(monitor).not.toHaveBeenCalled();
438
+ });
439
+
440
+ test('does not wait for a bond created by this acquire', async () => {
441
+ mockPairResult = { bonded: false, bonding: true, initiated: true };
442
+ const { transport, monitor } = createHarness();
443
+ const outcome = settle(transport.acquire({ uuid: UUID, expectedProtocol: 'V1' }));
444
+ for (let i = 0; i < 100 && !mockEmitBondState; i += 1) {
445
+ // eslint-disable-next-line no-await-in-loop
446
+ await advance(10);
447
+ }
448
+ mockEmitBondState?.({
449
+ id: UUID,
450
+ advertising: {},
451
+ bondState: { preState: 'BOND_BONDING', state: 'BOND_BONDED' },
452
+ });
453
+ await runUntil(() => outcome.done);
454
+
455
+ expect(outcome.value).toEqual({ uuid: UUID, protocolType: 'V1' });
456
+ expect(monitor).toHaveBeenCalledTimes(1);
457
+ await transport.release(UUID, true);
458
+ });
459
+
460
+ test('reuses a link that notifications already worked on without waiting', async () => {
461
+ const { transport, monitor, device } = createHarness();
462
+ // No result arrives: the link was already up before tracking could observe it.
463
+ const first = settle(transport.acquire({ uuid: UUID, expectedProtocol: 'V1' }));
464
+ await reachGate(device);
465
+ await advance(ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS - 100);
466
+ expect(monitor).not.toHaveBeenCalled();
467
+ await runUntil(() => first.done);
468
+ expect(first.value).toEqual({ uuid: UUID, protocolType: 'V1' });
469
+ await transport.release(UUID, true);
470
+ monitor.mockClear();
471
+ device.characteristicsForService.mockClear();
472
+
473
+ const second = settle(transport.acquire({ uuid: UUID, expectedProtocol: 'V1' }));
474
+ await reachGate(device);
475
+
476
+ expect(monitor).toHaveBeenCalledTimes(1);
477
+ await runUntil(() => second.done);
478
+ expect(second.value).toEqual({ uuid: UUID, protocolType: 'V1' });
479
+ await transport.release(UUID, true);
480
+ });
481
+ });
482
+ });