@onekeyfe/hd-transport-react-native 1.2.0-alpha.9 → 1.2.0-alpha.91

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.
@@ -2,11 +2,16 @@ import { EventEmitter } from 'events';
2
2
  import transportPackage, {
3
3
  PROTOCOL_V2_CHANNEL_BLE_UART,
4
4
  ProtocolV2,
5
- bytesToHex,
5
+ TRANSPORT_EVENT,
6
6
  } from '@onekeyfe/hd-transport';
7
- import { HardwareErrorCode } from '@onekeyfe/hd-shared';
7
+ import { HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
8
8
 
9
- import ReactNativeBleTransport from '../index';
9
+ import ReactNativeBleTransport, {
10
+ BLE_WRITE_PACKET_TIMEOUT_MS,
11
+ configureProtocolV2BleTuning,
12
+ getFirmwareUploadWriteRetryType,
13
+ resetProtocolV2BleTuning,
14
+ } from '../index';
10
15
 
11
16
  jest.mock(
12
17
  'react-native',
@@ -28,6 +33,7 @@ jest.mock('react-native-ble-plx', () => ({
28
33
  CharacteristicNotFound: 404,
29
34
  },
30
35
  BleManager: jest.fn(),
36
+ ConnectionPriority: { Balanced: 0, High: 1, LowPower: 2 },
31
37
  ScanMode: { LowLatency: 2 },
32
38
  }));
33
39
 
@@ -41,11 +47,17 @@ jest.mock('../subscribeBleOn', () => ({
41
47
  subscribeBleOn: jest.fn(() => Promise.resolve()),
42
48
  }));
43
49
 
50
+ const setPlatformOS = (os: 'ios' | 'android') => {
51
+ const reactNative: { Platform: { OS: string } } = jest.requireMock('react-native');
52
+ reactNative.Platform.OS = os;
53
+ };
54
+
44
55
  const { parseConfigure } = transportPackage;
45
56
 
46
57
  const protocolV1Schema = {
47
58
  nested: {
48
59
  Initialize: { fields: {} },
60
+ GetFeatures: { fields: {} },
49
61
  Success: {
50
62
  fields: {
51
63
  message: { type: 'string', id: 1 },
@@ -55,6 +67,7 @@ const protocolV1Schema = {
55
67
  values: {
56
68
  MessageType_Initialize: 1,
57
69
  MessageType_Success: 2,
70
+ MessageType_GetFeatures: 55,
58
71
  },
59
72
  },
60
73
  },
@@ -62,11 +75,13 @@ const protocolV1Schema = {
62
75
 
63
76
  const protocolV2Schema = {
64
77
  nested: {
78
+ ProtocolInfoRequest: { fields: {} },
65
79
  Ping: {
66
80
  fields: {
67
81
  message: { type: 'string', id: 1 },
68
82
  },
69
83
  },
84
+ DeviceInfoGet: { fields: {} },
70
85
  FileWrite: { fields: {} },
71
86
  Success: {
72
87
  fields: {
@@ -75,8 +90,10 @@ const protocolV2Schema = {
75
90
  },
76
91
  MessageType: {
77
92
  values: {
93
+ MessageType_ProtocolInfoRequest: 60200,
78
94
  MessageType_Ping: 60206,
79
95
  MessageType_Success: 60207,
96
+ MessageType_DeviceInfoGet: 60600,
80
97
  MessageType_FileWrite: 60805,
81
98
  },
82
99
  },
@@ -88,9 +105,16 @@ const schemas = {
88
105
  protocolV2: parseConfigure(protocolV2Schema),
89
106
  };
90
107
 
91
- const createHarness = () => {
108
+ const createHarness = ({
109
+ deviceName = 'OneKey Pro 2',
110
+ isWritableWithResponse = true,
111
+ }: {
112
+ deviceName?: string;
113
+ isWritableWithResponse?: boolean;
114
+ } = {}) => {
92
115
  const uuid = 'rn-pro2-id';
93
116
  const sentSeqs: number[] = [];
117
+ let responseSeq = 0;
94
118
  let shouldRespond = true;
95
119
  let notifyCallback:
96
120
  | ((
@@ -98,6 +122,7 @@ const createHarness = () => {
98
122
  characteristic: { value: string } | null
99
123
  ) => void)
100
124
  | undefined;
125
+ let disconnectCallback: (() => void) | undefined;
101
126
  const notifyCharacteristic = {
102
127
  uuid: '0003',
103
128
  deviceID: uuid,
@@ -107,85 +132,514 @@ const createHarness = () => {
107
132
  return { remove: jest.fn() };
108
133
  }),
109
134
  };
135
+ const handleWrite = (base64: string) => {
136
+ const frame = Buffer.from(base64, 'base64');
137
+ sentSeqs.push(frame[6]);
138
+ if (shouldRespond) {
139
+ responseSeq += 1;
140
+ const response = ProtocolV2.encodeFrame(
141
+ schemas,
142
+ 'Success',
143
+ { message: 'ok' },
144
+ { router: PROTOCOL_V2_CHANNEL_BLE_UART, seq: responseSeq }
145
+ );
146
+ notifyCallback?.(null, { value: Buffer.from(response).toString('base64') });
147
+ }
148
+ return Promise.resolve();
149
+ };
110
150
  const writeCharacteristic = {
111
151
  uuid: '0002',
112
152
  deviceID: uuid,
113
- isWritableWithResponse: true,
153
+ isWritableWithResponse,
114
154
  isWritableWithoutResponse: true,
115
- writeWithResponse: jest.fn((base64: string) => {
116
- const frame = Buffer.from(base64, 'base64');
117
- sentSeqs.push(frame[6]);
118
- if (shouldRespond) {
119
- const response = ProtocolV2.encodeFrame(
120
- schemas,
121
- 'Success',
122
- { message: 'ok' },
123
- { router: PROTOCOL_V2_CHANNEL_BLE_UART }
124
- );
125
- notifyCallback?.(null, { value: Buffer.from(response).toString('base64') });
126
- }
127
- return Promise.resolve();
128
- }),
129
- writeWithoutResponse: jest.fn(async (base64: string) => {
130
- await writeCharacteristic.writeWithResponse(base64);
131
- }),
155
+ writeWithResponse: jest.fn(handleWrite),
156
+ writeWithoutResponse: jest.fn(handleWrite),
132
157
  };
133
158
  const device = {
134
159
  id: uuid,
135
- name: 'OneKey Pro 2',
136
- localName: 'OneKey Pro 2',
137
- serviceUUIDs: ['fffd'],
160
+ name: deviceName,
161
+ localName: deviceName,
162
+ mtu: 247,
163
+ serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
138
164
  isConnected: jest.fn(() => Promise.resolve(true)),
139
- onDisconnected: jest.fn(() => ({ remove: jest.fn() })),
140
- };
165
+ cancelConnection: jest.fn(() => Promise.resolve()),
166
+ onDisconnected: jest.fn(callback => {
167
+ disconnectCallback = callback;
168
+ return { remove: jest.fn() };
169
+ }),
170
+ } as any;
171
+ device.requestMTU = jest.fn(() => Promise.resolve(device));
172
+ device.requestConnectionPriority = jest.fn(() => Promise.resolve(device));
141
173
  const bleManager = {
142
174
  devices: jest.fn(() => Promise.resolve([device])),
143
175
  connectedDevices: jest.fn(() => Promise.resolve([])),
144
176
  cancelTransaction: jest.fn(() => Promise.resolve()),
145
177
  };
146
178
  const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
179
+ const emitter = new EventEmitter();
180
+ const logger = { debug: jest.fn(), error: jest.fn() };
147
181
  transport.blePlxManager = bleManager;
148
182
  transport.resolveCharacteristics = jest.fn(() =>
149
183
  Promise.resolve({ writeCharacteristic, notifyCharacteristic })
150
184
  );
151
- transport.init({ debug: jest.fn(), error: jest.fn() }, new EventEmitter());
185
+ transport.init(logger, emitter);
152
186
  transport.configure(protocolV1Schema);
153
187
  transport.configureProtocolV2(protocolV2Schema);
154
188
 
155
189
  return {
156
190
  transport,
191
+ emitter,
192
+ logger,
157
193
  uuid,
194
+ device,
158
195
  sentSeqs,
196
+ writeCharacteristic,
159
197
  setShouldRespond(value: boolean) {
160
198
  shouldRespond = value;
161
199
  },
162
200
  emitMonitorError(error: Error & { reason?: string }) {
163
201
  notifyCallback?.(error, null);
164
202
  },
203
+ emitDisconnect() {
204
+ disconnectCallback?.();
205
+ },
206
+ };
207
+ };
208
+
209
+ const createV1Harness = ({
210
+ respondOnWriteCount = 1,
211
+ isWritableWithResponse = true,
212
+ }: {
213
+ respondOnWriteCount?: number | number[];
214
+ isWritableWithResponse?: boolean;
215
+ } = {}) => {
216
+ const uuid = 'rn-classic-id';
217
+ const notifySubscriptionRemovers: jest.Mock[] = [];
218
+ const disconnectSubscriptionRemovers: jest.Mock[] = [];
219
+ let notifyCallback:
220
+ | ((error: Error | null, characteristic: { value: string } | null) => void)
221
+ | undefined;
222
+ const notifyCharacteristic = {
223
+ uuid: '0003',
224
+ deviceID: uuid,
225
+ isNotifiable: true,
226
+ monitor: jest.fn(callback => {
227
+ notifyCallback = callback;
228
+ const remove = jest.fn();
229
+ notifySubscriptionRemovers.push(remove);
230
+ return { remove };
231
+ }),
232
+ };
233
+ let writeCount = 0;
234
+ const responseWriteCounts = new Set(
235
+ Array.isArray(respondOnWriteCount) ? respondOnWriteCount : [respondOnWriteCount]
236
+ );
237
+ const handleWrite = () => {
238
+ writeCount += 1;
239
+ if (responseWriteCounts.has(writeCount)) {
240
+ notifyCallback?.(null, {
241
+ value: Buffer.from('3f23230002000000040a026f6b', 'hex').toString('base64'),
242
+ });
243
+ }
244
+ return Promise.resolve();
245
+ };
246
+ const writeCharacteristic = {
247
+ uuid: '0002',
248
+ deviceID: uuid,
249
+ isWritableWithResponse,
250
+ isWritableWithoutResponse: true,
251
+ writeWithResponse: jest.fn(handleWrite),
252
+ writeWithoutResponse: jest.fn(handleWrite),
253
+ };
254
+ const device = {
255
+ id: uuid,
256
+ name: 'OneKey Classic',
257
+ localName: 'OneKey Classic',
258
+ mtu: 247,
259
+ serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
260
+ isConnected: jest.fn(() => Promise.resolve(true)),
261
+ cancelConnection: jest.fn(() => Promise.resolve()),
262
+ onDisconnected: jest.fn(() => {
263
+ const remove = jest.fn();
264
+ disconnectSubscriptionRemovers.push(remove);
265
+ return { remove };
266
+ }),
267
+ } as any;
268
+ device.requestMTU = jest.fn(() => Promise.resolve(device));
269
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
270
+ const bleManager = {
271
+ devices: jest.fn(() => Promise.resolve([device])),
272
+ connectedDevices: jest.fn(() => Promise.resolve([])),
273
+ cancelTransaction: jest.fn(() => Promise.resolve()),
274
+ };
275
+ transport.blePlxManager = bleManager as any;
276
+ transport.resolveCharacteristics = jest.fn(() =>
277
+ Promise.resolve({ writeCharacteristic, notifyCharacteristic })
278
+ );
279
+ transport.init({ debug: jest.fn(), error: jest.fn() }, new EventEmitter());
280
+ transport.configure(protocolV1Schema);
281
+ transport.configureProtocolV2(protocolV2Schema);
282
+ return {
283
+ transport,
284
+ uuid,
285
+ device,
286
+ bleManager,
287
+ writeCharacteristic,
288
+ notifySubscriptionRemovers,
289
+ disconnectSubscriptionRemovers,
165
290
  };
166
291
  };
167
292
 
168
293
  describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
169
- test('keeps the Protocol V2 sequence across probe and the next call', async () => {
170
- const { transport, uuid, sentSeqs } = createHarness();
294
+ test('does not classify disconnects as retryable firmware writes', () => {
295
+ expect(
296
+ getFirmwareUploadWriteRetryType({
297
+ errorCode: 205,
298
+ message: 'Device disconnected after write',
299
+ })
300
+ ).toBeNull();
301
+ });
171
302
 
172
- await transport.acquire({ uuid });
173
- await transport.call(uuid, 'Ping', { message: 'after-probe' });
303
+ test.each(['status 143', 'status:143', 'status = 143', 'GATT_CONGESTED'])(
304
+ 'classifies %s as transient GATT congestion',
305
+ message => {
306
+ expect(getFirmwareUploadWriteRetryType({ message })).toBe('congested');
307
+ }
308
+ );
174
309
 
310
+ test('handles long uncontrolled status messages without a backtracking regular expression', () => {
311
+ const message = `status${' '.repeat(100_000)}142`;
312
+
313
+ expect(getFirmwareUploadWriteRetryType({ message })).toBeNull();
314
+ });
315
+
316
+ test('keeps another device reader when releasing a device with an active V1 call', async () => {
317
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
318
+ const activeV1Call = createDeferred<string>();
319
+ const otherDeviceReader = createDeferred<Uint8Array>();
320
+ activeV1Call.promise.catch(() => undefined);
321
+ otherDeviceReader.promise.catch(() => undefined);
322
+ transport.runPromise = activeV1Call;
323
+ transport.runPromiseDeviceId = 'device-a';
324
+ transport.protocolV2FramePromises.set('device-b', otherDeviceReader);
325
+
326
+ await transport.releaseNative('device-a', true);
327
+
328
+ expect(transport.protocolV2FramePromises.get('device-b')).toBe(otherDeviceReader);
329
+ });
330
+
331
+ test('rejects a pending reader when its device frame state resets', async () => {
332
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
333
+ const reader = createDeferred<Uint8Array>();
334
+ transport.protocolV2FramePromises.set('device-a', reader);
335
+ const result = Promise.race([
336
+ reader.promise.then(
337
+ () => 'resolved',
338
+ () => 'rejected'
339
+ ),
340
+ new Promise(resolve => {
341
+ setTimeout(() => resolve('pending'), 20);
342
+ }),
343
+ ]);
344
+
345
+ transport.resetProtocolV2Frames('device-a');
346
+
347
+ await expect(result).resolves.toBe('rejected');
348
+ });
349
+
350
+ test('keeps the legacy default BLE scan timeout', () => {
351
+ expect(new ReactNativeBleTransport({}).scanTimeout).toBe(3000);
352
+ });
353
+
354
+ test('uses withResponse for consecutive iOS Protocol V1 control commands without releasing', async () => {
355
+ const { transport, uuid, writeCharacteristic } = createV1Harness({
356
+ respondOnWriteCount: [1, 2],
357
+ });
358
+
359
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V1' })).resolves.toEqual({
360
+ uuid,
361
+ protocolType: 'V1',
362
+ });
363
+ const releaseNative = jest.spyOn(transport as any, 'releaseNative');
364
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
365
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
366
+
367
+ await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).resolves.toBeDefined();
368
+ await expect(transport.call(uuid, 'GetFeatures', {}, { timeoutMs: 50 })).resolves.toBeDefined();
369
+
370
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(2);
371
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
372
+ expect(releaseNative).not.toHaveBeenCalled();
373
+ await transport.release(uuid, true);
374
+ });
375
+
376
+ test('falls back to withoutResponse for an iOS Protocol V1 control command when required', async () => {
377
+ const { transport, uuid, writeCharacteristic } = createV1Harness({
378
+ isWritableWithResponse: false,
379
+ });
380
+
381
+ await transport.acquire({ uuid, expectedProtocol: 'V1' });
382
+ await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).resolves.toBeDefined();
383
+
384
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
385
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
386
+ await transport.release(uuid, true);
387
+ });
388
+
389
+ test('does not resend a failed iOS Protocol V1 control write without response', async () => {
390
+ const { transport, uuid, writeCharacteristic } = createV1Harness();
391
+ const writeError = new Error('write with response failed');
392
+
393
+ await transport.acquire({ uuid, expectedProtocol: 'V1' });
394
+ writeCharacteristic.writeWithResponse.mockRejectedValueOnce(writeError);
395
+
396
+ await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).rejects.toMatchObject({
397
+ errorCode: HardwareErrorCode.BleWriteCharacteristicError,
398
+ });
399
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
400
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
401
+ await transport.release(uuid, true);
402
+ });
403
+
404
+ test('actively probes Protocol V2 on iOS when only a name-derived hint is available', async () => {
405
+ const { transport, uuid, device, sentSeqs, writeCharacteristic } = createHarness({
406
+ deviceName: 'Pro2 6E9E',
407
+ });
408
+
409
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
410
+ uuid,
411
+ protocolType: 'V2',
412
+ });
413
+ expect(device.requestMTU).toHaveBeenCalledWith(247);
414
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
415
+
416
+ await expect(
417
+ transport.call(uuid, 'Ping', { message: 'first-core-command' })
418
+ ).resolves.toBeDefined();
175
419
  expect(sentSeqs).toEqual([1, 2]);
176
- expect(bytesToHex(new Uint8Array([sentSeqs[0], sentSeqs[1]]))).toBe('0102');
420
+ await transport.release(uuid, true);
421
+ });
422
+
423
+ test('falls back to the other active probe on iOS when protocol metadata is absent', async () => {
424
+ const { transport, uuid } = createHarness({ deviceName: 'OneKey' });
425
+ const probeProtocolV1 = jest
426
+ .spyOn(transport as any, 'probeProtocolV1')
427
+ .mockResolvedValue(false);
428
+ const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2').mockResolvedValue(true);
429
+
430
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
431
+ uuid,
432
+ protocolType: 'V2',
433
+ });
434
+
435
+ expect(probeProtocolV1).toHaveBeenCalledTimes(1);
436
+ expect(probeProtocolV2).toHaveBeenCalledTimes(1);
437
+ expect(probeProtocolV1.mock.invocationCallOrder[0]).toBeLessThan(
438
+ probeProtocolV2.mock.invocationCallOrder[0]
439
+ );
440
+ await transport.release(uuid, true);
441
+ });
442
+
443
+ test('continues with the current MTU when the connected snapshot refresh fails', async () => {
444
+ const { transport, uuid, device } = createHarness();
445
+ const mtuError = new Error('MTU refresh failed');
446
+ device.requestMTU.mockRejectedValueOnce(mtuError);
447
+
448
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
449
+ uuid,
450
+ protocolType: 'V2',
451
+ });
452
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
453
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(247);
454
+ await transport.release(uuid, true);
455
+ });
456
+
457
+ test('refreshes a transient bootloader MTU after notifications are ready', async () => {
458
+ const { transport, uuid, device } = createHarness();
459
+ device.mtu = 23;
460
+ device.requestMTU
461
+ .mockResolvedValueOnce(device)
462
+ .mockResolvedValueOnce(device)
463
+ .mockImplementationOnce(() => {
464
+ device.mtu = 247;
465
+ return Promise.resolve(device);
466
+ });
467
+
468
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
469
+ uuid,
470
+ protocolType: 'V2',
471
+ });
472
+ expect(device.requestMTU).toHaveBeenCalledTimes(3);
473
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(247);
474
+ await transport.release(uuid, true);
475
+ });
476
+
477
+ test('continues with a low bootloader MTU when the bounded retry fails', async () => {
478
+ const { transport, uuid, device } = createHarness();
479
+ device.mtu = 23;
480
+ device.requestMTU
481
+ .mockResolvedValueOnce(device)
482
+ .mockResolvedValueOnce(device)
483
+ .mockRejectedValueOnce(new Error('bootloader MTU retry failed'));
484
+
485
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
486
+ uuid,
487
+ protocolType: 'V2',
488
+ });
489
+ expect(device.requestMTU).toHaveBeenCalledTimes(3);
490
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
491
+ await transport.release(uuid, true);
492
+ });
493
+
494
+ test('accepts a stable low MTU without the delayed refresh loop', async () => {
495
+ const { transport, uuid, device } = createHarness();
496
+ device.mtu = 185;
497
+
498
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
499
+ uuid,
500
+ protocolType: 'V2',
501
+ });
502
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
503
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(185);
504
+ await transport.release(uuid, true);
505
+ });
506
+
507
+ test('continues Protocol V2 probing with a conservative packet size when MTU is unavailable', async () => {
508
+ const { transport, uuid, device, writeCharacteristic } = createHarness();
509
+ device.mtu = undefined;
510
+
511
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
512
+ uuid,
513
+ protocolType: 'V2',
514
+ });
515
+ expect(device.requestMTU).toHaveBeenCalledTimes(3);
516
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBeUndefined();
517
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalled();
518
+ await transport.release(uuid, true);
519
+ });
520
+
521
+ test('refreshes an unavailable MTU before a Protocol V2 high-volume write', async () => {
522
+ const { transport, uuid, device, writeCharacteristic } = createHarness();
523
+ device.mtu = undefined;
524
+
525
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
526
+ device.requestMTU.mockImplementationOnce(() => {
527
+ device.mtu = 247;
528
+ return Promise.resolve(device);
529
+ });
530
+
531
+ await expect(transport.call(uuid, 'FileWrite', {})).resolves.toBeDefined();
532
+ expect(device.requestMTU).toHaveBeenCalledTimes(4);
533
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
534
+ await transport.release(uuid, true);
535
+ });
536
+
537
+ test('rejects a Protocol V2 high-volume write when MTU remains unavailable', async () => {
538
+ const { transport, uuid, device, writeCharacteristic } = createHarness();
539
+ device.mtu = undefined;
540
+
541
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
542
+
543
+ await expect(transport.call(uuid, 'FileWrite', {})).rejects.toMatchObject({
544
+ errorCode: HardwareErrorCode.BleConnectedError,
545
+ });
546
+ expect(device.requestMTU).toHaveBeenCalledTimes(4);
547
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
548
+ await transport.release(uuid, true);
549
+ });
550
+
551
+ test('reconnects before falling back to Protocol V1 after a fatal V2 probe failure', async () => {
552
+ setPlatformOS('android');
553
+ const { transport, uuid, device, notifySubscriptionRemovers, disconnectSubscriptionRemovers } =
554
+ createV1Harness();
555
+ const probeProtocolV2 = jest
556
+ .spyOn(transport as any, 'probeProtocolV2')
557
+ .mockImplementation(async () => {
558
+ await (transport as any).releaseNative(uuid, true);
559
+ return false;
560
+ });
561
+ const resolveCharacteristics = jest.spyOn(transport as any, 'resolveCharacteristics');
562
+
563
+ await expect(transport.acquire({ uuid, protocolHint: 'V2' })).resolves.toEqual({
564
+ uuid,
565
+ protocolType: 'V1',
566
+ });
567
+
568
+ expect(probeProtocolV2).toHaveBeenCalledTimes(1);
569
+ expect(resolveCharacteristics).toHaveBeenCalledTimes(2);
570
+ expect(transport.getProtocolType(uuid)).toBe('V1');
571
+ expect(device.onDisconnected).toHaveBeenCalledTimes(1);
572
+ expect(notifySubscriptionRemovers).toHaveLength(2);
573
+ expect(notifySubscriptionRemovers[0]).toHaveBeenCalledTimes(1);
574
+
575
+ await transport.release(uuid, true);
576
+
577
+ expect(notifySubscriptionRemovers[1]).toHaveBeenCalledTimes(1);
578
+ expect(disconnectSubscriptionRemovers).toHaveLength(1);
579
+ expect(disconnectSubscriptionRemovers[0]).toHaveBeenCalledTimes(1);
580
+ });
581
+
582
+ test('cleans the rebuilt transport when Protocol V1 fallback also fails', async () => {
583
+ setPlatformOS('android');
584
+ const { transport, uuid, device, bleManager, notifySubscriptionRemovers } = createV1Harness();
585
+ jest.spyOn(transport as any, 'probeProtocolV2').mockImplementation(async () => {
586
+ await (transport as any).releaseNative(uuid, true);
587
+ return false;
588
+ });
589
+ jest.spyOn(transport as any, 'probeProtocolV1').mockResolvedValue(false);
590
+
591
+ await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toMatchObject({
592
+ errorCode: HardwareErrorCode.BleTimeoutError,
593
+ });
594
+
595
+ expect(device.onDisconnected).not.toHaveBeenCalled();
596
+ expect(notifySubscriptionRemovers).toHaveLength(2);
597
+ expect(notifySubscriptionRemovers[0]).toHaveBeenCalledTimes(1);
598
+ expect(notifySubscriptionRemovers[1]).toHaveBeenCalledTimes(1);
599
+ expect(bleManager.cancelTransaction).toHaveBeenCalled();
600
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
601
+ });
602
+
603
+ test('disconnects and invalidates a Protocol V1 link after a response timeout', async () => {
604
+ const { transport, uuid, device } = createV1Harness({
605
+ respondOnWriteCount: Number.POSITIVE_INFINITY,
606
+ });
607
+
608
+ await transport.acquire({ uuid, expectedProtocol: 'V1' });
609
+ await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 5 })).rejects.toMatchObject({
610
+ errorCode: HardwareErrorCode.BleTimeoutError,
611
+ });
612
+
613
+ expect(device.cancelConnection).toHaveBeenCalled();
614
+ expect(transport.getProtocolType(uuid)).toBeUndefined();
615
+ });
616
+
617
+ afterEach(() => {
618
+ setPlatformOS('ios');
619
+ resetProtocolV2BleTuning();
620
+ });
621
+
622
+ test('uses the first Protocol V2 sequence for the first Core call when protocol is known', async () => {
623
+ const { transport, uuid, sentSeqs } = createHarness();
624
+
625
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
626
+ await transport.call(uuid, 'Ping', { message: 'first-core-command' });
627
+
628
+ expect(sentSeqs).toEqual([1]);
177
629
  await transport.release(uuid, true);
178
630
  });
179
631
 
180
632
  test('rejects the active Protocol V2 reader when the current monitor errors', async () => {
181
633
  const harness = createHarness();
182
634
  const { transport, uuid, sentSeqs } = harness;
183
- await transport.acquire({ uuid });
635
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
184
636
  harness.setShouldRespond(false);
185
637
 
186
638
  const call = transport.call(uuid, 'Ping', { message: 'wait-for-monitor' }, { timeoutMs: 50 });
187
- while (sentSeqs.length < 2) {
188
- await Promise.resolve();
639
+ while (sentSeqs.length < 1) {
640
+ await new Promise(resolve => {
641
+ setTimeout(resolve, 0);
642
+ });
189
643
  }
190
644
  await new Promise(resolve => {
191
645
  setTimeout(resolve, 0);
@@ -200,38 +654,296 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
200
654
  test('retains the sequence cursor when a new monitor generation is acquired', async () => {
201
655
  const { transport, uuid, sentSeqs } = createHarness();
202
656
 
203
- await transport.acquire({ uuid });
657
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
658
+ await transport.call(uuid, 'Ping', { message: 'first-generation' });
204
659
  await transport.release(uuid, true);
205
- await transport.acquire({ uuid });
660
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
661
+ await transport.call(uuid, 'Ping', { message: 'second-generation' });
206
662
 
207
663
  expect(sentSeqs).toEqual([1, 2]);
208
664
  await transport.release(uuid, true);
209
665
  });
210
666
 
211
- test('passes high-volume context to the persistent link adapter', async () => {
212
- const { transport, uuid } = createHarness();
213
- const writeSpy = jest.spyOn(transport as any, 'writeProtocolV2Frame');
667
+ test('uses withResponse for consecutive iOS Protocol V2 control calls without releasing', async () => {
668
+ const { transport, uuid, writeCharacteristic } = createHarness();
214
669
 
215
- await transport.acquire({ uuid });
670
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
671
+ const releaseNative = jest.spyOn(transport as any, 'releaseNative');
672
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
673
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
674
+
675
+ await transport.call(uuid, 'DeviceInfoGet', {});
676
+ await transport.call(uuid, 'ProtocolInfoRequest', {});
677
+
678
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(2);
679
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
680
+ expect(releaseNative).not.toHaveBeenCalled();
681
+
682
+ await transport.release(uuid, true);
683
+ });
684
+
685
+ test('keeps iOS Protocol V2 high-volume calls on withoutResponse', async () => {
686
+ const { transport, uuid, logger, writeCharacteristic } = createHarness();
687
+
688
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
689
+
690
+ await transport.call(uuid, 'FileWrite', {});
216
691
  await transport.call(uuid, 'FileWrite', {});
692
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(2);
693
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
694
+ expect(
695
+ logger.debug.mock.calls.filter(
696
+ ([message]) =>
697
+ message === '[ReactNativeBleTransport] Protocol V2 high-volume write configured'
698
+ )
699
+ ).toHaveLength(1);
700
+ await transport.release(uuid, true);
701
+ });
702
+
703
+ test('uses Android 517 MTU and high connection priority during Protocol V2 high-volume calls', async () => {
704
+ setPlatformOS('android');
705
+ const { transport, uuid, device } = createHarness();
706
+
707
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
708
+ expect(device.requestMTU).toHaveBeenCalledWith(517);
709
+
710
+ await transport.call(uuid, 'FileWrite', {});
711
+ await transport.call(uuid, 'FileWrite', {});
712
+
713
+ expect(device.requestConnectionPriority).toHaveBeenCalledTimes(1);
714
+ expect(device.requestConnectionPriority).toHaveBeenCalledWith(1);
715
+
716
+ await transport.release(uuid, true);
717
+ expect(device.requestConnectionPriority).toHaveBeenLastCalledWith(0);
718
+ });
719
+
720
+ test('uses withResponse for an iOS Protocol V2 firmware file write when requested', async () => {
721
+ const { transport, uuid, writeCharacteristic } = createHarness();
722
+
723
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
724
+
725
+ await transport.call(uuid, 'FileWrite', {}, { writeWithResponse: true });
726
+ expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
727
+ expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
728
+ await transport.release(uuid, true);
729
+ });
730
+
731
+ test('falls back to withoutResponse for an iOS Protocol V2 control call when required', async () => {
732
+ const { transport, uuid, writeCharacteristic } = createHarness({
733
+ isWritableWithResponse: false,
734
+ });
217
735
 
218
- expect(writeSpy.mock.calls.map(([, , options]) => options?.highVolume)).toEqual([false, true]);
736
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
737
+ await transport.call(uuid, 'ProtocolInfoRequest', {});
738
+
739
+ expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
740
+ expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1);
219
741
  await transport.release(uuid, true);
220
742
  });
221
743
 
744
+ test('does not resend a failed iOS Protocol V2 control write without response', async () => {
745
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
746
+ const writeError = new Error('write with response failed');
747
+ const writeWithResponse = jest.fn().mockRejectedValue(writeError);
748
+ const writeWithoutResponse = jest.fn().mockResolvedValue(undefined);
749
+ const context = {
750
+ messageName: 'ProtocolInfoRequest',
751
+ timeoutMs: 1000,
752
+ highThroughput: false,
753
+ generation: 1,
754
+ signal: new AbortController().signal,
755
+ };
756
+
757
+ await expect(
758
+ transport.writeProtocolV2Packet(
759
+ 'test-device',
760
+ {
761
+ writeCharacteristic: {
762
+ isWritableWithResponse: true,
763
+ writeWithResponse,
764
+ writeWithoutResponse,
765
+ },
766
+ },
767
+ Buffer.from('control').toString('base64'),
768
+ context,
769
+ jest.fn()
770
+ )
771
+ ).rejects.toBe(writeError);
772
+ expect(writeWithResponse).toHaveBeenCalledTimes(1);
773
+ expect(writeWithoutResponse).not.toHaveBeenCalled();
774
+ });
775
+
776
+ test('does not pace a one-packet Protocol V2 control write on iOS', async () => {
777
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
778
+ const writeWithoutResponse = jest.fn().mockResolvedValue(undefined);
779
+ const bleTransport = {
780
+ mtuSize: 23,
781
+ writeCharacteristic: { writeWithoutResponse },
782
+ };
783
+ const context = {
784
+ messageName: 'ProtocolInfoRequest',
785
+ timeoutMs: 1000,
786
+ highThroughput: false,
787
+ generation: 1,
788
+ signal: new AbortController().signal,
789
+ };
790
+ configureProtocolV2BleTuning({ iosPacketLength: 20 });
791
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
792
+
793
+ try {
794
+ const call = transport.writeProtocolV2Frame(
795
+ 'test-device',
796
+ bleTransport,
797
+ new Uint8Array(10),
798
+ context,
799
+ jest.fn()
800
+ );
801
+
802
+ await call;
803
+ // The only scheduled timer is the per-packet BLE write watchdog: no pacing delay.
804
+ expect(setTimeoutSpy.mock.calls.map(([, timeout]) => timeout)).toEqual([
805
+ BLE_WRITE_PACKET_TIMEOUT_MS,
806
+ ]);
807
+ expect(writeWithoutResponse).toHaveBeenCalledTimes(1);
808
+ } finally {
809
+ setTimeoutSpy.mockRestore();
810
+ }
811
+ });
812
+
222
813
  test('rejects an active Protocol V2 reader when disconnect resets the link', async () => {
223
814
  const harness = createHarness();
224
815
  const { transport, uuid, sentSeqs } = harness;
225
- await transport.acquire({ uuid });
816
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
226
817
  harness.setShouldRespond(false);
227
818
 
228
819
  const call = transport.call(uuid, 'Ping', { message: 'disconnect' }, { timeoutMs: 50 });
229
- while (sentSeqs.length < 2) {
230
- await Promise.resolve();
820
+ while (sentSeqs.length < 1) {
821
+ await new Promise(resolve => {
822
+ setTimeout(resolve, 0);
823
+ });
231
824
  }
232
825
 
233
826
  const rejection = expect(call).rejects.toThrow('React Native BLE transport disconnected');
234
827
  await transport.disconnect(uuid);
235
828
  await rejection;
236
829
  });
830
+
831
+ test('emits one disconnect event when the physical callback races manual cleanup', async () => {
832
+ const harness = createHarness();
833
+ const disconnectListener = jest.fn();
834
+ harness.emitter.on(TRANSPORT_EVENT.DEVICE_DISCONNECT, disconnectListener);
835
+
836
+ await harness.transport.acquire({ uuid: harness.uuid, expectedProtocol: 'V2' });
837
+ harness.emitDisconnect();
838
+ await harness.transport.disconnect(harness.uuid);
839
+
840
+ expect(disconnectListener).toHaveBeenCalledTimes(1);
841
+ expect(disconnectListener).toHaveBeenCalledWith({
842
+ name: 'OneKey Pro 2',
843
+ id: harness.uuid,
844
+ connectId: harness.uuid,
845
+ });
846
+ });
847
+
848
+ test('chunks large frames and retries only transient GATT congestion', async () => {
849
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
850
+ const congested = { status: 143, message: 'GATT_CONGESTED' };
851
+ const writeWithoutResponse = jest
852
+ .fn()
853
+ .mockRejectedValueOnce(congested)
854
+ .mockResolvedValue(undefined);
855
+ const bleTransport = {
856
+ mtuSize: 23,
857
+ writeCharacteristic: { writeWithoutResponse },
858
+ };
859
+ const context = {
860
+ messageName: 'Ping',
861
+ timeoutMs: 1000,
862
+ highThroughput: false,
863
+ generation: 1,
864
+ signal: new AbortController().signal,
865
+ };
866
+ const assertCurrentGeneration = jest.fn();
867
+ configureProtocolV2BleTuning({ iosPacketLength: 20 });
868
+
869
+ await transport.writeProtocolV2Frame(
870
+ 'device-uuid',
871
+ bleTransport,
872
+ new Uint8Array(30),
873
+ context,
874
+ assertCurrentGeneration
875
+ );
876
+
877
+ expect(writeWithoutResponse).toHaveBeenCalledTimes(3);
878
+ expect(writeWithoutResponse.mock.calls[0][0]).toBe(writeWithoutResponse.mock.calls[1][0]);
879
+ expect(Buffer.from(writeWithoutResponse.mock.calls[2][0], 'base64')).toHaveLength(10);
880
+ expect(assertCurrentGeneration).toHaveBeenCalled();
881
+ });
882
+
883
+ test('does not retry a disconnected Protocol V2 write inside a partial frame', async () => {
884
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
885
+ const writeWithoutResponse = jest
886
+ .fn()
887
+ .mockRejectedValue({ errorCode: 205, message: 'Device disconnected' });
888
+ const bleTransport = {
889
+ mtuSize: 23,
890
+ writeCharacteristic: { writeWithoutResponse },
891
+ };
892
+ const context = {
893
+ messageName: 'FileWrite',
894
+ timeoutMs: 1000,
895
+ highThroughput: true,
896
+ generation: 1,
897
+ signal: new AbortController().signal,
898
+ };
899
+ configureProtocolV2BleTuning({ iosPacketLength: 20 });
900
+
901
+ await expect(
902
+ transport.writeProtocolV2Frame(
903
+ 'device-uuid',
904
+ bleTransport,
905
+ new Uint8Array(30),
906
+ context,
907
+ jest.fn()
908
+ )
909
+ ).rejects.toMatchObject({ errorCode: 205 });
910
+ expect(writeWithoutResponse).toHaveBeenCalledTimes(1);
911
+ });
912
+
913
+ test('does not apply fixed burst or flush pauses to high-volume Protocol V2 writes', async () => {
914
+ const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any;
915
+ const writeWithoutResponse = jest.fn().mockResolvedValue(undefined);
916
+ const bleTransport = {
917
+ mtuSize: 247,
918
+ writeCharacteristic: { writeWithoutResponse },
919
+ };
920
+ const context = {
921
+ messageName: 'FileWrite',
922
+ timeoutMs: 1000,
923
+ highThroughput: true,
924
+ generation: 1,
925
+ signal: new AbortController().signal,
926
+ };
927
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
928
+
929
+ try {
930
+ await transport.writeProtocolV2Frame(
931
+ 'test-device',
932
+ bleTransport,
933
+ new Uint8Array(600),
934
+ context,
935
+ jest.fn()
936
+ );
937
+
938
+ expect(writeWithoutResponse).toHaveBeenCalledTimes(3);
939
+ // One BLE write watchdog per packet and nothing else: no burst or flush pauses.
940
+ expect(setTimeoutSpy.mock.calls.map(([, timeout]) => timeout)).toEqual([
941
+ BLE_WRITE_PACKET_TIMEOUT_MS,
942
+ BLE_WRITE_PACKET_TIMEOUT_MS,
943
+ BLE_WRITE_PACKET_TIMEOUT_MS,
944
+ ]);
945
+ } finally {
946
+ setTimeoutSpy.mockRestore();
947
+ }
948
+ });
237
949
  });