@onekeyfe/hd-transport-react-native 1.2.0-alpha.99 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-react-native",
3
- "version": "1.2.0-alpha.99",
3
+ "version": "1.2.0",
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.0-alpha.99",
24
- "@onekeyfe/hd-shared": "1.2.0-alpha.99",
25
- "@onekeyfe/hd-transport": "1.2.0-alpha.99",
23
+ "@onekeyfe/hd-core": "1.2.0",
24
+ "@onekeyfe/hd-shared": "1.2.0",
25
+ "@onekeyfe/hd-transport": "1.2.0",
26
26
  "@onekeyfe/react-native-ble-utils": "^0.1.6",
27
27
  "react-native-ble-plx": "3.5.1"
28
28
  },
29
- "gitHead": "8924c0e509f84675a2c33c0062166967f5bcb2d0"
29
+ "gitHead": "cc51df0a415bead21304ad1d8bc92b5312c91344"
30
30
  }
@@ -0,0 +1,39 @@
1
+ import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+
3
+ import { isNativeBleStaleBondError, toBleStaleBondHardwareError } from '../bleStaleBond';
4
+
5
+ describe('native BLE stale bond mapping', () => {
6
+ test.each([
7
+ [
8
+ { attErrorCode: 15, reason: 'Encryption is insufficient' },
9
+ HardwareErrorCode.BleDeviceBondError,
10
+ ],
11
+ [
12
+ { attErrorCode: 5, reason: 'GATT_INSUF_AUTHENTICATION' },
13
+ HardwareErrorCode.BleDeviceBondError,
14
+ ],
15
+ [
16
+ { iosErrorCode: 14, reason: 'Peer removed pairing information' },
17
+ HardwareErrorCode.BlePeerRemovedPairingInformation,
18
+ ],
19
+ [
20
+ { reason: 'peer removed pairing information' },
21
+ HardwareErrorCode.BlePeerRemovedPairingInformation,
22
+ ],
23
+ [
24
+ { message: 'PEER REMOVED PAIRING INFORMATION' },
25
+ HardwareErrorCode.BlePeerRemovedPairingInformation,
26
+ ],
27
+ ])('maps %j to %s', (nativeError, errorCode) => {
28
+ expect(isNativeBleStaleBondError(nativeError)).toBe(true);
29
+ expect(toBleStaleBondHardwareError(nativeError)).toMatchObject({ errorCode });
30
+ });
31
+
32
+ test('does not treat the generic ATT unlikely error as a stale bond', () => {
33
+ expect(isNativeBleStaleBondError({ attErrorCode: 14, reason: 'Unlikely error' })).toBe(false);
34
+ });
35
+
36
+ test('does not treat a generic disconnect as a stale bond', () => {
37
+ expect(isNativeBleStaleBondError({ reason: 'Device disconnected' })).toBe(false);
38
+ });
39
+ });
@@ -6,6 +6,7 @@ import ReactNativeBleTransport, {
6
6
  BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD,
7
7
  BLE_CONNECT_TIMEOUT_MS,
8
8
  BLE_GATT_SETUP_TIMEOUT_MS,
9
+ BLE_SETUP_WEDGED_MESSAGE,
9
10
  } from '../index';
10
11
  import protocolV1Schema from './protocolV1SchemaFixture';
11
12
 
@@ -179,7 +180,7 @@ describe('BLE connect timeout', () => {
179
180
  expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID);
180
181
  });
181
182
 
182
- test('repeated stalled connects recreate the BLE manager', async () => {
183
+ test('repeated stalled connects recreate the BLE manager and stop with PollingTimeout', async () => {
183
184
  const { transport, bleManager } = createHarness(
184
185
  () =>
185
186
  new Promise(() => {
@@ -188,10 +189,12 @@ describe('BLE connect timeout', () => {
188
189
  );
189
190
  const destroy = jest.fn();
190
191
  (bleManager as unknown as { destroy: jest.Mock }).destroy = destroy;
192
+ const errors: Array<{ errorCode?: unknown; message?: unknown }> = [];
191
193
 
192
194
  for (let attempt = 0; attempt < BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD; attempt += 1) {
193
195
  let settled = false;
194
- transport.acquire({ uuid: UUID }).catch(() => {
196
+ transport.acquire({ uuid: UUID }).catch(error => {
197
+ errors.push(error);
195
198
  settled = true;
196
199
  });
197
200
  // eslint-disable-next-line no-await-in-loop
@@ -202,6 +205,11 @@ describe('BLE connect timeout', () => {
202
205
 
203
206
  expect(destroy).toHaveBeenCalledTimes(1);
204
207
  expect((transport as unknown as { blePlxManager?: unknown }).blePlxManager).toBeUndefined();
208
+ expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleConnectedError);
209
+ expect(errors[1]).toMatchObject({
210
+ errorCode: HardwareErrorCode.PollingTimeout,
211
+ message: BLE_SETUP_WEDGED_MESSAGE,
212
+ });
205
213
  });
206
214
 
207
215
  test('a recreated BLE manager starts with fresh timeout budgets for every device', () => {
@@ -224,12 +232,15 @@ describe('BLE connect timeout', () => {
224
232
  errorCode: BleErrorCode.OperationTimedOut,
225
233
  });
226
234
 
227
- for (let attempt = 0; attempt < BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD; attempt += 1) {
228
- // eslint-disable-next-line no-await-in-loop
229
- await expect(
230
- (transport as any).connectWithTimeout(UUID, () => Promise.reject(nativeTimeout))
231
- ).rejects.toBe(nativeTimeout);
232
- }
235
+ await expect(
236
+ (transport as any).connectWithTimeout(UUID, () => Promise.reject(nativeTimeout))
237
+ ).rejects.toBe(nativeTimeout);
238
+ await expect(
239
+ (transport as any).connectWithTimeout(UUID, () => Promise.reject(nativeTimeout))
240
+ ).rejects.toMatchObject({
241
+ errorCode: HardwareErrorCode.PollingTimeout,
242
+ message: BLE_SETUP_WEDGED_MESSAGE,
243
+ });
233
244
 
234
245
  expect(destroy).toHaveBeenCalledTimes(1);
235
246
  });
@@ -32,20 +32,14 @@ jest.mock('../subscribeBleOn', () => ({
32
32
  const ONEKEY_SERVICE_UUID = '00000001-0000-1000-8000-00805f9b34fb';
33
33
 
34
34
  describe('ReactNativeBleTransport iOS discovery', () => {
35
- test('filters a bonded Pro2 Find My peripheral while keeping the wallet peripheral', async () => {
35
+ test('keeps a bonded Pro2 communication peripheral after Find My changes its name', async () => {
36
36
  jest.mocked(getConnectedDeviceIds).mockResolvedValueOnce([
37
37
  {
38
- id: 'find-my-peripheral',
38
+ id: 'wallet-peripheral',
39
39
  name: 'Pro2 6E9E - Find My',
40
40
  localName: null,
41
41
  serviceUUIDs: [ONEKEY_SERVICE_UUID],
42
42
  },
43
- {
44
- id: 'wallet-peripheral',
45
- name: 'Pro2 6E9E',
46
- localName: 'Pro2 6E9E',
47
- serviceUUIDs: [ONEKEY_SERVICE_UUID],
48
- },
49
43
  ] as never);
50
44
  const blePlxManager = {
51
45
  startDeviceScan: jest.fn(),
@@ -60,7 +54,7 @@ describe('ReactNativeBleTransport iOS discovery', () => {
60
54
  expect(devices.map(device => device.id)).toEqual(['wallet-peripheral']);
61
55
  });
62
56
 
63
- test('filters a scanned Pro2 Find My peripheral by name when localName is null', async () => {
57
+ test('uses services to distinguish a Pro2 communication peripheral from Find My', async () => {
64
58
  jest.mocked(getConnectedDeviceIds).mockResolvedValueOnce([]);
65
59
  const blePlxManager = {
66
60
  startDeviceScan: jest.fn((_serviceUUIDs, _options, listener) => {
@@ -69,12 +63,12 @@ describe('ReactNativeBleTransport iOS discovery', () => {
69
63
  id: 'find-my-peripheral',
70
64
  name: 'Pro2 6E9E - Find My',
71
65
  localName: null,
72
- serviceUUIDs: [ONEKEY_SERVICE_UUID],
66
+ serviceUUIDs: ['0000fffd-0000-1000-8000-00805f9b34fb'],
73
67
  });
74
68
  listener(null, {
75
69
  id: 'wallet-peripheral',
76
- name: 'Pro2 6E9E',
77
- localName: 'Pro2 6E9E',
70
+ name: 'Pro2 6E9E - Find My',
71
+ localName: null,
78
72
  serviceUUIDs: [ONEKEY_SERVICE_UUID],
79
73
  });
80
74
  });
@@ -90,6 +84,30 @@ describe('ReactNativeBleTransport iOS discovery', () => {
90
84
  expect(devices.map(device => device.id)).toEqual(['wallet-peripheral']);
91
85
  });
92
86
 
87
+ test('keeps a service-filtered Pro2 scan result when ble-plx omits service UUIDs', async () => {
88
+ jest.mocked(getConnectedDeviceIds).mockResolvedValueOnce([]);
89
+ const blePlxManager = {
90
+ startDeviceScan: jest.fn((_serviceUUIDs, _options, listener) => {
91
+ queueMicrotask(() => {
92
+ listener(null, {
93
+ id: 'wallet-peripheral',
94
+ name: 'Pro2 6E9E - Find My',
95
+ localName: null,
96
+ serviceUUIDs: null,
97
+ });
98
+ });
99
+ }),
100
+ stopDeviceScan: jest.fn(),
101
+ };
102
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
103
+ transport.blePlxManager = blePlxManager as never;
104
+ transport.init({ debug: jest.fn(), error: jest.fn() }, new EventEmitter());
105
+
106
+ const devices = await transport.enumerate();
107
+
108
+ expect(devices.map(device => device.id)).toEqual(['wallet-peripheral']);
109
+ });
110
+
93
111
  test('ignores an unnamed scanned advertisement while keeping the named wallet peripheral', async () => {
94
112
  jest.mocked(getConnectedDeviceIds).mockResolvedValueOnce([]);
95
113
  const blePlxManager = {
@@ -61,6 +61,30 @@ const detect = (transport: ReactNativeBleTransport) =>
61
61
  Promise.resolve()
62
62
  ) as Promise<string>;
63
63
 
64
+ describe('iOS expected-protocol detection', () => {
65
+ test('still probes an expected Protocol V2 so USB-priority can surface', async () => {
66
+ const { transport, probeV1, probeV2 } = createHarness({ v1: false, v2: true });
67
+
68
+ await expect(
69
+ (transport as any).detectProtocol(UUID, 'V2', undefined, () => Promise.resolve())
70
+ ).resolves.toBe('V2');
71
+
72
+ expect(probeV2).toHaveBeenCalledTimes(1);
73
+ expect(probeV1).not.toHaveBeenCalled();
74
+ });
75
+
76
+ test('keeps the existing iOS shortcut for an expected Protocol V1', async () => {
77
+ const { transport, probeV1, probeV2 } = createHarness({ v1: false, v2: false });
78
+
79
+ await expect(
80
+ (transport as any).detectProtocol(UUID, 'V1', undefined, () => Promise.resolve())
81
+ ).resolves.toBe('V1');
82
+
83
+ expect(probeV1).not.toHaveBeenCalled();
84
+ expect(probeV2).not.toHaveBeenCalled();
85
+ });
86
+ });
87
+
64
88
  describe('protocol re-probe after a known device goes away', () => {
65
89
  test('an unknown device still probes both protocols', async () => {
66
90
  const { transport, probeV1, probeV2 } = createHarness({ v1: false, v2: false });
@@ -7,6 +7,7 @@ import transportPackage, {
7
7
  import { HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
8
8
 
9
9
  import ReactNativeBleTransport, {
10
+ BLE_NATIVE_TEARDOWN_TIMEOUT_MS,
10
11
  BLE_WRITE_PACKET_TIMEOUT_MS,
11
12
  configureProtocolV2BleTuning,
12
13
  getFirmwareUploadWriteRetryType,
@@ -108,9 +109,11 @@ const schemas = {
108
109
  const createHarness = ({
109
110
  deviceName = 'OneKey Pro 2',
110
111
  isWritableWithResponse = true,
112
+ monitorError,
111
113
  }: {
112
114
  deviceName?: string;
113
115
  isWritableWithResponse?: boolean;
116
+ monitorError?: (Error & { reason?: string; attErrorCode?: number; iosErrorCode?: number }) | null;
114
117
  } = {}) => {
115
118
  const uuid = 'rn-pro2-id';
116
119
  const sentSeqs: number[] = [];
@@ -129,6 +132,9 @@ const createHarness = ({
129
132
  isNotifiable: true,
130
133
  monitor: jest.fn(callback => {
131
134
  notifyCallback = callback;
135
+ if (monitorError) {
136
+ queueMicrotask(() => callback(monitorError, null));
137
+ }
132
138
  return { remove: jest.fn() };
133
139
  }),
134
140
  };
@@ -174,6 +180,8 @@ const createHarness = ({
174
180
  devices: jest.fn(() => Promise.resolve([device])),
175
181
  connectedDevices: jest.fn(() => Promise.resolve([])),
176
182
  cancelTransaction: jest.fn(() => Promise.resolve()),
183
+ cancelDeviceConnection: jest.fn(() => Promise.resolve()),
184
+ destroy: jest.fn(),
177
185
  };
178
186
  const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
179
187
  const emitter = new EventEmitter();
@@ -192,6 +200,7 @@ const createHarness = ({
192
200
  logger,
193
201
  uuid,
194
202
  device,
203
+ bleManager,
195
204
  sentSeqs,
196
205
  writeCharacteristic,
197
206
  setShouldRespond(value: boolean) {
@@ -401,7 +410,23 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
401
410
  await transport.release(uuid, true);
402
411
  });
403
412
 
404
- test('actively probes Protocol V2 on iOS when only a name-derived hint is available', async () => {
413
+ test('keeps native stale-bond write mapping out of Protocol V1 calls', async () => {
414
+ const { transport, uuid, writeCharacteristic } = createV1Harness();
415
+ const nativeError = Object.assign(new Error('Encryption is insufficient'), {
416
+ attErrorCode: 15,
417
+ reason: 'Encryption is insufficient',
418
+ });
419
+
420
+ await transport.acquire({ uuid, expectedProtocol: 'V1' });
421
+ writeCharacteristic.writeWithResponse.mockRejectedValueOnce(nativeError);
422
+
423
+ await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).rejects.toMatchObject({
424
+ errorCode: HardwareErrorCode.BleWriteCharacteristicError,
425
+ });
426
+ await transport.release(uuid, true);
427
+ });
428
+
429
+ test('detects Protocol V2 on iOS without using the BLE name as a protocol hint', async () => {
405
430
  const { transport, uuid, device, sentSeqs, writeCharacteristic } = createHarness({
406
431
  deviceName: 'Pro2 6E9E',
407
432
  });
@@ -411,15 +436,271 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
411
436
  protocolType: 'V2',
412
437
  });
413
438
  expect(device.requestMTU).toHaveBeenCalledWith(247);
414
- expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
439
+ expect(writeCharacteristic.writeWithResponse.mock.calls.length).toBeGreaterThan(1);
415
440
 
416
441
  await expect(
417
442
  transport.call(uuid, 'Ping', { message: 'first-core-command' })
418
443
  ).resolves.toBeDefined();
419
- expect(sentSeqs).toEqual([1, 2]);
444
+ expect(sentSeqs.filter(seq => seq > 0)).toEqual([1, 2]);
445
+ await transport.release(uuid, true);
446
+ });
447
+
448
+ test('physically refreshes an uncached Protocol V2 firmware install link without Ping', async () => {
449
+ const { transport, uuid, device, bleManager, writeCharacteristic } = createHarness();
450
+ const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2');
451
+ (transport as any).sessionProtocols.set(uuid, 'V2');
452
+ device.connect = jest.fn().mockResolvedValue(device);
453
+ device.isConnected.mockResolvedValueOnce(false);
454
+
455
+ await expect(
456
+ transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true })
457
+ ).resolves.toEqual({
458
+ uuid,
459
+ protocolType: 'V2',
460
+ });
461
+
462
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
463
+ expect(device.cancelConnection).not.toHaveBeenCalled();
464
+ expect(device.connect).toHaveBeenCalled();
465
+ expect(probeProtocolV2).not.toHaveBeenCalled();
466
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
467
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
468
+ expect(transport.getProtocolType(uuid)).toBe('V2');
469
+ await transport.release(uuid, true);
470
+ });
471
+
472
+ test('refreshes a cached firmware install connection instead of trusting GATT state', async () => {
473
+ const { transport, uuid, device, bleManager, writeCharacteristic } = createHarness();
474
+ const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2');
475
+
476
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
477
+ device.connect = jest.fn().mockResolvedValue(device);
478
+ device.isConnected.mockResolvedValueOnce(false);
479
+ writeCharacteristic.writeWithResponse.mockClear();
480
+ writeCharacteristic.writeWithoutResponse.mockClear();
481
+
482
+ await expect(
483
+ transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true })
484
+ ).resolves.toEqual({
485
+ uuid,
486
+ protocolType: 'V2',
487
+ });
488
+
489
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
490
+ expect(device.cancelConnection).toHaveBeenCalled();
491
+ expect(device.connect).toHaveBeenCalled();
492
+ expect(probeProtocolV2).toHaveBeenCalledTimes(1);
493
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
494
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
420
495
  await transport.release(uuid, true);
421
496
  });
422
497
 
498
+ test('rejects no-probe acquire before the BLE endpoint has confirmed a protocol', async () => {
499
+ const { transport, uuid } = createHarness();
500
+
501
+ await expect(
502
+ transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true })
503
+ ).rejects.toThrow('previously confirmed protocol');
504
+
505
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
506
+ });
507
+
508
+ test('keeps confirmed V2 authorization across a BLE manager reset', async () => {
509
+ const { transport, uuid, device, bleManager, writeCharacteristic } = createHarness();
510
+ const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2');
511
+
512
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
513
+ expect(probeProtocolV2).toHaveBeenCalledTimes(1);
514
+
515
+ (transport as any).resetPlxManager();
516
+ transport.blePlxManager = bleManager as any;
517
+ device.connect = jest.fn().mockResolvedValue(device);
518
+ device.isConnected.mockResolvedValueOnce(false);
519
+ writeCharacteristic.writeWithResponse.mockClear();
520
+ writeCharacteristic.writeWithoutResponse.mockClear();
521
+
522
+ await expect(
523
+ transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true })
524
+ ).resolves.toEqual({
525
+ uuid,
526
+ protocolType: 'V2',
527
+ });
528
+
529
+ expect(probeProtocolV2).toHaveBeenCalledTimes(1);
530
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
531
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
532
+ await transport.release(uuid, true);
533
+ });
534
+
535
+ test.each(['ios', 'android'] as const)(
536
+ 'keeps a first expected Protocol V2 probe miss retryable on %s',
537
+ async platform => {
538
+ setPlatformOS(platform);
539
+ const { transport, uuid, device } = createHarness();
540
+ jest.spyOn(transport as any, 'probeProtocolV2').mockResolvedValue(false);
541
+
542
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
543
+ errorCode: HardwareErrorCode.RuntimeError,
544
+ });
545
+
546
+ expect(device.cancelConnection).not.toHaveBeenCalled();
547
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
548
+ }
549
+ );
550
+
551
+ test.each(['ios', 'android'] as const)(
552
+ 'keeps a second expected Protocol V2 probe miss retryable on %s',
553
+ async platform => {
554
+ setPlatformOS(platform);
555
+ const { transport, uuid, device } = createHarness();
556
+ jest.spyOn(transport as any, 'probeProtocolV2').mockResolvedValue(false);
557
+
558
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
559
+ errorCode: HardwareErrorCode.RuntimeError,
560
+ });
561
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
562
+ errorCode: HardwareErrorCode.RuntimeError,
563
+ });
564
+
565
+ expect(device.cancelConnection).not.toHaveBeenCalled();
566
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
567
+ }
568
+ );
569
+
570
+ test.each([
571
+ [
572
+ 'Encryption is insufficient',
573
+ { reason: 'Encryption is insufficient', attErrorCode: 15 },
574
+ HardwareErrorCode.BleDeviceBondError,
575
+ ],
576
+ [
577
+ 'Peer removed pairing information',
578
+ { reason: 'Peer removed pairing information', iosErrorCode: 14 },
579
+ HardwareErrorCode.BlePeerRemovedPairingInformation,
580
+ ],
581
+ ] as const)(
582
+ 'fails Protocol V2 acquire immediately on %s instead of waiting for Ping',
583
+ async (_label, nativeError, errorCode) => {
584
+ const { transport, uuid, device } = createHarness({
585
+ monitorError: Object.assign(new Error(nativeError.reason), nativeError),
586
+ });
587
+ const probe = jest.spyOn(transport as any, 'probeProtocolV2');
588
+
589
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
590
+ errorCode,
591
+ });
592
+
593
+ expect(probe).not.toHaveBeenCalled();
594
+ expect(device.cancelConnection).toHaveBeenCalled();
595
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
596
+ }
597
+ );
598
+
599
+ test.each(['ios', 'android'] as const)(
600
+ 'keeps a confirmed Protocol V2 probe miss retryable on %s without native bond evidence',
601
+ async platform => {
602
+ setPlatformOS(platform);
603
+ const { transport, uuid, device } = createHarness();
604
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
605
+ await transport.release(uuid, true);
606
+ jest.spyOn(transport as any, 'probeProtocolV2').mockResolvedValue(false);
607
+
608
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
609
+ errorCode: HardwareErrorCode.RuntimeError,
610
+ });
611
+
612
+ expect(device.cancelConnection).not.toHaveBeenCalled();
613
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
614
+ }
615
+ );
616
+
617
+ test('waits for an in-flight release before reacquiring the same device', async () => {
618
+ const { transport, uuid } = createHarness();
619
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
620
+ const releaseGate = createDeferred<void>();
621
+ const releaseStarted = createDeferred<void>();
622
+ const { protocolV2Links } = transport as any;
623
+ const invalidateLink = protocolV2Links.invalidateLink.bind(protocolV2Links);
624
+ jest
625
+ .spyOn(protocolV2Links, 'invalidateLink')
626
+ .mockImplementationOnce(async (...args: unknown[]) => {
627
+ releaseStarted.resolve();
628
+ await releaseGate.promise;
629
+ return invalidateLink(...args);
630
+ });
631
+
632
+ const release = transport.release(uuid, true);
633
+ await releaseStarted.promise;
634
+ let reacquired = false;
635
+ const acquire = transport.acquire({ uuid, expectedProtocol: 'V2' }).then(result => {
636
+ reacquired = true;
637
+ return result;
638
+ });
639
+
640
+ await Promise.resolve();
641
+ expect(reacquired).toBe(false);
642
+
643
+ releaseGate.resolve();
644
+ await release;
645
+ await expect(acquire).resolves.toEqual({ uuid, protocolType: 'V2' });
646
+ await expect(transport.call(uuid, 'Ping', { message: 'after-release' })).resolves.toMatchObject(
647
+ {
648
+ type: 'Success',
649
+ message: { message: 'ok' },
650
+ }
651
+ );
652
+ });
653
+
654
+ test(
655
+ 'releases the lifecycle queue when native teardown never settles',
656
+ async () => {
657
+ const { transport, uuid, device, bleManager } = createHarness();
658
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
659
+ const otherUuid = 'rn-pro2-other-id';
660
+ const otherDevice = {
661
+ ...device,
662
+ id: otherUuid,
663
+ name: 'OneKey Pro 2 Other',
664
+ localName: 'OneKey Pro 2 Other',
665
+ };
666
+ await (transport as any).installTransportForAcquire(otherUuid, otherDevice);
667
+ (transport as any).deviceProtocol.set(otherUuid, 'V2');
668
+ const disconnectEvents: Array<{ connectId: string }> = [];
669
+ transport.emitter?.on(TRANSPORT_EVENT.DEVICE_DISCONNECT, event => {
670
+ disconnectEvents.push(event);
671
+ });
672
+ const stalledNativeCleanup = createDeferred<void>();
673
+ bleManager.cancelTransaction
674
+ .mockImplementationOnce(() => stalledNativeCleanup.promise)
675
+ .mockResolvedValue(undefined);
676
+ const resetPlxManager = jest.spyOn(transport as any, 'resetPlxManager');
677
+ const nextLifecycleOperation = jest.fn().mockResolvedValue('next-operation');
678
+
679
+ const release = transport.release(uuid, true);
680
+ const nextOperation = (transport as any).runLifecycleOperation(uuid, nextLifecycleOperation);
681
+
682
+ await expect(release).resolves.toBe(true);
683
+ await expect(nextOperation).resolves.toBe('next-operation');
684
+ expect(resetPlxManager).toHaveBeenCalledTimes(1);
685
+ expect(nextLifecycleOperation).toHaveBeenCalledTimes(1);
686
+ expect(disconnectEvents).toContainEqual(expect.objectContaining({ connectId: otherUuid }));
687
+ expect(() => (transport as any).getCachedTransport(otherUuid)).toThrow();
688
+
689
+ await (transport as any).installTransportForAcquire(uuid, device);
690
+ (transport as any).deviceProtocol.set(uuid, 'V2');
691
+
692
+ stalledNativeCleanup.resolve();
693
+ await Promise.resolve();
694
+ await expect(
695
+ transport.call(uuid, 'Ping', { message: 'after-stale-cleanup' })
696
+ ).resolves.toMatchObject({
697
+ type: 'Success',
698
+ message: { message: 'ok' },
699
+ });
700
+ },
701
+ BLE_NATIVE_TEARDOWN_TIMEOUT_MS + 5_000
702
+ );
703
+
423
704
  test('falls back to the other active probe on iOS when protocol metadata is absent', async () => {
424
705
  const { transport, uuid } = createHarness({ deviceName: 'OneKey' });
425
706
  const probeProtocolV1 = jest
@@ -625,7 +906,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
625
906
  await transport.acquire({ uuid, expectedProtocol: 'V2' });
626
907
  await transport.call(uuid, 'Ping', { message: 'first-core-command' });
627
908
 
628
- expect(sentSeqs).toEqual([1]);
909
+ expect(sentSeqs).toEqual([1, 2]);
629
910
  await transport.release(uuid, true);
630
911
  });
631
912
 
@@ -660,7 +941,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
660
941
  await transport.acquire({ uuid, expectedProtocol: 'V2' });
661
942
  await transport.call(uuid, 'Ping', { message: 'second-generation' });
662
943
 
663
- expect(sentSeqs).toEqual([1, 2]);
944
+ expect(sentSeqs).toEqual([1, 2, 3, 4]);
664
945
  await transport.release(uuid, true);
665
946
  });
666
947
 
@@ -670,12 +951,12 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
670
951
  await transport.acquire({ uuid, expectedProtocol: 'V2' });
671
952
  const releaseNative = jest.spyOn(transport as any, 'releaseNative');
672
953
  expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
673
- expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
954
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
674
955
 
675
956
  await transport.call(uuid, 'DeviceInfoGet', {});
676
957
  await transport.call(uuid, 'ProtocolInfoRequest', {});
677
958
 
678
- expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(2);
959
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(3);
679
960
  expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
680
961
  expect(releaseNative).not.toHaveBeenCalled();
681
962
 
@@ -690,7 +971,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
690
971
  await transport.call(uuid, 'FileWrite', {});
691
972
  await transport.call(uuid, 'FileWrite', {});
692
973
  expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(2);
693
- expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
974
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
694
975
  expect(
695
976
  logger.debug.mock.calls.filter(
696
977
  ([message]) =>
@@ -723,7 +1004,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
723
1004
  await transport.acquire({ uuid, expectedProtocol: 'V2' });
724
1005
 
725
1006
  await transport.call(uuid, 'FileWrite', {}, { writeWithResponse: true });
726
- expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
1007
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(2);
727
1008
  expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
728
1009
  await transport.release(uuid, true);
729
1010
  });
@@ -737,7 +1018,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
737
1018
  await transport.call(uuid, 'ProtocolInfoRequest', {});
738
1019
 
739
1020
  expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
740
- expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
1021
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(2);
741
1022
  await transport.release(uuid, true);
742
1023
  });
743
1024