@onekeyfe/hd-transport-react-native 1.2.3-alpha.9 → 1.2.4-alpha.0

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.
@@ -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
+ });
@@ -25,13 +25,20 @@ jest.mock('../BleManager', () => ({
25
25
  pairDevice: jest.fn(),
26
26
  }));
27
27
 
28
- // The native facade is replaced wholesale above; key missing is covered by its own suite.
28
+ // The native facade is replaced wholesale above; key missing and link encryption are covered
29
+ // by their own suites.
29
30
  jest.mock('../bleKeyMissing', () => ({
30
31
  isBleKeyMissingSupported: jest.fn(() => false),
31
32
  startBleKeyMissingTracking: jest.fn(() => false),
32
33
  stopBleKeyMissingTracking: jest.fn(),
33
34
  waitForBleKeyMissing: jest.fn(() => Promise.resolve(false)),
34
35
  }));
36
+ jest.mock('../bleEncryption', () => ({
37
+ markBleLinkEncrypted: jest.fn(),
38
+ startBleEncryptionTracking: jest.fn(() => false),
39
+ stopBleEncryptionTracking: jest.fn(),
40
+ waitForAndroidLinkEncryption: jest.fn(() => Promise.resolve('unresolved')),
41
+ }));
35
42
 
36
43
  jest.mock('../subscribeBleOn', () => ({
37
44
  subscribeBleOn: jest.fn(() => Promise.resolve()),
@@ -1,3 +1,6 @@
1
+ import { Platform } from 'react-native';
2
+ import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
3
+
1
4
  import ReactNativeBleTransport, { PROTOCOL_REPROBE_FALLBACK_ATTEMPTS } from '../index';
2
5
  import protocolV1Schema from './protocolV1SchemaFixture';
3
6
 
@@ -89,7 +92,9 @@ describe('protocol re-probe after a known device goes away', () => {
89
92
  test('an unknown device still probes both protocols', async () => {
90
93
  const { transport, probeV1, probeV2 } = createHarness({ v1: false, v2: false });
91
94
 
92
- await expect(detect(transport)).rejects.toBeDefined();
95
+ await expect(detect(transport)).rejects.toMatchObject({
96
+ errorCode: HardwareErrorCode.BleTimeoutError,
97
+ });
93
98
 
94
99
  expect(probeV1).toHaveBeenCalledTimes(1);
95
100
  expect(probeV2).toHaveBeenCalledTimes(1);
@@ -154,3 +159,113 @@ describe('protocol re-probe after a known device goes away', () => {
154
159
  expect((known.transport as any).protocolReprobeFailures.get(UUID)).toBeUndefined();
155
160
  });
156
161
  });
162
+
163
+ describe('iOS reset-device stale bond confirmation', () => {
164
+ afterEach(() => {
165
+ Object.assign(Platform, { OS: 'ios' });
166
+ });
167
+
168
+ test('promotes a probe disconnect followed by a silent reconnect to BleBondInvalid', async () => {
169
+ const transport = new ReactNativeBleTransport({});
170
+ transport.configure(protocolV1Schema);
171
+ (transport as any).callProtocolV1 = jest
172
+ .fn()
173
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded));
174
+
175
+ await expect(detect(transport)).rejects.toMatchObject({
176
+ errorCode: HardwareErrorCode.BleDeviceNotBonded,
177
+ });
178
+
179
+ (transport as any).probeProtocolV1 = jest.fn(() => Promise.resolve(false));
180
+ (transport as any).probeProtocolV2 = jest.fn(() => Promise.resolve(false));
181
+ (transport as any).resetProbeStateAfterProtocolProbe = jest.fn(() => Promise.resolve());
182
+
183
+ await expect(detect(transport)).rejects.toMatchObject({
184
+ errorCode: HardwareErrorCode.BleBondInvalid,
185
+ });
186
+ await expect(detect(transport)).rejects.toMatchObject({
187
+ errorCode: HardwareErrorCode.BleBondInvalid,
188
+ });
189
+ expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(true);
190
+ });
191
+
192
+ test('promotes a repeated iOS probe disconnect without waiting for probe timeouts', () => {
193
+ const { transport } = createHarness({ v1: false, v2: false });
194
+ const firstError = Object.assign(new Error('The specified device has disconnected from us'), {
195
+ errorCode: 201,
196
+ iosErrorCode: 7,
197
+ });
198
+ const secondError = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
199
+
200
+ expect(
201
+ (transport as any).rememberIosProbeDisconnect(UUID, 'protocol-v1-get-features', firstError)
202
+ ).toBeUndefined();
203
+ expect(
204
+ (transport as any).rememberIosProbeDisconnect(UUID, 'protocol-v2-ping', secondError)
205
+ ).toMatchObject({ errorCode: HardwareErrorCode.BleBondInvalid });
206
+ expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(true);
207
+ });
208
+
209
+ test('clears the disconnect evidence after a valid protocol response', async () => {
210
+ const { transport } = createHarness({ v1: true, v2: false });
211
+ (transport as any).rememberIosProbeDisconnect(
212
+ UUID,
213
+ 'protocol-v1-get-features',
214
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded)
215
+ );
216
+
217
+ await expect(detect(transport)).resolves.toBe('V1');
218
+ expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(false);
219
+ });
220
+
221
+ test('preserves evidence across disconnect and stalled setup cleanup', async () => {
222
+ const { transport } = createHarness({ v1: false, v2: false });
223
+ (transport as any).rememberIosProbeDisconnect(
224
+ UUID,
225
+ 'protocol-v1-get-features',
226
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded)
227
+ );
228
+
229
+ await (transport as any).disconnectUnlocked(UUID);
230
+ expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(true);
231
+
232
+ (transport as any).abandonStalledConnection(UUID, 'connect-native');
233
+ expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(true);
234
+
235
+ await expect(detect(transport)).rejects.toMatchObject({
236
+ errorCode: HardwareErrorCode.BleBondInvalid,
237
+ });
238
+ });
239
+
240
+ test('expires old disconnect evidence before classifying a later timeout', async () => {
241
+ const { transport } = createHarness({ v1: false, v2: false });
242
+ (transport as any).rememberIosProbeDisconnect(
243
+ UUID,
244
+ 'protocol-v1-get-features',
245
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded)
246
+ );
247
+ const evidence = (transport as any).iosProbeDisconnectEvidence.get(UUID);
248
+ evidence.recordedAt = Date.now() - 5 * 60_000 - 1;
249
+
250
+ await expect(detect(transport)).rejects.toMatchObject({
251
+ errorCode: HardwareErrorCode.BleTimeoutError,
252
+ });
253
+ expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(false);
254
+ });
255
+
256
+ test('does not classify the same sequence as a stale bond on Android', async () => {
257
+ Object.assign(Platform, { OS: 'android' });
258
+ const { transport } = createHarness({ v1: false, v2: false });
259
+
260
+ expect(
261
+ (transport as any).rememberIosProbeDisconnect(
262
+ UUID,
263
+ 'protocol-v1-get-features',
264
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded)
265
+ )
266
+ ).toBeUndefined();
267
+ await expect(detect(transport)).rejects.toMatchObject({
268
+ errorCode: HardwareErrorCode.BleTimeoutError,
269
+ });
270
+ });
271
+ });
@@ -45,13 +45,20 @@ jest.mock('../BleManager', () => ({
45
45
  pairDevice: jest.fn(() => Promise.resolve({ bonded: true, bonding: false })),
46
46
  }));
47
47
 
48
- // The native facade is replaced wholesale above; key missing is covered by its own suite.
48
+ // The native facade is replaced wholesale above; key missing and link encryption are covered
49
+ // by their own suites.
49
50
  jest.mock('../bleKeyMissing', () => ({
50
51
  isBleKeyMissingSupported: jest.fn(() => false),
51
52
  startBleKeyMissingTracking: jest.fn(() => false),
52
53
  stopBleKeyMissingTracking: jest.fn(),
53
54
  waitForBleKeyMissing: jest.fn(() => Promise.resolve(false)),
54
55
  }));
56
+ jest.mock('../bleEncryption', () => ({
57
+ markBleLinkEncrypted: jest.fn(),
58
+ startBleEncryptionTracking: jest.fn(() => false),
59
+ stopBleEncryptionTracking: jest.fn(),
60
+ waitForAndroidLinkEncryption: jest.fn(() => Promise.resolve('unresolved')),
61
+ }));
55
62
 
56
63
  jest.mock('../subscribeBleOn', () => ({
57
64
  subscribeBleOn: jest.fn(() => Promise.resolve()),