@onekeyfe/hd-transport-react-native 1.2.0-alpha.64 → 1.2.0-alpha.66

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,6 +28,7 @@ import {
28
28
  HardwareErrorCode,
29
29
  createDeferred,
30
30
  isOnekeyBluetoothDevice,
31
+ isPro2FindMyAdvertisementName,
31
32
  } from '@onekeyfe/hd-shared';
32
33
 
33
34
  import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
@@ -60,6 +61,7 @@ const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
60
61
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
61
62
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
62
63
  const FIRMWARE_UPLOAD_WRITE_MAX_RETRIES = 8;
64
+ const IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS = 5;
63
65
  const ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH = 192;
64
66
  const FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY =
65
67
  Platform.OS === 'ios' ? IOS_PACKET_LENGTH : ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH;
@@ -71,6 +73,33 @@ type ResolvedBleCharacteristics = {
71
73
  notifyCharacteristic: Characteristic;
72
74
  };
73
75
 
76
+ const isAsciiWhitespace = (code: number) =>
77
+ code === 0x09 ||
78
+ code === 0x0a ||
79
+ code === 0x0b ||
80
+ code === 0x0c ||
81
+ code === 0x0d ||
82
+ code === 0x20;
83
+
84
+ const hasGattCongestedStatus = (text: string) => {
85
+ let searchFrom = 0;
86
+ while (searchFrom < text.length) {
87
+ const statusIndex = text.indexOf('status', searchFrom);
88
+ if (statusIndex < 0) return false;
89
+
90
+ let cursor = statusIndex + 'status'.length;
91
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
92
+ if (text[cursor] === ':' || text[cursor] === '=') {
93
+ cursor += 1;
94
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
95
+ }
96
+ if (text.startsWith(String(ANDROID_GATT_CONGESTED_STATUS), cursor)) return true;
97
+
98
+ searchFrom = statusIndex + 'status'.length;
99
+ }
100
+ return false;
101
+ };
102
+
74
103
  const delay = (ms: number) =>
75
104
  new Promise<void>(resolve => {
76
105
  setTimeout(resolve, ms);
@@ -99,29 +128,13 @@ export const getFirmwareUploadWriteRetryType = (
99
128
  const text = [bleWriteError.reason, bleWriteError.message, bleWriteError.name]
100
129
  .filter(value => typeof value === 'string')
101
130
  .join(' ');
102
- return /GATT_CONGESTED|status\s*[:=]?\s*143/.test(text) ? 'congested' : null;
131
+ return text.includes('GATT_CONGESTED') || hasGattCongestedStatus(text) ? 'congested' : null;
103
132
  };
104
133
 
105
134
  const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, maxDelayMs = 1200) =>
106
135
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
107
136
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
108
137
  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;
125
138
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
126
139
  const IOS_NOTIFY_READY_DELAY_MS = 150;
127
140
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
@@ -178,43 +191,12 @@ function getDeviceDisplayName(device?: Device | null) {
178
191
 
179
192
  const ANDROID_REQUEST_MTU = 256;
180
193
 
181
- const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
182
-
183
194
  const connectOptions: Record<string, unknown> = {
184
195
  requestMTU: ANDROID_REQUEST_MTU,
185
- timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
196
+ timeout: 3000,
186
197
  refreshGatt: 'OnConnected',
187
198
  };
188
199
 
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
- /**
203
- * How many times a known device may fail its own protocol before we probe the others
204
- * again. Reconnect polling during a device reboot repeats this every few seconds, and
205
- * probing Protocol V2 costs a 10s Ping timeout, so paying it on every attempt for a
206
- * device we just spoke V1 to dominates the wait. A firmware update can legitimately
207
- * change a device's protocol, so the shortcut has to expire rather than stick.
208
- */
209
- export const PROTOCOL_REPROBE_FALLBACK_ATTEMPTS = 3;
210
- /** Consecutive connect timeouts on one device before the BLE manager itself is recreated. */
211
- export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
212
- const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
213
- const isConnectTimeoutError = (error: unknown): boolean =>
214
- (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
215
- typeof (error as { message?: unknown })?.message === 'string' &&
216
- (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
217
-
218
200
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
219
201
 
220
202
  const tryToGetConfiguration = (device: Device) => {
@@ -303,28 +285,8 @@ export default class ReactNativeBleTransport {
303
285
  /** Per-device protocol type detected by active wire-level probe after connect. */
304
286
  private deviceProtocol: Map<string, ProtocolType> = new Map();
305
287
 
306
- /**
307
- * Protocol a probe is currently trying, before the device has confirmed it. Calls
308
- * must route with it, but acquire() must not treat it as a detected protocol: a
309
- * probe that never answers would otherwise leave the reuse fast path handing out a
310
- * transport that was never validated.
311
- */
312
- private probingProtocols: Map<string, ProtocolType> = new Map();
313
-
314
- /** Consecutive write timeouts per device; reset by any write that completes. */
315
- private writeTimeoutCounts: Map<string, number> = new Map();
316
-
317
- /** Consecutive connect timeouts per device; reset by any connect that settles. */
318
- private connectTimeoutCounts: Map<string, number> = new Map();
319
-
320
288
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
321
289
 
322
- /** Protocol this device actually answered on, kept across reconnects of one session. */
323
- private sessionProtocols: Map<string, ProtocolType> = new Map();
324
-
325
- /** Consecutive detections that failed while trusting sessionProtocols. */
326
- private protocolReprobeFailures: Map<string, number> = new Map();
327
-
328
290
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
329
291
 
330
292
  private protocolV2FrameQueues: Map<string, Uint8Array[]> = new Map();
@@ -542,13 +504,13 @@ export default class ReactNativeBleTransport {
542
504
  const isConnected = await device.isConnected().catch(() => false);
543
505
  if (!isConnected) {
544
506
  try {
545
- device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
507
+ device = await device.connect(connectOptions);
546
508
  } catch (e) {
547
509
  if (
548
510
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
549
511
  e.errorCode === BleErrorCode.OperationCancelled
550
512
  ) {
551
- device = await this.connectWithTimeout(uuid, () => device.connect());
513
+ device = await device.connect();
552
514
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
553
515
  throw e;
554
516
  }
@@ -647,12 +609,21 @@ export default class ReactNativeBleTransport {
647
609
  }
648
610
 
649
611
  const displayName = getDeviceDisplayName(device);
650
- const isOneKey = isOnekeyBluetoothDevice({
651
- id: device?.id,
652
- name: device?.name,
653
- localName: device?.localName,
654
- serviceUuids: device?.serviceUUIDs,
655
- });
612
+ // iOS may report a service-only advertisement before the named scan response.
613
+ // Do not cache that incomplete advertisement as an unknown device.
614
+ const isUnnamedIOSPeripheral = Platform.OS === 'ios' && !displayName?.trim();
615
+ const isFindMyPeripheral =
616
+ isPro2FindMyAdvertisementName(device?.name) ||
617
+ isPro2FindMyAdvertisementName(device?.localName);
618
+ const isOneKey =
619
+ !isUnnamedIOSPeripheral &&
620
+ !isFindMyPeripheral &&
621
+ isOnekeyBluetoothDevice({
622
+ id: device?.id,
623
+ name: device?.name,
624
+ localName: device?.localName,
625
+ serviceUuids: device?.serviceUUIDs,
626
+ });
656
627
  if (isOneKey) {
657
628
  addDevice(device as unknown as Device);
658
629
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
@@ -673,7 +644,12 @@ export default class ReactNativeBleTransport {
673
644
  'localName' in device && typeof device.localName === 'string'
674
645
  ? device.localName
675
646
  : null;
647
+ const isFindMyPeripheral =
648
+ isPro2FindMyAdvertisementName(device.name) ||
649
+ isPro2FindMyAdvertisementName(localName);
650
+
676
651
  if (
652
+ !isFindMyPeripheral &&
677
653
  isOnekeyBluetoothDevice({
678
654
  id: device.id,
679
655
  name: device.name,
@@ -826,22 +802,15 @@ export default class ReactNativeBleTransport {
826
802
  if (!device) {
827
803
  Log?.debug('try to connect to device: ', uuid);
828
804
  try {
829
- device = await this.connectWithTimeout(uuid, () =>
830
- blePlxManager.connectToDevice(uuid, connectOptions)
831
- );
805
+ device = await blePlxManager.connectToDevice(uuid, connectOptions);
832
806
  } catch (e) {
833
807
  Log?.debug('try to connect to device has error: ', e);
834
- if (isConnectTimeoutError(e)) {
835
- throw e;
836
- }
837
808
  if (
838
809
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
839
810
  e.errorCode === BleErrorCode.OperationCancelled
840
811
  ) {
841
812
  Log?.debug('first try to reconnect without params');
842
- device = await this.connectWithTimeout(uuid, () =>
843
- blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
844
- );
813
+ device = await blePlxManager.connectToDevice(uuid);
845
814
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
846
815
  Log?.debug('device already connected');
847
816
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -857,36 +826,26 @@ export default class ReactNativeBleTransport {
857
826
 
858
827
  if (!(await device.isConnected())) {
859
828
  Log?.debug('not connected, try to connect to device: ', uuid);
860
- const disconnectedDevice = device;
861
829
 
862
830
  try {
863
- device = await this.connectWithTimeout(uuid, () =>
864
- disconnectedDevice.connect(connectOptions)
865
- );
831
+ device = await device.connect(connectOptions);
866
832
  } catch (e) {
867
833
  Log?.debug('not connected, try to connect to device has error: ', e);
868
- if (isConnectTimeoutError(e)) {
869
- throw e;
870
- }
871
834
  if (
872
835
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
873
836
  e.errorCode === BleErrorCode.OperationCancelled
874
837
  ) {
875
838
  Log?.debug('second try to reconnect without params');
876
839
  try {
877
- device = await this.connectWithTimeout(uuid, () =>
878
- disconnectedDevice.connect(fallbackConnectOptions)
879
- );
840
+ device = await device.connect();
880
841
  } catch (e) {
881
842
  Log?.debug('last try to reconnect error: ', e);
882
843
  // last try to reconnect device if this issue exists
883
844
  // https://github.com/dotintent/react-native-ble-plx/issues/426
884
845
  if (e.errorCode === BleErrorCode.OperationCancelled) {
885
846
  Log?.debug('last try to reconnect');
886
- await disconnectedDevice.cancelConnection();
887
- device = await this.connectWithTimeout(uuid, () =>
888
- disconnectedDevice.connect(fallbackConnectOptions)
889
- );
847
+ await device.cancelConnection();
848
+ device = await device.connect();
890
849
  }
891
850
  }
892
851
  } else {
@@ -963,7 +922,7 @@ export default class ReactNativeBleTransport {
963
922
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
964
923
  return;
965
924
  }
966
- if (this.getActiveProtocol(uuid) === 'V2') {
925
+ if (this.deviceProtocol.get(uuid) === 'V2') {
967
926
  let errorCode:
968
927
  | typeof HardwareErrorCode.BleDeviceBondError
969
928
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -1033,7 +992,7 @@ export default class ReactNativeBleTransport {
1033
992
 
1034
993
  try {
1035
994
  const data = Buffer.from(c.value as string, 'base64');
1036
- const protocol = this.getActiveProtocol(uuid);
995
+ const protocol = this.deviceProtocol.get(uuid);
1037
996
  if (!protocol) {
1038
997
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
1039
998
  return;
@@ -1067,7 +1026,7 @@ export default class ReactNativeBleTransport {
1067
1026
  } catch (error) {
1068
1027
  Log?.debug('monitor data error: ', error);
1069
1028
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1070
- if (this.getActiveProtocol(uuid) === 'V2') {
1029
+ if (this.deviceProtocol.get(uuid) === 'V2') {
1071
1030
  this.rejectProtocolV2Frames(uuid, notifyError);
1072
1031
  } else if (this.runPromiseDeviceId === uuid) {
1073
1032
  this.runPromise?.reject(notifyError);
@@ -1131,7 +1090,6 @@ export default class ReactNativeBleTransport {
1131
1090
  }
1132
1091
 
1133
1092
  this.deviceProtocol.delete(uuid);
1134
- this.probingProtocols.delete(uuid);
1135
1093
  // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1136
1094
  this.protocolV2Assemblers.get(uuid)?.reset();
1137
1095
  this.protocolV2Assemblers.delete(uuid);
@@ -1198,25 +1156,8 @@ export default class ReactNativeBleTransport {
1198
1156
  const transport = this.getCachedTransport(uuid);
1199
1157
  const runPromise = createDeferred<string>();
1200
1158
  runPromise.promise.catch(() => undefined);
1201
- const supersededRunPromise = this.runPromise;
1202
- if (supersededRunPromise) {
1203
- // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1204
- // the superseded deferred now so its response race resolves and its finally block
1205
- // clears its timeout timer; an orphaned timer would otherwise fire much later and
1206
- // tear down the shared connection while another call is using it.
1207
- supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1208
- }
1209
1159
  this.runPromise = runPromise;
1210
1160
  this.runPromiseDeviceId = uuid;
1211
- // A superseded call's late write failure must not clear the successor's ownership;
1212
- // only the call that still owns the slot may release it.
1213
- const releaseOwnershipIfCurrent = () => {
1214
- if (this.runPromise === runPromise) {
1215
- this.runPromise = null;
1216
- this.runPromiseDeviceId = null;
1217
- }
1218
- };
1219
- const isCurrentOwner = () => this.runPromise === runPromise;
1220
1161
  const messages = this._messages;
1221
1162
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1222
1163
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1242,9 +1183,6 @@ export default class ReactNativeBleTransport {
1242
1183
  chunk = ByteBuffer.allocate(packetCapacity);
1243
1184
  } catch (e) {
1244
1185
  onError(e);
1245
- if (isWedgedWriteError(e)) {
1246
- throw e;
1247
- }
1248
1186
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1249
1187
  }
1250
1188
  }
@@ -1276,9 +1214,6 @@ export default class ReactNativeBleTransport {
1276
1214
  }
1277
1215
  } catch (e) {
1278
1216
  onError(e);
1279
- if (isWedgedWriteError(e)) {
1280
- throw e;
1281
- }
1282
1217
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1283
1218
  }
1284
1219
  }
@@ -1292,15 +1227,9 @@ export default class ReactNativeBleTransport {
1292
1227
  if (name === 'EmmcFileWrite') {
1293
1228
  await writeChunkedData(
1294
1229
  buffers,
1295
- data =>
1296
- this.writeBlePacket(
1297
- uuid,
1298
- data,
1299
- payload => transport.writeWithRetry(payload),
1300
- isCurrentOwner
1301
- ),
1230
+ data => transport.writeWithRetry(data),
1302
1231
  e => {
1303
- releaseOwnershipIfCurrent();
1232
+ this.runPromise = null;
1304
1233
  Log?.error('writeCharacteristic write error: ', e);
1305
1234
  }
1306
1235
  );
@@ -1321,12 +1250,7 @@ export default class ReactNativeBleTransport {
1321
1250
  // eslint-disable-next-line no-constant-condition
1322
1251
  while (true) {
1323
1252
  try {
1324
- await this.writeBlePacket(
1325
- uuid,
1326
- data,
1327
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1328
- isCurrentOwner
1329
- );
1253
+ await transport.writeWithRetry(data);
1330
1254
  return;
1331
1255
  } catch (error) {
1332
1256
  const retryType = getFirmwareUploadWriteRetryType(error);
@@ -1345,7 +1269,7 @@ export default class ReactNativeBleTransport {
1345
1269
  }
1346
1270
  },
1347
1271
  e => {
1348
- releaseOwnershipIfCurrent();
1272
+ this.runPromise = null;
1349
1273
  Log?.error('writeCharacteristic write error: ', e);
1350
1274
  }
1351
1275
  );
@@ -1354,18 +1278,16 @@ export default class ReactNativeBleTransport {
1354
1278
  const outData = o.toString('base64');
1355
1279
  // Upload resources on low-end phones may OOM
1356
1280
  try {
1357
- await this.writeBlePacket(
1358
- uuid,
1359
- outData,
1360
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1361
- isCurrentOwner
1362
- );
1281
+ const shouldUseWriteWithResponse =
1282
+ Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1283
+ if (shouldUseWriteWithResponse) {
1284
+ await transport.writeCharacteristic.writeWithResponse(outData);
1285
+ } else {
1286
+ await transport.writeCharacteristic.writeWithoutResponse(outData);
1287
+ }
1363
1288
  } catch (e) {
1364
1289
  Log?.debug('writeCharacteristic write error: ', e);
1365
- releaseOwnershipIfCurrent();
1366
- if (isWedgedWriteError(e)) {
1367
- throw e;
1368
- }
1290
+ this.runPromise = null;
1369
1291
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1370
1292
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1371
1293
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1408,13 +1330,8 @@ export default class ReactNativeBleTransport {
1408
1330
  }
1409
1331
  const isProbeTimeout =
1410
1332
  name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1411
- // A call that has been superseded (forceRun) or cleaned up no longer owns the
1412
- // transport; its late timeout must not tear down the connection the current
1413
- // call is actively using.
1414
- const isStaleCall = this.runPromise !== runPromise;
1415
1333
  if (
1416
1334
  !isProbeTimeout &&
1417
- !isStaleCall &&
1418
1335
  (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1419
1336
  ) {
1420
1337
  await this.disconnect(uuid);
@@ -1494,10 +1411,7 @@ export default class ReactNativeBleTransport {
1494
1411
  delete transportCache[session];
1495
1412
  }
1496
1413
  this.deviceProtocol.delete(session);
1497
- this.probingProtocols.delete(session);
1498
1414
  this.deviceProtocolHints.delete(session);
1499
- this.sessionProtocols.delete(session);
1500
- this.protocolReprobeFailures.delete(session);
1501
1415
  this.protocolV2Assemblers.delete(session);
1502
1416
  this.resetProtocolV2Frames(session);
1503
1417
 
@@ -1523,75 +1437,6 @@ export default class ReactNativeBleTransport {
1523
1437
  this.runPromiseDeviceId = null;
1524
1438
  }
1525
1439
 
1526
- /** Run a native connect under the JS backstop budget. */
1527
- private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1528
- let timer: ReturnType<typeof setTimeout> | undefined;
1529
- let timedOut = false;
1530
- const pending = connect();
1531
- // The abandoned attempt keeps running; swallow its late outcome so it cannot
1532
- // surface as an unhandled rejection after we have already given up on it.
1533
- pending.catch(() => undefined);
1534
- try {
1535
- const result = await Promise.race([
1536
- pending,
1537
- new Promise<never>((_, reject) => {
1538
- timer = setTimeout(() => {
1539
- timedOut = true;
1540
- reject(
1541
- ERRORS.TypedError(
1542
- HardwareErrorCode.BleConnectedError,
1543
- `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1544
- )
1545
- );
1546
- }, BLE_CONNECT_TIMEOUT_MS);
1547
- }),
1548
- ]);
1549
- this.connectTimeoutCounts.delete(uuid);
1550
- return result;
1551
- } catch (error) {
1552
- if (timedOut) {
1553
- this.abandonStalledConnect(uuid);
1554
- }
1555
- throw error;
1556
- } finally {
1557
- if (timer) clearTimeout(timer);
1558
- }
1559
- }
1560
-
1561
- /**
1562
- * Give up on a connect the native layer never settled. The abandoned attempt still
1563
- * holds a native "connecting" entry that would cancel the NEXT attempt out from under
1564
- * itself, so it is cleared here — fire and forget, because that call talks to the very
1565
- * queue that just stopped responding.
1566
- */
1567
- private abandonStalledConnect(uuid: string) {
1568
- const timeouts = (this.connectTimeoutCounts.get(uuid) ?? 0) + 1;
1569
- this.connectTimeoutCounts.set(uuid, timeouts);
1570
- Log?.error('[ReactNativeBleTransport] BLE connect timed out:', uuid, {
1571
- consecutiveConnectTimeouts: timeouts,
1572
- });
1573
-
1574
- this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1575
- // Rejects with "Operation was cancelled" while merely connecting — expected.
1576
- });
1577
- const stalled = transportCache[uuid];
1578
- if (stalled) {
1579
- delete transportCache[uuid];
1580
- }
1581
- this.deviceProtocol.delete(uuid);
1582
- this.probingProtocols.delete(uuid);
1583
- this.protocolV2Assemblers.delete(uuid);
1584
- this.resetProtocolV2Frames(uuid);
1585
-
1586
- if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1587
- // BleManager.destroy() force-rejects every promise the native queue abandoned —
1588
- // the only JS-reachable way to settle them — and drops all cached peripherals.
1589
- Log?.error('[ReactNativeBleTransport] BLE connects wedged repeatedly, resetting BLE manager');
1590
- this.resetPlxManager();
1591
- this.connectTimeoutCounts.delete(uuid);
1592
- }
1593
- }
1594
-
1595
1440
  private getCachedTransport(uuid: string) {
1596
1441
  const transport = transportCache[uuid];
1597
1442
  if (!transport) {
@@ -1600,107 +1445,6 @@ export default class ReactNativeBleTransport {
1600
1445
  return transport;
1601
1446
  }
1602
1447
 
1603
- /**
1604
- * Write one packet under a bounded budget. A write that never settles means the
1605
- * peripheral is wedged even though the GATT link still reports connected, so the
1606
- * link is torn down: releasing JS state alone would leave the poisoned peripheral
1607
- * cached and every later call would hang on it again.
1608
- */
1609
- private async writeBlePacket(
1610
- uuid: string,
1611
- data: string,
1612
- write: (payload: string) => Promise<unknown>,
1613
- isCurrentOwner?: () => boolean
1614
- ) {
1615
- let timer: ReturnType<typeof setTimeout> | undefined;
1616
- let timedOut = false;
1617
- try {
1618
- await Promise.race([
1619
- write(data),
1620
- new Promise<never>((_, reject) => {
1621
- timer = setTimeout(() => {
1622
- timedOut = true;
1623
- reject(
1624
- ERRORS.TypedError(
1625
- HardwareErrorCode.BleWriteCharacteristicError,
1626
- `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1627
- )
1628
- );
1629
- }, BLE_WRITE_PACKET_TIMEOUT_MS);
1630
- }),
1631
- ]);
1632
- this.writeTimeoutCounts.delete(uuid);
1633
- } catch (error) {
1634
- if (timedOut) {
1635
- // A superseded call's late write must not tear down the link the current
1636
- // call is using; only the owner of the transport may declare it dead.
1637
- if (isCurrentOwner && !isCurrentOwner()) {
1638
- Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1639
- } else {
1640
- this.tearDownWedgedLink(uuid);
1641
- }
1642
- }
1643
- throw error;
1644
- } finally {
1645
- if (timer) clearTimeout(timer);
1646
- }
1647
- }
1648
-
1649
- /**
1650
- * Drop a link whose writes stopped completing. The JS state is purged synchronously
1651
- * so the next acquire() cannot reuse the dead transport, while the native teardown is
1652
- * intentionally NOT awaited: it talks to the very layer that just stopped settling
1653
- * promises, so awaiting it could hang exactly like the write it is recovering from.
1654
- */
1655
- private tearDownWedgedLink(uuid: string) {
1656
- const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1657
- this.writeTimeoutCounts.set(uuid, timeouts);
1658
- Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1659
- consecutiveWriteTimeouts: timeouts,
1660
- });
1661
-
1662
- const wedged = transportCache[uuid];
1663
- this.disconnect(uuid).catch(error => {
1664
- Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1665
- });
1666
- if (wedged && transportCache[uuid] === wedged) {
1667
- delete transportCache[uuid];
1668
- }
1669
- this.deviceProtocol.delete(uuid);
1670
- this.probingProtocols.delete(uuid);
1671
- this.protocolV2Assemblers.delete(uuid);
1672
- this.resetProtocolV2Frames(uuid);
1673
-
1674
- if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1675
- // Reconnecting reuses the same native peripheral object. When it stays wedged
1676
- // across attempts the poison lives in the BLE manager itself, and only a fresh
1677
- // manager drops every cached peripheral — the JS equivalent of restarting the app.
1678
- Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1679
- this.resetPlxManager();
1680
- this.writeTimeoutCounts.delete(uuid);
1681
- }
1682
- }
1683
-
1684
- private resetPlxManager() {
1685
- const manager = this.blePlxManager;
1686
- this.blePlxManager = undefined;
1687
- // Every cached transport belongs to the destroyed manager's peripherals.
1688
- Object.keys(transportCache).forEach(key => {
1689
- delete transportCache[key];
1690
- });
1691
- this.deviceProtocol.clear();
1692
- this.probingProtocols.clear();
1693
- this.sessionProtocols.clear();
1694
- this.protocolReprobeFailures.clear();
1695
- this.monitorTokens.clear();
1696
- this.protocolV2Assemblers.clear();
1697
- try {
1698
- manager?.destroy();
1699
- } catch (error) {
1700
- Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1701
- }
1702
- }
1703
-
1704
1448
  private createProtocolMismatchError(expected: ProtocolType) {
1705
1449
  return ERRORS.TypedError(
1706
1450
  HardwareErrorCode.RuntimeError,
@@ -1716,29 +1460,30 @@ export default class ReactNativeBleTransport {
1716
1460
  }
1717
1461
 
1718
1462
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1719
- if (this.probingProtocols.get(uuid) === protocol) {
1720
- this.probingProtocols.delete(uuid);
1721
- }
1722
1463
  if (this.deviceProtocol.get(uuid) === protocol) {
1723
1464
  this.deviceProtocol.delete(uuid);
1724
1465
  }
1725
1466
  }
1726
1467
 
1727
- /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1728
- private getActiveProtocol(uuid: string): ProtocolType | undefined {
1729
- return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1730
- }
1731
-
1732
1468
  private async detectProtocol(
1733
1469
  uuid: string,
1734
1470
  expectedProtocol?: ProtocolType,
1735
1471
  protocolHint?: ProtocolType,
1736
1472
  rebuildTransport?: () => Promise<void>
1737
1473
  ): Promise<ProtocolType> {
1474
+ if (Platform.OS === 'ios' && expectedProtocol) {
1475
+ this.deviceProtocol.set(uuid, expectedProtocol);
1476
+ Log?.debug('[ReactNativeBleTransport] protocol selected', {
1477
+ deviceId: uuid,
1478
+ protocol: expectedProtocol,
1479
+ source: 'expected',
1480
+ });
1481
+ return expectedProtocol;
1482
+ }
1483
+
1738
1484
  if (expectedProtocol === 'V1') {
1739
1485
  if (await this.probeProtocolV1(uuid)) {
1740
1486
  this.deviceProtocol.set(uuid, 'V1');
1741
- this.sessionProtocols.set(uuid, 'V1');
1742
1487
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1743
1488
  deviceId: uuid,
1744
1489
  protocol: 'V1',
@@ -1752,7 +1497,6 @@ export default class ReactNativeBleTransport {
1752
1497
  if (expectedProtocol === 'V2') {
1753
1498
  if (await this.probeProtocolV2(uuid)) {
1754
1499
  this.deviceProtocol.set(uuid, 'V2');
1755
- this.sessionProtocols.set(uuid, 'V2');
1756
1500
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1757
1501
  deviceId: uuid,
1758
1502
  protocol: 'V2',
@@ -1765,18 +1509,8 @@ export default class ReactNativeBleTransport {
1765
1509
 
1766
1510
  // Protocol must be actively probed after connection. Name, PID, and descriptors only
1767
1511
  // influence probe order; a V2 hint probes V2 first and falls back to V1.
1768
- const sessionProtocol = this.sessionProtocols.get(uuid);
1769
- const reprobeFailures = this.protocolReprobeFailures.get(uuid) ?? 0;
1770
- const fullProbeOrder: ProtocolType[] =
1512
+ const probeOrder: ProtocolType[] =
1771
1513
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
1772
- // A device that already answered on a protocol in this session keeps answering on
1773
- // it; while it is rebooting nothing answers at all, so probing the other protocol
1774
- // only adds its timeout to every poll.
1775
- const trustSessionProtocol =
1776
- sessionProtocol !== undefined &&
1777
- !protocolHint &&
1778
- reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
1779
- const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
1780
1514
 
1781
1515
  for (let i = 0; i < probeOrder.length; i += 1) {
1782
1516
  const protocol = probeOrder[i];
@@ -1794,8 +1528,6 @@ export default class ReactNativeBleTransport {
1794
1528
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1795
1529
  if (detected) {
1796
1530
  this.deviceProtocol.set(uuid, protocol);
1797
- this.sessionProtocols.set(uuid, protocol);
1798
- this.protocolReprobeFailures.delete(uuid);
1799
1531
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1800
1532
  deviceId: uuid,
1801
1533
  protocol,
@@ -1805,16 +1537,7 @@ export default class ReactNativeBleTransport {
1805
1537
  }
1806
1538
  }
1807
1539
 
1808
- if (trustSessionProtocol) {
1809
- // Still silent on its own protocol: count it, and let the streak expire the
1810
- // shortcut so a device that genuinely switched protocols is found again.
1811
- this.protocolReprobeFailures.set(uuid, reprobeFailures + 1);
1812
- } else {
1813
- this.protocolReprobeFailures.delete(uuid);
1814
- }
1815
-
1816
1540
  this.deviceProtocol.delete(uuid);
1817
- this.probingProtocols.delete(uuid);
1818
1541
  throw this.createProtocolDetectionError();
1819
1542
  }
1820
1543
 
@@ -1876,20 +1599,14 @@ export default class ReactNativeBleTransport {
1876
1599
  }
1877
1600
 
1878
1601
  try {
1879
- this.probingProtocols.set(uuid, 'V1');
1602
+ this.deviceProtocol.set(uuid, 'V1');
1880
1603
  // GetFeatures identifies Protocol V1 without resetting an existing wallet
1881
1604
  // session before Core has a chance to restore a hidden wallet.
1882
1605
  await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1883
- this.probingProtocols.delete(uuid);
1884
1606
  return true;
1885
1607
  } catch (error) {
1886
1608
  this.clearProbeProtocol(uuid, 'V1');
1887
1609
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1888
- // A wedged write already dropped the link, so probing another protocol on it
1889
- // would only fail against a torn-down transport: surface the real cause.
1890
- if (isWedgedWriteError(error)) {
1891
- throw error;
1892
- }
1893
1610
  return false;
1894
1611
  }
1895
1612
  }
@@ -1899,7 +1616,7 @@ export default class ReactNativeBleTransport {
1899
1616
  return false;
1900
1617
  }
1901
1618
 
1902
- this.probingProtocols.set(uuid, 'V2');
1619
+ this.deviceProtocol.set(uuid, 'V2');
1903
1620
  this.protocolV2Assemblers.get(uuid)?.reset();
1904
1621
  const detected = await probeProtocolV2Helper({
1905
1622
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1914,8 +1631,6 @@ export default class ReactNativeBleTransport {
1914
1631
  });
1915
1632
  if (!detected) {
1916
1633
  this.clearProbeProtocol(uuid, 'V2');
1917
- } else {
1918
- this.probingProtocols.delete(uuid);
1919
1634
  }
1920
1635
  return detected;
1921
1636
  }
@@ -1997,12 +1712,14 @@ export default class ReactNativeBleTransport {
1997
1712
  }
1998
1713
 
1999
1714
  private async writeProtocolV2Packet(
2000
- uuid: string,
2001
1715
  transport: BleTransport,
2002
1716
  base64: string,
2003
1717
  context: ProtocolV2CallContext,
2004
1718
  assertCurrentGeneration: () => void
2005
1719
  ) {
1720
+ const shouldUseWriteWithResponse =
1721
+ transport.writeCharacteristic.isWritableWithResponse &&
1722
+ (context.writeWithResponse === true || (Platform.OS === 'ios' && !context.highVolume));
2006
1723
  let attempt = 0;
2007
1724
  for (;;) {
2008
1725
  assertCurrentGeneration();
@@ -2010,21 +1727,11 @@ export default class ReactNativeBleTransport {
2010
1727
  throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
2011
1728
  }
2012
1729
  try {
2013
- await this.writeBlePacket(
2014
- uuid,
2015
- base64,
2016
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
2017
- // Same rule as Protocol V1: a write from a superseded generation must not
2018
- // tear down the link that the current generation is using.
2019
- () => {
2020
- try {
2021
- assertCurrentGeneration();
2022
- return !context.signal.aborted;
2023
- } catch {
2024
- return false;
2025
- }
2026
- }
2027
- );
1730
+ if (shouldUseWriteWithResponse) {
1731
+ await transport.writeCharacteristic.writeWithResponse(base64);
1732
+ } else {
1733
+ await transport.writeCharacteristic.writeWithoutResponse(base64);
1734
+ }
2028
1735
  assertCurrentGeneration();
2029
1736
  return;
2030
1737
  } catch (error) {
@@ -2047,7 +1754,6 @@ export default class ReactNativeBleTransport {
2047
1754
  }
2048
1755
 
2049
1756
  private async writeProtocolV2Frame(
2050
- uuid: string,
2051
1757
  transport: BleTransport,
2052
1758
  frame: Uint8Array,
2053
1759
  context: ProtocolV2CallContext,
@@ -2060,19 +1766,25 @@ export default class ReactNativeBleTransport {
2060
1766
  androidPacketLength: tuning.androidPacketLength,
2061
1767
  mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
2062
1768
  });
1769
+ // Match Desktop BLE pacing so Pro2 firmware can finish the previous response
1770
+ // before the next single-packet control command is written.
1771
+ const initialDelayMs =
1772
+ Platform.OS === 'ios' && !context.highVolume && frame.length <= packetCapacity
1773
+ ? IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS
1774
+ : 0;
2063
1775
  await writeProtocolV2BleFrame({
2064
1776
  frame,
2065
1777
  packetCapacity,
2066
1778
  assertActive: assertCurrentGeneration,
2067
1779
  signal: context.signal,
2068
1780
  abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
1781
+ initialDelayMs,
2069
1782
  burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
2070
1783
  burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
2071
1784
  flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
2072
1785
  wait: delay,
2073
1786
  writePacket: packet =>
2074
1787
  this.writeProtocolV2Packet(
2075
- uuid,
2076
1788
  transport,
2077
1789
  Buffer.from(packet).toString('base64'),
2078
1790
  context,
@@ -2098,7 +1810,7 @@ export default class ReactNativeBleTransport {
2098
1810
  const tuning = getProtocolV2BleTuning();
2099
1811
  Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
2100
1812
  name,
2101
- writeMode: 'withoutResponse',
1813
+ writeMode: options?.writeWithResponse ? 'withResponse' : 'withoutResponse',
2102
1814
  packetCapacity: Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
2103
1815
  });
2104
1816
  }
@@ -2137,13 +1849,7 @@ export default class ReactNativeBleTransport {
2137
1849
  writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
2138
1850
  assertCurrentGeneration();
2139
1851
  const currentTransport = this.getCachedTransport(uuid);
2140
- await this.writeProtocolV2Frame(
2141
- uuid,
2142
- currentTransport,
2143
- frame,
2144
- context,
2145
- assertCurrentGeneration
2146
- );
1852
+ await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
2147
1853
  },
2148
1854
  readFrame: async () => {
2149
1855
  assertCurrentGeneration();
@@ -2168,6 +1874,6 @@ export default class ReactNativeBleTransport {
2168
1874
  }
2169
1875
 
2170
1876
  getProtocolType(path: string): ProtocolType | undefined {
2171
- return this.getActiveProtocol(path);
1877
+ return this.deviceProtocol.get(path);
2172
1878
  }
2173
1879
  }