@onekeyfe/hd-transport-react-native 1.2.3-alpha.3 → 1.2.3-alpha.4

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.
@@ -4,10 +4,11 @@ import transportPackage, {
4
4
  ProtocolV2,
5
5
  TRANSPORT_EVENT,
6
6
  } from '@onekeyfe/hd-transport';
7
- import { HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
7
+ import { ERRORS, HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
8
8
 
9
9
  import ReactNativeBleTransport, {
10
10
  BLE_NATIVE_TEARDOWN_TIMEOUT_MS,
11
+ BLE_SETUP_WEDGED_MESSAGE,
11
12
  BLE_WRITE_PACKET_TIMEOUT_MS,
12
13
  configureProtocolV2BleTuning,
13
14
  getFirmwareUploadWriteRetryType,
@@ -28,7 +29,7 @@ jest.mock('react-native-ble-plx', () => ({
28
29
  BleError: class BleError extends Error {},
29
30
  BleErrorCode: {
30
31
  DeviceAlreadyConnected: 203,
31
- DeviceDisconnected: 205,
32
+ DeviceDisconnected: 201,
32
33
  DeviceMTUChangeFailed: 206,
33
34
  OperationCancelled: 2,
34
35
  CharacteristicNotFound: 404,
@@ -169,11 +170,13 @@ const createHarness = ({
169
170
  serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
170
171
  isConnected: jest.fn(() => Promise.resolve(true)),
171
172
  cancelConnection: jest.fn(() => Promise.resolve()),
173
+ connect: jest.fn(),
172
174
  onDisconnected: jest.fn(callback => {
173
175
  disconnectCallback = callback;
174
176
  return { remove: jest.fn() };
175
177
  }),
176
178
  } as any;
179
+ device.connect.mockResolvedValue(device);
177
180
  device.requestMTU = jest.fn(() => Promise.resolve(device));
178
181
  device.requestConnectionPriority = jest.fn(() => Promise.resolve(device));
179
182
  const bleManager = {
@@ -360,32 +363,46 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
360
363
  expect(new ReactNativeBleTransport({}).scanTimeout).toBe(3000);
361
364
  });
362
365
 
363
- test('connects the Android GATT link before starting system bonding', async () => {
364
- setPlatformOS('android');
365
- const { transport, uuid, device } = createHarness();
366
- const operationOrder: string[] = [];
367
- const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock;
366
+ test.each(['V1', 'V2'] as const)(
367
+ 'waits for Android bonding before connecting the %s GATT link',
368
+ async protocol => {
369
+ setPlatformOS('android');
370
+ const { transport, uuid, device } = protocol === 'V1' ? createV1Harness() : createHarness();
371
+ const operationOrder: string[] = [];
372
+ const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock;
373
+ const bondStateMock = jest.requireMock('../BleManager').onDeviceBondState as jest.Mock;
374
+ const bonding = createDeferred<void>();
368
375
 
369
- device.isConnected.mockResolvedValueOnce(false);
370
- device.connect = jest.fn(() => {
371
- operationOrder.push('connect');
372
- return Promise.resolve(device);
373
- });
374
- pairDeviceMock.mockImplementationOnce(() => {
375
- operationOrder.push('bond');
376
- return Promise.resolve({ bonded: true, bonding: false });
377
- });
376
+ device.isConnected.mockResolvedValueOnce(false);
377
+ device.connect = jest.fn(() => {
378
+ operationOrder.push('connect');
379
+ return Promise.resolve(device);
380
+ });
381
+ pairDeviceMock.mockImplementationOnce(() => {
382
+ operationOrder.push('bond');
383
+ return Promise.resolve({ bonded: false, bonding: true });
384
+ });
385
+ bondStateMock.mockImplementationOnce(() => bonding.promise);
378
386
 
379
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
380
- uuid,
381
- protocolType: 'V2',
382
- });
387
+ const acquiring = transport.acquire({ uuid, expectedProtocol: protocol });
388
+ await new Promise(resolve => {
389
+ setImmediate(resolve);
390
+ });
391
+ expect(operationOrder).toEqual(['bond']);
392
+ expect(device.connect).not.toHaveBeenCalled();
383
393
 
384
- expect(operationOrder).toEqual(['connect', 'bond']);
385
- await transport.release(uuid, true);
386
- });
394
+ bonding.resolve();
395
+ await expect(acquiring).resolves.toEqual({
396
+ uuid,
397
+ protocolType: protocol,
398
+ });
399
+
400
+ expect(operationOrder).toEqual(['bond', 'connect']);
401
+ await transport.release(uuid, true);
402
+ }
403
+ );
387
404
 
388
- test('does not start Android bonding when both reconnect attempts fail', async () => {
405
+ test('preserves Android connect errors after bonding when both reconnect attempts fail', async () => {
389
406
  setPlatformOS('android');
390
407
  const { transport, uuid, device } = createHarness();
391
408
  const BleErrorMock = jest.requireMock('react-native-ble-plx').BleError as new (
@@ -412,22 +429,35 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
412
429
  errorCode: HardwareErrorCode.BleConnectedError,
413
430
  });
414
431
  expect(device.connect).toHaveBeenCalledTimes(2);
415
- expect(pairDeviceMock).not.toHaveBeenCalled();
432
+ expect(pairDeviceMock).toHaveBeenCalledTimes(1);
416
433
  });
417
434
 
418
- test('checks that the Android GATT link is connected before bonding', async () => {
435
+ test('does not connect Android GATT when the system cannot start bonding', async () => {
419
436
  setPlatformOS('android');
420
437
  const { transport, uuid, device, bleManager } = createHarness();
421
438
  const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock;
422
439
 
423
- device.isConnected.mockResolvedValueOnce(false).mockResolvedValueOnce(false);
424
440
  device.connect = jest.fn().mockResolvedValue(device);
425
441
  pairDeviceMock.mockClear();
442
+ pairDeviceMock.mockResolvedValueOnce({ bonded: false, bonding: false });
443
+
444
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
445
+ errorCode: HardwareErrorCode.BleDeviceNotBonded,
446
+ });
447
+ expect(device.connect).not.toHaveBeenCalled();
448
+ expect(bleManager.devices).not.toHaveBeenCalled();
449
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
450
+ });
451
+
452
+ test('checks that Android GATT is connected after bonding and reconnecting', async () => {
453
+ setPlatformOS('android');
454
+ const { transport, uuid, device, bleManager } = createHarness();
455
+ device.isConnected.mockResolvedValueOnce(false).mockResolvedValueOnce(false);
456
+ device.connect = jest.fn().mockResolvedValue(device);
426
457
 
427
458
  await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
428
459
  errorCode: HardwareErrorCode.BleConnectedError,
429
460
  });
430
- expect(pairDeviceMock).not.toHaveBeenCalled();
431
461
  expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
432
462
  expect(device.cancelConnection).toHaveBeenCalledTimes(1);
433
463
  });
@@ -468,12 +498,12 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
468
498
  .mockRejectedValue(Object.assign(new BleErrorMock(nativeError.reason), nativeError));
469
499
 
470
500
  await expect(transport.acquire({ uuid })).rejects.toMatchObject({ errorCode });
471
- expect(pairDeviceMock).not.toHaveBeenCalled();
501
+ expect(pairDeviceMock).toHaveBeenCalledTimes(platform === 'android' ? 1 : 0);
472
502
  expect(transport.getProtocolType(uuid)).toBeUndefined();
473
503
  }
474
504
  );
475
505
 
476
- test('closes the Android GATT link when system bonding fails', async () => {
506
+ test('cleans up Android bonding failures without starting GATT', async () => {
477
507
  setPlatformOS('android');
478
508
  const { transport, uuid, device, bleManager } = createHarness();
479
509
  const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock;
@@ -485,7 +515,32 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
485
515
  );
486
516
 
487
517
  expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
488
- expect(device.cancelConnection).toHaveBeenCalledTimes(1);
518
+ expect(bleManager.devices).not.toHaveBeenCalled();
519
+ expect(device.isConnected).not.toHaveBeenCalled();
520
+ });
521
+
522
+ test('does not connect after stop while Android bonding is pending', async () => {
523
+ setPlatformOS('android');
524
+ const { transport, uuid, bleManager } = createHarness();
525
+ const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock;
526
+ const bondStateMock = jest.requireMock('../BleManager').onDeviceBondState as jest.Mock;
527
+ const bonding = createDeferred<void>();
528
+ pairDeviceMock.mockResolvedValueOnce({ bonded: false, bonding: true });
529
+ bondStateMock.mockImplementationOnce(() => bonding.promise);
530
+
531
+ const acquiring = transport.acquire({ uuid, expectedProtocol: 'V2' });
532
+ const rejection = expect(acquiring).rejects.toMatchObject({
533
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
534
+ });
535
+ await new Promise(resolve => {
536
+ setImmediate(resolve);
537
+ });
538
+ const stopping = transport.stop();
539
+ bonding.resolve();
540
+
541
+ await rejection;
542
+ await stopping;
543
+ expect(bleManager.devices).not.toHaveBeenCalled();
489
544
  });
490
545
 
491
546
  test('uses withResponse for consecutive iOS Protocol V1 control commands without releasing', async () => {
@@ -563,7 +618,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
563
618
  uuid,
564
619
  protocolType: 'V2',
565
620
  });
566
- expect(device.requestMTU).toHaveBeenCalledWith(247);
621
+ expect(device.requestMTU).not.toHaveBeenCalled();
567
622
  expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
568
623
  expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalled();
569
624
 
@@ -662,7 +717,72 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
662
717
  });
663
718
 
664
719
  test.each(['ios', 'android'] as const)(
665
- 'keeps a first expected Protocol V2 probe miss retryable on %s',
720
+ 'waits for native disconnect after failed protocol detection before reconnecting on %s',
721
+ async platform => {
722
+ setPlatformOS(platform);
723
+ const { transport, uuid, device, bleManager } = createHarness();
724
+ const probes = transport as unknown as {
725
+ probeProtocolV1(uuid: string): Promise<boolean>;
726
+ probeProtocolV2(uuid: string): Promise<boolean>;
727
+ releaseNative(uuid: string, onclose: boolean): Promise<void>;
728
+ };
729
+ jest.spyOn(probes, 'probeProtocolV1').mockResolvedValueOnce(false);
730
+ const probesFinished = createDeferred<void>();
731
+ const probeProtocolV2 = jest
732
+ .spyOn(probes, 'probeProtocolV2')
733
+ .mockImplementationOnce(async () => {
734
+ // A link-fatal V2 timeout removes the cached transport before acquire fails.
735
+ await probes.releaseNative(uuid, true);
736
+ probesFinished.resolve();
737
+ return false;
738
+ });
739
+ let connected = true;
740
+ device.isConnected.mockImplementation(() => Promise.resolve(connected));
741
+ device.connect = jest.fn(() => {
742
+ connected = true;
743
+ return Promise.resolve(device);
744
+ });
745
+ const disconnectGate = createDeferred<void>();
746
+ bleManager.cancelDeviceConnection.mockImplementation(async () => {
747
+ await disconnectGate.promise;
748
+ connected = false;
749
+ });
750
+
751
+ const acquiring = transport.acquire({ uuid });
752
+ const failure = expect(acquiring).rejects.toMatchObject({
753
+ errorCode: HardwareErrorCode.BleTimeoutError,
754
+ });
755
+ const reconnecting = transport.acquire({ uuid, expectedProtocol: 'V2' });
756
+
757
+ try {
758
+ await probesFinished.promise;
759
+ await new Promise(resolve => {
760
+ setImmediate(resolve);
761
+ });
762
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
763
+ expect(device.connect).not.toHaveBeenCalled();
764
+ expect(probeProtocolV2).toHaveBeenCalledTimes(1);
765
+
766
+ disconnectGate.resolve();
767
+ await failure;
768
+ await expect(reconnecting).resolves.toEqual({ uuid, protocolType: 'V2' });
769
+ expect(device.connect).toHaveBeenCalledTimes(1);
770
+ await expect(
771
+ transport.call(uuid, 'Ping', { message: 'after-reconnect' })
772
+ ).resolves.toMatchObject({
773
+ type: 'Success',
774
+ message: { message: 'ok' },
775
+ });
776
+ } finally {
777
+ disconnectGate.resolve();
778
+ await Promise.allSettled([failure, reconnecting]);
779
+ await transport.release(uuid, true);
780
+ }
781
+ }
782
+ );
783
+
784
+ test.each(['ios', 'android'] as const)(
785
+ 'disconnects after a first expected Protocol V2 probe miss while keeping it retryable on %s',
666
786
  async platform => {
667
787
  setPlatformOS(platform);
668
788
  const { transport, uuid, device } = createHarness();
@@ -672,13 +792,13 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
672
792
  errorCode: HardwareErrorCode.RuntimeError,
673
793
  });
674
794
 
675
- expect(device.cancelConnection).not.toHaveBeenCalled();
795
+ expect(device.cancelConnection).toHaveBeenCalledTimes(1);
676
796
  expect(transport.getProtocolType(uuid)).toBeUndefined();
677
797
  }
678
798
  );
679
799
 
680
800
  test.each(['ios', 'android'] as const)(
681
- 'keeps a second expected Protocol V2 probe miss retryable on %s',
801
+ 'disconnects after each expected Protocol V2 probe miss while keeping it retryable on %s',
682
802
  async platform => {
683
803
  setPlatformOS(platform);
684
804
  const { transport, uuid, device } = createHarness();
@@ -691,7 +811,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
691
811
  errorCode: HardwareErrorCode.RuntimeError,
692
812
  });
693
813
 
694
- expect(device.cancelConnection).not.toHaveBeenCalled();
814
+ expect(device.cancelConnection).toHaveBeenCalledTimes(2);
695
815
  expect(transport.getProtocolType(uuid)).toBeUndefined();
696
816
  }
697
817
  );
@@ -726,7 +846,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
726
846
  );
727
847
 
728
848
  test.each(['ios', 'android'] as const)(
729
- 'keeps a confirmed Protocol V2 probe miss retryable on %s without native bond evidence',
849
+ 'disconnects after a confirmed Protocol V2 probe miss on %s without reporting a bond error',
730
850
  async platform => {
731
851
  setPlatformOS(platform);
732
852
  const { transport, uuid, device } = createHarness();
@@ -738,7 +858,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
738
858
  errorCode: HardwareErrorCode.RuntimeError,
739
859
  });
740
860
 
741
- expect(device.cancelConnection).not.toHaveBeenCalled();
861
+ expect(device.cancelConnection).toHaveBeenCalledTimes(1);
742
862
  expect(transport.getProtocolType(uuid)).toBeUndefined();
743
863
  }
744
864
  );
@@ -830,6 +950,118 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
830
950
  BLE_NATIVE_TEARDOWN_TIMEOUT_MS + 5_000
831
951
  );
832
952
 
953
+ test('preserves the unpaired error when iOS disconnects during the first V1 probe', async () => {
954
+ const { transport, uuid, writeCharacteristic } = createHarness({ deviceName: 'Neo Test' });
955
+ writeCharacteristic.writeWithResponse.mockRejectedValueOnce({
956
+ errorCode: 201,
957
+ iosErrorCode: 7,
958
+ reason: 'The specified device has disconnected from us.',
959
+ });
960
+ const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2');
961
+
962
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
963
+ errorCode: HardwareErrorCode.BleDeviceNotBonded,
964
+ });
965
+
966
+ expect(probeProtocolV2).not.toHaveBeenCalled();
967
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
968
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
969
+ });
970
+
971
+ test('preserves a native iOS disconnect during an expected Protocol V2 probe', async () => {
972
+ const { transport, uuid, writeCharacteristic, bleManager } = createHarness({
973
+ deviceName: 'Neo Test',
974
+ });
975
+ const nativeDisconnect = {
976
+ errorCode: 201,
977
+ iosErrorCode: 7,
978
+ reason: 'The specified device has disconnected from us.',
979
+ };
980
+ writeCharacteristic.writeWithoutResponse.mockRejectedValueOnce(nativeDisconnect);
981
+ const probeProtocolV1 = jest.spyOn(transport as any, 'probeProtocolV1');
982
+
983
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
984
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
985
+ });
986
+
987
+ expect(probeProtocolV1).not.toHaveBeenCalled();
988
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
989
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
990
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
991
+ });
992
+
993
+ test('preserves a native iOS disconnect during a V2-first probe without falling back to V1', async () => {
994
+ const { transport, uuid, writeCharacteristic } = createHarness({ deviceName: 'Neo Test' });
995
+ writeCharacteristic.writeWithoutResponse.mockRejectedValueOnce({
996
+ errorCode: 201,
997
+ iosErrorCode: 7,
998
+ reason: 'The specified device has disconnected from us.',
999
+ });
1000
+ const probeProtocolV1 = jest.spyOn(transport as any, 'probeProtocolV1');
1001
+
1002
+ await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toMatchObject({
1003
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
1004
+ });
1005
+
1006
+ expect(probeProtocolV1).not.toHaveBeenCalled();
1007
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
1008
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
1009
+ });
1010
+
1011
+ test.each(['V1', 'V2'] as const)(
1012
+ 'preserves terminal BLE failures from the %s probe without trying another protocol',
1013
+ async protocol => {
1014
+ for (const errorCode of [
1015
+ HardwareErrorCode.BleDeviceNotBonded,
1016
+ HardwareErrorCode.BleDeviceBondedCanceled,
1017
+ HardwareErrorCode.BlePeerRemovedPairingInformation,
1018
+ HardwareErrorCode.BleDeviceDisconnected,
1019
+ HardwareErrorCode.BleCharacteristicNotifyError,
1020
+ HardwareErrorCode.BleCharacteristicNotifyChangeFailure,
1021
+ HardwareErrorCode.BleWriteCharacteristicError,
1022
+ ]) {
1023
+ const { transport, uuid } = createHarness();
1024
+ const error = ERRORS.TypedError(errorCode);
1025
+ const call = jest
1026
+ .spyOn(transport as any, protocol === 'V1' ? 'callProtocolV1' : 'callProtocolV2')
1027
+ .mockRejectedValue(error);
1028
+ const otherProbe = jest.spyOn(
1029
+ transport as any,
1030
+ protocol === 'V1' ? 'probeProtocolV2' : 'probeProtocolV1'
1031
+ );
1032
+
1033
+ await expect(transport.acquire({ uuid, protocolHint: protocol })).rejects.toBe(error);
1034
+
1035
+ expect(call).toHaveBeenCalledTimes(1);
1036
+ expect(otherProbe).not.toHaveBeenCalled();
1037
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
1038
+ }
1039
+ }
1040
+ );
1041
+
1042
+ test.each(['V1', 'V2'] as const)(
1043
+ 'still falls back after a silent %s probe timeout',
1044
+ async protocol => {
1045
+ const { transport, uuid, device, bleManager } = createHarness();
1046
+ jest
1047
+ .spyOn(transport as any, protocol === 'V1' ? 'callProtocolV1' : 'callProtocolV2')
1048
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.BleTimeoutError));
1049
+ const otherProbe = jest
1050
+ .spyOn(transport as any, protocol === 'V1' ? 'probeProtocolV2' : 'probeProtocolV1')
1051
+ .mockResolvedValue(true);
1052
+
1053
+ await expect(transport.acquire({ uuid, protocolHint: protocol })).resolves.toEqual({
1054
+ uuid,
1055
+ protocolType: protocol === 'V1' ? 'V2' : 'V1',
1056
+ });
1057
+
1058
+ expect(otherProbe).toHaveBeenCalledTimes(1);
1059
+ expect(bleManager.cancelDeviceConnection).not.toHaveBeenCalled();
1060
+ expect(device.cancelConnection).not.toHaveBeenCalled();
1061
+ await transport.release(uuid, true);
1062
+ }
1063
+ );
1064
+
833
1065
  test('falls back to the other active probe on iOS when protocol metadata is absent', async () => {
834
1066
  const { transport, uuid } = createHarness({ deviceName: 'OneKey' });
835
1067
  const probeProtocolV1 = jest
@@ -850,57 +1082,621 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
850
1082
  await transport.release(uuid, true);
851
1083
  });
852
1084
 
853
- test('continues with the current MTU when the connected snapshot refresh fails', async () => {
1085
+ test('does not renegotiate an already usable MTU during acquire', async () => {
854
1086
  const { transport, uuid, device } = createHarness();
855
- const mtuError = new Error('MTU refresh failed');
856
- device.requestMTU.mockRejectedValueOnce(mtuError);
857
1087
 
858
1088
  await expect(transport.acquire({ uuid })).resolves.toEqual({
859
1089
  uuid,
860
1090
  protocolType: 'V2',
861
1091
  });
862
- expect(device.requestMTU).toHaveBeenCalledTimes(1);
1092
+ expect(device.requestMTU).not.toHaveBeenCalled();
863
1093
  expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(247);
864
1094
  await transport.release(uuid, true);
865
1095
  });
866
1096
 
867
- test('refreshes a transient bootloader MTU after notifications are ready', async () => {
1097
+ test('requests a low MTU once before protocol probing', async () => {
868
1098
  const { transport, uuid, device } = createHarness();
869
1099
  device.mtu = 23;
870
- device.requestMTU
871
- .mockResolvedValueOnce(device)
872
- .mockResolvedValueOnce(device)
873
- .mockImplementationOnce(() => {
874
- device.mtu = 247;
875
- return Promise.resolve(device);
876
- });
1100
+ device.requestMTU.mockImplementationOnce(() => {
1101
+ device.mtu = 247;
1102
+ return Promise.resolve(device);
1103
+ });
877
1104
 
878
1105
  await expect(transport.acquire({ uuid })).resolves.toEqual({
879
1106
  uuid,
880
1107
  protocolType: 'V2',
881
1108
  });
882
- expect(device.requestMTU).toHaveBeenCalledTimes(3);
1109
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
1110
+ expect(device.requestMTU).toHaveBeenCalledWith(
1111
+ 247,
1112
+ expect.stringContaining(`${uuid}:mtu:connected:0:`)
1113
+ );
883
1114
  expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(247);
884
1115
  await transport.release(uuid, true);
885
1116
  });
886
1117
 
887
- test('continues with a low bootloader MTU when the bounded retry fails', async () => {
1118
+ test('continues with a low MTU when the single negotiation fails', async () => {
1119
+ const { transport, uuid, device } = createHarness();
1120
+ device.mtu = 23;
1121
+ device.requestMTU.mockRejectedValueOnce(new Error('MTU negotiation failed'));
1122
+
1123
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
1124
+ uuid,
1125
+ protocolType: 'V2',
1126
+ });
1127
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
1128
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
1129
+ await transport.release(uuid, true);
1130
+ });
1131
+
1132
+ test('bounds a stalled MTU negotiation and continues protocol probing', async () => {
1133
+ const { transport, uuid, device, bleManager } = createHarness();
1134
+ device.mtu = 23;
1135
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1136
+
1137
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
1138
+ uuid,
1139
+ protocolType: 'V2',
1140
+ });
1141
+ const transactionId = device.requestMTU.mock.calls[0]?.[1];
1142
+ expect(transactionId).toEqual(expect.stringContaining(`${uuid}:mtu:connected:0:`));
1143
+ expect(bleManager.cancelTransaction).toHaveBeenCalledWith(transactionId);
1144
+ expect(device.cancelConnection).toHaveBeenCalled();
1145
+ expect(device.connect).toHaveBeenCalledWith(
1146
+ expect.objectContaining({ timeout: expect.any(Number) })
1147
+ );
1148
+ expect(device.connect.mock.calls.at(-1)?.[0]).not.toHaveProperty('requestMTU');
1149
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
1150
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
1151
+ await transport.release(uuid, true);
1152
+ }, 10_000);
1153
+
1154
+ test('keeps the iOS link when the MTU-timeout reconnect reports it is still connected', async () => {
1155
+ const { BleError: BleErrorMock, BleErrorCode } = jest.requireMock('react-native-ble-plx');
1156
+ const { transport, uuid, device, bleManager } = createHarness();
1157
+ device.mtu = 23;
1158
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1159
+ device.connect.mockRejectedValueOnce(
1160
+ Object.assign(new BleErrorMock('Device already connected'), {
1161
+ errorCode: BleErrorCode.DeviceAlreadyConnected,
1162
+ })
1163
+ );
1164
+
1165
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
1166
+ uuid,
1167
+ protocolType: 'V2',
1168
+ });
1169
+ expect(device.cancelConnection).toHaveBeenCalledTimes(1);
1170
+ expect(device.connect).toHaveBeenCalledTimes(1);
1171
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
1172
+ expect(bleManager.destroy).not.toHaveBeenCalled();
1173
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
1174
+ await transport.release(uuid, true);
1175
+ }, 10_000);
1176
+
1177
+ test('reconnects after an iOS MTU timeout when cancelling the timed-out link fails', async () => {
1178
+ const { BleError: BleErrorMock, BleErrorCode } = jest.requireMock('react-native-ble-plx');
1179
+ const { transport, uuid, device, bleManager } = createHarness();
1180
+ device.mtu = 23;
1181
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1182
+ device.cancelConnection.mockRejectedValueOnce(
1183
+ Object.assign(new BleErrorMock('Operation was cancelled'), {
1184
+ errorCode: BleErrorCode.OperationCancelled,
1185
+ })
1186
+ );
1187
+
1188
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
1189
+ uuid,
1190
+ protocolType: 'V2',
1191
+ });
1192
+ expect(device.connect).toHaveBeenCalledTimes(1);
1193
+ expect(device.connect.mock.calls[0]?.[0]).not.toHaveProperty('requestMTU');
1194
+ expect(bleManager.destroy).not.toHaveBeenCalled();
1195
+ await transport.release(uuid, true);
1196
+ }, 10_000);
1197
+
1198
+ test('maps a stale bond from the iOS MTU-timeout reconnect', async () => {
1199
+ const { BleError: BleErrorMock } = jest.requireMock('react-native-ble-plx');
1200
+ const { transport, uuid, device } = createHarness();
1201
+ device.mtu = 23;
1202
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1203
+ device.connect.mockRejectedValueOnce(
1204
+ Object.assign(new BleErrorMock('Peer removed pairing information'), {
1205
+ errorCode: 200,
1206
+ iosErrorCode: 14,
1207
+ })
1208
+ );
1209
+
1210
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
1211
+ errorCode: HardwareErrorCode.BlePeerRemovedPairingInformation,
1212
+ });
1213
+ await transport.release(uuid, true).catch(() => undefined);
1214
+ }, 10_000);
1215
+
1216
+ test('bounds a hung iOS MTU-timeout teardown and resets the BLE manager without reconnecting', async () => {
1217
+ const { transport, uuid, device, bleManager } = createHarness();
1218
+ device.mtu = 23;
1219
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1220
+ device.cancelConnection.mockImplementationOnce(() => new Promise(() => {}));
1221
+
1222
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
1223
+ errorCode: HardwareErrorCode.BleTimeoutError,
1224
+ message: expect.stringContaining('BLE MTU cleanup timed out'),
1225
+ });
1226
+ expect(bleManager.destroy).toHaveBeenCalledTimes(1);
1227
+ expect(transport.blePlxManager).toBeUndefined();
1228
+ expect(device.connect).not.toHaveBeenCalled();
1229
+ expect((transport as any).lifecycleOperations.has(uuid)).toBe(false);
1230
+ }, 15_000);
1231
+
1232
+ test('does not reconnect after a hung iOS MTU-timeout teardown when the manager reset is already pending', async () => {
1233
+ const { transport, uuid, device, bleManager } = createHarness();
1234
+ device.mtu = 23;
1235
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1236
+ device.cancelConnection.mockImplementationOnce(() => new Promise(() => {}));
1237
+ // Another device's reset is still draining, so this teardown timeout cannot swap the manager.
1238
+ jest.spyOn(transport as any, 'resetPlxManager').mockImplementation(() => undefined);
1239
+
1240
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
1241
+ errorCode: HardwareErrorCode.BleTimeoutError,
1242
+ });
1243
+ expect(transport.blePlxManager).toBe(bleManager);
1244
+ expect(device.connect).not.toHaveBeenCalled();
1245
+ }, 15_000);
1246
+
1247
+ test('does not request MTU again after connect falls back without requestMTU', async () => {
1248
+ const { BleError: BleErrorMock, BleErrorCode } = jest.requireMock('react-native-ble-plx');
888
1249
  const { transport, uuid, device } = createHarness();
889
1250
  device.mtu = 23;
890
- device.requestMTU
891
- .mockResolvedValueOnce(device)
892
- .mockResolvedValueOnce(device)
893
- .mockRejectedValueOnce(new Error('bootloader MTU retry failed'));
1251
+ device.isConnected.mockResolvedValueOnce(false).mockResolvedValue(true);
1252
+ device.connect
1253
+ .mockRejectedValueOnce(
1254
+ Object.assign(new BleErrorMock('Operation was cancelled'), {
1255
+ errorCode: BleErrorCode.OperationCancelled,
1256
+ })
1257
+ )
1258
+ .mockResolvedValue(device);
894
1259
 
895
1260
  await expect(transport.acquire({ uuid })).resolves.toEqual({
896
1261
  uuid,
897
1262
  protocolType: 'V2',
898
1263
  });
899
- expect(device.requestMTU).toHaveBeenCalledTimes(3);
1264
+ expect(device.connect).toHaveBeenCalledTimes(2);
1265
+ expect(device.connect.mock.calls[0][0]).toEqual(expect.objectContaining({ requestMTU: 247 }));
1266
+ expect(device.connect.mock.calls[1][0]).not.toHaveProperty('requestMTU');
1267
+ expect(device.requestMTU).not.toHaveBeenCalled();
900
1268
  expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
901
1269
  await transport.release(uuid, true);
902
1270
  });
903
1271
 
1272
+ test('keeps the iOS connect chain unchanged', async () => {
1273
+ const { transport, uuid, device } = createHarness();
1274
+ device.isConnected.mockResolvedValueOnce(false);
1275
+
1276
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1277
+ uuid,
1278
+ protocolType: 'V2',
1279
+ });
1280
+ expect(device.connect).toHaveBeenCalledWith({
1281
+ requestMTU: 247,
1282
+ timeout: expect.any(Number),
1283
+ refreshGatt: 'OnConnected',
1284
+ });
1285
+ expect(device.requestMTU).not.toHaveBeenCalled();
1286
+ await transport.release(uuid, true);
1287
+ });
1288
+
1289
+ describe('Android MTU before service discovery', () => {
1290
+ beforeEach(() => {
1291
+ setPlatformOS('android');
1292
+ jest.useFakeTimers({
1293
+ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick', 'performance'],
1294
+ });
1295
+ });
1296
+
1297
+ afterEach(() => {
1298
+ jest.clearAllTimers();
1299
+ jest.useRealTimers();
1300
+ });
1301
+
1302
+ const advanceUntil = async (condition: () => boolean, budgetMs = 60_000) => {
1303
+ for (let elapsed = 0; !condition(); elapsed += 50) {
1304
+ if (elapsed >= budgetMs) throw new Error(`fake time exhausted after ${budgetMs}ms`);
1305
+ jest.advanceTimersByTime(50);
1306
+ // eslint-disable-next-line no-await-in-loop
1307
+ await new Promise(resolve => {
1308
+ setImmediate(resolve);
1309
+ });
1310
+ }
1311
+ };
1312
+
1313
+ const settle = async <T>(promise: Promise<T>, budgetMs?: number): Promise<T> => {
1314
+ let done = false;
1315
+ promise.then(
1316
+ () => {
1317
+ done = true;
1318
+ },
1319
+ () => {
1320
+ done = true;
1321
+ }
1322
+ );
1323
+ await advanceUntil(() => done, budgetMs);
1324
+ return promise;
1325
+ };
1326
+
1327
+ /**
1328
+ * A device whose LE link is only up between connect() and cancelConnection(), so the
1329
+ * transport has to reconnect after every drop instead of the test flipping the link.
1330
+ */
1331
+ const reconnectingDevice = (device: any) => {
1332
+ const link = { connected: false };
1333
+ device.isConnected.mockImplementation(() => Promise.resolve(link.connected));
1334
+ device.connect = jest.fn(() => {
1335
+ link.connected = true;
1336
+ return Promise.resolve(device);
1337
+ });
1338
+ device.cancelConnection.mockImplementation(() => {
1339
+ link.connected = false;
1340
+ return Promise.resolve();
1341
+ });
1342
+ return link;
1343
+ };
1344
+
1345
+ /** requestMTU resolves a fresh Device snapshot, as react-native-ble-plx does. */
1346
+ const negotiatedSnapshot = (device: any, mtu: number) => {
1347
+ const negotiated = { ...device, mtu };
1348
+ device.requestMTU.mockImplementation(() => Promise.resolve(negotiated));
1349
+ return negotiated;
1350
+ };
1351
+
1352
+ test('negotiates the MTU after a bare connect and before service discovery, adopting the returned device', async () => {
1353
+ const { transport, uuid, device } = createHarness();
1354
+ reconnectingDevice(device);
1355
+ device.mtu = 23;
1356
+ const negotiated = negotiatedSnapshot(device, 247);
1357
+
1358
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1359
+ uuid,
1360
+ protocolType: 'V2',
1361
+ });
1362
+
1363
+ expect(device.connect).toHaveBeenCalledTimes(1);
1364
+ expect(device.connect).toHaveBeenCalledWith({ timeout: expect.any(Number) });
1365
+ const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
1366
+ const [connectOrder] = device.connect.mock.invocationCallOrder;
1367
+ const [mtuOrder] = device.requestMTU.mock.invocationCallOrder;
1368
+ const [discoveryOrder] = resolveCharacteristics.mock.invocationCallOrder;
1369
+ expect(connectOrder).toBeLessThan(mtuOrder);
1370
+ expect(mtuOrder).toBeLessThan(discoveryOrder);
1371
+ const cached = (transport as any).getCachedTransport(uuid);
1372
+ expect(cached.device).toBe(negotiated);
1373
+ expect(cached.mtuSize).toBe(247);
1374
+ await settle(transport.release(uuid, true));
1375
+ });
1376
+
1377
+ test.each([
1378
+ { expectedProtocol: 'V2' as const, label: 'an expected Protocol V2 device' },
1379
+ { expectedProtocol: 'V1' as const, label: 'an expected Protocol V1 device' },
1380
+ { expectedProtocol: undefined, label: 'a device of unknown protocol' },
1381
+ ])('drops a link that stays at the default MTU for $label', async ({ expectedProtocol }) => {
1382
+ const { transport, uuid, device } = createHarness();
1383
+ device.mtu = 23;
1384
+
1385
+ await expect(settle(transport.acquire({ uuid, expectedProtocol }))).rejects.toMatchObject({
1386
+ errorCode: HardwareErrorCode.BleConnectedError,
1387
+ });
1388
+ expect((transport as any).resolveCharacteristics).not.toHaveBeenCalled();
1389
+ expect(device.cancelConnection).toHaveBeenCalled();
1390
+ });
1391
+
1392
+ test.each([
1393
+ ['never completes', () => new Promise(() => {})],
1394
+ ['completes at the default MTU', undefined],
1395
+ ['is rejected', () => Promise.reject(new Error('MTU request failed'))],
1396
+ ])(
1397
+ 'a second consecutive MTU failure that %s trips the wedged-link guard',
1398
+ async (_label, requestMTU) => {
1399
+ const { transport, uuid, device, bleManager } = createHarness();
1400
+ device.mtu = 23;
1401
+ if (requestMTU) device.requestMTU.mockImplementation(requestMTU);
1402
+
1403
+ await expect(
1404
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1405
+ ).rejects.toMatchObject({
1406
+ errorCode: HardwareErrorCode.BleConnectedError,
1407
+ });
1408
+ await expect(
1409
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1410
+ ).rejects.toMatchObject({
1411
+ errorCode: HardwareErrorCode.PollingTimeout,
1412
+ message: expect.stringContaining(BLE_SETUP_WEDGED_MESSAGE),
1413
+ });
1414
+ expect(bleManager.destroy).toHaveBeenCalled();
1415
+ }
1416
+ );
1417
+
1418
+ test('stop() during the link-drop wait releases it promptly', async () => {
1419
+ const { transport, uuid, device } = createHarness();
1420
+ device.mtu = 23;
1421
+ device.requestMTU.mockImplementation(() => new Promise(() => {}));
1422
+
1423
+ const acquiring = transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error);
1424
+ // The link-drop teardown cancels the device connection before the wait starts.
1425
+ await advanceUntil(() => device.cancelConnection.mock.calls.length > 0);
1426
+ await advanceUntil(() => false, 1000).catch(() => undefined);
1427
+ await settle(transport.stop(), 1000);
1428
+ await expect(settle(acquiring, 1000)).resolves.toBeInstanceOf(Error);
1429
+ });
1430
+
1431
+ test.each([
1432
+ {
1433
+ label: 'a missing OneKey service',
1434
+ error: () => ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound),
1435
+ },
1436
+ {
1437
+ label: 'a missing characteristic',
1438
+ error: () => ERRORS.TypedError(HardwareErrorCode.BleCharacteristicNotFound),
1439
+ },
1440
+ {
1441
+ label: 'a mis-typed characteristic',
1442
+ error: () =>
1443
+ ERRORS.TypedError('BLECharacteristicNotWritable: write characteristic not writable'),
1444
+ },
1445
+ ])(
1446
+ 'marks the endpoint on $label and refreshes the GATT table on the next connect, before the MTU exchange',
1447
+ async ({ error }) => {
1448
+ const { transport, uuid, device } = createHarness();
1449
+ const link = reconnectingDevice(device);
1450
+ device.mtu = 23;
1451
+ negotiatedSnapshot(device, 247);
1452
+ const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
1453
+ resolveCharacteristics.mockImplementationOnce(() => Promise.reject(error()));
1454
+
1455
+ await expect(
1456
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1457
+ ).rejects.toBeDefined();
1458
+ expect(link.connected).toBe(true);
1459
+
1460
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1461
+ uuid,
1462
+ protocolType: 'V2',
1463
+ });
1464
+ expect(device.connect).toHaveBeenLastCalledWith({
1465
+ timeout: expect.any(Number),
1466
+ refreshGatt: 'OnConnected',
1467
+ });
1468
+ // The stale table is only refreshed through a connect, so the live link is dropped first.
1469
+ const connectOrders: number[] = device.connect.mock.invocationCallOrder;
1470
+ expect(device.cancelConnection.mock.invocationCallOrder[0]).toBeLessThan(
1471
+ connectOrders[connectOrders.length - 1]
1472
+ );
1473
+ const discoveryOrders = resolveCharacteristics.mock.invocationCallOrder;
1474
+ const mtuOrders: number[] = device.requestMTU.mock.invocationCallOrder;
1475
+ expect(discoveryOrders[discoveryOrders.length - 1]).toBeLessThan(
1476
+ mtuOrders[mtuOrders.length - 1]
1477
+ );
1478
+
1479
+ // The refresh is spent once the table resolved through a refreshed connect.
1480
+ await settle(transport.release(uuid, true));
1481
+ link.connected = false;
1482
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }));
1483
+ expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number) });
1484
+ await settle(transport.release(uuid, true));
1485
+ }
1486
+ );
1487
+
1488
+ test('keeps the refresh marker when the refresh connect fell back without refreshGatt', async () => {
1489
+ const { transport, uuid, device } = createHarness();
1490
+ const link = reconnectingDevice(device);
1491
+ device.mtu = 23;
1492
+ negotiatedSnapshot(device, 247);
1493
+ const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
1494
+ resolveCharacteristics.mockImplementationOnce(() =>
1495
+ Promise.reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound))
1496
+ );
1497
+ await expect(
1498
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1499
+ ).rejects.toBeDefined();
1500
+
1501
+ // The fallback connect carries no refreshGatt.
1502
+ const cancelled = Object.assign(new Error('Operation was cancelled'), { errorCode: 2 });
1503
+ device.connect.mockImplementationOnce(() => Promise.reject(cancelled));
1504
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1505
+ uuid,
1506
+ protocolType: 'V2',
1507
+ });
1508
+
1509
+ await settle(transport.release(uuid, true));
1510
+ link.connected = false;
1511
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }));
1512
+ expect(device.connect).toHaveBeenLastCalledWith({
1513
+ timeout: expect.any(Number),
1514
+ refreshGatt: 'OnConnected',
1515
+ });
1516
+ await settle(transport.release(uuid, true));
1517
+ });
1518
+
1519
+ test('does not drop a link it has just connected with refreshGatt', async () => {
1520
+ const { transport, uuid, device, bleManager } = createHarness();
1521
+ bleManager.devices.mockResolvedValue([]);
1522
+ const connectToDevice = jest.fn(() => Promise.resolve(device));
1523
+ Object.assign(bleManager, { connectToDevice });
1524
+ (transport as any).androidGattCacheRefreshes.add(uuid);
1525
+
1526
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1527
+ uuid,
1528
+ protocolType: 'V2',
1529
+ });
1530
+ expect(connectToDevice).toHaveBeenCalledTimes(1);
1531
+ expect(connectToDevice).toHaveBeenCalledWith(uuid, {
1532
+ timeout: expect.any(Number),
1533
+ refreshGatt: 'OnConnected',
1534
+ });
1535
+ expect(device.cancelConnection).not.toHaveBeenCalled();
1536
+ expect(device.connect).not.toHaveBeenCalled();
1537
+ await settle(transport.release(uuid, true));
1538
+ });
1539
+
1540
+ test('still refreshes after a connect-by-id refresh fell back without refreshGatt', async () => {
1541
+ const { transport, uuid, device, bleManager } = createHarness();
1542
+ const link = reconnectingDevice(device);
1543
+ bleManager.devices.mockResolvedValue([]);
1544
+ const cancelled = Object.assign(new Error('Operation was cancelled'), { errorCode: 2 });
1545
+ const connectToDevice = jest
1546
+ .fn()
1547
+ .mockImplementationOnce(() => Promise.reject(cancelled))
1548
+ .mockImplementation(() => {
1549
+ link.connected = true;
1550
+ return Promise.resolve(device);
1551
+ });
1552
+ Object.assign(bleManager, { connectToDevice });
1553
+ (transport as any).androidGattCacheRefreshes.add(uuid);
1554
+
1555
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1556
+ uuid,
1557
+ protocolType: 'V2',
1558
+ });
1559
+ expect(connectToDevice).toHaveBeenLastCalledWith(uuid, { timeout: expect.any(Number) });
1560
+ expect(device.connect).toHaveBeenLastCalledWith({
1561
+ timeout: expect.any(Number),
1562
+ refreshGatt: 'OnConnected',
1563
+ });
1564
+ expect((transport as any).androidGattCacheRefreshes.has(uuid)).toBe(false);
1565
+ await settle(transport.release(uuid, true));
1566
+ });
1567
+
1568
+ test.each([
1569
+ 'Cannot write client characteristic config descriptor',
1570
+ 'Cannot find client characteristic config descriptor',
1571
+ 'The handle is invalid',
1572
+ 'Writing is not permitted',
1573
+ ])('arms a GATT refresh from the notify failure "%s"', async reason => {
1574
+ const harness = createHarness();
1575
+ const { transport, uuid, device } = harness;
1576
+ reconnectingDevice(device);
1577
+ device.mtu = 23;
1578
+ negotiatedSnapshot(device, 247);
1579
+
1580
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }));
1581
+ harness.emitMonitorError(Object.assign(new Error('notify failed'), { reason }));
1582
+
1583
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1584
+ uuid,
1585
+ protocolType: 'V2',
1586
+ });
1587
+ expect(device.connect).toHaveBeenLastCalledWith({
1588
+ timeout: expect.any(Number),
1589
+ refreshGatt: 'OnConnected',
1590
+ });
1591
+ await settle(transport.release(uuid, true));
1592
+ });
1593
+
1594
+ test('arms a GATT refresh from a notify failure while notifications are being enabled', async () => {
1595
+ const { transport, uuid, device } = createHarness({
1596
+ monitorError: Object.assign(new Error('notify failed'), {
1597
+ reason: 'Cannot write client characteristic config descriptor',
1598
+ }),
1599
+ });
1600
+ const link = reconnectingDevice(device);
1601
+ device.mtu = 23;
1602
+ negotiatedSnapshot(device, 247);
1603
+
1604
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error));
1605
+ await settle(transport.release(uuid, true));
1606
+ link.connected = false;
1607
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error));
1608
+ expect(device.connect).toHaveBeenLastCalledWith({
1609
+ timeout: expect.any(Number),
1610
+ refreshGatt: 'OnConnected',
1611
+ });
1612
+ await settle(transport.release(uuid, true));
1613
+ });
1614
+
1615
+ test('keeps the GATT refresh for a firmware-install reconnect', async () => {
1616
+ const { transport, uuid, device } = createHarness();
1617
+ (transport as any).sessionProtocols.set(uuid, 'V2');
1618
+ reconnectingDevice(device);
1619
+
1620
+ await expect(
1621
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true }))
1622
+ ).resolves.toEqual({ uuid, protocolType: 'V2' });
1623
+ expect(device.connect).toHaveBeenCalledWith({
1624
+ timeout: expect.any(Number),
1625
+ refreshGatt: 'OnConnected',
1626
+ });
1627
+ await settle(transport.release(uuid, true));
1628
+ });
1629
+ });
1630
+
1631
+ test('spends one Initialize wake on the detection after a fully silent one', async () => {
1632
+ setPlatformOS('android');
1633
+ const { transport, uuid } = createHarness();
1634
+ const probes = transport as any;
1635
+ jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false);
1636
+ jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false);
1637
+ const callProtocolV1 = jest.spyOn(probes, 'callProtocolV1').mockResolvedValue({});
1638
+
1639
+ // Nothing has gone silent yet, so the device keeps whatever session it has.
1640
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1641
+ expect(callProtocolV1).not.toHaveBeenCalled();
1642
+
1643
+ // The previous detection answered on no protocol, which is what a sleeping Classic
1644
+ // looks like, so this one wakes on the fresh link before probing.
1645
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1646
+ expect(callProtocolV1).toHaveBeenCalledTimes(1);
1647
+ expect(callProtocolV1.mock.calls[0]?.[1]).toBe('Initialize');
1648
+
1649
+ // Still silent: the wake is not repeated, so a device that is simply away is not
1650
+ // pushed into a fresh wallet session on every poll.
1651
+ callProtocolV1.mockClear();
1652
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1653
+ expect(callProtocolV1).not.toHaveBeenCalled();
1654
+ });
1655
+
1656
+ test('does not send the Initialize wake before a V2-first probe', async () => {
1657
+ setPlatformOS('android');
1658
+ const { transport, uuid } = createHarness();
1659
+ const probes = transport as any;
1660
+ jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false);
1661
+ jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false);
1662
+ const callProtocolV1 = jest.spyOn(probes, 'callProtocolV1').mockResolvedValue({});
1663
+
1664
+ await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toBeDefined();
1665
+ // Silent now, but the next detection probes V2 first: a late V1 reply would land on
1666
+ // the V2 probe, so no Initialize is sent.
1667
+ await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toBeDefined();
1668
+ expect(callProtocolV1).not.toHaveBeenCalled();
1669
+ });
1670
+
1671
+ test('does not send the Initialize wake on iOS', async () => {
1672
+ const { transport, uuid } = createHarness();
1673
+ const probes = transport as any;
1674
+ jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false);
1675
+ jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false);
1676
+ const callProtocolV1 = jest.spyOn(probes, 'callProtocolV1').mockResolvedValue({});
1677
+
1678
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1679
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1680
+ expect(callProtocolV1).not.toHaveBeenCalled();
1681
+ });
1682
+
1683
+ test('an unanswered Initialize wake settles the acquire and frees the lifecycle lock', async () => {
1684
+ setPlatformOS('android');
1685
+ const harness = createHarness();
1686
+ const { transport, uuid } = harness;
1687
+ const probes = transport as any;
1688
+ harness.setShouldRespond(false);
1689
+ jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false);
1690
+ jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false);
1691
+
1692
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1693
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
1694
+ errorCode: HardwareErrorCode.BleTimeoutError,
1695
+ });
1696
+ await transport.disconnect(uuid);
1697
+ await transport.stop();
1698
+ }, 10_000);
1699
+
904
1700
  test('accepts a stable low MTU without the delayed refresh loop', async () => {
905
1701
  const { transport, uuid, device } = createHarness();
906
1702
  device.mtu = 185;
@@ -909,7 +1705,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
909
1705
  uuid,
910
1706
  protocolType: 'V2',
911
1707
  });
912
- expect(device.requestMTU).toHaveBeenCalledTimes(1);
1708
+ expect(device.requestMTU).not.toHaveBeenCalled();
913
1709
  expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(185);
914
1710
  await transport.release(uuid, true);
915
1711
  });
@@ -922,7 +1718,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
922
1718
  uuid,
923
1719
  protocolType: 'V2',
924
1720
  });
925
- expect(device.requestMTU).toHaveBeenCalledTimes(3);
1721
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
926
1722
  expect((transport as any).getCachedTransport(uuid).mtuSize).toBeUndefined();
927
1723
  expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalled();
928
1724
  await transport.release(uuid, true);
@@ -940,7 +1736,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
940
1736
  });
941
1737
 
942
1738
  await expect(transport.call(uuid, 'FileWrite', {})).resolves.toBeDefined();
943
- expect(device.requestMTU).toHaveBeenCalledTimes(4);
1739
+ expect(device.requestMTU).toHaveBeenCalledTimes(2);
944
1740
  expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(
945
1741
  writesBeforeFileWrite + 1
946
1742
  );
@@ -957,7 +1753,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
957
1753
  await expect(transport.call(uuid, 'FileWrite', {})).rejects.toMatchObject({
958
1754
  errorCode: HardwareErrorCode.BleConnectedError,
959
1755
  });
960
- expect(device.requestMTU).toHaveBeenCalledTimes(4);
1756
+ expect(device.requestMTU).toHaveBeenCalledTimes(2);
961
1757
  expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(writesBeforeFileWrite);
962
1758
  await transport.release(uuid, true);
963
1759
  });
@@ -1011,6 +1807,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1011
1807
  expect(notifySubscriptionRemovers[0]).toHaveBeenCalledTimes(1);
1012
1808
  expect(notifySubscriptionRemovers[1]).toHaveBeenCalledTimes(1);
1013
1809
  expect(bleManager.cancelTransaction).toHaveBeenCalled();
1810
+ expect(device.cancelConnection).toHaveBeenCalledTimes(1);
1014
1811
  expect(transport.getProtocolType(uuid)).toBeUndefined();
1015
1812
  });
1016
1813
 
@@ -1065,6 +1862,115 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1065
1862
  });
1066
1863
  });
1067
1864
 
1865
+ test.each(['ios', 'android'] as const)(
1866
+ 'disconnects after an active V2 timeout and reconnects even if a listener fails on %s',
1867
+ async platform => {
1868
+ setPlatformOS(platform);
1869
+ const harness = createHarness();
1870
+ const { transport, uuid, device, bleManager, sentSeqs, emitter } = harness;
1871
+ const disconnected = jest.fn(() => {
1872
+ throw new Error('Disconnect listener failed');
1873
+ });
1874
+ emitter.on(TRANSPORT_EVENT.DEVICE_DISCONNECT, disconnected);
1875
+ let connected = true;
1876
+ device.isConnected.mockImplementation(() => Promise.resolve(connected));
1877
+ device.connect = jest.fn(() => {
1878
+ connected = true;
1879
+ return Promise.resolve(device);
1880
+ });
1881
+ bleManager.cancelDeviceConnection.mockImplementation(() => {
1882
+ connected = false;
1883
+ return Promise.resolve();
1884
+ });
1885
+
1886
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
1887
+ harness.setShouldRespond(false);
1888
+ await expect(transport.call(uuid, 'Ping', {}, { timeoutMs: 10 })).rejects.toMatchObject({
1889
+ errorCode: HardwareErrorCode.BleTimeoutError,
1890
+ });
1891
+
1892
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
1893
+ expect(connected).toBe(false);
1894
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
1895
+ expect(disconnected).toHaveBeenCalledTimes(1);
1896
+ expect(disconnected).toHaveBeenCalledWith({
1897
+ id: uuid,
1898
+ connectId: uuid,
1899
+ name: device.name,
1900
+ });
1901
+
1902
+ harness.setShouldRespond(true);
1903
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
1904
+ await expect(transport.call(uuid, 'Ping', {})).resolves.toMatchObject({ type: 'Success' });
1905
+ expect(device.connect).toHaveBeenCalledTimes(1);
1906
+ expect(sentSeqs).toEqual([1, 2, 3, 4]);
1907
+ await transport.release(uuid, true);
1908
+ }
1909
+ );
1910
+
1911
+ test('does not disconnect an active Protocol V2 call when a queued call times out', async () => {
1912
+ const harness = createHarness();
1913
+ const { transport, uuid, bleManager } = harness;
1914
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
1915
+ harness.setShouldRespond(false);
1916
+ const active = transport.call(uuid, 'Ping', {}, { timeoutMs: 1000 }).catch(error => error);
1917
+ await new Promise(resolve => {
1918
+ setImmediate(resolve);
1919
+ });
1920
+
1921
+ try {
1922
+ await expect(transport.call(uuid, 'Ping', {}, { timeoutMs: 10 })).rejects.toMatchObject({
1923
+ errorCode: HardwareErrorCode.BleTimeoutError,
1924
+ });
1925
+ expect(bleManager.cancelDeviceConnection).not.toHaveBeenCalled();
1926
+ expect(transport.getProtocolType(uuid)).toBe('V2');
1927
+ } finally {
1928
+ await transport.disconnect(uuid);
1929
+ await active;
1930
+ }
1931
+ });
1932
+
1933
+ test('does not disconnect a newer acquire when V2 timeout cleanup waits for the lifecycle lock', async () => {
1934
+ const harness = createHarness();
1935
+ const { transport, uuid, bleManager } = harness;
1936
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
1937
+ const lifecycle = transport as unknown as {
1938
+ runLifecycleOperation(id: string, operation: () => Promise<void>): Promise<void>;
1939
+ releaseNative(id: string, onclose: boolean): Promise<boolean>;
1940
+ };
1941
+ const gate = createDeferred<void>();
1942
+ const holding = lifecycle.runLifecycleOperation(uuid, () => gate.promise);
1943
+ const reacquiring = transport.acquire({ uuid, expectedProtocol: 'V2' });
1944
+ const invalidated = createDeferred<void>();
1945
+ const releaseNative = lifecycle.releaseNative.bind(lifecycle);
1946
+ jest.spyOn(lifecycle, 'releaseNative').mockImplementationOnce(async (id, onclose) => {
1947
+ const result = await releaseNative(id, onclose);
1948
+ invalidated.resolve();
1949
+ return result;
1950
+ });
1951
+ harness.setShouldRespond(false);
1952
+ const failure = expect(
1953
+ transport.call(uuid, 'Ping', {}, { timeoutMs: 10 })
1954
+ ).rejects.toMatchObject({
1955
+ errorCode: HardwareErrorCode.BleTimeoutError,
1956
+ });
1957
+
1958
+ try {
1959
+ await invalidated.promise;
1960
+ harness.setShouldRespond(true);
1961
+ gate.resolve();
1962
+ await holding;
1963
+ await reacquiring;
1964
+ await failure;
1965
+ expect(bleManager.cancelDeviceConnection).not.toHaveBeenCalled();
1966
+ await expect(transport.call(uuid, 'Ping', {})).resolves.toMatchObject({ type: 'Success' });
1967
+ } finally {
1968
+ gate.resolve();
1969
+ await Promise.allSettled([holding, reacquiring, failure]);
1970
+ await transport.release(uuid, true);
1971
+ }
1972
+ });
1973
+
1068
1974
  test('retains the sequence cursor when a new monitor generation is acquired', async () => {
1069
1975
  const { transport, uuid, sentSeqs } = createHarness();
1070
1976
 
@@ -1117,9 +2023,17 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1117
2023
  test('uses Android 517 MTU and high connection priority during Protocol V2 high-volume calls', async () => {
1118
2024
  setPlatformOS('android');
1119
2025
  const { transport, uuid, device } = createHarness();
2026
+ device.mtu = 23;
2027
+ device.requestMTU.mockImplementationOnce(() => {
2028
+ device.mtu = 517;
2029
+ return Promise.resolve(device);
2030
+ });
1120
2031
 
1121
2032
  await transport.acquire({ uuid, expectedProtocol: 'V2' });
1122
- expect(device.requestMTU).toHaveBeenCalledWith(517);
2033
+ expect(device.requestMTU).toHaveBeenCalledWith(
2034
+ 517,
2035
+ expect.stringContaining(`${uuid}:mtu:connected:0:`)
2036
+ );
1123
2037
 
1124
2038
  await transport.call(uuid, 'FileWrite', {});
1125
2039
  await transport.call(uuid, 'FileWrite', {});