@onekeyfe/hd-transport-react-native 1.2.2-alpha.11 → 1.2.2-alpha.110

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,7 +4,7 @@ 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,
@@ -28,7 +28,7 @@ jest.mock('react-native-ble-plx', () => ({
28
28
  BleError: class BleError extends Error {},
29
29
  BleErrorCode: {
30
30
  DeviceAlreadyConnected: 203,
31
- DeviceDisconnected: 205,
31
+ DeviceDisconnected: 201,
32
32
  DeviceMTUChangeFailed: 206,
33
33
  OperationCancelled: 2,
34
34
  CharacteristicNotFound: 404,
@@ -360,6 +360,186 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
360
360
  expect(new ReactNativeBleTransport({}).scanTimeout).toBe(3000);
361
361
  });
362
362
 
363
+ test.each(['V1', 'V2'] as const)(
364
+ 'waits for Android bonding before connecting the %s GATT link',
365
+ async protocol => {
366
+ setPlatformOS('android');
367
+ const { transport, uuid, device } = protocol === 'V1' ? createV1Harness() : createHarness();
368
+ const operationOrder: string[] = [];
369
+ const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock;
370
+ const bondStateMock = jest.requireMock('../BleManager').onDeviceBondState as jest.Mock;
371
+ const bonding = createDeferred<void>();
372
+
373
+ device.isConnected.mockResolvedValueOnce(false);
374
+ device.connect = jest.fn(() => {
375
+ operationOrder.push('connect');
376
+ return Promise.resolve(device);
377
+ });
378
+ pairDeviceMock.mockImplementationOnce(() => {
379
+ operationOrder.push('bond');
380
+ return Promise.resolve({ bonded: false, bonding: true });
381
+ });
382
+ bondStateMock.mockImplementationOnce(() => bonding.promise);
383
+
384
+ const acquiring = transport.acquire({ uuid, expectedProtocol: protocol });
385
+ await new Promise(resolve => {
386
+ setImmediate(resolve);
387
+ });
388
+ expect(operationOrder).toEqual(['bond']);
389
+ expect(device.connect).not.toHaveBeenCalled();
390
+
391
+ bonding.resolve();
392
+ await expect(acquiring).resolves.toEqual({
393
+ uuid,
394
+ protocolType: protocol,
395
+ });
396
+
397
+ expect(operationOrder).toEqual(['bond', 'connect']);
398
+ await transport.release(uuid, true);
399
+ }
400
+ );
401
+
402
+ test('preserves Android connect errors after bonding when both reconnect attempts fail', async () => {
403
+ setPlatformOS('android');
404
+ const { transport, uuid, device } = createHarness();
405
+ const BleErrorMock = jest.requireMock('react-native-ble-plx').BleError as new (
406
+ message: string
407
+ ) => Error;
408
+ const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock;
409
+ const firstError = Object.assign(new BleErrorMock('MTU change failed'), {
410
+ errorCode: 206,
411
+ reason: 'MTU change failed',
412
+ });
413
+ const fallbackError = Object.assign(new BleErrorMock('GATT connect failed'), {
414
+ errorCode: 205,
415
+ reason: 'GATT connect failed',
416
+ });
417
+
418
+ device.isConnected.mockResolvedValueOnce(false);
419
+ device.connect = jest
420
+ .fn()
421
+ .mockRejectedValueOnce(firstError)
422
+ .mockRejectedValueOnce(fallbackError);
423
+ pairDeviceMock.mockClear();
424
+
425
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
426
+ errorCode: HardwareErrorCode.BleConnectedError,
427
+ });
428
+ expect(device.connect).toHaveBeenCalledTimes(2);
429
+ expect(pairDeviceMock).toHaveBeenCalledTimes(1);
430
+ });
431
+
432
+ test('does not connect Android GATT when the system cannot start bonding', async () => {
433
+ setPlatformOS('android');
434
+ const { transport, uuid, device, bleManager } = createHarness();
435
+ const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock;
436
+
437
+ device.connect = jest.fn().mockResolvedValue(device);
438
+ pairDeviceMock.mockClear();
439
+ pairDeviceMock.mockResolvedValueOnce({ bonded: false, bonding: false });
440
+
441
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
442
+ errorCode: HardwareErrorCode.BleDeviceNotBonded,
443
+ });
444
+ expect(device.connect).not.toHaveBeenCalled();
445
+ expect(bleManager.devices).not.toHaveBeenCalled();
446
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
447
+ });
448
+
449
+ test('checks that Android GATT is connected after bonding and reconnecting', async () => {
450
+ setPlatformOS('android');
451
+ const { transport, uuid, device, bleManager } = createHarness();
452
+ device.isConnected.mockResolvedValueOnce(false).mockResolvedValueOnce(false);
453
+ device.connect = jest.fn().mockResolvedValue(device);
454
+
455
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
456
+ errorCode: HardwareErrorCode.BleConnectedError,
457
+ });
458
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
459
+ expect(device.cancelConnection).toHaveBeenCalledTimes(1);
460
+ });
461
+
462
+ test.each([
463
+ [
464
+ 'ios',
465
+ 'OneKey Neo',
466
+ {
467
+ iosErrorCode: 14,
468
+ reason: 'Peer removed pairing information',
469
+ },
470
+ HardwareErrorCode.BlePeerRemovedPairingInformation,
471
+ ],
472
+ [
473
+ 'android',
474
+ 'OneKey Pro 2',
475
+ {
476
+ androidErrorCode: 5,
477
+ reason: 'Connection state changed with status 5',
478
+ },
479
+ HardwareErrorCode.BleDeviceBondError,
480
+ ],
481
+ ] as const)(
482
+ 'maps a %s %s stale bond during connect before protocol detection',
483
+ async (platform, deviceName, nativeError, errorCode) => {
484
+ setPlatformOS(platform);
485
+ const { transport, uuid, device } = createHarness({ deviceName });
486
+ const BleErrorMock = jest.requireMock('react-native-ble-plx').BleError as new (
487
+ message: string
488
+ ) => Error;
489
+ const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock;
490
+ pairDeviceMock.mockClear();
491
+
492
+ device.isConnected.mockResolvedValueOnce(false);
493
+ device.connect = jest
494
+ .fn()
495
+ .mockRejectedValue(Object.assign(new BleErrorMock(nativeError.reason), nativeError));
496
+
497
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({ errorCode });
498
+ expect(pairDeviceMock).toHaveBeenCalledTimes(platform === 'android' ? 1 : 0);
499
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
500
+ }
501
+ );
502
+
503
+ test('cleans up Android bonding failures without starting GATT', async () => {
504
+ setPlatformOS('android');
505
+ const { transport, uuid, device, bleManager } = createHarness();
506
+ const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock;
507
+
508
+ pairDeviceMock.mockRejectedValueOnce(new Error('bonding canceled'));
509
+
510
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toThrow(
511
+ 'bonding canceled'
512
+ );
513
+
514
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
515
+ expect(bleManager.devices).not.toHaveBeenCalled();
516
+ expect(device.isConnected).not.toHaveBeenCalled();
517
+ });
518
+
519
+ test('does not connect after stop while Android bonding is pending', async () => {
520
+ setPlatformOS('android');
521
+ const { transport, uuid, bleManager } = createHarness();
522
+ const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock;
523
+ const bondStateMock = jest.requireMock('../BleManager').onDeviceBondState as jest.Mock;
524
+ const bonding = createDeferred<void>();
525
+ pairDeviceMock.mockResolvedValueOnce({ bonded: false, bonding: true });
526
+ bondStateMock.mockImplementationOnce(() => bonding.promise);
527
+
528
+ const acquiring = transport.acquire({ uuid, expectedProtocol: 'V2' });
529
+ const rejection = expect(acquiring).rejects.toMatchObject({
530
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
531
+ });
532
+ await new Promise(resolve => {
533
+ setImmediate(resolve);
534
+ });
535
+ const stopping = transport.stop();
536
+ bonding.resolve();
537
+
538
+ await rejection;
539
+ await stopping;
540
+ expect(bleManager.devices).not.toHaveBeenCalled();
541
+ });
542
+
363
543
  test('uses withResponse for consecutive iOS Protocol V1 control commands without releasing', async () => {
364
544
  const { transport, uuid, writeCharacteristic } = createV1Harness({
365
545
  respondOnWriteCount: [1, 2],
@@ -702,6 +882,116 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
702
882
  BLE_NATIVE_TEARDOWN_TIMEOUT_MS + 5_000
703
883
  );
704
884
 
885
+ test('preserves the unpaired error when iOS disconnects during the first V1 probe', async () => {
886
+ const { transport, uuid, writeCharacteristic } = createHarness({ deviceName: 'Neo Test' });
887
+ writeCharacteristic.writeWithResponse.mockRejectedValueOnce({
888
+ errorCode: 201,
889
+ iosErrorCode: 7,
890
+ reason: 'The specified device has disconnected from us.',
891
+ });
892
+ const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2');
893
+
894
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
895
+ errorCode: HardwareErrorCode.BleDeviceNotBonded,
896
+ });
897
+
898
+ expect(probeProtocolV2).not.toHaveBeenCalled();
899
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
900
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
901
+ });
902
+
903
+ test('preserves a native iOS disconnect during an expected Protocol V2 probe', async () => {
904
+ const { transport, uuid, writeCharacteristic, bleManager } = createHarness({
905
+ deviceName: 'Neo Test',
906
+ });
907
+ const nativeDisconnect = {
908
+ errorCode: 201,
909
+ iosErrorCode: 7,
910
+ reason: 'The specified device has disconnected from us.',
911
+ };
912
+ writeCharacteristic.writeWithoutResponse.mockRejectedValueOnce(nativeDisconnect);
913
+ const probeProtocolV1 = jest.spyOn(transport as any, 'probeProtocolV1');
914
+
915
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
916
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
917
+ });
918
+
919
+ expect(probeProtocolV1).not.toHaveBeenCalled();
920
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
921
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
922
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
923
+ });
924
+
925
+ test('preserves a native iOS disconnect during a V2-first probe without falling back to V1', async () => {
926
+ const { transport, uuid, writeCharacteristic } = createHarness({ deviceName: 'Neo Test' });
927
+ writeCharacteristic.writeWithoutResponse.mockRejectedValueOnce({
928
+ errorCode: 201,
929
+ iosErrorCode: 7,
930
+ reason: 'The specified device has disconnected from us.',
931
+ });
932
+ const probeProtocolV1 = jest.spyOn(transport as any, 'probeProtocolV1');
933
+
934
+ await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toMatchObject({
935
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
936
+ });
937
+
938
+ expect(probeProtocolV1).not.toHaveBeenCalled();
939
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
940
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
941
+ });
942
+
943
+ test.each(['V1', 'V2'] as const)(
944
+ 'preserves terminal BLE failures from the %s probe without trying another protocol',
945
+ async protocol => {
946
+ for (const errorCode of [
947
+ HardwareErrorCode.BleDeviceNotBonded,
948
+ HardwareErrorCode.BleDeviceBondedCanceled,
949
+ HardwareErrorCode.BlePeerRemovedPairingInformation,
950
+ HardwareErrorCode.BleDeviceDisconnected,
951
+ HardwareErrorCode.BleCharacteristicNotifyError,
952
+ HardwareErrorCode.BleCharacteristicNotifyChangeFailure,
953
+ HardwareErrorCode.BleWriteCharacteristicError,
954
+ ]) {
955
+ const { transport, uuid } = createHarness();
956
+ const error = ERRORS.TypedError(errorCode);
957
+ const call = jest
958
+ .spyOn(transport as any, protocol === 'V1' ? 'callProtocolV1' : 'callProtocolV2')
959
+ .mockRejectedValue(error);
960
+ const otherProbe = jest.spyOn(
961
+ transport as any,
962
+ protocol === 'V1' ? 'probeProtocolV2' : 'probeProtocolV1'
963
+ );
964
+
965
+ await expect(transport.acquire({ uuid, protocolHint: protocol })).rejects.toBe(error);
966
+
967
+ expect(call).toHaveBeenCalledTimes(1);
968
+ expect(otherProbe).not.toHaveBeenCalled();
969
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
970
+ }
971
+ }
972
+ );
973
+
974
+ test.each(['V1', 'V2'] as const)(
975
+ 'still falls back after a silent %s probe timeout',
976
+ async protocol => {
977
+ const { transport, uuid } = createHarness();
978
+ jest
979
+ .spyOn(transport as any, protocol === 'V1' ? 'callProtocolV1' : 'callProtocolV2')
980
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.BleTimeoutError));
981
+ const otherProbe = jest
982
+ .spyOn(transport as any, protocol === 'V1' ? 'probeProtocolV2' : 'probeProtocolV1')
983
+ .mockResolvedValue(true);
984
+
985
+ await expect(transport.acquire({ uuid, protocolHint: protocol })).resolves.toEqual({
986
+ uuid,
987
+ protocolType: protocol === 'V1' ? 'V2' : 'V1',
988
+ });
989
+
990
+ expect(otherProbe).toHaveBeenCalledTimes(1);
991
+ await transport.release(uuid, true);
992
+ }
993
+ );
994
+
705
995
  test('falls back to the other active probe on iOS when protocol metadata is absent', async () => {
706
996
  const { transport, uuid } = createHarness({ deviceName: 'OneKey' });
707
997
  const probeProtocolV1 = jest
@@ -56,6 +56,34 @@ function createHarness() {
56
56
  }
57
57
 
58
58
  describe('Protocol V1 stale call timeout', () => {
59
+ test('settles a cancelled read and waits for native teardown before completing cancel', async () => {
60
+ const { t, disconnectSpy } = createHarness();
61
+ let finishDisconnect!: () => void;
62
+ disconnectSpy.mockImplementationOnce(
63
+ () =>
64
+ new Promise<void>(resolve => {
65
+ finishDisconnect = resolve;
66
+ })
67
+ );
68
+ const call = t.call(UUID, 'Initialize', {}, { timeoutMs: 25000 });
69
+ const result = call.catch(error => error);
70
+ await flush();
71
+ let cancelled = false;
72
+ const cleanup = t.cancel().then(() => {
73
+ cancelled = true;
74
+ });
75
+ await flush();
76
+ expect(await result).toMatchObject({ errorCode: HardwareErrorCode.CallQueueActionCancelled });
77
+ expect(t.runPromise).toBeNull();
78
+ expect(disconnectSpy).toHaveBeenCalledWith(UUID);
79
+ expect(cancelled).toBe(false);
80
+ finishDisconnect();
81
+ await cleanup;
82
+ jest.advanceTimersByTime(25000);
83
+ await flush();
84
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
85
+ });
86
+
59
87
  beforeAll(() => {
60
88
  jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] });
61
89
  });
@@ -172,24 +200,25 @@ describe('Protocol V1 stale call timeout', () => {
172
200
  expect(secondErrors).toHaveLength(1);
173
201
  });
174
202
 
175
- test('orphan timer left behind by cancel() must not disconnect the transport', async () => {
203
+ test('a cancelled read cannot disconnect a later transport when its old deadline passes', async () => {
176
204
  const { t, disconnectSpy } = createHarness();
177
205
 
178
- // cancel() nulls the ownership slot without settling the deferred, so the
179
- // call's response timer stays armed (reachable via DeviceCommands.dispose).
180
206
  const errors: Array<{ errorCode?: unknown }> = [];
181
207
  const p = t.call(UUID, 'GetFeatures', {}, { timeoutMs: 5000 });
182
208
  p.catch(e => errors.push(e));
183
209
  await flush();
184
210
 
185
- t.cancel();
211
+ await t.cancel();
212
+ await flush();
213
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
214
+ disconnectSpy.mockClear();
186
215
 
187
216
  jest.advanceTimersByTime(5000);
188
217
  await flush();
189
218
 
190
219
  expect(disconnectSpy).not.toHaveBeenCalled();
191
220
  expect(errors).toHaveLength(1);
192
- expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleTimeoutError);
221
+ expect(errors[0]?.errorCode).toBe(HardwareErrorCode.CallQueueActionCancelled);
193
222
  });
194
223
 
195
224
  test('timeout on the active call still disconnects the transport', async () => {
@@ -0,0 +1,40 @@
1
+ import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+
3
+ const BLE_PLX_DEVICE_DISCONNECTED = 201;
4
+ const IOS_PERIPHERAL_DISCONNECTED = 7;
5
+
6
+ type NativeBleErrorFields = {
7
+ errorCode?: unknown;
8
+ iosErrorCode?: unknown;
9
+ reason?: unknown;
10
+ message?: unknown;
11
+ };
12
+
13
+ const nativeErrorText = (error: NativeBleErrorFields) =>
14
+ [error.reason, error.message]
15
+ .filter((value): value is string => typeof value === 'string')
16
+ .join(' ');
17
+
18
+ export const isNativeBleDisconnectError = (error: unknown): boolean => {
19
+ if (!error || typeof error !== 'object') {
20
+ return false;
21
+ }
22
+
23
+ const nativeError = error as NativeBleErrorFields;
24
+ return (
25
+ nativeError.errorCode === BLE_PLX_DEVICE_DISCONNECTED ||
26
+ nativeError.iosErrorCode === IOS_PERIPHERAL_DISCONNECTED
27
+ );
28
+ };
29
+
30
+ export const toBleDisconnectHardwareError = (error: unknown) => {
31
+ if ((error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected) {
32
+ return error as Error;
33
+ }
34
+
35
+ const nativeError = (error ?? {}) as NativeBleErrorFields;
36
+ return ERRORS.TypedError(
37
+ HardwareErrorCode.BleDeviceDisconnected,
38
+ nativeErrorText(nativeError) || undefined
39
+ );
40
+ };
@@ -13,6 +13,7 @@ const IOS_PEER_REMOVED_PAIRING_INFORMATION = 14;
13
13
 
14
14
  type NativeBleErrorFields = {
15
15
  attErrorCode?: unknown;
16
+ androidErrorCode?: unknown;
16
17
  iosErrorCode?: unknown;
17
18
  reason?: unknown;
18
19
  message?: unknown;
@@ -32,6 +33,8 @@ export const isNativeBleStaleBondError = (error: unknown): boolean => {
32
33
  if (
33
34
  nativeError.attErrorCode === ATT_INSUFFICIENT_AUTHENTICATION ||
34
35
  nativeError.attErrorCode === ATT_INSUFFICIENT_ENCRYPTION ||
36
+ nativeError.androidErrorCode === ATT_INSUFFICIENT_AUTHENTICATION ||
37
+ nativeError.androidErrorCode === ATT_INSUFFICIENT_ENCRYPTION ||
35
38
  nativeError.iosErrorCode === IOS_PEER_REMOVED_PAIRING_INFORMATION
36
39
  ) {
37
40
  return true;