@onekeyfe/hd-transport-react-native 1.2.0-alpha.6 → 1.2.0-alpha.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,527 @@
1
+ import { EventEmitter } from 'events';
2
+ import transportPackage, {
3
+ PROTOCOL_V2_CHANNEL_BLE_UART,
4
+ ProtocolV2,
5
+ TRANSPORT_EVENT,
6
+ bytesToHex,
7
+ } from '@onekeyfe/hd-transport';
8
+ import { HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
9
+
10
+ import ReactNativeBleTransport, {
11
+ configureProtocolV2BleTuning,
12
+ getFirmwareUploadWriteRetryType,
13
+ resetProtocolV2BleTuning,
14
+ } from '../index';
15
+
16
+ jest.mock(
17
+ 'react-native',
18
+ () => ({
19
+ PermissionsAndroid: {},
20
+ Platform: { OS: 'ios' },
21
+ }),
22
+ { virtual: true }
23
+ );
24
+
25
+ jest.mock('react-native-ble-plx', () => ({
26
+ BleATTErrorCode: { UnlikelyError: 14 },
27
+ BleError: class BleError extends Error {},
28
+ BleErrorCode: {
29
+ DeviceAlreadyConnected: 203,
30
+ DeviceDisconnected: 205,
31
+ DeviceMTUChangeFailed: 206,
32
+ OperationCancelled: 2,
33
+ CharacteristicNotFound: 404,
34
+ },
35
+ BleManager: jest.fn(),
36
+ ScanMode: { LowLatency: 2 },
37
+ }));
38
+
39
+ jest.mock('../BleManager', () => ({
40
+ getConnectedDeviceIds: jest.fn(() => Promise.resolve([])),
41
+ onDeviceBondState: jest.fn(() => Promise.resolve()),
42
+ pairDevice: jest.fn(() => Promise.resolve({ bonded: true, bonding: false })),
43
+ }));
44
+
45
+ jest.mock('../subscribeBleOn', () => ({
46
+ subscribeBleOn: jest.fn(() => Promise.resolve()),
47
+ }));
48
+
49
+ const { parseConfigure } = transportPackage;
50
+
51
+ const protocolV1Schema = {
52
+ nested: {
53
+ Initialize: { fields: {} },
54
+ GetFeatures: { fields: {} },
55
+ Success: {
56
+ fields: {
57
+ message: { type: 'string', id: 1 },
58
+ },
59
+ },
60
+ MessageType: {
61
+ values: {
62
+ MessageType_Initialize: 1,
63
+ MessageType_Success: 2,
64
+ MessageType_GetFeatures: 55,
65
+ },
66
+ },
67
+ },
68
+ };
69
+
70
+ const protocolV2Schema = {
71
+ nested: {
72
+ Ping: {
73
+ fields: {
74
+ message: { type: 'string', id: 1 },
75
+ },
76
+ },
77
+ FileWrite: { fields: {} },
78
+ Success: {
79
+ fields: {
80
+ message: { type: 'string', id: 1 },
81
+ },
82
+ },
83
+ MessageType: {
84
+ values: {
85
+ MessageType_Ping: 60206,
86
+ MessageType_Success: 60207,
87
+ MessageType_FileWrite: 60805,
88
+ },
89
+ },
90
+ },
91
+ };
92
+
93
+ const schemas = {
94
+ protocolV1: parseConfigure(protocolV1Schema),
95
+ protocolV2: parseConfigure(protocolV2Schema),
96
+ };
97
+
98
+ const createHarness = () => {
99
+ const uuid = 'rn-pro2-id';
100
+ const sentSeqs: number[] = [];
101
+ let responseSeq = 0;
102
+ let shouldRespond = true;
103
+ let notifyCallback:
104
+ | ((
105
+ error: (Error & { reason?: string }) | null,
106
+ characteristic: { value: string } | null
107
+ ) => void)
108
+ | undefined;
109
+ let disconnectCallback: (() => void) | undefined;
110
+ const notifyCharacteristic = {
111
+ uuid: '0003',
112
+ deviceID: uuid,
113
+ isNotifiable: true,
114
+ monitor: jest.fn(callback => {
115
+ notifyCallback = callback;
116
+ return { remove: jest.fn() };
117
+ }),
118
+ };
119
+ const handleWrite = (base64: string) => {
120
+ const frame = Buffer.from(base64, 'base64');
121
+ sentSeqs.push(frame[6]);
122
+ if (shouldRespond) {
123
+ responseSeq += 1;
124
+ const response = ProtocolV2.encodeFrame(
125
+ schemas,
126
+ 'Success',
127
+ { message: 'ok' },
128
+ { router: PROTOCOL_V2_CHANNEL_BLE_UART, seq: responseSeq }
129
+ );
130
+ notifyCallback?.(null, { value: Buffer.from(response).toString('base64') });
131
+ }
132
+ return Promise.resolve();
133
+ };
134
+ const writeCharacteristic = {
135
+ uuid: '0002',
136
+ deviceID: uuid,
137
+ isWritableWithResponse: true,
138
+ isWritableWithoutResponse: true,
139
+ writeWithResponse: jest.fn(handleWrite),
140
+ writeWithoutResponse: jest.fn(handleWrite),
141
+ };
142
+ const device = {
143
+ id: uuid,
144
+ name: 'OneKey Pro 2',
145
+ localName: 'OneKey Pro 2',
146
+ serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
147
+ isConnected: jest.fn(() => Promise.resolve(true)),
148
+ cancelConnection: jest.fn(() => Promise.resolve()),
149
+ onDisconnected: jest.fn(callback => {
150
+ disconnectCallback = callback;
151
+ return { remove: jest.fn() };
152
+ }),
153
+ };
154
+ const bleManager = {
155
+ devices: jest.fn(() => Promise.resolve([device])),
156
+ connectedDevices: jest.fn(() => Promise.resolve([])),
157
+ cancelTransaction: jest.fn(() => Promise.resolve()),
158
+ };
159
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
160
+ const emitter = new EventEmitter();
161
+ transport.blePlxManager = bleManager;
162
+ transport.resolveCharacteristics = jest.fn(() =>
163
+ Promise.resolve({ writeCharacteristic, notifyCharacteristic })
164
+ );
165
+ transport.init({ debug: jest.fn(), error: jest.fn() }, emitter);
166
+ transport.configure(protocolV1Schema);
167
+ transport.configureProtocolV2(protocolV2Schema);
168
+
169
+ return {
170
+ transport,
171
+ emitter,
172
+ uuid,
173
+ sentSeqs,
174
+ writeCharacteristic,
175
+ setShouldRespond(value: boolean) {
176
+ shouldRespond = value;
177
+ },
178
+ emitMonitorError(error: Error & { reason?: string }) {
179
+ notifyCallback?.(error, null);
180
+ },
181
+ emitDisconnect() {
182
+ disconnectCallback?.();
183
+ },
184
+ };
185
+ };
186
+
187
+ const createV1Harness = () => {
188
+ const uuid = 'rn-classic-id';
189
+ const notifySubscriptionRemovers: jest.Mock[] = [];
190
+ const disconnectSubscriptionRemovers: jest.Mock[] = [];
191
+ let notifyCallback:
192
+ | ((error: Error | null, characteristic: { value: string } | null) => void)
193
+ | undefined;
194
+ const notifyCharacteristic = {
195
+ uuid: '0003',
196
+ deviceID: uuid,
197
+ isNotifiable: true,
198
+ monitor: jest.fn(callback => {
199
+ notifyCallback = callback;
200
+ const remove = jest.fn();
201
+ notifySubscriptionRemovers.push(remove);
202
+ return { remove };
203
+ }),
204
+ };
205
+ let writeCount = 0;
206
+ const writeCharacteristic = {
207
+ uuid: '0002',
208
+ deviceID: uuid,
209
+ isWritableWithResponse: true,
210
+ isWritableWithoutResponse: true,
211
+ writeWithoutResponse: jest.fn(() => {
212
+ writeCount += 1;
213
+ if (writeCount === 1) {
214
+ notifyCallback?.(null, {
215
+ value: Buffer.from('3f23230002000000040a026f6b', 'hex').toString('base64'),
216
+ });
217
+ }
218
+ return Promise.resolve();
219
+ }),
220
+ };
221
+ const device = {
222
+ id: uuid,
223
+ name: 'OneKey Classic',
224
+ localName: 'OneKey Classic',
225
+ serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
226
+ isConnected: jest.fn(() => Promise.resolve(true)),
227
+ cancelConnection: jest.fn(() => Promise.resolve()),
228
+ onDisconnected: jest.fn(() => {
229
+ const remove = jest.fn();
230
+ disconnectSubscriptionRemovers.push(remove);
231
+ return { remove };
232
+ }),
233
+ };
234
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
235
+ const bleManager = {
236
+ devices: jest.fn(() => Promise.resolve([device])),
237
+ connectedDevices: jest.fn(() => Promise.resolve([])),
238
+ cancelTransaction: jest.fn(() => Promise.resolve()),
239
+ };
240
+ transport.blePlxManager = bleManager as any;
241
+ transport.resolveCharacteristics = jest.fn(() =>
242
+ Promise.resolve({ writeCharacteristic, notifyCharacteristic })
243
+ );
244
+ transport.init({ debug: jest.fn(), error: jest.fn() }, new EventEmitter());
245
+ transport.configure(protocolV1Schema);
246
+ transport.configureProtocolV2(protocolV2Schema);
247
+ return {
248
+ transport,
249
+ uuid,
250
+ device,
251
+ bleManager,
252
+ notifySubscriptionRemovers,
253
+ disconnectSubscriptionRemovers,
254
+ };
255
+ };
256
+
257
+ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
258
+ test('does not classify disconnects as retryable firmware writes', () => {
259
+ expect(
260
+ getFirmwareUploadWriteRetryType({
261
+ errorCode: 205,
262
+ message: 'Device disconnected after write',
263
+ })
264
+ ).toBeNull();
265
+ });
266
+
267
+ test('keeps another device reader when releasing a device with an active V1 call', async () => {
268
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
269
+ const activeV1Call = createDeferred<string>();
270
+ const otherDeviceReader = createDeferred<Uint8Array>();
271
+ activeV1Call.promise.catch(() => undefined);
272
+ otherDeviceReader.promise.catch(() => undefined);
273
+ transport.runPromise = activeV1Call;
274
+ transport.runPromiseDeviceId = 'device-a';
275
+ transport.protocolV2FramePromises.set('device-b', otherDeviceReader);
276
+
277
+ await transport.releaseNative('device-a', true);
278
+
279
+ expect(transport.protocolV2FramePromises.get('device-b')).toBe(otherDeviceReader);
280
+ });
281
+
282
+ test('rejects a pending reader when its device frame state resets', async () => {
283
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
284
+ const reader = createDeferred<Uint8Array>();
285
+ transport.protocolV2FramePromises.set('device-a', reader);
286
+ const result = Promise.race([
287
+ reader.promise.then(
288
+ () => 'resolved',
289
+ () => 'rejected'
290
+ ),
291
+ new Promise(resolve => {
292
+ setTimeout(() => resolve('pending'), 20);
293
+ }),
294
+ ]);
295
+
296
+ transport.resetProtocolV2Frames('device-a');
297
+
298
+ await expect(result).resolves.toBe('rejected');
299
+ });
300
+
301
+ test('keeps the legacy default BLE scan timeout', () => {
302
+ expect(new ReactNativeBleTransport({}).scanTimeout).toBe(3000);
303
+ });
304
+
305
+ test('reconnects before falling back to Protocol V1 after a fatal V2 probe failure', async () => {
306
+ const { transport, uuid, device, notifySubscriptionRemovers, disconnectSubscriptionRemovers } =
307
+ createV1Harness();
308
+ const probeProtocolV2 = jest
309
+ .spyOn(transport as any, 'probeProtocolV2')
310
+ .mockImplementationOnce(async () => {
311
+ await (transport as any).releaseNative(uuid, true);
312
+ return false;
313
+ });
314
+ const resolveCharacteristics = jest.spyOn(transport as any, 'resolveCharacteristics');
315
+
316
+ await expect(transport.acquire({ uuid, protocolHint: 'V2' })).resolves.toEqual({
317
+ uuid,
318
+ protocolType: 'V1',
319
+ });
320
+
321
+ expect(probeProtocolV2).toHaveBeenCalledTimes(1);
322
+ expect(resolveCharacteristics).toHaveBeenCalledTimes(2);
323
+ expect(transport.getProtocolType(uuid)).toBe('V1');
324
+ expect(device.onDisconnected).toHaveBeenCalledTimes(1);
325
+ expect(notifySubscriptionRemovers).toHaveLength(2);
326
+ expect(notifySubscriptionRemovers[0]).toHaveBeenCalledTimes(1);
327
+
328
+ await transport.release(uuid, true);
329
+
330
+ expect(notifySubscriptionRemovers[1]).toHaveBeenCalledTimes(1);
331
+ expect(disconnectSubscriptionRemovers).toHaveLength(1);
332
+ expect(disconnectSubscriptionRemovers[0]).toHaveBeenCalledTimes(1);
333
+ });
334
+
335
+ test('cleans the rebuilt transport when Protocol V1 fallback also fails', async () => {
336
+ const { transport, uuid, device, bleManager, notifySubscriptionRemovers } = createV1Harness();
337
+ jest.spyOn(transport as any, 'probeProtocolV2').mockImplementationOnce(async () => {
338
+ await (transport as any).releaseNative(uuid, true);
339
+ return false;
340
+ });
341
+ jest.spyOn(transport as any, 'probeProtocolV1').mockResolvedValue(false);
342
+
343
+ await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toMatchObject({
344
+ errorCode: HardwareErrorCode.BleTimeoutError,
345
+ });
346
+
347
+ expect(device.onDisconnected).not.toHaveBeenCalled();
348
+ expect(notifySubscriptionRemovers).toHaveLength(2);
349
+ expect(notifySubscriptionRemovers[0]).toHaveBeenCalledTimes(1);
350
+ expect(notifySubscriptionRemovers[1]).toHaveBeenCalledTimes(1);
351
+ expect(bleManager.cancelTransaction).toHaveBeenCalled();
352
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
353
+ });
354
+
355
+ test('disconnects and invalidates a Protocol V1 link after a response timeout', async () => {
356
+ const { transport, uuid, device } = createV1Harness();
357
+
358
+ await transport.acquire({ uuid, expectedProtocol: 'V1' });
359
+ await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 5 })).rejects.toMatchObject({
360
+ errorCode: HardwareErrorCode.BleTimeoutError,
361
+ });
362
+
363
+ expect(device.cancelConnection).toHaveBeenCalled();
364
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
365
+ });
366
+
367
+ afterEach(() => {
368
+ resetProtocolV2BleTuning();
369
+ });
370
+
371
+ test('keeps the Protocol V2 sequence across probe and the next call', async () => {
372
+ const { transport, uuid, sentSeqs } = createHarness();
373
+
374
+ await transport.acquire({ uuid });
375
+ await transport.call(uuid, 'Ping', { message: 'after-probe' });
376
+
377
+ expect(sentSeqs).toEqual([1, 2]);
378
+ expect(bytesToHex(new Uint8Array([sentSeqs[0], sentSeqs[1]]))).toBe('0102');
379
+ await transport.release(uuid, true);
380
+ });
381
+
382
+ test('rejects the active Protocol V2 reader when the current monitor errors', async () => {
383
+ const harness = createHarness();
384
+ const { transport, uuid, sentSeqs } = harness;
385
+ await transport.acquire({ uuid });
386
+ harness.setShouldRespond(false);
387
+
388
+ const call = transport.call(uuid, 'Ping', { message: 'wait-for-monitor' }, { timeoutMs: 50 });
389
+ while (sentSeqs.length < 2) {
390
+ await Promise.resolve();
391
+ }
392
+ await new Promise(resolve => {
393
+ setTimeout(resolve, 0);
394
+ });
395
+ harness.emitMonitorError(Object.assign(new Error('monitor failed'), { reason: 'link lost' }));
396
+
397
+ await expect(call).rejects.toMatchObject({
398
+ errorCode: HardwareErrorCode.BleCharacteristicNotifyError,
399
+ });
400
+ });
401
+
402
+ test('retains the sequence cursor when a new monitor generation is acquired', async () => {
403
+ const { transport, uuid, sentSeqs } = createHarness();
404
+
405
+ await transport.acquire({ uuid });
406
+ await transport.release(uuid, true);
407
+ await transport.acquire({ uuid });
408
+
409
+ expect(sentSeqs).toEqual([1, 2]);
410
+ await transport.release(uuid, true);
411
+ });
412
+
413
+ test('uses withoutResponse for normal and high-volume calls', async () => {
414
+ const { transport, uuid, writeCharacteristic } = createHarness();
415
+
416
+ await transport.acquire({ uuid });
417
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
418
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
419
+
420
+ await transport.call(uuid, 'Ping', { message: 'normal' });
421
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(2);
422
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
423
+
424
+ await transport.call(uuid, 'FileWrite', {});
425
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(3);
426
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
427
+ await transport.release(uuid, true);
428
+ });
429
+
430
+ test('rejects an active Protocol V2 reader when disconnect resets the link', async () => {
431
+ const harness = createHarness();
432
+ const { transport, uuid, sentSeqs } = harness;
433
+ await transport.acquire({ uuid });
434
+ harness.setShouldRespond(false);
435
+
436
+ const call = transport.call(uuid, 'Ping', { message: 'disconnect' }, { timeoutMs: 50 });
437
+ while (sentSeqs.length < 2) {
438
+ await Promise.resolve();
439
+ }
440
+
441
+ const rejection = expect(call).rejects.toThrow('React Native BLE transport disconnected');
442
+ await transport.disconnect(uuid);
443
+ await rejection;
444
+ });
445
+
446
+ test('emits one disconnect event when the physical callback races manual cleanup', async () => {
447
+ const harness = createHarness();
448
+ const disconnectListener = jest.fn();
449
+ harness.emitter.on(TRANSPORT_EVENT.DEVICE_DISCONNECT, disconnectListener);
450
+
451
+ await harness.transport.acquire({ uuid: harness.uuid });
452
+ harness.emitDisconnect();
453
+ await harness.transport.disconnect(harness.uuid);
454
+
455
+ expect(disconnectListener).toHaveBeenCalledTimes(1);
456
+ expect(disconnectListener).toHaveBeenCalledWith({
457
+ name: 'OneKey Pro 2',
458
+ id: harness.uuid,
459
+ connectId: harness.uuid,
460
+ });
461
+ });
462
+
463
+ test('chunks large frames and retries only transient GATT congestion', async () => {
464
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
465
+ const congested = { status: 143, message: 'GATT_CONGESTED' };
466
+ const writeWithoutResponse = jest
467
+ .fn()
468
+ .mockRejectedValueOnce(congested)
469
+ .mockResolvedValue(undefined);
470
+ const bleTransport = {
471
+ mtuSize: 23,
472
+ writeCharacteristic: { writeWithoutResponse },
473
+ };
474
+ const context = {
475
+ messageName: 'Ping',
476
+ timeoutMs: 1000,
477
+ highVolume: false,
478
+ generation: 1,
479
+ signal: new AbortController().signal,
480
+ };
481
+ const assertCurrentGeneration = jest.fn();
482
+ configureProtocolV2BleTuning({ iosPacketLength: 20 });
483
+
484
+ await transport.writeProtocolV2Frame(
485
+ 'device-uuid',
486
+ bleTransport,
487
+ new Uint8Array(30),
488
+ context,
489
+ assertCurrentGeneration
490
+ );
491
+
492
+ expect(writeWithoutResponse).toHaveBeenCalledTimes(3);
493
+ expect(writeWithoutResponse.mock.calls[0][0]).toBe(writeWithoutResponse.mock.calls[1][0]);
494
+ expect(Buffer.from(writeWithoutResponse.mock.calls[2][0], 'base64')).toHaveLength(10);
495
+ expect(assertCurrentGeneration).toHaveBeenCalled();
496
+ });
497
+
498
+ test('does not retry a disconnected Protocol V2 write inside a partial frame', async () => {
499
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
500
+ const writeWithoutResponse = jest
501
+ .fn()
502
+ .mockRejectedValue({ errorCode: 205, message: 'Device disconnected' });
503
+ const bleTransport = {
504
+ mtuSize: 23,
505
+ writeCharacteristic: { writeWithoutResponse },
506
+ };
507
+ const context = {
508
+ messageName: 'FileWrite',
509
+ timeoutMs: 1000,
510
+ highVolume: true,
511
+ generation: 1,
512
+ signal: new AbortController().signal,
513
+ };
514
+ configureProtocolV2BleTuning({ iosPacketLength: 20 });
515
+
516
+ await expect(
517
+ transport.writeProtocolV2Frame(
518
+ 'device-uuid',
519
+ bleTransport,
520
+ new Uint8Array(30),
521
+ context,
522
+ jest.fn()
523
+ )
524
+ ).rejects.toMatchObject({ errorCode: 205 });
525
+ expect(writeWithoutResponse).toHaveBeenCalledTimes(1);
526
+ });
527
+ });