@onekeyfe/hd-transport-react-native 1.2.0-alpha.53 → 1.2.0-alpha.54

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.
package/src/index.ts CHANGED
@@ -28,7 +28,6 @@ import {
28
28
  HardwareErrorCode,
29
29
  createDeferred,
30
30
  isOnekeyBluetoothDevice,
31
- isPro2FindMyAdvertisementName,
32
31
  } from '@onekeyfe/hd-shared';
33
32
 
34
33
  import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
@@ -61,7 +60,6 @@ const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
61
60
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
62
61
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
63
62
  const FIRMWARE_UPLOAD_WRITE_MAX_RETRIES = 8;
64
- const IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS = 5;
65
63
  const ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH = 192;
66
64
  const FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY =
67
65
  Platform.OS === 'ios' ? IOS_PACKET_LENGTH : ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH;
@@ -108,6 +106,22 @@ const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, max
108
106
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
109
107
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
110
108
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
109
+ /**
110
+ * Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
111
+ * reports the peripheral ready again; a peripheral wedged by its own firmware reboot
112
+ * stops reporting ready while staying connected, so the write promise never settles.
113
+ * Response timeouts cannot cover that — they are armed after the writes complete —
114
+ * and an unbounded write leaves the whole transport unusable until the process dies.
115
+ * A healthy packet completes in milliseconds, so this only fires on a dead link.
116
+ */
117
+ export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
118
+ const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
119
+ const isWedgedWriteError = (error: unknown): boolean =>
120
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
121
+ typeof (error as { message?: unknown })?.message === 'string' &&
122
+ (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
123
+ /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
124
+ export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
111
125
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
112
126
  const IOS_NOTIFY_READY_DELAY_MS = 150;
113
127
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
@@ -258,6 +272,17 @@ export default class ReactNativeBleTransport {
258
272
  /** Per-device protocol type detected by active wire-level probe after connect. */
259
273
  private deviceProtocol: Map<string, ProtocolType> = new Map();
260
274
 
275
+ /**
276
+ * Protocol a probe is currently trying, before the device has confirmed it. Calls
277
+ * must route with it, but acquire() must not treat it as a detected protocol: a
278
+ * probe that never answers would otherwise leave the reuse fast path handing out a
279
+ * transport that was never validated.
280
+ */
281
+ private probingProtocols: Map<string, ProtocolType> = new Map();
282
+
283
+ /** Consecutive write timeouts per device; reset by any write that completes. */
284
+ private writeTimeoutCounts: Map<string, number> = new Map();
285
+
261
286
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
262
287
 
263
288
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
@@ -582,21 +607,12 @@ export default class ReactNativeBleTransport {
582
607
  }
583
608
 
584
609
  const displayName = getDeviceDisplayName(device);
585
- // iOS may report a service-only advertisement before the named scan response.
586
- // Do not cache that incomplete advertisement as an unknown device.
587
- const isUnnamedIOSPeripheral = Platform.OS === 'ios' && !displayName?.trim();
588
- const isFindMyPeripheral =
589
- isPro2FindMyAdvertisementName(device?.name) ||
590
- isPro2FindMyAdvertisementName(device?.localName);
591
- const isOneKey =
592
- !isUnnamedIOSPeripheral &&
593
- !isFindMyPeripheral &&
594
- isOnekeyBluetoothDevice({
595
- id: device?.id,
596
- name: device?.name,
597
- localName: device?.localName,
598
- serviceUuids: device?.serviceUUIDs,
599
- });
610
+ const isOneKey = isOnekeyBluetoothDevice({
611
+ id: device?.id,
612
+ name: device?.name,
613
+ localName: device?.localName,
614
+ serviceUuids: device?.serviceUUIDs,
615
+ });
600
616
  if (isOneKey) {
601
617
  addDevice(device as unknown as Device);
602
618
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
@@ -617,12 +633,7 @@ export default class ReactNativeBleTransport {
617
633
  'localName' in device && typeof device.localName === 'string'
618
634
  ? device.localName
619
635
  : null;
620
- const isFindMyPeripheral =
621
- isPro2FindMyAdvertisementName(device.name) ||
622
- isPro2FindMyAdvertisementName(localName);
623
-
624
636
  if (
625
- !isFindMyPeripheral &&
626
637
  isOnekeyBluetoothDevice({
627
638
  id: device.id,
628
639
  name: device.name,
@@ -895,7 +906,7 @@ export default class ReactNativeBleTransport {
895
906
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
896
907
  return;
897
908
  }
898
- if (this.deviceProtocol.get(uuid) === 'V2') {
909
+ if (this.getActiveProtocol(uuid) === 'V2') {
899
910
  let errorCode:
900
911
  | typeof HardwareErrorCode.BleDeviceBondError
901
912
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -965,7 +976,7 @@ export default class ReactNativeBleTransport {
965
976
 
966
977
  try {
967
978
  const data = Buffer.from(c.value as string, 'base64');
968
- const protocol = this.deviceProtocol.get(uuid);
979
+ const protocol = this.getActiveProtocol(uuid);
969
980
  if (!protocol) {
970
981
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
971
982
  return;
@@ -999,7 +1010,7 @@ export default class ReactNativeBleTransport {
999
1010
  } catch (error) {
1000
1011
  Log?.debug('monitor data error: ', error);
1001
1012
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1002
- if (this.deviceProtocol.get(uuid) === 'V2') {
1013
+ if (this.getActiveProtocol(uuid) === 'V2') {
1003
1014
  this.rejectProtocolV2Frames(uuid, notifyError);
1004
1015
  } else if (this.runPromiseDeviceId === uuid) {
1005
1016
  this.runPromise?.reject(notifyError);
@@ -1063,6 +1074,7 @@ export default class ReactNativeBleTransport {
1063
1074
  }
1064
1075
 
1065
1076
  this.deviceProtocol.delete(uuid);
1077
+ this.probingProtocols.delete(uuid);
1066
1078
  // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1067
1079
  this.protocolV2Assemblers.get(uuid)?.reset();
1068
1080
  this.protocolV2Assemblers.delete(uuid);
@@ -1129,8 +1141,25 @@ export default class ReactNativeBleTransport {
1129
1141
  const transport = this.getCachedTransport(uuid);
1130
1142
  const runPromise = createDeferred<string>();
1131
1143
  runPromise.promise.catch(() => undefined);
1144
+ const supersededRunPromise = this.runPromise;
1145
+ if (supersededRunPromise) {
1146
+ // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1147
+ // the superseded deferred now so its response race resolves and its finally block
1148
+ // clears its timeout timer; an orphaned timer would otherwise fire much later and
1149
+ // tear down the shared connection while another call is using it.
1150
+ supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1151
+ }
1132
1152
  this.runPromise = runPromise;
1133
1153
  this.runPromiseDeviceId = uuid;
1154
+ // A superseded call's late write failure must not clear the successor's ownership;
1155
+ // only the call that still owns the slot may release it.
1156
+ const releaseOwnershipIfCurrent = () => {
1157
+ if (this.runPromise === runPromise) {
1158
+ this.runPromise = null;
1159
+ this.runPromiseDeviceId = null;
1160
+ }
1161
+ };
1162
+ const isCurrentOwner = () => this.runPromise === runPromise;
1134
1163
  const messages = this._messages;
1135
1164
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1136
1165
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1156,6 +1185,9 @@ export default class ReactNativeBleTransport {
1156
1185
  chunk = ByteBuffer.allocate(packetCapacity);
1157
1186
  } catch (e) {
1158
1187
  onError(e);
1188
+ if (isWedgedWriteError(e)) {
1189
+ throw e;
1190
+ }
1159
1191
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1160
1192
  }
1161
1193
  }
@@ -1187,6 +1219,9 @@ export default class ReactNativeBleTransport {
1187
1219
  }
1188
1220
  } catch (e) {
1189
1221
  onError(e);
1222
+ if (isWedgedWriteError(e)) {
1223
+ throw e;
1224
+ }
1190
1225
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1191
1226
  }
1192
1227
  }
@@ -1200,9 +1235,15 @@ export default class ReactNativeBleTransport {
1200
1235
  if (name === 'EmmcFileWrite') {
1201
1236
  await writeChunkedData(
1202
1237
  buffers,
1203
- data => transport.writeWithRetry(data),
1238
+ data =>
1239
+ this.writeBlePacket(
1240
+ uuid,
1241
+ data,
1242
+ payload => transport.writeWithRetry(payload),
1243
+ isCurrentOwner
1244
+ ),
1204
1245
  e => {
1205
- this.runPromise = null;
1246
+ releaseOwnershipIfCurrent();
1206
1247
  Log?.error('writeCharacteristic write error: ', e);
1207
1248
  }
1208
1249
  );
@@ -1223,7 +1264,12 @@ export default class ReactNativeBleTransport {
1223
1264
  // eslint-disable-next-line no-constant-condition
1224
1265
  while (true) {
1225
1266
  try {
1226
- await transport.writeCharacteristic.writeWithoutResponse(data);
1267
+ await this.writeBlePacket(
1268
+ uuid,
1269
+ data,
1270
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1271
+ isCurrentOwner
1272
+ );
1227
1273
  return;
1228
1274
  } catch (error) {
1229
1275
  const retryType = getFirmwareUploadWriteRetryType(error);
@@ -1242,7 +1288,7 @@ export default class ReactNativeBleTransport {
1242
1288
  }
1243
1289
  },
1244
1290
  e => {
1245
- this.runPromise = null;
1291
+ releaseOwnershipIfCurrent();
1246
1292
  Log?.error('writeCharacteristic write error: ', e);
1247
1293
  }
1248
1294
  );
@@ -1251,16 +1297,18 @@ export default class ReactNativeBleTransport {
1251
1297
  const outData = o.toString('base64');
1252
1298
  // Upload resources on low-end phones may OOM
1253
1299
  try {
1254
- const shouldUseWriteWithResponse =
1255
- Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1256
- if (shouldUseWriteWithResponse) {
1257
- await transport.writeCharacteristic.writeWithResponse(outData);
1258
- } else {
1259
- await transport.writeCharacteristic.writeWithoutResponse(outData);
1260
- }
1300
+ await this.writeBlePacket(
1301
+ uuid,
1302
+ outData,
1303
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1304
+ isCurrentOwner
1305
+ );
1261
1306
  } catch (e) {
1262
1307
  Log?.debug('writeCharacteristic write error: ', e);
1263
- this.runPromise = null;
1308
+ releaseOwnershipIfCurrent();
1309
+ if (isWedgedWriteError(e)) {
1310
+ throw e;
1311
+ }
1264
1312
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1265
1313
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1266
1314
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1303,8 +1351,13 @@ export default class ReactNativeBleTransport {
1303
1351
  }
1304
1352
  const isProbeTimeout =
1305
1353
  name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1354
+ // A call that has been superseded (forceRun) or cleaned up no longer owns the
1355
+ // transport; its late timeout must not tear down the connection the current
1356
+ // call is actively using.
1357
+ const isStaleCall = this.runPromise !== runPromise;
1306
1358
  if (
1307
1359
  !isProbeTimeout &&
1360
+ !isStaleCall &&
1308
1361
  (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1309
1362
  ) {
1310
1363
  await this.disconnect(uuid);
@@ -1384,6 +1437,7 @@ export default class ReactNativeBleTransport {
1384
1437
  delete transportCache[session];
1385
1438
  }
1386
1439
  this.deviceProtocol.delete(session);
1440
+ this.probingProtocols.delete(session);
1387
1441
  this.deviceProtocolHints.delete(session);
1388
1442
  this.protocolV2Assemblers.delete(session);
1389
1443
  this.resetProtocolV2Frames(session);
@@ -1418,6 +1472,105 @@ export default class ReactNativeBleTransport {
1418
1472
  return transport;
1419
1473
  }
1420
1474
 
1475
+ /**
1476
+ * Write one packet under a bounded budget. A write that never settles means the
1477
+ * peripheral is wedged even though the GATT link still reports connected, so the
1478
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
1479
+ * cached and every later call would hang on it again.
1480
+ */
1481
+ private async writeBlePacket(
1482
+ uuid: string,
1483
+ data: string,
1484
+ write: (payload: string) => Promise<unknown>,
1485
+ isCurrentOwner?: () => boolean
1486
+ ) {
1487
+ let timer: ReturnType<typeof setTimeout> | undefined;
1488
+ let timedOut = false;
1489
+ try {
1490
+ await Promise.race([
1491
+ write(data),
1492
+ new Promise<never>((_, reject) => {
1493
+ timer = setTimeout(() => {
1494
+ timedOut = true;
1495
+ reject(
1496
+ ERRORS.TypedError(
1497
+ HardwareErrorCode.BleWriteCharacteristicError,
1498
+ `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1499
+ )
1500
+ );
1501
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1502
+ }),
1503
+ ]);
1504
+ this.writeTimeoutCounts.delete(uuid);
1505
+ } catch (error) {
1506
+ if (timedOut) {
1507
+ // A superseded call's late write must not tear down the link the current
1508
+ // call is using; only the owner of the transport may declare it dead.
1509
+ if (isCurrentOwner && !isCurrentOwner()) {
1510
+ Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1511
+ } else {
1512
+ this.tearDownWedgedLink(uuid);
1513
+ }
1514
+ }
1515
+ throw error;
1516
+ } finally {
1517
+ if (timer) clearTimeout(timer);
1518
+ }
1519
+ }
1520
+
1521
+ /**
1522
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
1523
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
1524
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
1525
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
1526
+ */
1527
+ private tearDownWedgedLink(uuid: string) {
1528
+ const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1529
+ this.writeTimeoutCounts.set(uuid, timeouts);
1530
+ Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1531
+ consecutiveWriteTimeouts: timeouts,
1532
+ });
1533
+
1534
+ const wedged = transportCache[uuid];
1535
+ this.disconnect(uuid).catch(error => {
1536
+ Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1537
+ });
1538
+ if (wedged && transportCache[uuid] === wedged) {
1539
+ delete transportCache[uuid];
1540
+ }
1541
+ this.deviceProtocol.delete(uuid);
1542
+ this.probingProtocols.delete(uuid);
1543
+ this.protocolV2Assemblers.delete(uuid);
1544
+ this.resetProtocolV2Frames(uuid);
1545
+
1546
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1547
+ // Reconnecting reuses the same native peripheral object. When it stays wedged
1548
+ // across attempts the poison lives in the BLE manager itself, and only a fresh
1549
+ // manager drops every cached peripheral — the JS equivalent of restarting the app.
1550
+ Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1551
+ this.resetPlxManager();
1552
+ this.writeTimeoutCounts.delete(uuid);
1553
+ }
1554
+ }
1555
+
1556
+ private resetPlxManager() {
1557
+ const manager = this.blePlxManager;
1558
+ this.blePlxManager = undefined;
1559
+ // Every cached transport belongs to the destroyed manager's peripherals.
1560
+ Object.keys(transportCache).forEach(key => {
1561
+ delete transportCache[key];
1562
+ });
1563
+ this.deviceProtocol.clear();
1564
+ this.probingProtocols.clear();
1565
+ this.monitorTokens.clear();
1566
+ this.protocolV2Assemblers.clear();
1567
+ try {
1568
+ manager?.destroy();
1569
+ } catch (error) {
1570
+ Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1571
+ }
1572
+ }
1573
+
1421
1574
  private createProtocolMismatchError(expected: ProtocolType) {
1422
1575
  return ERRORS.TypedError(
1423
1576
  HardwareErrorCode.RuntimeError,
@@ -1433,34 +1586,25 @@ export default class ReactNativeBleTransport {
1433
1586
  }
1434
1587
 
1435
1588
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1589
+ if (this.probingProtocols.get(uuid) === protocol) {
1590
+ this.probingProtocols.delete(uuid);
1591
+ }
1436
1592
  if (this.deviceProtocol.get(uuid) === protocol) {
1437
1593
  this.deviceProtocol.delete(uuid);
1438
1594
  }
1439
1595
  }
1440
1596
 
1597
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1598
+ private getActiveProtocol(uuid: string): ProtocolType | undefined {
1599
+ return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1600
+ }
1601
+
1441
1602
  private async detectProtocol(
1442
1603
  uuid: string,
1443
1604
  expectedProtocol?: ProtocolType,
1444
1605
  protocolHint?: ProtocolType,
1445
1606
  rebuildTransport?: () => Promise<void>
1446
1607
  ): Promise<ProtocolType> {
1447
- if (Platform.OS === 'ios') {
1448
- const protocol = expectedProtocol ?? protocolHint ?? 'V1';
1449
- let source = 'ios-legacy-default';
1450
- if (expectedProtocol) {
1451
- source = 'expected';
1452
- } else if (protocolHint) {
1453
- source = 'hint';
1454
- }
1455
- this.deviceProtocol.set(uuid, protocol);
1456
- Log?.debug('[ReactNativeBleTransport] protocol selected', {
1457
- deviceId: uuid,
1458
- protocol,
1459
- source,
1460
- });
1461
- return protocol;
1462
- }
1463
-
1464
1608
  if (expectedProtocol === 'V1') {
1465
1609
  if (await this.probeProtocolV1(uuid)) {
1466
1610
  this.deviceProtocol.set(uuid, 'V1');
@@ -1518,6 +1662,7 @@ export default class ReactNativeBleTransport {
1518
1662
  }
1519
1663
 
1520
1664
  this.deviceProtocol.delete(uuid);
1665
+ this.probingProtocols.delete(uuid);
1521
1666
  throw this.createProtocolDetectionError();
1522
1667
  }
1523
1668
 
@@ -1579,14 +1724,20 @@ export default class ReactNativeBleTransport {
1579
1724
  }
1580
1725
 
1581
1726
  try {
1582
- this.deviceProtocol.set(uuid, 'V1');
1727
+ this.probingProtocols.set(uuid, 'V1');
1583
1728
  // GetFeatures identifies Protocol V1 without resetting an existing wallet
1584
1729
  // session before Core has a chance to restore a hidden wallet.
1585
1730
  await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1731
+ this.probingProtocols.delete(uuid);
1586
1732
  return true;
1587
1733
  } catch (error) {
1588
1734
  this.clearProbeProtocol(uuid, 'V1');
1589
1735
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1736
+ // A wedged write already dropped the link, so probing another protocol on it
1737
+ // would only fail against a torn-down transport: surface the real cause.
1738
+ if (isWedgedWriteError(error)) {
1739
+ throw error;
1740
+ }
1590
1741
  return false;
1591
1742
  }
1592
1743
  }
@@ -1596,7 +1747,7 @@ export default class ReactNativeBleTransport {
1596
1747
  return false;
1597
1748
  }
1598
1749
 
1599
- this.deviceProtocol.set(uuid, 'V2');
1750
+ this.probingProtocols.set(uuid, 'V2');
1600
1751
  this.protocolV2Assemblers.get(uuid)?.reset();
1601
1752
  const detected = await probeProtocolV2Helper({
1602
1753
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1611,6 +1762,8 @@ export default class ReactNativeBleTransport {
1611
1762
  });
1612
1763
  if (!detected) {
1613
1764
  this.clearProbeProtocol(uuid, 'V2');
1765
+ } else {
1766
+ this.probingProtocols.delete(uuid);
1614
1767
  }
1615
1768
  return detected;
1616
1769
  }
@@ -1692,15 +1845,12 @@ export default class ReactNativeBleTransport {
1692
1845
  }
1693
1846
 
1694
1847
  private async writeProtocolV2Packet(
1848
+ uuid: string,
1695
1849
  transport: BleTransport,
1696
1850
  base64: string,
1697
1851
  context: ProtocolV2CallContext,
1698
1852
  assertCurrentGeneration: () => void
1699
1853
  ) {
1700
- const shouldUseWriteWithResponse =
1701
- Platform.OS === 'ios' &&
1702
- !context.highVolume &&
1703
- transport.writeCharacteristic.isWritableWithResponse;
1704
1854
  let attempt = 0;
1705
1855
  for (;;) {
1706
1856
  assertCurrentGeneration();
@@ -1708,11 +1858,21 @@ export default class ReactNativeBleTransport {
1708
1858
  throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
1709
1859
  }
1710
1860
  try {
1711
- if (shouldUseWriteWithResponse) {
1712
- await transport.writeCharacteristic.writeWithResponse(base64);
1713
- } else {
1714
- await transport.writeCharacteristic.writeWithoutResponse(base64);
1715
- }
1861
+ await this.writeBlePacket(
1862
+ uuid,
1863
+ base64,
1864
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1865
+ // Same rule as Protocol V1: a write from a superseded generation must not
1866
+ // tear down the link that the current generation is using.
1867
+ () => {
1868
+ try {
1869
+ assertCurrentGeneration();
1870
+ return !context.signal.aborted;
1871
+ } catch {
1872
+ return false;
1873
+ }
1874
+ }
1875
+ );
1716
1876
  assertCurrentGeneration();
1717
1877
  return;
1718
1878
  } catch (error) {
@@ -1735,6 +1895,7 @@ export default class ReactNativeBleTransport {
1735
1895
  }
1736
1896
 
1737
1897
  private async writeProtocolV2Frame(
1898
+ uuid: string,
1738
1899
  transport: BleTransport,
1739
1900
  frame: Uint8Array,
1740
1901
  context: ProtocolV2CallContext,
@@ -1747,25 +1908,19 @@ export default class ReactNativeBleTransport {
1747
1908
  androidPacketLength: tuning.androidPacketLength,
1748
1909
  mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
1749
1910
  });
1750
- // Match Desktop BLE pacing so Pro2 firmware can finish the previous response
1751
- // before the next single-packet control command is written.
1752
- const initialDelayMs =
1753
- Platform.OS === 'ios' && !context.highVolume && frame.length <= packetCapacity
1754
- ? IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS
1755
- : 0;
1756
1911
  await writeProtocolV2BleFrame({
1757
1912
  frame,
1758
1913
  packetCapacity,
1759
1914
  assertActive: assertCurrentGeneration,
1760
1915
  signal: context.signal,
1761
1916
  abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
1762
- initialDelayMs,
1763
1917
  burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
1764
1918
  burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
1765
1919
  flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
1766
1920
  wait: delay,
1767
1921
  writePacket: packet =>
1768
1922
  this.writeProtocolV2Packet(
1923
+ uuid,
1769
1924
  transport,
1770
1925
  Buffer.from(packet).toString('base64'),
1771
1926
  context,
@@ -1830,7 +1985,13 @@ export default class ReactNativeBleTransport {
1830
1985
  writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
1831
1986
  assertCurrentGeneration();
1832
1987
  const currentTransport = this.getCachedTransport(uuid);
1833
- await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
1988
+ await this.writeProtocolV2Frame(
1989
+ uuid,
1990
+ currentTransport,
1991
+ frame,
1992
+ context,
1993
+ assertCurrentGeneration
1994
+ );
1834
1995
  },
1835
1996
  readFrame: async () => {
1836
1997
  assertCurrentGeneration();
@@ -1855,6 +2016,6 @@ export default class ReactNativeBleTransport {
1855
2016
  }
1856
2017
 
1857
2018
  getProtocolType(path: string): ProtocolType | undefined {
1858
- return this.deviceProtocol.get(path);
2019
+ return this.getActiveProtocol(path);
1859
2020
  }
1860
2021
  }
@@ -1,132 +0,0 @@
1
- import { EventEmitter } from 'events';
2
-
3
- import { getConnectedDeviceIds } from '../BleManager';
4
- import ReactNativeBleTransport from '../index';
5
-
6
- jest.mock(
7
- 'react-native',
8
- () => ({
9
- PermissionsAndroid: {},
10
- Platform: { OS: 'ios' },
11
- }),
12
- { virtual: true }
13
- );
14
-
15
- jest.mock('react-native-ble-plx', () => ({
16
- BleError: class BleError extends Error {},
17
- BleErrorCode: {},
18
- BleManager: jest.fn(),
19
- ScanMode: { LowLatency: 2 },
20
- }));
21
-
22
- jest.mock('../BleManager', () => ({
23
- getConnectedDeviceIds: jest.fn(),
24
- onDeviceBondState: jest.fn(),
25
- pairDevice: jest.fn(),
26
- }));
27
-
28
- jest.mock('../subscribeBleOn', () => ({
29
- subscribeBleOn: jest.fn(() => Promise.resolve()),
30
- }));
31
-
32
- const ONEKEY_SERVICE_UUID = '00000001-0000-1000-8000-00805f9b34fb';
33
-
34
- describe('ReactNativeBleTransport iOS discovery', () => {
35
- test('filters a bonded Pro2 Find My peripheral while keeping the wallet peripheral', async () => {
36
- jest.mocked(getConnectedDeviceIds).mockResolvedValueOnce([
37
- {
38
- id: 'find-my-peripheral',
39
- name: 'Pro2 6E9E - Find My',
40
- localName: null,
41
- serviceUUIDs: [ONEKEY_SERVICE_UUID],
42
- },
43
- {
44
- id: 'wallet-peripheral',
45
- name: 'Pro2 6E9E',
46
- localName: 'Pro2 6E9E',
47
- serviceUUIDs: [ONEKEY_SERVICE_UUID],
48
- },
49
- ] as never);
50
- const blePlxManager = {
51
- startDeviceScan: jest.fn(),
52
- stopDeviceScan: jest.fn(),
53
- };
54
- const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
55
- transport.blePlxManager = blePlxManager as never;
56
- transport.init({ debug: jest.fn(), error: jest.fn() }, new EventEmitter());
57
-
58
- const devices = await transport.enumerate();
59
-
60
- expect(devices.map(device => device.id)).toEqual(['wallet-peripheral']);
61
- });
62
-
63
- test('filters a scanned Pro2 Find My peripheral by name when localName is null', async () => {
64
- jest.mocked(getConnectedDeviceIds).mockResolvedValueOnce([]);
65
- const blePlxManager = {
66
- startDeviceScan: jest.fn((_serviceUUIDs, _options, listener) => {
67
- queueMicrotask(() => {
68
- listener(null, {
69
- id: 'find-my-peripheral',
70
- name: 'Pro2 6E9E - Find My',
71
- localName: null,
72
- serviceUUIDs: [ONEKEY_SERVICE_UUID],
73
- });
74
- listener(null, {
75
- id: 'wallet-peripheral',
76
- name: 'Pro2 6E9E',
77
- localName: 'Pro2 6E9E',
78
- serviceUUIDs: [ONEKEY_SERVICE_UUID],
79
- });
80
- });
81
- }),
82
- stopDeviceScan: jest.fn(),
83
- };
84
- const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
85
- transport.blePlxManager = blePlxManager as never;
86
- transport.init({ debug: jest.fn(), error: jest.fn() }, new EventEmitter());
87
-
88
- const devices = await transport.enumerate();
89
-
90
- expect(devices.map(device => device.id)).toEqual(['wallet-peripheral']);
91
- });
92
-
93
- test('ignores an unnamed scanned advertisement while keeping the named wallet peripheral', async () => {
94
- jest.mocked(getConnectedDeviceIds).mockResolvedValueOnce([]);
95
- const blePlxManager = {
96
- startDeviceScan: jest.fn((_serviceUUIDs, _options, listener) => {
97
- queueMicrotask(() => {
98
- listener(null, {
99
- id: 'unnamed-peripheral',
100
- name: null,
101
- localName: null,
102
- serviceUUIDs: [
103
- '0000180a-0000-1000-8000-00805f9b34fb',
104
- '0000180f-0000-1000-8000-00805f9b34fb',
105
- '0000fffd-0000-1000-8000-00805f9b34fb',
106
- ONEKEY_SERVICE_UUID,
107
- ],
108
- });
109
- listener(null, {
110
- id: 'wallet-peripheral',
111
- name: 'Pro2 769D',
112
- localName: 'Pro2 769D',
113
- serviceUUIDs: [
114
- '0000180a-0000-1000-8000-00805f9b34fb',
115
- '0000180f-0000-1000-8000-00805f9b34fb',
116
- '0000fffd-0000-1000-8000-00805f9b34fb',
117
- ONEKEY_SERVICE_UUID,
118
- ],
119
- });
120
- });
121
- }),
122
- stopDeviceScan: jest.fn(),
123
- };
124
- const transport = new ReactNativeBleTransport({ scanTimeout: 1 });
125
- transport.blePlxManager = blePlxManager as never;
126
- transport.init({ debug: jest.fn(), error: jest.fn() }, new EventEmitter());
127
-
128
- const devices = await transport.enumerate();
129
-
130
- expect(devices.map(device => device.id)).toEqual(['wallet-peripheral']);
131
- });
132
- });