@onekeyfe/hd-transport-react-native 1.2.2-alpha.104 → 1.2.2-alpha.106

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.
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from 'events';
2
- import { BleErrorCode } from 'react-native-ble-plx';
3
- import { HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+ import { BleErrorCode, BleManager } from 'react-native-ble-plx';
3
+ import { HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
4
4
 
5
5
  import ReactNativeBleTransport, {
6
6
  BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD,
@@ -131,6 +131,59 @@ describe('BLE connect timeout', () => {
131
131
  jest.restoreAllMocks();
132
132
  });
133
133
 
134
+ test('waits for singleton destruction before creating the next manager', async () => {
135
+ const { transport, bleManager } = createHarness(() => Promise.resolve());
136
+ const destruction = createDeferred<void>();
137
+ Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) });
138
+ const createManager = jest.mocked(BleManager);
139
+ createManager.mockClear();
140
+ (transport as unknown as { resetPlxManager(): void }).resetPlxManager();
141
+ const first = transport.getPlxManager();
142
+ const second = transport.getPlxManager();
143
+ await flush();
144
+ expect(createManager).not.toHaveBeenCalled();
145
+ destruction.resolve();
146
+ expect(await first).toBe(await second);
147
+ expect(createManager).toHaveBeenCalledTimes(1);
148
+ });
149
+
150
+ test('does not reuse a manager after asynchronous destruction fails', async () => {
151
+ const { bleManager } = createHarness(() => Promise.resolve());
152
+ let transport!: ReactNativeBleTransport;
153
+ jest.isolateModules(() => {
154
+ const { default: Transport } = jest.requireActual<typeof import('../index')>('../index');
155
+ transport = new Transport({});
156
+ });
157
+ transport.blePlxManager = bleManager as never;
158
+ const destruction = createDeferred<void>();
159
+ Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) });
160
+ (transport as unknown as { resetPlxManager(): void }).resetPlxManager();
161
+ const result = transport.getPlxManager();
162
+ const failure = expect(result).rejects.toMatchObject({
163
+ errorCode: HardwareErrorCode.PollingTimeout,
164
+ });
165
+ destruction.reject(new Error('Native destruction failed'));
166
+ await failure;
167
+ });
168
+
169
+ test('bounds reset waiting without letting a new transport bypass unfinished destruction', async () => {
170
+ const { transport, bleManager } = createHarness(() => Promise.resolve());
171
+ const destruction = createDeferred<void>();
172
+ Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) });
173
+ const createManager = jest.mocked(BleManager);
174
+ createManager.mockClear();
175
+ (transport as unknown as { resetPlxManager(): void }).resetPlxManager();
176
+ const nextTransport = new ReactNativeBleTransport({});
177
+ const result = nextTransport.getPlxManager().catch(error => error);
178
+ await flush();
179
+ jest.advanceTimersByTime(BLE_CONNECT_TIMEOUT_MS);
180
+ await expect(result).resolves.toMatchObject({ errorCode: HardwareErrorCode.PollingTimeout });
181
+ expect(createManager).not.toHaveBeenCalled();
182
+ destruction.resolve();
183
+ await nextTransport.getPlxManager();
184
+ expect(createManager).toHaveBeenCalledTimes(1);
185
+ });
186
+
134
187
  test('a native connect that never settles is bounded instead of blocking forever', async () => {
135
188
  // iOS applies its own connect timeout on a serial queue; when that queue is busy
136
189
  // the timeout never fires and acquire() blocks until the app-level 60s timeout.
@@ -155,6 +208,61 @@ describe('BLE connect timeout', () => {
155
208
  expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleConnectedError);
156
209
  });
157
210
 
211
+ test('stop drains its scan and removes the timer before another transport scans', async () => {
212
+ const { transport, bleManager } = createHarness(() => Promise.resolve());
213
+ const nativeStop = createDeferred<void>();
214
+ bleManager.stopDeviceScan.mockImplementation(() => nativeStop.promise);
215
+ const scanned = transport.enumerate().catch(error => error);
216
+ await flush();
217
+ await flush();
218
+ expect(bleManager.startDeviceScan).toHaveBeenCalledTimes(1);
219
+ const stopping = transport.stop();
220
+ expect(transport.stop()).toBe(stopping);
221
+ let stopped = false;
222
+ stopping.then(() => {
223
+ stopped = true;
224
+ });
225
+ await flush();
226
+ expect(stopped).toBe(false);
227
+ nativeStop.resolve();
228
+ await stopping;
229
+ await expect(scanned).resolves.toMatchObject({
230
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
231
+ });
232
+ jest.advanceTimersByTime(transport.scanTimeout);
233
+ await flush();
234
+ expect(bleManager.stopDeviceScan).toHaveBeenCalledTimes(1);
235
+ await expect(transport.getPlxManager()).rejects.toMatchObject({
236
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
237
+ });
238
+ });
239
+
240
+ test('stop rejects a pending read and waits for native disconnection without destroying the shared manager', async () => {
241
+ const { transport, bleManager } = createHarness(() => Promise.resolve());
242
+ const nativeDisconnect = createDeferred<void>();
243
+ bleManager.cancelDeviceConnection.mockImplementation(() => nativeDisconnect.promise);
244
+ const destroy = jest.fn();
245
+ Object.assign(bleManager, { destroy });
246
+ const read = createDeferred<void>();
247
+ transport.runPromise = read;
248
+ Object.assign(transport, { runPromiseDeviceId: UUID });
249
+ const readResult = read.promise.catch(error => error);
250
+ let stopped = false;
251
+ const stopping = transport.stop().then(() => {
252
+ stopped = true;
253
+ });
254
+ await flush();
255
+ await expect(readResult).resolves.toMatchObject({
256
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
257
+ });
258
+ expect(stopped).toBe(false);
259
+ nativeDisconnect.resolve();
260
+ await advanceUntil(() => stopped, 1000);
261
+ await stopping;
262
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID);
263
+ expect(destroy).not.toHaveBeenCalled();
264
+ });
265
+
158
266
  test('the connect budget leaves generous headroom over a healthy connect', () => {
159
267
  // Healthy connects finish in ~2-3s (the native budget is 3s); this backstop only
160
268
  // fires when the native timeout itself fails to.
@@ -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,
@@ -830,6 +830,116 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
830
830
  BLE_NATIVE_TEARDOWN_TIMEOUT_MS + 5_000
831
831
  );
832
832
 
833
+ test('preserves the unpaired error when iOS disconnects during the first V1 probe', async () => {
834
+ const { transport, uuid, writeCharacteristic } = createHarness({ deviceName: 'Neo Test' });
835
+ writeCharacteristic.writeWithResponse.mockRejectedValueOnce({
836
+ errorCode: 201,
837
+ iosErrorCode: 7,
838
+ reason: 'The specified device has disconnected from us.',
839
+ });
840
+ const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2');
841
+
842
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
843
+ errorCode: HardwareErrorCode.BleDeviceNotBonded,
844
+ });
845
+
846
+ expect(probeProtocolV2).not.toHaveBeenCalled();
847
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
848
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
849
+ });
850
+
851
+ test('preserves a native iOS disconnect during an expected Protocol V2 probe', async () => {
852
+ const { transport, uuid, writeCharacteristic, bleManager } = createHarness({
853
+ deviceName: 'Neo Test',
854
+ });
855
+ const nativeDisconnect = {
856
+ errorCode: 201,
857
+ iosErrorCode: 7,
858
+ reason: 'The specified device has disconnected from us.',
859
+ };
860
+ writeCharacteristic.writeWithoutResponse.mockRejectedValueOnce(nativeDisconnect);
861
+ const probeProtocolV1 = jest.spyOn(transport as any, 'probeProtocolV1');
862
+
863
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
864
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
865
+ });
866
+
867
+ expect(probeProtocolV1).not.toHaveBeenCalled();
868
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
869
+ expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
870
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
871
+ });
872
+
873
+ test('preserves a native iOS disconnect during a V2-first probe without falling back to V1', async () => {
874
+ const { transport, uuid, writeCharacteristic } = createHarness({ deviceName: 'Neo Test' });
875
+ writeCharacteristic.writeWithoutResponse.mockRejectedValueOnce({
876
+ errorCode: 201,
877
+ iosErrorCode: 7,
878
+ reason: 'The specified device has disconnected from us.',
879
+ });
880
+ const probeProtocolV1 = jest.spyOn(transport as any, 'probeProtocolV1');
881
+
882
+ await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toMatchObject({
883
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
884
+ });
885
+
886
+ expect(probeProtocolV1).not.toHaveBeenCalled();
887
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
888
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
889
+ });
890
+
891
+ test.each(['V1', 'V2'] as const)(
892
+ 'preserves terminal BLE failures from the %s probe without trying another protocol',
893
+ async protocol => {
894
+ for (const errorCode of [
895
+ HardwareErrorCode.BleDeviceNotBonded,
896
+ HardwareErrorCode.BleDeviceBondedCanceled,
897
+ HardwareErrorCode.BlePeerRemovedPairingInformation,
898
+ HardwareErrorCode.BleDeviceDisconnected,
899
+ HardwareErrorCode.BleCharacteristicNotifyError,
900
+ HardwareErrorCode.BleCharacteristicNotifyChangeFailure,
901
+ HardwareErrorCode.BleWriteCharacteristicError,
902
+ ]) {
903
+ const { transport, uuid } = createHarness();
904
+ const error = ERRORS.TypedError(errorCode);
905
+ const call = jest
906
+ .spyOn(transport as any, protocol === 'V1' ? 'callProtocolV1' : 'callProtocolV2')
907
+ .mockRejectedValue(error);
908
+ const otherProbe = jest.spyOn(
909
+ transport as any,
910
+ protocol === 'V1' ? 'probeProtocolV2' : 'probeProtocolV1'
911
+ );
912
+
913
+ await expect(transport.acquire({ uuid, protocolHint: protocol })).rejects.toBe(error);
914
+
915
+ expect(call).toHaveBeenCalledTimes(1);
916
+ expect(otherProbe).not.toHaveBeenCalled();
917
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
918
+ }
919
+ }
920
+ );
921
+
922
+ test.each(['V1', 'V2'] as const)(
923
+ 'still falls back after a silent %s probe timeout',
924
+ async protocol => {
925
+ const { transport, uuid } = createHarness();
926
+ jest
927
+ .spyOn(transport as any, protocol === 'V1' ? 'callProtocolV1' : 'callProtocolV2')
928
+ .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.BleTimeoutError));
929
+ const otherProbe = jest
930
+ .spyOn(transport as any, protocol === 'V1' ? 'probeProtocolV2' : 'probeProtocolV1')
931
+ .mockResolvedValue(true);
932
+
933
+ await expect(transport.acquire({ uuid, protocolHint: protocol })).resolves.toEqual({
934
+ uuid,
935
+ protocolType: protocol === 'V1' ? 'V2' : 'V1',
936
+ });
937
+
938
+ expect(otherProbe).toHaveBeenCalledTimes(1);
939
+ await transport.release(uuid, true);
940
+ }
941
+ );
942
+
833
943
  test('falls back to the other active probe on iOS when protocol metadata is absent', async () => {
834
944
  const { transport, uuid } = createHarness({ deviceName: 'OneKey' });
835
945
  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
+ };