@onekeyfe/hd-transport-react-native 1.2.0-alpha.56 → 1.2.0-alpha.62

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;
@@ -164,12 +178,35 @@ function getDeviceDisplayName(device?: Device | null) {
164
178
 
165
179
  const ANDROID_REQUEST_MTU = 256;
166
180
 
181
+ const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
182
+
167
183
  const connectOptions: Record<string, unknown> = {
168
184
  requestMTU: ANDROID_REQUEST_MTU,
169
- timeout: 3000,
185
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
170
186
  refreshGatt: 'OnConnected',
171
187
  };
172
188
 
189
+ /** Fallback connect options: drops requestMTU (the thing being worked around) but keeps the native budget. */
190
+ const fallbackConnectOptions: Record<string, unknown> = {
191
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
192
+ };
193
+
194
+ /**
195
+ * JS backstop for connect. The native adapter applies its own 3s budget, but it
196
+ * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
197
+ * firmware install tears the link down) can leave the promise unsettled — observed
198
+ * blocking a reconnect for 61s until the app-level timeout. Healthy connects finish
199
+ * inside the native budget, so this only fires when the native timeout did not.
200
+ */
201
+ export const BLE_CONNECT_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS * 2 + 2000;
202
+ /** Consecutive connect timeouts on one device before the BLE manager itself is recreated. */
203
+ export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
204
+ const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
205
+ const isConnectTimeoutError = (error: unknown): boolean =>
206
+ (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
207
+ typeof (error as { message?: unknown })?.message === 'string' &&
208
+ (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
209
+
173
210
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
174
211
 
175
212
  const tryToGetConfiguration = (device: Device) => {
@@ -258,6 +295,20 @@ export default class ReactNativeBleTransport {
258
295
  /** Per-device protocol type detected by active wire-level probe after connect. */
259
296
  private deviceProtocol: Map<string, ProtocolType> = new Map();
260
297
 
298
+ /**
299
+ * Protocol a probe is currently trying, before the device has confirmed it. Calls
300
+ * must route with it, but acquire() must not treat it as a detected protocol: a
301
+ * probe that never answers would otherwise leave the reuse fast path handing out a
302
+ * transport that was never validated.
303
+ */
304
+ private probingProtocols: Map<string, ProtocolType> = new Map();
305
+
306
+ /** Consecutive write timeouts per device; reset by any write that completes. */
307
+ private writeTimeoutCounts: Map<string, number> = new Map();
308
+
309
+ /** Consecutive connect timeouts per device; reset by any connect that settles. */
310
+ private connectTimeoutCounts: Map<string, number> = new Map();
311
+
261
312
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
262
313
 
263
314
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
@@ -477,13 +528,13 @@ export default class ReactNativeBleTransport {
477
528
  const isConnected = await device.isConnected().catch(() => false);
478
529
  if (!isConnected) {
479
530
  try {
480
- device = await device.connect(connectOptions);
531
+ device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
481
532
  } catch (e) {
482
533
  if (
483
534
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
484
535
  e.errorCode === BleErrorCode.OperationCancelled
485
536
  ) {
486
- device = await device.connect();
537
+ device = await this.connectWithTimeout(uuid, () => device.connect());
487
538
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
488
539
  throw e;
489
540
  }
@@ -582,21 +633,12 @@ export default class ReactNativeBleTransport {
582
633
  }
583
634
 
584
635
  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
- });
636
+ const isOneKey = isOnekeyBluetoothDevice({
637
+ id: device?.id,
638
+ name: device?.name,
639
+ localName: device?.localName,
640
+ serviceUuids: device?.serviceUUIDs,
641
+ });
600
642
  if (isOneKey) {
601
643
  addDevice(device as unknown as Device);
602
644
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
@@ -617,12 +659,7 @@ export default class ReactNativeBleTransport {
617
659
  'localName' in device && typeof device.localName === 'string'
618
660
  ? device.localName
619
661
  : null;
620
- const isFindMyPeripheral =
621
- isPro2FindMyAdvertisementName(device.name) ||
622
- isPro2FindMyAdvertisementName(localName);
623
-
624
662
  if (
625
- !isFindMyPeripheral &&
626
663
  isOnekeyBluetoothDevice({
627
664
  id: device.id,
628
665
  name: device.name,
@@ -775,15 +812,22 @@ export default class ReactNativeBleTransport {
775
812
  if (!device) {
776
813
  Log?.debug('try to connect to device: ', uuid);
777
814
  try {
778
- device = await blePlxManager.connectToDevice(uuid, connectOptions);
815
+ device = await this.connectWithTimeout(uuid, () =>
816
+ blePlxManager.connectToDevice(uuid, connectOptions)
817
+ );
779
818
  } catch (e) {
780
819
  Log?.debug('try to connect to device has error: ', e);
820
+ if (isConnectTimeoutError(e)) {
821
+ throw e;
822
+ }
781
823
  if (
782
824
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
783
825
  e.errorCode === BleErrorCode.OperationCancelled
784
826
  ) {
785
827
  Log?.debug('first try to reconnect without params');
786
- device = await blePlxManager.connectToDevice(uuid);
828
+ device = await this.connectWithTimeout(uuid, () =>
829
+ blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
830
+ );
787
831
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
788
832
  Log?.debug('device already connected');
789
833
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -799,26 +843,36 @@ export default class ReactNativeBleTransport {
799
843
 
800
844
  if (!(await device.isConnected())) {
801
845
  Log?.debug('not connected, try to connect to device: ', uuid);
846
+ const disconnectedDevice = device;
802
847
 
803
848
  try {
804
- device = await device.connect(connectOptions);
849
+ device = await this.connectWithTimeout(uuid, () =>
850
+ disconnectedDevice.connect(connectOptions)
851
+ );
805
852
  } catch (e) {
806
853
  Log?.debug('not connected, try to connect to device has error: ', e);
854
+ if (isConnectTimeoutError(e)) {
855
+ throw e;
856
+ }
807
857
  if (
808
858
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
809
859
  e.errorCode === BleErrorCode.OperationCancelled
810
860
  ) {
811
861
  Log?.debug('second try to reconnect without params');
812
862
  try {
813
- device = await device.connect();
863
+ device = await this.connectWithTimeout(uuid, () =>
864
+ disconnectedDevice.connect(fallbackConnectOptions)
865
+ );
814
866
  } catch (e) {
815
867
  Log?.debug('last try to reconnect error: ', e);
816
868
  // last try to reconnect device if this issue exists
817
869
  // https://github.com/dotintent/react-native-ble-plx/issues/426
818
870
  if (e.errorCode === BleErrorCode.OperationCancelled) {
819
871
  Log?.debug('last try to reconnect');
820
- await device.cancelConnection();
821
- device = await device.connect();
872
+ await disconnectedDevice.cancelConnection();
873
+ device = await this.connectWithTimeout(uuid, () =>
874
+ disconnectedDevice.connect(fallbackConnectOptions)
875
+ );
822
876
  }
823
877
  }
824
878
  } else {
@@ -895,7 +949,7 @@ export default class ReactNativeBleTransport {
895
949
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
896
950
  return;
897
951
  }
898
- if (this.deviceProtocol.get(uuid) === 'V2') {
952
+ if (this.getActiveProtocol(uuid) === 'V2') {
899
953
  let errorCode:
900
954
  | typeof HardwareErrorCode.BleDeviceBondError
901
955
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -965,7 +1019,7 @@ export default class ReactNativeBleTransport {
965
1019
 
966
1020
  try {
967
1021
  const data = Buffer.from(c.value as string, 'base64');
968
- const protocol = this.deviceProtocol.get(uuid);
1022
+ const protocol = this.getActiveProtocol(uuid);
969
1023
  if (!protocol) {
970
1024
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
971
1025
  return;
@@ -999,7 +1053,7 @@ export default class ReactNativeBleTransport {
999
1053
  } catch (error) {
1000
1054
  Log?.debug('monitor data error: ', error);
1001
1055
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1002
- if (this.deviceProtocol.get(uuid) === 'V2') {
1056
+ if (this.getActiveProtocol(uuid) === 'V2') {
1003
1057
  this.rejectProtocolV2Frames(uuid, notifyError);
1004
1058
  } else if (this.runPromiseDeviceId === uuid) {
1005
1059
  this.runPromise?.reject(notifyError);
@@ -1063,6 +1117,7 @@ export default class ReactNativeBleTransport {
1063
1117
  }
1064
1118
 
1065
1119
  this.deviceProtocol.delete(uuid);
1120
+ this.probingProtocols.delete(uuid);
1066
1121
  // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1067
1122
  this.protocolV2Assemblers.get(uuid)?.reset();
1068
1123
  this.protocolV2Assemblers.delete(uuid);
@@ -1129,8 +1184,25 @@ export default class ReactNativeBleTransport {
1129
1184
  const transport = this.getCachedTransport(uuid);
1130
1185
  const runPromise = createDeferred<string>();
1131
1186
  runPromise.promise.catch(() => undefined);
1187
+ const supersededRunPromise = this.runPromise;
1188
+ if (supersededRunPromise) {
1189
+ // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1190
+ // the superseded deferred now so its response race resolves and its finally block
1191
+ // clears its timeout timer; an orphaned timer would otherwise fire much later and
1192
+ // tear down the shared connection while another call is using it.
1193
+ supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1194
+ }
1132
1195
  this.runPromise = runPromise;
1133
1196
  this.runPromiseDeviceId = uuid;
1197
+ // A superseded call's late write failure must not clear the successor's ownership;
1198
+ // only the call that still owns the slot may release it.
1199
+ const releaseOwnershipIfCurrent = () => {
1200
+ if (this.runPromise === runPromise) {
1201
+ this.runPromise = null;
1202
+ this.runPromiseDeviceId = null;
1203
+ }
1204
+ };
1205
+ const isCurrentOwner = () => this.runPromise === runPromise;
1134
1206
  const messages = this._messages;
1135
1207
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1136
1208
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1156,6 +1228,9 @@ export default class ReactNativeBleTransport {
1156
1228
  chunk = ByteBuffer.allocate(packetCapacity);
1157
1229
  } catch (e) {
1158
1230
  onError(e);
1231
+ if (isWedgedWriteError(e)) {
1232
+ throw e;
1233
+ }
1159
1234
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1160
1235
  }
1161
1236
  }
@@ -1187,6 +1262,9 @@ export default class ReactNativeBleTransport {
1187
1262
  }
1188
1263
  } catch (e) {
1189
1264
  onError(e);
1265
+ if (isWedgedWriteError(e)) {
1266
+ throw e;
1267
+ }
1190
1268
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1191
1269
  }
1192
1270
  }
@@ -1200,9 +1278,15 @@ export default class ReactNativeBleTransport {
1200
1278
  if (name === 'EmmcFileWrite') {
1201
1279
  await writeChunkedData(
1202
1280
  buffers,
1203
- data => transport.writeWithRetry(data),
1281
+ data =>
1282
+ this.writeBlePacket(
1283
+ uuid,
1284
+ data,
1285
+ payload => transport.writeWithRetry(payload),
1286
+ isCurrentOwner
1287
+ ),
1204
1288
  e => {
1205
- this.runPromise = null;
1289
+ releaseOwnershipIfCurrent();
1206
1290
  Log?.error('writeCharacteristic write error: ', e);
1207
1291
  }
1208
1292
  );
@@ -1223,7 +1307,12 @@ export default class ReactNativeBleTransport {
1223
1307
  // eslint-disable-next-line no-constant-condition
1224
1308
  while (true) {
1225
1309
  try {
1226
- await transport.writeCharacteristic.writeWithResponse(data);
1310
+ await this.writeBlePacket(
1311
+ uuid,
1312
+ data,
1313
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1314
+ isCurrentOwner
1315
+ );
1227
1316
  return;
1228
1317
  } catch (error) {
1229
1318
  const retryType = getFirmwareUploadWriteRetryType(error);
@@ -1242,7 +1331,7 @@ export default class ReactNativeBleTransport {
1242
1331
  }
1243
1332
  },
1244
1333
  e => {
1245
- this.runPromise = null;
1334
+ releaseOwnershipIfCurrent();
1246
1335
  Log?.error('writeCharacteristic write error: ', e);
1247
1336
  }
1248
1337
  );
@@ -1251,16 +1340,18 @@ export default class ReactNativeBleTransport {
1251
1340
  const outData = o.toString('base64');
1252
1341
  // Upload resources on low-end phones may OOM
1253
1342
  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
- }
1343
+ await this.writeBlePacket(
1344
+ uuid,
1345
+ outData,
1346
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1347
+ isCurrentOwner
1348
+ );
1261
1349
  } catch (e) {
1262
1350
  Log?.debug('writeCharacteristic write error: ', e);
1263
- this.runPromise = null;
1351
+ releaseOwnershipIfCurrent();
1352
+ if (isWedgedWriteError(e)) {
1353
+ throw e;
1354
+ }
1264
1355
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1265
1356
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1266
1357
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1303,8 +1394,13 @@ export default class ReactNativeBleTransport {
1303
1394
  }
1304
1395
  const isProbeTimeout =
1305
1396
  name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1397
+ // A call that has been superseded (forceRun) or cleaned up no longer owns the
1398
+ // transport; its late timeout must not tear down the connection the current
1399
+ // call is actively using.
1400
+ const isStaleCall = this.runPromise !== runPromise;
1306
1401
  if (
1307
1402
  !isProbeTimeout &&
1403
+ !isStaleCall &&
1308
1404
  (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1309
1405
  ) {
1310
1406
  await this.disconnect(uuid);
@@ -1384,6 +1480,7 @@ export default class ReactNativeBleTransport {
1384
1480
  delete transportCache[session];
1385
1481
  }
1386
1482
  this.deviceProtocol.delete(session);
1483
+ this.probingProtocols.delete(session);
1387
1484
  this.deviceProtocolHints.delete(session);
1388
1485
  this.protocolV2Assemblers.delete(session);
1389
1486
  this.resetProtocolV2Frames(session);
@@ -1410,6 +1507,75 @@ export default class ReactNativeBleTransport {
1410
1507
  this.runPromiseDeviceId = null;
1411
1508
  }
1412
1509
 
1510
+ /** Run a native connect under the JS backstop budget. */
1511
+ private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1512
+ let timer: ReturnType<typeof setTimeout> | undefined;
1513
+ let timedOut = false;
1514
+ const pending = connect();
1515
+ // The abandoned attempt keeps running; swallow its late outcome so it cannot
1516
+ // surface as an unhandled rejection after we have already given up on it.
1517
+ pending.catch(() => undefined);
1518
+ try {
1519
+ const result = await Promise.race([
1520
+ pending,
1521
+ new Promise<never>((_, reject) => {
1522
+ timer = setTimeout(() => {
1523
+ timedOut = true;
1524
+ reject(
1525
+ ERRORS.TypedError(
1526
+ HardwareErrorCode.BleConnectedError,
1527
+ `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1528
+ )
1529
+ );
1530
+ }, BLE_CONNECT_TIMEOUT_MS);
1531
+ }),
1532
+ ]);
1533
+ this.connectTimeoutCounts.delete(uuid);
1534
+ return result;
1535
+ } catch (error) {
1536
+ if (timedOut) {
1537
+ this.abandonStalledConnect(uuid);
1538
+ }
1539
+ throw error;
1540
+ } finally {
1541
+ if (timer) clearTimeout(timer);
1542
+ }
1543
+ }
1544
+
1545
+ /**
1546
+ * Give up on a connect the native layer never settled. The abandoned attempt still
1547
+ * holds a native "connecting" entry that would cancel the NEXT attempt out from under
1548
+ * itself, so it is cleared here — fire and forget, because that call talks to the very
1549
+ * queue that just stopped responding.
1550
+ */
1551
+ private abandonStalledConnect(uuid: string) {
1552
+ const timeouts = (this.connectTimeoutCounts.get(uuid) ?? 0) + 1;
1553
+ this.connectTimeoutCounts.set(uuid, timeouts);
1554
+ Log?.error('[ReactNativeBleTransport] BLE connect timed out:', uuid, {
1555
+ consecutiveConnectTimeouts: timeouts,
1556
+ });
1557
+
1558
+ this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1559
+ // Rejects with "Operation was cancelled" while merely connecting — expected.
1560
+ });
1561
+ const stalled = transportCache[uuid];
1562
+ if (stalled) {
1563
+ delete transportCache[uuid];
1564
+ }
1565
+ this.deviceProtocol.delete(uuid);
1566
+ this.probingProtocols.delete(uuid);
1567
+ this.protocolV2Assemblers.delete(uuid);
1568
+ this.resetProtocolV2Frames(uuid);
1569
+
1570
+ if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1571
+ // BleManager.destroy() force-rejects every promise the native queue abandoned —
1572
+ // the only JS-reachable way to settle them — and drops all cached peripherals.
1573
+ Log?.error('[ReactNativeBleTransport] BLE connects wedged repeatedly, resetting BLE manager');
1574
+ this.resetPlxManager();
1575
+ this.connectTimeoutCounts.delete(uuid);
1576
+ }
1577
+ }
1578
+
1413
1579
  private getCachedTransport(uuid: string) {
1414
1580
  const transport = transportCache[uuid];
1415
1581
  if (!transport) {
@@ -1418,6 +1584,105 @@ export default class ReactNativeBleTransport {
1418
1584
  return transport;
1419
1585
  }
1420
1586
 
1587
+ /**
1588
+ * Write one packet under a bounded budget. A write that never settles means the
1589
+ * peripheral is wedged even though the GATT link still reports connected, so the
1590
+ * link is torn down: releasing JS state alone would leave the poisoned peripheral
1591
+ * cached and every later call would hang on it again.
1592
+ */
1593
+ private async writeBlePacket(
1594
+ uuid: string,
1595
+ data: string,
1596
+ write: (payload: string) => Promise<unknown>,
1597
+ isCurrentOwner?: () => boolean
1598
+ ) {
1599
+ let timer: ReturnType<typeof setTimeout> | undefined;
1600
+ let timedOut = false;
1601
+ try {
1602
+ await Promise.race([
1603
+ write(data),
1604
+ new Promise<never>((_, reject) => {
1605
+ timer = setTimeout(() => {
1606
+ timedOut = true;
1607
+ reject(
1608
+ ERRORS.TypedError(
1609
+ HardwareErrorCode.BleWriteCharacteristicError,
1610
+ `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1611
+ )
1612
+ );
1613
+ }, BLE_WRITE_PACKET_TIMEOUT_MS);
1614
+ }),
1615
+ ]);
1616
+ this.writeTimeoutCounts.delete(uuid);
1617
+ } catch (error) {
1618
+ if (timedOut) {
1619
+ // A superseded call's late write must not tear down the link the current
1620
+ // call is using; only the owner of the transport may declare it dead.
1621
+ if (isCurrentOwner && !isCurrentOwner()) {
1622
+ Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1623
+ } else {
1624
+ this.tearDownWedgedLink(uuid);
1625
+ }
1626
+ }
1627
+ throw error;
1628
+ } finally {
1629
+ if (timer) clearTimeout(timer);
1630
+ }
1631
+ }
1632
+
1633
+ /**
1634
+ * Drop a link whose writes stopped completing. The JS state is purged synchronously
1635
+ * so the next acquire() cannot reuse the dead transport, while the native teardown is
1636
+ * intentionally NOT awaited: it talks to the very layer that just stopped settling
1637
+ * promises, so awaiting it could hang exactly like the write it is recovering from.
1638
+ */
1639
+ private tearDownWedgedLink(uuid: string) {
1640
+ const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1641
+ this.writeTimeoutCounts.set(uuid, timeouts);
1642
+ Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1643
+ consecutiveWriteTimeouts: timeouts,
1644
+ });
1645
+
1646
+ const wedged = transportCache[uuid];
1647
+ this.disconnect(uuid).catch(error => {
1648
+ Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1649
+ });
1650
+ if (wedged && transportCache[uuid] === wedged) {
1651
+ delete transportCache[uuid];
1652
+ }
1653
+ this.deviceProtocol.delete(uuid);
1654
+ this.probingProtocols.delete(uuid);
1655
+ this.protocolV2Assemblers.delete(uuid);
1656
+ this.resetProtocolV2Frames(uuid);
1657
+
1658
+ if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1659
+ // Reconnecting reuses the same native peripheral object. When it stays wedged
1660
+ // across attempts the poison lives in the BLE manager itself, and only a fresh
1661
+ // manager drops every cached peripheral — the JS equivalent of restarting the app.
1662
+ Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1663
+ this.resetPlxManager();
1664
+ this.writeTimeoutCounts.delete(uuid);
1665
+ }
1666
+ }
1667
+
1668
+ private resetPlxManager() {
1669
+ const manager = this.blePlxManager;
1670
+ this.blePlxManager = undefined;
1671
+ // Every cached transport belongs to the destroyed manager's peripherals.
1672
+ Object.keys(transportCache).forEach(key => {
1673
+ delete transportCache[key];
1674
+ });
1675
+ this.deviceProtocol.clear();
1676
+ this.probingProtocols.clear();
1677
+ this.monitorTokens.clear();
1678
+ this.protocolV2Assemblers.clear();
1679
+ try {
1680
+ manager?.destroy();
1681
+ } catch (error) {
1682
+ Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1683
+ }
1684
+ }
1685
+
1421
1686
  private createProtocolMismatchError(expected: ProtocolType) {
1422
1687
  return ERRORS.TypedError(
1423
1688
  HardwareErrorCode.RuntimeError,
@@ -1433,34 +1698,25 @@ export default class ReactNativeBleTransport {
1433
1698
  }
1434
1699
 
1435
1700
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1701
+ if (this.probingProtocols.get(uuid) === protocol) {
1702
+ this.probingProtocols.delete(uuid);
1703
+ }
1436
1704
  if (this.deviceProtocol.get(uuid) === protocol) {
1437
1705
  this.deviceProtocol.delete(uuid);
1438
1706
  }
1439
1707
  }
1440
1708
 
1709
+ /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1710
+ private getActiveProtocol(uuid: string): ProtocolType | undefined {
1711
+ return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1712
+ }
1713
+
1441
1714
  private async detectProtocol(
1442
1715
  uuid: string,
1443
1716
  expectedProtocol?: ProtocolType,
1444
1717
  protocolHint?: ProtocolType,
1445
1718
  rebuildTransport?: () => Promise<void>
1446
1719
  ): 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
1720
  if (expectedProtocol === 'V1') {
1465
1721
  if (await this.probeProtocolV1(uuid)) {
1466
1722
  this.deviceProtocol.set(uuid, 'V1');
@@ -1518,6 +1774,7 @@ export default class ReactNativeBleTransport {
1518
1774
  }
1519
1775
 
1520
1776
  this.deviceProtocol.delete(uuid);
1777
+ this.probingProtocols.delete(uuid);
1521
1778
  throw this.createProtocolDetectionError();
1522
1779
  }
1523
1780
 
@@ -1579,14 +1836,20 @@ export default class ReactNativeBleTransport {
1579
1836
  }
1580
1837
 
1581
1838
  try {
1582
- this.deviceProtocol.set(uuid, 'V1');
1839
+ this.probingProtocols.set(uuid, 'V1');
1583
1840
  // GetFeatures identifies Protocol V1 without resetting an existing wallet
1584
1841
  // session before Core has a chance to restore a hidden wallet.
1585
1842
  await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1843
+ this.probingProtocols.delete(uuid);
1586
1844
  return true;
1587
1845
  } catch (error) {
1588
1846
  this.clearProbeProtocol(uuid, 'V1');
1589
1847
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1848
+ // A wedged write already dropped the link, so probing another protocol on it
1849
+ // would only fail against a torn-down transport: surface the real cause.
1850
+ if (isWedgedWriteError(error)) {
1851
+ throw error;
1852
+ }
1590
1853
  return false;
1591
1854
  }
1592
1855
  }
@@ -1596,7 +1859,7 @@ export default class ReactNativeBleTransport {
1596
1859
  return false;
1597
1860
  }
1598
1861
 
1599
- this.deviceProtocol.set(uuid, 'V2');
1862
+ this.probingProtocols.set(uuid, 'V2');
1600
1863
  this.protocolV2Assemblers.get(uuid)?.reset();
1601
1864
  const detected = await probeProtocolV2Helper({
1602
1865
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1611,6 +1874,8 @@ export default class ReactNativeBleTransport {
1611
1874
  });
1612
1875
  if (!detected) {
1613
1876
  this.clearProbeProtocol(uuid, 'V2');
1877
+ } else {
1878
+ this.probingProtocols.delete(uuid);
1614
1879
  }
1615
1880
  return detected;
1616
1881
  }
@@ -1692,14 +1957,12 @@ export default class ReactNativeBleTransport {
1692
1957
  }
1693
1958
 
1694
1959
  private async writeProtocolV2Packet(
1960
+ uuid: string,
1695
1961
  transport: BleTransport,
1696
1962
  base64: string,
1697
1963
  context: ProtocolV2CallContext,
1698
1964
  assertCurrentGeneration: () => void
1699
1965
  ) {
1700
- const shouldUseWriteWithResponse =
1701
- transport.writeCharacteristic.isWritableWithResponse &&
1702
- (context.writeWithResponse === true || (Platform.OS === 'ios' && !context.highVolume));
1703
1966
  let attempt = 0;
1704
1967
  for (;;) {
1705
1968
  assertCurrentGeneration();
@@ -1707,11 +1970,21 @@ export default class ReactNativeBleTransport {
1707
1970
  throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
1708
1971
  }
1709
1972
  try {
1710
- if (shouldUseWriteWithResponse) {
1711
- await transport.writeCharacteristic.writeWithResponse(base64);
1712
- } else {
1713
- await transport.writeCharacteristic.writeWithoutResponse(base64);
1714
- }
1973
+ await this.writeBlePacket(
1974
+ uuid,
1975
+ base64,
1976
+ payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1977
+ // Same rule as Protocol V1: a write from a superseded generation must not
1978
+ // tear down the link that the current generation is using.
1979
+ () => {
1980
+ try {
1981
+ assertCurrentGeneration();
1982
+ return !context.signal.aborted;
1983
+ } catch {
1984
+ return false;
1985
+ }
1986
+ }
1987
+ );
1715
1988
  assertCurrentGeneration();
1716
1989
  return;
1717
1990
  } catch (error) {
@@ -1734,6 +2007,7 @@ export default class ReactNativeBleTransport {
1734
2007
  }
1735
2008
 
1736
2009
  private async writeProtocolV2Frame(
2010
+ uuid: string,
1737
2011
  transport: BleTransport,
1738
2012
  frame: Uint8Array,
1739
2013
  context: ProtocolV2CallContext,
@@ -1746,25 +2020,19 @@ export default class ReactNativeBleTransport {
1746
2020
  androidPacketLength: tuning.androidPacketLength,
1747
2021
  mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
1748
2022
  });
1749
- // Match Desktop BLE pacing so Pro2 firmware can finish the previous response
1750
- // before the next single-packet control command is written.
1751
- const initialDelayMs =
1752
- Platform.OS === 'ios' && !context.highVolume && frame.length <= packetCapacity
1753
- ? IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS
1754
- : 0;
1755
2023
  await writeProtocolV2BleFrame({
1756
2024
  frame,
1757
2025
  packetCapacity,
1758
2026
  assertActive: assertCurrentGeneration,
1759
2027
  signal: context.signal,
1760
2028
  abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
1761
- initialDelayMs,
1762
2029
  burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
1763
2030
  burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
1764
2031
  flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
1765
2032
  wait: delay,
1766
2033
  writePacket: packet =>
1767
2034
  this.writeProtocolV2Packet(
2035
+ uuid,
1768
2036
  transport,
1769
2037
  Buffer.from(packet).toString('base64'),
1770
2038
  context,
@@ -1790,7 +2058,7 @@ export default class ReactNativeBleTransport {
1790
2058
  const tuning = getProtocolV2BleTuning();
1791
2059
  Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
1792
2060
  name,
1793
- writeMode: options?.writeWithResponse ? 'withResponse' : 'withoutResponse',
2061
+ writeMode: 'withoutResponse',
1794
2062
  packetCapacity: Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
1795
2063
  });
1796
2064
  }
@@ -1829,7 +2097,13 @@ export default class ReactNativeBleTransport {
1829
2097
  writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
1830
2098
  assertCurrentGeneration();
1831
2099
  const currentTransport = this.getCachedTransport(uuid);
1832
- await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
2100
+ await this.writeProtocolV2Frame(
2101
+ uuid,
2102
+ currentTransport,
2103
+ frame,
2104
+ context,
2105
+ assertCurrentGeneration
2106
+ );
1833
2107
  },
1834
2108
  readFrame: async () => {
1835
2109
  assertCurrentGeneration();
@@ -1854,6 +2128,6 @@ export default class ReactNativeBleTransport {
1854
2128
  }
1855
2129
 
1856
2130
  getProtocolType(path: string): ProtocolType | undefined {
1857
- return this.deviceProtocol.get(path);
2131
+ return this.getActiveProtocol(path);
1858
2132
  }
1859
2133
  }