@onekeyfe/hd-transport-react-native 1.2.3-alpha.3 → 1.2.3-alpha.4

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
@@ -47,6 +47,7 @@ import {
47
47
  getInfosForServiceUuid,
48
48
  isSameBleUuid,
49
49
  } from './constants';
50
+ import { isNativeBleDisconnectError, toBleDisconnectHardwareError } from './bleNativeDisconnect';
50
51
  import {
51
52
  isBleStaleBondHardwareError,
52
53
  isNativeBleStaleBondError,
@@ -76,6 +77,8 @@ const { check, ProtocolV1, parseConfigure } = transport;
76
77
  const Log = bleLogger;
77
78
 
78
79
  const transportCache: Record<string, BleTransport> = {};
80
+ // ble-plx shares one manager across transport instances in this JS runtime.
81
+ let bleManagerResetPromise: Promise<void> | undefined;
79
82
  const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
80
83
  const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
81
84
  const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
@@ -168,6 +171,23 @@ const isWedgedWriteError = (error: unknown): boolean =>
168
171
  (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
169
172
  typeof (error as { message?: unknown })?.message === 'string' &&
170
173
  (error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
174
+ const shouldRethrowProtocolProbeError = (error: unknown): boolean => {
175
+ const code = (error as { errorCode?: unknown })?.errorCode;
176
+ // Bonding and GATT failures are not evidence of a protocol mismatch. Preserve
177
+ // them instead of probing another protocol on an unusable connection.
178
+ // Native PLX disconnects (errorCode 201 / iOS 7) must match before they are
179
+ // mapped: Protocol V2 writes rethrow them unchanged unless normalized first.
180
+ return (
181
+ isBleStaleBondHardwareError(error) ||
182
+ isNativeBleDisconnectError(error) ||
183
+ code === HardwareErrorCode.BleDeviceNotBonded ||
184
+ code === HardwareErrorCode.BleDeviceBondedCanceled ||
185
+ code === HardwareErrorCode.BleDeviceDisconnected ||
186
+ code === HardwareErrorCode.BleCharacteristicNotifyError ||
187
+ code === HardwareErrorCode.BleCharacteristicNotifyChangeFailure ||
188
+ code === HardwareErrorCode.BleWriteCharacteristicError
189
+ );
190
+ };
171
191
  /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
172
192
  export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
173
193
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
@@ -222,13 +242,13 @@ function getDeviceDisplayName(device?: Device | null) {
222
242
 
223
243
  const IOS_REQUEST_MTU = 247;
224
244
  const ANDROID_REQUEST_MTU = 517;
225
- const BLE_MTU_REFRESH_RETRY_DELAY_MS = 200;
226
245
  const ANDROID_HIGH_PRIORITY_IDLE_MS = 1000;
227
246
 
228
247
  const getRequestedBleMtu = () =>
229
248
  Platform.OS === 'android' ? ANDROID_REQUEST_MTU : IOS_REQUEST_MTU;
230
249
 
231
250
  const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
251
+ const BLE_MTU_REQUEST_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS;
232
252
 
233
253
  const connectOptions: Record<string, unknown> = {
234
254
  requestMTU: getRequestedBleMtu(),
@@ -236,11 +256,63 @@ const connectOptions: Record<string, unknown> = {
236
256
  refreshGatt: 'OnConnected',
237
257
  };
238
258
 
239
- /** Fallback connect options: drops requestMTU (the thing being worked around) but keeps the native budget. */
259
+ /** Connect options without requestMTU: the iOS fallback and every bare Android connect. */
240
260
  const fallbackConnectOptions: Record<string, unknown> = {
241
261
  timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
242
262
  };
243
263
 
264
+ /**
265
+ * Android never requests the MTU inside the native connect budget: refreshGatt makes the stack
266
+ * rediscover first, and a budget that expires with the MTU request unsent parks every later MTU
267
+ * request on that LE link. refreshGatt itself is only added after a firmware install or a
268
+ * stale-table symptom, and discovery finishes before the MTU exchange.
269
+ */
270
+ const androidRefreshGattConnectOptions: Record<string, unknown> = {
271
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
272
+ refreshGatt: 'OnConnected',
273
+ };
274
+
275
+ /**
276
+ * With no cached GATT table the stack runs its own discovery (up to ~8s) before the MTU
277
+ * exchange, so the bound sits above that; a stuck exchange never completes.
278
+ */
279
+ export const ANDROID_MTU_EXCHANGE_TIMEOUT_MS = 12_000;
280
+
281
+ /**
282
+ * Android keeps an LE link, with per-link ATT state such as a pending MTU exchange, for its
283
+ * 4s GATT link idle timer after the last client closes; a reconnect inside it reuses the link.
284
+ */
285
+ export const ANDROID_LINK_DROP_QUIET_MS = 5000;
286
+ const ANDROID_LINK_DROP_POLL_MS = 250;
287
+
288
+ /**
289
+ * Android cannot use a link at the default 23-byte ATT MTU: Protocol V1 writes 192-byte
290
+ * packets regardless, and a Pro 2 sends only the first ATT_MTU-3 bytes of a V2 reply.
291
+ * An unknown MTU is not treated as default.
292
+ */
293
+ const isKnownDefaultMtu = (mtu: unknown): boolean =>
294
+ typeof mtu === 'number' && Number.isFinite(mtu) && mtu <= 23;
295
+
296
+ /** Discovery found no OneKey service, or a characteristic of the wrong shape: the cached GATT table may be stale. */
297
+ const isMissingGattShapeError = (error: unknown): boolean => {
298
+ const code = (error as { errorCode?: unknown })?.errorCode;
299
+ const message = (error as { message?: unknown })?.message;
300
+ return (
301
+ code === HardwareErrorCode.BleServiceNotFound ||
302
+ code === HardwareErrorCode.BleCharacteristicNotFound ||
303
+ (typeof message === 'string' &&
304
+ (message.includes('BLECharacteristicNotFound') ||
305
+ message.includes('BLECharacteristicNotWritable') ||
306
+ message.includes('BLECharacteristicNotNotifiable')))
307
+ );
308
+ };
309
+ const isStaleGattTableNotifyReason = (reason: string | null | undefined): boolean =>
310
+ !!reason &&
311
+ (reason.includes('Cannot write client characteristic config descriptor') ||
312
+ reason.includes('Cannot find client characteristic config descriptor') || // pro firmware 2.3.0 upgrade
313
+ reason.includes('The handle is invalid') ||
314
+ reason.includes('Writing is not permitted')); // pro firmware 2.3.4 upgrade
315
+
244
316
  /**
245
317
  * JS backstop for connect. The native adapter applies its own 3s budget, but it
246
318
  * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
@@ -280,6 +352,15 @@ const shouldRethrowBleSetupError = (error: unknown): boolean =>
280
352
  isConnectTimeoutError(error) || isWedgedBleSetupError(error);
281
353
  const isNativeOperationTimeoutError = (error: unknown): boolean =>
282
354
  (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
355
+ const isMtuOrCancelledConnectError = (error: unknown): boolean => {
356
+ const errorCode = (error as { errorCode?: unknown })?.errorCode;
357
+ return (
358
+ errorCode === BleErrorCode.DeviceMTUChangeFailed ||
359
+ errorCode === BleErrorCode.OperationCancelled
360
+ );
361
+ };
362
+
363
+ type NegotiatedMtuResult = { device: Device; timedOut: boolean };
283
364
 
284
365
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
285
366
 
@@ -294,29 +375,75 @@ const tryToGetConfiguration = (device: Device) => {
294
375
 
295
376
  const requestNegotiatedMtu = async (
296
377
  device: Device,
297
- stage: 'connected' | 'servicesAndNotifyReady' | 'highThroughput',
298
- attempt: number
299
- ) => {
300
- if (Platform.OS !== 'ios' && Platform.OS !== 'android') return device;
378
+ stage: 'connected' | 'highThroughput',
379
+ attempt: number,
380
+ cancelTransaction?: (transactionId: string) => Promise<void> | void
381
+ ): Promise<NegotiatedMtuResult> => {
382
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') return { device, timedOut: false };
383
+
384
+ const transactionId = `${device.id}:mtu:${stage}:${attempt}:${Date.now()}`;
385
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
386
+ let timedOut = false;
301
387
 
302
388
  try {
303
389
  // iOS ignores the requested value but react-native-ble-plx returns a fresh
304
390
  // Device snapshot whose MTU is derived from CoreBluetooth's maximum write length.
305
- const mtuDevice = await device.requestMTU(getRequestedBleMtu());
306
- return mtuDevice;
391
+ const request = device.requestMTU(getRequestedBleMtu(), transactionId);
392
+ // The timeout race may settle before the native request does. Attach a
393
+ // rejection handler so a late native cancellation cannot become an
394
+ // unhandled rejection after we continue with the current MTU.
395
+ request.catch(() => undefined);
396
+ const mtuDevice = await Promise.race([
397
+ request,
398
+ new Promise<never>((_, reject) => {
399
+ timeoutId = setTimeout(() => {
400
+ timedOut = true;
401
+ reject(new Error(`BLE MTU request timeout after ${BLE_MTU_REQUEST_TIMEOUT_MS}ms`));
402
+ }, BLE_MTU_REQUEST_TIMEOUT_MS);
403
+ }),
404
+ ]);
405
+ return { device: mtuDevice, timedOut: false };
307
406
  } catch (error) {
407
+ if (timedOut && cancelTransaction) {
408
+ try {
409
+ Promise.resolve(cancelTransaction(transactionId)).catch(cancelError => {
410
+ Log?.debug('[ReactNativeBleTransport] MTU cancellation failed', {
411
+ platform: Platform.OS,
412
+ stage,
413
+ attempt,
414
+ error: cancelError instanceof Error ? cancelError.message : String(cancelError),
415
+ });
416
+ });
417
+ } catch (cancelError) {
418
+ Log?.debug('[ReactNativeBleTransport] MTU cancellation failed', {
419
+ platform: Platform.OS,
420
+ stage,
421
+ attempt,
422
+ error: cancelError instanceof Error ? cancelError.message : String(cancelError),
423
+ });
424
+ }
425
+ }
308
426
  Log?.debug('[ReactNativeBleTransport] MTU refresh failed, continuing with current value', {
309
427
  platform: Platform.OS,
310
428
  stage,
311
429
  attempt,
312
430
  actual: device.mtu,
431
+ timedOut,
313
432
  error: error instanceof Error ? error.message : String(error),
314
433
  });
315
- return device;
434
+ return { device, timedOut };
435
+ } finally {
436
+ if (timeoutId) clearTimeout(timeoutId);
316
437
  }
317
438
  };
318
439
 
319
- const resolveNegotiatedMtu = (device: Device) => requestNegotiatedMtu(device, 'connected', 0);
440
+ const resolveNegotiatedMtu = (
441
+ device: Device,
442
+ cancelTransaction?: (transactionId: string) => Promise<void> | void
443
+ ): Promise<NegotiatedMtuResult> =>
444
+ shouldRefreshNegotiatedMtu(device.mtu)
445
+ ? requestNegotiatedMtu(device, 'connected', 0, cancelTransaction)
446
+ : Promise.resolve({ device, timedOut: false });
320
447
 
321
448
  type IOBleErrorRemap = Error | BleError | null | undefined;
322
449
 
@@ -360,6 +487,8 @@ export default class ReactNativeBleTransport {
360
487
 
361
488
  stopped = false;
362
489
 
490
+ private readonly bondAbortController = new AbortController();
491
+
363
492
  scanTimeout = DEVICE_SCAN_TIMEOUT_MS;
364
493
 
365
494
  runPromise: Deferred<any> | null = null;
@@ -398,6 +527,12 @@ export default class ReactNativeBleTransport {
398
527
  /** Consecutive detections that failed while trusting sessionProtocols. */
399
528
  private protocolReprobeFailures: Map<string, number> = new Map();
400
529
 
530
+ /** Endpoints whose last detection got no answer; 'woken' once their Initialize wake is spent. */
531
+ private silentDetections = new Map<string, 'silent' | 'woken'>();
532
+
533
+ /** Android endpoints whose cached GATT table is suspect; the next connect refreshes it. */
534
+ private androidGattCacheRefreshes = new Set<string>();
535
+
401
536
  /**
402
537
  * Native encryption/pairing failures seen before Protocol V2 probe starts.
403
538
  * Pro2/Neo GATT connect can succeed on a stale iOS bond; the CCCD write then
@@ -430,6 +565,14 @@ export default class ReactNativeBleTransport {
430
565
  this.rejectProtocolV2Frames(uuid, new Error(reason));
431
566
  Log?.debug('[ReactNativeBleTransport] Protocol V2 link invalidated:', uuid, reason);
432
567
  if (reason.startsWith('Protocol V2 link-fatal error:')) {
568
+ if (this.probingProtocols.get(uuid) !== 'V2') {
569
+ const transport = transportCache[uuid];
570
+ try {
571
+ this.emitDeviceDisconnect(uuid, transport?.device?.name, transport?.monitorToken);
572
+ } catch {
573
+ Log?.error('[ReactNativeBleTransport] Protocol V2 disconnect listener failed');
574
+ }
575
+ }
433
576
  await this.releaseNative(uuid, true);
434
577
  }
435
578
  },
@@ -450,6 +593,10 @@ export default class ReactNativeBleTransport {
450
593
  /** Serializes transport lifecycle changes for the same physical device. */
451
594
  private lifecycleOperations: Map<string, Promise<void>> = new Map();
452
595
 
596
+ private stopPromise?: Promise<void>;
597
+
598
+ private scanCleanups = new Set<() => Promise<void>>();
599
+
453
600
  constructor(options: TransportOptions) {
454
601
  this.scanTimeout = options.scanTimeout ?? DEVICE_SCAN_TIMEOUT_MS;
455
602
  }
@@ -485,10 +632,34 @@ export default class ReactNativeBleTransport {
485
632
  // empty
486
633
  }
487
634
 
488
- getPlxManager(): Promise<BlePlxManager> {
489
- if (this.blePlxManager) return Promise.resolve(this.blePlxManager);
490
- this.blePlxManager = new BlePlxManager();
491
- return Promise.resolve(this.blePlxManager);
635
+ async getPlxManager(): Promise<BlePlxManager> {
636
+ while (bleManagerResetPromise) {
637
+ await this.waitForManagerReset();
638
+ }
639
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
640
+ if (!this.blePlxManager) this.blePlxManager = new BlePlxManager();
641
+ return this.blePlxManager;
642
+ }
643
+
644
+ private async waitForManagerReset(): Promise<void> {
645
+ if (!bleManagerResetPromise) return;
646
+ let timeout: ReturnType<typeof setTimeout> | undefined;
647
+ try {
648
+ await Promise.race([
649
+ bleManagerResetPromise,
650
+ new Promise<never>((_, reject) => {
651
+ timeout = setTimeout(
652
+ () => reject(this.createWedgedBleSetupError()),
653
+ BLE_CONNECT_TIMEOUT_MS
654
+ );
655
+ }),
656
+ ]);
657
+ } catch {
658
+ // A timeout or failed destroy is not permission to reuse the old singleton.
659
+ throw this.createWedgedBleSetupError();
660
+ } finally {
661
+ if (timeout) clearTimeout(timeout);
662
+ }
492
663
  }
493
664
 
494
665
  async resolveCharacteristics(device: Device): Promise<ResolvedBleCharacteristics> {
@@ -677,35 +848,58 @@ export default class ReactNativeBleTransport {
677
848
  * @returns
678
849
  */
679
850
  async enumerate() {
680
- // eslint-disable-next-line no-async-promise-executor
681
- return new Promise<IOneKeyDevice[]>(async (resolve, reject) => {
682
- const deviceList: IOneKeyDevice[] = [];
683
- const blePlxManager = await this.getPlxManager();
684
- try {
685
- await subscribeBleOn(blePlxManager);
686
- } catch (error) {
687
- Log?.debug('subscribeBleOn error: ', error);
688
- reject(error);
689
- return;
690
- }
691
-
692
- if (Platform.OS === 'android' && Platform.Version >= 31) {
693
- Log?.debug('requesting permissions, please wait...');
851
+ const scanStartedAt = Date.now();
852
+ let firstDeviceMs: number | undefined;
853
+ const blePlxManager = await this.getPlxManager();
854
+ await subscribeBleOn(blePlxManager);
855
+ if (Platform.OS === 'android' && Platform.Version >= 31) {
856
+ Log?.debug('requesting permissions, please wait...');
694
857
 
695
- const resultConnect = await PermissionsAndroid.requestMultiple([
696
- PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
697
- PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
698
- ]);
858
+ const resultConnect = await PermissionsAndroid.requestMultiple([
859
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
860
+ PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
861
+ ]);
699
862
 
700
- Log?.debug('requesting permissions, result: ', resultConnect);
701
- if (
702
- resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] !== 'granted' ||
703
- resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] !== 'granted'
704
- ) {
705
- reject(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
706
- return;
707
- }
863
+ Log?.debug('requesting permissions, result: ', resultConnect);
864
+ if (
865
+ resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] !== 'granted' ||
866
+ resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] !== 'granted'
867
+ ) {
868
+ throw ERRORS.TypedError(HardwareErrorCode.BlePermissionError);
708
869
  }
870
+ }
871
+
872
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
873
+ return new Promise<IOneKeyDevice[]>((resolve, reject) => {
874
+ const deviceList: IOneKeyDevice[] = [];
875
+ let finished = false;
876
+ let scanCleanup: Promise<void> | undefined;
877
+ const finishScan = (error?: unknown) => {
878
+ if (scanCleanup) return scanCleanup;
879
+ finished = true;
880
+ clearScanTimer();
881
+ scanCleanup = this.runNativeTeardown('scan', blePlxManager, async () => {
882
+ await blePlxManager.stopDeviceScan();
883
+ }).then(() => {
884
+ this.scanCleanups.delete(cancelScan);
885
+ if (error) reject(error);
886
+ else resolve(deviceList);
887
+ });
888
+ return scanCleanup;
889
+ };
890
+ const cancelScan = () =>
891
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
892
+ this.scanCleanups.add(cancelScan);
893
+
894
+ const clearScanTimer = timer.timeout(() => {
895
+ Log?.debug('[ReactNativeBleTransport] scan completed', {
896
+ elapsedMs: Date.now() - scanStartedAt,
897
+ firstDeviceMs,
898
+ deviceCount: deviceList.length,
899
+ scanWindowMs: this.scanTimeout,
900
+ });
901
+ finishScan();
902
+ }, this.scanTimeout);
709
903
 
710
904
  blePlxManager.startDeviceScan(
711
905
  getBluetoothServiceUuids(),
@@ -721,18 +915,17 @@ export default class ReactNativeBleTransport {
721
915
  error.errorCode
722
916
  )
723
917
  ) {
724
- reject(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
918
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
725
919
  } else if (error.errorCode === BleErrorCode.BluetoothUnauthorized) {
726
- reject(ERRORS.TypedError(HardwareErrorCode.BleLocationError));
920
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationError));
727
921
  } else if (error.errorCode === BleErrorCode.LocationServicesDisabled) {
728
- reject(ERRORS.TypedError(HardwareErrorCode.BleLocationServicesDisabled));
922
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationServicesDisabled));
729
923
  } else if (error.errorCode === BleErrorCode.ScanStartFailed) {
730
924
  // Android Bluetooth will report an error when the search frequency is too fast,
731
925
  // then nothing is processed and an empty array of devices is returned.
732
926
  // Then the next search will be back to normal
733
- timer.timeout(() => {}, this.scanTimeout);
734
927
  } else {
735
- reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.reason ?? ''));
928
+ finishScan(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.reason ?? ''));
736
929
  }
737
930
  return;
738
931
  }
@@ -764,6 +957,7 @@ export default class ReactNativeBleTransport {
764
957
  }
765
958
  );
766
959
 
960
+ if (finished) return;
767
961
  getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
768
962
  devices => {
769
963
  for (const device of devices) {
@@ -783,11 +977,13 @@ export default class ReactNativeBleTransport {
783
977
  addDevice(device as unknown as Device);
784
978
  }
785
979
  }
786
- }
980
+ },
981
+ error => Log?.debug('search connected peripheral failed:', error)
787
982
  );
788
983
 
789
984
  const addDevice = (device: Device) => {
790
- if (deviceList.every(d => d.id !== device.id)) {
985
+ if (!finished && deviceList.every(d => d.id !== device.id)) {
986
+ firstDeviceMs ??= Date.now() - scanStartedAt;
791
987
  const displayName = getDeviceDisplayName(device) ?? 'Unknown BLE Device';
792
988
 
793
989
  deviceList.push({
@@ -802,11 +998,6 @@ export default class ReactNativeBleTransport {
802
998
  });
803
999
  }
804
1000
  };
805
-
806
- timer.timeout(() => {
807
- blePlxManager.stopDeviceScan();
808
- resolve(deviceList);
809
- }, this.scanTimeout);
810
1001
  });
811
1002
  }
812
1003
 
@@ -817,6 +1008,7 @@ export default class ReactNativeBleTransport {
817
1008
  ) {
818
1009
  const { writeCharacteristic, notifyCharacteristic } =
819
1010
  characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
1011
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
820
1012
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
821
1013
  transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
822
1014
  const monitorToken = this.nextMonitorToken;
@@ -845,39 +1037,12 @@ export default class ReactNativeBleTransport {
845
1037
  } else if (Platform.OS === 'android') {
846
1038
  await delay(ANDROID_NOTIFY_READY_DELAY_MS);
847
1039
  }
848
-
849
- const initialMtu = transport.mtuSize;
850
- let refreshAttempts = 0;
851
- if (
852
- (Platform.OS === 'ios' || Platform.OS === 'android') &&
853
- shouldRefreshNegotiatedMtu(transport.mtuSize)
854
- ) {
855
- refreshAttempts += 1;
856
- let refreshedDevice = await requestNegotiatedMtu(
857
- transport.device,
858
- 'servicesAndNotifyReady',
859
- 1
860
- );
861
- transport.device = refreshedDevice;
862
- transport.mtuSize =
863
- typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
864
-
865
- if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
866
- await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
867
- refreshAttempts += 1;
868
- refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
869
- transport.device = refreshedDevice;
870
- transport.mtuSize =
871
- typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
872
- }
873
- }
1040
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
874
1041
 
875
1042
  Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
876
1043
  platform: Platform.OS,
877
1044
  requested: getRequestedBleMtu(),
878
- initial: initialMtu,
879
1045
  actual: transport.mtuSize,
880
- refreshAttempts,
881
1046
  });
882
1047
 
883
1048
  return transport;
@@ -894,6 +1059,7 @@ export default class ReactNativeBleTransport {
894
1059
  }
895
1060
 
896
1061
  private async acquireUnlocked(input: FirmwareInstallBleAcquireInput) {
1062
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
897
1063
  const { uuid, forceCleanRunPromise, expectedProtocol, skipProtocolProbe } = input;
898
1064
  const shouldMapProtocolV2StaleBond = expectedProtocol
899
1065
  ? expectedProtocol === 'V2'
@@ -944,11 +1110,16 @@ export default class ReactNativeBleTransport {
944
1110
  const isCachedDeviceConnected = await cachedTransport.device
945
1111
  .isConnected()
946
1112
  .catch(() => false);
1113
+ // A suspect GATT table is only refreshed through a new connect.
1114
+ const isCachedAndroidLinkUsable =
1115
+ Platform.OS !== 'android' || !this.androidGattCacheRefreshes.has(uuid);
947
1116
  if (
948
1117
  isCachedDeviceConnected &&
1118
+ isCachedAndroidLinkUsable &&
949
1119
  cachedProtocol &&
950
1120
  (!expectedProtocol || cachedProtocol === expectedProtocol)
951
1121
  ) {
1122
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
952
1123
  Log?.debug('[ReactNativeBleTransport] reuse cached BLE transport:', uuid, cachedProtocol);
953
1124
  return { uuid, protocolType: cachedProtocol };
954
1125
  }
@@ -963,6 +1134,18 @@ export default class ReactNativeBleTransport {
963
1134
  }
964
1135
 
965
1136
  let device: Device | null = null;
1137
+ const isAndroid = Platform.OS === 'android';
1138
+ // A firmware-install reconnect always refreshes: the new firmware may expose a different table.
1139
+ const refreshAndroidGattCache =
1140
+ isAndroid && (!!skipProtocolProbe || this.androidGattCacheRefreshes.has(uuid));
1141
+ let nativeConnectOptions = connectOptions;
1142
+ if (isAndroid) {
1143
+ nativeConnectOptions = refreshAndroidGattCache
1144
+ ? androidRefreshGattConnectOptions
1145
+ : fallbackConnectOptions;
1146
+ }
1147
+ // Only a connect that carried refreshGatt clears the marker; the fallback connects drop it.
1148
+ let androidRefreshConnectRan = false;
966
1149
 
967
1150
  if (forceCleanRunPromise && this.runPromise) {
968
1151
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
@@ -973,6 +1156,7 @@ export default class ReactNativeBleTransport {
973
1156
  }
974
1157
 
975
1158
  const blePlxManager = await this.getPlxManager();
1159
+ let skipPostConnectMtu = false;
976
1160
  try {
977
1161
  await subscribeBleOn(blePlxManager);
978
1162
  } catch (error) {
@@ -980,6 +1164,27 @@ export default class ReactNativeBleTransport {
980
1164
  throw error;
981
1165
  }
982
1166
 
1167
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1168
+ if (Platform.OS === 'android') {
1169
+ // Initiate bonding locally before GATT can trigger peripheral-initiated pairing.
1170
+ try {
1171
+ const bondState = await pairDevice(uuid);
1172
+ if (bondState.bonding) {
1173
+ await onDeviceBondState(uuid, this.bondAbortController.signal);
1174
+ } else if (!bondState.bonded) {
1175
+ throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded');
1176
+ }
1177
+ } catch (error) {
1178
+ await this.runNativeTeardown(uuid, blePlxManager, async () => {
1179
+ await this.runBestEffortNativeOperation('bond failure: cancel manager connection', () =>
1180
+ blePlxManager.cancelDeviceConnection(uuid)
1181
+ );
1182
+ });
1183
+ throw error;
1184
+ }
1185
+ }
1186
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1187
+
983
1188
  if (!device) {
984
1189
  const devices = await blePlxManager.devices([uuid]);
985
1190
  [device] = devices;
@@ -996,17 +1201,16 @@ export default class ReactNativeBleTransport {
996
1201
  Log?.debug('try to connect to device: ', uuid);
997
1202
  try {
998
1203
  device = await this.connectWithTimeout(uuid, () =>
999
- blePlxManager.connectToDevice(uuid, connectOptions)
1204
+ blePlxManager.connectToDevice(uuid, nativeConnectOptions)
1000
1205
  );
1206
+ androidRefreshConnectRan = refreshAndroidGattCache;
1001
1207
  } catch (e) {
1002
1208
  Log?.debug('try to connect to device has error: ', e);
1003
1209
  if (shouldRethrowBleSetupError(e)) {
1004
1210
  throw e;
1005
1211
  }
1006
- if (
1007
- e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
1008
- e.errorCode === BleErrorCode.OperationCancelled
1009
- ) {
1212
+ if (isMtuOrCancelledConnectError(e)) {
1213
+ skipPostConnectMtu = true;
1010
1214
  Log?.debug('first try to reconnect without params');
1011
1215
  device = await this.connectWithTimeout(uuid, () =>
1012
1216
  blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
@@ -1024,23 +1228,33 @@ export default class ReactNativeBleTransport {
1024
1228
  throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'unable to connect to device');
1025
1229
  }
1026
1230
 
1231
+ if (
1232
+ refreshAndroidGattCache &&
1233
+ !androidRefreshConnectRan &&
1234
+ (await device.isConnected().catch(() => false))
1235
+ ) {
1236
+ // refreshGatt only reaches the stack through a connect. A link that is still up would
1237
+ // skip the connect below and keep serving the stale table, so it is dropped first.
1238
+ await this.dropAndroidLink(uuid, blePlxManager, device, 'gatt cache refresh');
1239
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1240
+ }
1241
+
1027
1242
  if (!(await device.isConnected())) {
1028
1243
  Log?.debug('not connected, try to connect to device: ', uuid);
1029
1244
  const disconnectedDevice = device;
1030
1245
 
1031
1246
  try {
1032
1247
  device = await this.connectWithTimeout(uuid, () =>
1033
- disconnectedDevice.connect(connectOptions)
1248
+ disconnectedDevice.connect(nativeConnectOptions)
1034
1249
  );
1250
+ androidRefreshConnectRan = refreshAndroidGattCache;
1035
1251
  } catch (e) {
1036
1252
  Log?.debug('not connected, try to connect to device has error: ', e);
1037
1253
  if (shouldRethrowBleSetupError(e)) {
1038
1254
  throw e;
1039
1255
  }
1040
- if (
1041
- e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
1042
- e.errorCode === BleErrorCode.OperationCancelled
1043
- ) {
1256
+ if (isMtuOrCancelledConnectError(e)) {
1257
+ skipPostConnectMtu = true;
1044
1258
  Log?.debug('second try to reconnect without params');
1045
1259
  try {
1046
1260
  device = await this.connectWithTimeout(uuid, () =>
@@ -1066,42 +1280,80 @@ export default class ReactNativeBleTransport {
1066
1280
  }
1067
1281
  }
1068
1282
 
1069
- if (Platform.OS === 'android') {
1070
- // Establish the LE link before createBond(). Without an existing LE ACL,
1071
- // Android TRANSPORT_AUTO can choose BR/EDR for a BLE-only device.
1072
- const connectedDevice = device;
1073
- try {
1074
- if (!(await connectedDevice.isConnected().catch(() => false))) {
1075
- throw ERRORS.TypedError(
1076
- HardwareErrorCode.BleConnectedError,
1077
- `Device ${uuid} is not connected before bonding`
1283
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1284
+ if (Platform.OS === 'android' && !(await device.isConnected().catch(() => false))) {
1285
+ const disconnectedDevice = device;
1286
+ await this.runNativeTeardown(uuid, blePlxManager, async () => {
1287
+ await Promise.all([
1288
+ this.runBestEffortNativeOperation('connect failure: cancel manager connection', () =>
1289
+ blePlxManager.cancelDeviceConnection(uuid)
1290
+ ),
1291
+ this.runBestEffortNativeOperation('connect failure: cancel device connection', () =>
1292
+ disconnectedDevice.cancelConnection()
1293
+ ),
1294
+ ]);
1295
+ });
1296
+ throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'device is not connected');
1297
+ }
1298
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1299
+ let characteristics: ResolvedBleCharacteristics | undefined;
1300
+ if (isAndroid) {
1301
+ if (refreshAndroidGattCache) {
1302
+ // refreshGatt has already started a full rediscovery; let it finish before the MTU
1303
+ // exchange so the request is not queued behind it.
1304
+ characteristics = await this.resolveCharacteristicsWithTimeout(uuid, device);
1305
+ if (androidRefreshConnectRan) this.androidGattCacheRefreshes.delete(uuid);
1306
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1307
+ }
1308
+ device = await this.negotiateAndroidMtu(uuid, blePlxManager, device);
1309
+ } else if (!skipPostConnectMtu) {
1310
+ // Match 1.1.31: MTU is a connect() best-effort. If connect already fell back
1311
+ // without requestMTU, do not put another requestMTU on the native serial
1312
+ // queue — that is what wedges GATT after Account#2 reconnect.
1313
+ const mtuResult = await resolveNegotiatedMtu(device, transactionId =>
1314
+ blePlxManager.cancelTransaction(transactionId)
1315
+ );
1316
+ device = mtuResult.device;
1317
+ if (mtuResult.timedOut) {
1318
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1319
+ Log?.debug(
1320
+ '[ReactNativeBleTransport] post-connect MTU timed out, reconnecting without requesting MTU'
1321
+ );
1322
+ const timedOutDevice = device;
1323
+ let mtuTeardownSettled = false;
1324
+ await this.runNativeTeardown(uuid, blePlxManager, async () => {
1325
+ await this.runBestEffortNativeOperation('mtu timeout: cancel device connection', () =>
1326
+ timedOutDevice.cancelConnection()
1078
1327
  );
1328
+ mtuTeardownSettled = true;
1329
+ });
1330
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1331
+ if (!mtuTeardownSettled || this.blePlxManager !== blePlxManager) {
1332
+ // The teardown budget expired and the manager that owned timedOutDevice was reset.
1333
+ throw ERRORS.TypedError(HardwareErrorCode.BleTimeoutError, 'BLE MTU cleanup timed out');
1079
1334
  }
1080
- const bondState = await pairDevice(uuid);
1081
- if (bondState.bonding) {
1082
- await onDeviceBondState(uuid);
1083
- } else if (!bondState.bonded) {
1084
- throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded');
1335
+ try {
1336
+ device = await this.connectWithTimeout(uuid, () =>
1337
+ timedOutDevice.connect(fallbackConnectOptions)
1338
+ );
1339
+ } catch (error) {
1340
+ if (shouldRethrowBleSetupError(error)) throw error;
1341
+ if (
1342
+ (error as { errorCode?: unknown })?.errorCode === BleErrorCode.DeviceAlreadyConnected
1343
+ ) {
1344
+ // GATT resolution and the protocol probe below still validate the retained link.
1345
+ device = timedOutDevice;
1346
+ } else {
1347
+ remapError(error);
1348
+ }
1085
1349
  }
1086
- } catch (error) {
1087
- await this.runNativeTeardown(uuid, blePlxManager, async () => {
1088
- await Promise.all([
1089
- this.runBestEffortNativeOperation('bond failure: cancel manager connection', () =>
1090
- blePlxManager.cancelDeviceConnection(uuid)
1091
- ),
1092
- this.runBestEffortNativeOperation('bond failure: cancel device connection', () =>
1093
- connectedDevice.cancelConnection()
1094
- ),
1095
- ]);
1096
- });
1097
- throw error;
1098
1350
  }
1099
1351
  }
1100
-
1101
- device = await resolveNegotiatedMtu(device);
1352
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1102
1353
  const acquiredDevice = device;
1103
1354
  const { writeCharacteristic, notifyCharacteristic } =
1104
- await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
1355
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice));
1356
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1105
1357
 
1106
1358
  const protocolHint = expectedProtocol
1107
1359
  ? undefined
@@ -1163,6 +1415,7 @@ export default class ReactNativeBleTransport {
1163
1415
  await this.installTransportForAcquire(uuid, acquiredDevice);
1164
1416
  }
1165
1417
  );
1418
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1166
1419
  const currentTransport = transportCache[uuid];
1167
1420
  if (!currentTransport) {
1168
1421
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
@@ -1170,11 +1423,9 @@ export default class ReactNativeBleTransport {
1170
1423
  this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1171
1424
  return { uuid, protocolType };
1172
1425
  } catch (error) {
1173
- if (isBleStaleBondHardwareError(error)) {
1174
- await this.disconnectUnlocked(uuid);
1175
- } else {
1176
- await this.releaseUnlocked(uuid, true);
1177
- }
1426
+ // A failed acquire must retire the physical link before Core retries. Logical
1427
+ // release leaves GATT connected even when neither protocol receives a response.
1428
+ await this.disconnectUnlocked(uuid);
1178
1429
  throw error;
1179
1430
  } finally {
1180
1431
  this.acquiringProtocolV2.delete(uuid);
@@ -1212,6 +1463,9 @@ export default class ReactNativeBleTransport {
1212
1463
  this.rememberStaleBondError(uuid, toBleStaleBondHardwareError(error));
1213
1464
  return;
1214
1465
  }
1466
+ if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
1467
+ this.androidGattCacheRefreshes.add(uuid);
1468
+ }
1215
1469
  if (this.getActiveProtocol(uuid) === 'V2') {
1216
1470
  let errorCode:
1217
1471
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -1221,10 +1475,7 @@ export default class ReactNativeBleTransport {
1221
1475
  if (error.reason?.includes('The connection has timed out unexpectedly')) {
1222
1476
  errorCode = HardwareErrorCode.BleTimeoutError;
1223
1477
  } else if (
1224
- error.reason?.includes('Cannot write client characteristic config descriptor') ||
1225
- error.reason?.includes('Cannot find client characteristic config descriptor') ||
1226
- error.reason?.includes('The handle is invalid') ||
1227
- error.reason?.includes('Writing is not permitted') ||
1478
+ isStaleGattTableNotifyReason(error.reason) ||
1228
1479
  error.reason?.includes('notify change failed for device')
1229
1480
  ) {
1230
1481
  errorCode = HardwareErrorCode.BleCharacteristicNotifyChangeFailure;
@@ -1241,10 +1492,7 @@ export default class ReactNativeBleTransport {
1241
1492
  ERROR = HardwareErrorCode.BleTimeoutError;
1242
1493
  }
1243
1494
  if (
1244
- error.reason?.includes('Cannot write client characteristic config descriptor') ||
1245
- error.reason?.includes('Cannot find client characteristic config descriptor') || // pro firmware 2.3.0 upgrade
1246
- error.reason?.includes('The handle is invalid') ||
1247
- error.reason?.includes('Writing is not permitted') || // pro firmware 2.3.4 upgrade
1495
+ isStaleGattTableNotifyReason(error.reason) ||
1248
1496
  error.reason?.includes('notify change failed for device')
1249
1497
  ) {
1250
1498
  const notifyError = ERRORS.TypedError(
@@ -1674,13 +1922,14 @@ export default class ReactNativeBleTransport {
1674
1922
  const jsonData = ProtocolV1.decodeMessage(messages, response);
1675
1923
  return check.call(jsonData);
1676
1924
  } catch (e) {
1677
- if (name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1678
- Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe call failed:', e);
1925
+ const isProbeTimeout =
1926
+ options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS &&
1927
+ (name === 'GetFeatures' || name === 'Initialize');
1928
+ if (isProbeTimeout) {
1929
+ Log?.debug(`[ReactNativeBleTransport] Protocol V1 ${name} probe call failed:`, e);
1679
1930
  } else {
1680
1931
  Log?.error('call error: ', e);
1681
1932
  }
1682
- const isProbeTimeout =
1683
- name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1684
1933
  // A call that has been superseded (forceRun) or cleaned up no longer owns the
1685
1934
  // transport; its late timeout must not tear down the connection the current
1686
1935
  // call is actively using.
@@ -1703,7 +1952,49 @@ export default class ReactNativeBleTransport {
1703
1952
  }
1704
1953
 
1705
1954
  stop() {
1955
+ if (this.stopPromise) return this.stopPromise;
1706
1956
  this.stopped = true;
1957
+ // Bonding precedes GATT, so cancelDeviceConnection cannot end this wait.
1958
+ this.bondAbortController.abort();
1959
+ const deviceIds = new Set([
1960
+ ...this.monitorTokens.keys(),
1961
+ ...this.sessionProtocols.keys(),
1962
+ ...this.lifecycleOperations.keys(),
1963
+ ...(this.runPromiseDeviceId ? [this.runPromiseDeviceId] : []),
1964
+ ]);
1965
+ const scans = Array.from(this.scanCleanups, cleanup => cleanup());
1966
+ this.androidPriorityResetTimers.forEach(timeout => clearTimeout(timeout));
1967
+ this.androidPriorityResetTimers.clear();
1968
+ this.androidHighPriorityDevices.clear();
1969
+ const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1970
+ this.runPromise?.reject(error);
1971
+ this.runPromise = null;
1972
+ this.runPromiseDeviceId = null;
1973
+ deviceIds.forEach(uuid => this.rejectProtocolV2Frames(uuid, error));
1974
+ const manager = this.blePlxManager;
1975
+ // Cancel native setup before waiting for its lifecycle lock. Otherwise stop
1976
+ // waits for the very connect/MTU/GATT operation it needs to interrupt.
1977
+ const pendingConnections = manager
1978
+ ? Array.from(this.lifecycleOperations.keys(), uuid =>
1979
+ this.runNativeTeardown(uuid, manager, async () => {
1980
+ await this.runBestEffortNativeOperation('stop: cancel pending device connection', () =>
1981
+ manager.cancelDeviceConnection(uuid)
1982
+ );
1983
+ })
1984
+ )
1985
+ : [];
1986
+ // Release only this transport's endpoints; other connectors may share ble-plx.
1987
+ this.stopPromise = Promise.all([
1988
+ ...scans,
1989
+ ...pendingConnections,
1990
+ ...Array.from(deviceIds, uuid => this.disconnect(uuid)),
1991
+ ]).then(async () => {
1992
+ await this.protocolV2Links.invalidateAllLinks('React Native BLE transport stopped');
1993
+ await this.waitForManagerReset();
1994
+ this.blePlxManager = undefined;
1995
+ this.emitter = undefined;
1996
+ });
1997
+ return this.stopPromise;
1707
1998
  }
1708
1999
 
1709
2000
  async disconnect(session: string) {
@@ -1855,17 +2146,119 @@ export default class ReactNativeBleTransport {
1855
2146
  }
1856
2147
  }
1857
2148
 
1858
- cancel() {
2149
+ async cancel() {
1859
2150
  Log?.debug('transport-react-native transport cancel');
1860
- if (this.runPromise) {
1861
- // this.runPromise.reject(new Error('Transport_CallCanceled'));
2151
+ const pending = this.runPromise;
2152
+ const deviceId = this.runPromiseDeviceId;
2153
+ if (pending) {
2154
+ pending.reject(ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled));
2155
+ if (this.runPromise === pending) {
2156
+ this.runPromise = null;
2157
+ this.runPromiseDeviceId = null;
2158
+ }
2159
+ // A V1 read cannot be safely reused after abandoning its response.
2160
+ // Drain native teardown before DeviceCommands releases the operation.
2161
+ if (deviceId) await this.disconnect(deviceId);
1862
2162
  }
1863
- this.runPromise = null;
1864
- this.runPromiseDeviceId = null;
2163
+ }
2164
+
2165
+ /**
2166
+ * Android: request the MTU with nothing queued ahead and never close the client while it is
2167
+ * outstanding; a link that times out or stays at MTU 23 is dropped.
2168
+ */
2169
+ private async negotiateAndroidMtu(
2170
+ uuid: string,
2171
+ manager: BlePlxManager,
2172
+ device: Device
2173
+ ): Promise<Device> {
2174
+ if (!shouldRefreshNegotiatedMtu(device.mtu)) return device;
2175
+
2176
+ const startedAt = Date.now();
2177
+ let timer: ReturnType<typeof setTimeout> | undefined;
2178
+ let timedOut = false;
2179
+ let negotiated = device;
2180
+ let failure: string | undefined;
2181
+ try {
2182
+ negotiated = await Promise.race([
2183
+ device.requestMTU(ANDROID_REQUEST_MTU, `${device.id}:mtu:connected:0:${startedAt}`),
2184
+ new Promise<never>((_, reject) => {
2185
+ timer = setTimeout(() => {
2186
+ timedOut = true;
2187
+ reject(
2188
+ new Error(`BLE MTU exchange timeout after ${ANDROID_MTU_EXCHANGE_TIMEOUT_MS}ms`)
2189
+ );
2190
+ }, ANDROID_MTU_EXCHANGE_TIMEOUT_MS);
2191
+ }),
2192
+ ]);
2193
+ } catch (error) {
2194
+ failure = error instanceof Error ? error.message : String(error);
2195
+ } finally {
2196
+ if (timer) clearTimeout(timer);
2197
+ }
2198
+ Log?.debug(`[ReactNativeBleTransport] BLE MTU exchange ${failure ? 'failed' : 'completed'}`, {
2199
+ connectIdSuffix: uuid.slice(-8),
2200
+ elapsedMs: Date.now() - startedAt,
2201
+ timedOut,
2202
+ actual: negotiated.mtu,
2203
+ error: failure,
2204
+ });
2205
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
2206
+ if (!timedOut && !isKnownDefaultMtu(negotiated.mtu)) return negotiated;
2207
+
2208
+ // Counted like a setup timeout so a link that keeps failing reaches the wedged-link guard.
2209
+ const resetManager = this.abandonStalledConnection(
2210
+ uuid,
2211
+ timedOut ? 'mtu-backstop' : 'mtu-default'
2212
+ );
2213
+ await this.dropAndroidLink(
2214
+ uuid,
2215
+ manager,
2216
+ negotiated,
2217
+ timedOut ? 'mtu exchange timeout' : 'default mtu'
2218
+ );
2219
+ if (resetManager) throw this.createWedgedBleSetupError();
2220
+ throw ERRORS.TypedError(
2221
+ HardwareErrorCode.BleConnectedError,
2222
+ timedOut
2223
+ ? 'BLE MTU exchange did not complete, reconnecting on a fresh link'
2224
+ : `BLE link stayed at the default MTU ${negotiated.mtu}, reconnecting on a fresh link`
2225
+ );
2226
+ }
2227
+
2228
+ /** Close the client and wait out the link idle timer so the next connect gets a fresh link. */
2229
+ private async dropAndroidLink(
2230
+ uuid: string,
2231
+ manager: BlePlxManager,
2232
+ device: Device,
2233
+ reason: string
2234
+ ) {
2235
+ await this.runNativeTeardown(uuid, manager, async () => {
2236
+ await Promise.all([
2237
+ this.runBestEffortNativeOperation(`${reason}: cancel manager connection`, () =>
2238
+ manager.cancelDeviceConnection(uuid)
2239
+ ),
2240
+ this.runBestEffortNativeOperation(`${reason}: cancel device connection`, () =>
2241
+ device.cancelConnection()
2242
+ ),
2243
+ ]);
2244
+ });
2245
+
2246
+ const startedAt = Date.now();
2247
+ while (!this.stopped && Date.now() - startedAt < ANDROID_LINK_DROP_QUIET_MS) {
2248
+ await delay(ANDROID_LINK_DROP_POLL_MS);
2249
+ }
2250
+ Log?.debug('[ReactNativeBleTransport] Android BLE link drop', {
2251
+ connectIdSuffix: uuid.slice(-8),
2252
+ reason,
2253
+ stopped: this.stopped,
2254
+ });
1865
2255
  }
1866
2256
 
1867
2257
  /** Run a native connect under the JS backstop budget. */
1868
2258
  private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
2259
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
2260
+ const startedAt = Date.now();
2261
+ let succeeded = false;
1869
2262
  let timer: ReturnType<typeof setTimeout> | undefined;
1870
2263
  let timedOut = false;
1871
2264
  const pending = connect();
@@ -1887,6 +2280,7 @@ export default class ReactNativeBleTransport {
1887
2280
  }, BLE_CONNECT_TIMEOUT_MS);
1888
2281
  }),
1889
2282
  ]);
2283
+ succeeded = true;
1890
2284
  return result;
1891
2285
  } catch (error) {
1892
2286
  if (timedOut || isNativeOperationTimeoutError(error)) {
@@ -1901,6 +2295,12 @@ export default class ReactNativeBleTransport {
1901
2295
  throw error;
1902
2296
  } finally {
1903
2297
  if (timer) clearTimeout(timer);
2298
+ Log?.debug('[ReactNativeBleTransport] connect completed', {
2299
+ connectIdSuffix: uuid.slice(-8),
2300
+ elapsedMs: Date.now() - startedAt,
2301
+ succeeded,
2302
+ backstopExpired: timedOut,
2303
+ });
1904
2304
  }
1905
2305
  }
1906
2306
 
@@ -1909,6 +2309,8 @@ export default class ReactNativeBleTransport {
1909
2309
  uuid: string,
1910
2310
  device: Device
1911
2311
  ): Promise<ResolvedBleCharacteristics> {
2312
+ const startedAt = Date.now();
2313
+ let succeeded = false;
1912
2314
  let timer: ReturnType<typeof setTimeout> | undefined;
1913
2315
  let timedOut = false;
1914
2316
  const pending = this.resolveCharacteristics(device);
@@ -1929,6 +2331,7 @@ export default class ReactNativeBleTransport {
1929
2331
  }),
1930
2332
  ]);
1931
2333
  this.connectionSetupTimeoutCounts.delete(uuid);
2334
+ succeeded = true;
1932
2335
  return result;
1933
2336
  } catch (error) {
1934
2337
  if (timedOut || isNativeOperationTimeoutError(error)) {
@@ -1940,9 +2343,18 @@ export default class ReactNativeBleTransport {
1940
2343
  throw this.createWedgedBleSetupError();
1941
2344
  }
1942
2345
  }
2346
+ if (Platform.OS === 'android' && isMissingGattShapeError(error)) {
2347
+ this.androidGattCacheRefreshes.add(uuid);
2348
+ }
1943
2349
  throw error;
1944
2350
  } finally {
1945
2351
  if (timer) clearTimeout(timer);
2352
+ Log?.debug('[ReactNativeBleTransport] GATT setup completed', {
2353
+ connectIdSuffix: uuid.slice(-8),
2354
+ elapsedMs: Date.now() - startedAt,
2355
+ succeeded,
2356
+ backstopExpired: timedOut,
2357
+ });
1946
2358
  }
1947
2359
  }
1948
2360
 
@@ -1954,7 +2366,13 @@ export default class ReactNativeBleTransport {
1954
2366
  */
1955
2367
  private abandonStalledConnection(
1956
2368
  uuid: string,
1957
- stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
2369
+ stage:
2370
+ | 'connect-backstop'
2371
+ | 'connect-native'
2372
+ | 'gatt-backstop'
2373
+ | 'gatt-native'
2374
+ | 'mtu-backstop'
2375
+ | 'mtu-default'
1958
2376
  ): boolean {
1959
2377
  const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
1960
2378
  this.connectionSetupTimeoutCounts.set(uuid, timeouts);
@@ -2086,6 +2504,7 @@ export default class ReactNativeBleTransport {
2086
2504
  }
2087
2505
 
2088
2506
  private resetPlxManager() {
2507
+ if (bleManagerResetPromise) return;
2089
2508
  const manager = this.blePlxManager;
2090
2509
  this.blePlxManager = undefined;
2091
2510
  const reason = 'React Native BLE manager reset';
@@ -2128,15 +2547,26 @@ export default class ReactNativeBleTransport {
2128
2547
  // Keep transport-lifetime V2 proof so the same endpoint can finish a no-probe
2129
2548
  // firmware reconnect after the native BLE manager is recreated.
2130
2549
  this.protocolReprobeFailures.clear();
2550
+ this.silentDetections.clear();
2131
2551
  this.writeTimeoutCounts.clear();
2132
2552
  this.connectionSetupTimeoutCounts.clear();
2133
2553
  this.monitorTokens.clear();
2134
2554
  this.protocolV2Assemblers.clear();
2555
+ let reset: Promise<void>;
2135
2556
  try {
2136
- manager?.destroy();
2557
+ reset = Promise.resolve(manager?.destroy());
2137
2558
  } catch (error) {
2138
- Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
2559
+ reset = Promise.reject(error);
2139
2560
  }
2561
+ bleManagerResetPromise = reset;
2562
+ reset.then(
2563
+ () => {
2564
+ if (bleManagerResetPromise === reset) bleManagerResetPromise = undefined;
2565
+ },
2566
+ error => {
2567
+ Log?.error('[ReactNativeBleTransport] BLE manager destroy failed:', error);
2568
+ }
2569
+ );
2140
2570
  }
2141
2571
 
2142
2572
  private createProtocolMismatchError(expected: ProtocolType) {
@@ -2229,6 +2659,8 @@ export default class ReactNativeBleTransport {
2229
2659
  reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
2230
2660
  const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
2231
2661
 
2662
+ await this.wakeSilentProtocolV1Device(uuid, probeOrder);
2663
+
2232
2664
  for (let i = 0; i < probeOrder.length; i += 1) {
2233
2665
  const protocol = probeOrder[i];
2234
2666
  if (i > 0) {
@@ -2250,6 +2682,7 @@ export default class ReactNativeBleTransport {
2250
2682
  this.confirmedProtocolV2.add(uuid);
2251
2683
  }
2252
2684
  this.protocolReprobeFailures.delete(uuid);
2685
+ this.silentDetections.delete(uuid);
2253
2686
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
2254
2687
  deviceId: uuid,
2255
2688
  protocol,
@@ -2259,6 +2692,9 @@ export default class ReactNativeBleTransport {
2259
2692
  }
2260
2693
  }
2261
2694
 
2695
+ // Arms the wake for the next detection.
2696
+ if (!this.silentDetections.has(uuid)) this.silentDetections.set(uuid, 'silent');
2697
+
2262
2698
  if (trustSessionProtocol) {
2263
2699
  // Still silent on its own protocol: count it, and let the streak expire the
2264
2700
  // shortcut so a device that genuinely switched protocols is found again.
@@ -2272,6 +2708,34 @@ export default class ReactNativeBleTransport {
2272
2708
  throw this.createProtocolDetectionError();
2273
2709
  }
2274
2710
 
2711
+ /**
2712
+ * A sleeping Classic drops GetFeatures/Ping and only leaves its screensaver on Initialize, which
2713
+ * resets the wallet session, so it is sent once after a fully silent detection. The firmware does
2714
+ * not reliably answer it, so its timeout must not drop the link. It only runs when V1 is probed
2715
+ * first, so a late reply lands on the V1 probe rather than on a V2 one.
2716
+ */
2717
+ private async wakeSilentProtocolV1Device(uuid: string, probeOrder: ProtocolType[]) {
2718
+ if (
2719
+ Platform.OS !== 'android' ||
2720
+ probeOrder[0] !== 'V1' ||
2721
+ this.silentDetections.get(uuid) !== 'silent'
2722
+ ) {
2723
+ return;
2724
+ }
2725
+ this.silentDetections.set(uuid, 'woken');
2726
+ Log?.debug('[ReactNativeBleTransport] sending Protocol V1 Initialize wake', {
2727
+ connectIdSuffix: uuid.slice(-8),
2728
+ });
2729
+ try {
2730
+ this.probingProtocols.set(uuid, 'V1');
2731
+ await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2732
+ } catch (error) {
2733
+ if (shouldRethrowProtocolProbeError(error)) throw error;
2734
+ } finally {
2735
+ this.clearProbeProtocol(uuid, 'V1');
2736
+ }
2737
+ }
2738
+
2275
2739
  private async resetProbeStateAfterProtocolProbe(uuid: string, protocol: ProtocolType) {
2276
2740
  const transport = transportCache[uuid];
2277
2741
  await this.protocolV2Links.invalidateLink(
@@ -2339,9 +2803,7 @@ export default class ReactNativeBleTransport {
2339
2803
  } catch (error) {
2340
2804
  this.clearProbeProtocol(uuid, 'V1');
2341
2805
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
2342
- // A wedged write already dropped the link, so probing another protocol on it
2343
- // would only fail against a torn-down transport: surface the real cause.
2344
- if (isWedgedWriteError(error)) {
2806
+ if (shouldRethrowProtocolProbeError(error)) {
2345
2807
  throw error;
2346
2808
  }
2347
2809
  return false;
@@ -2366,7 +2828,7 @@ export default class ReactNativeBleTransport {
2366
2828
  this.protocolV2Assemblers.get(uuid)?.reset();
2367
2829
  this.resetProtocolV2Frames(uuid);
2368
2830
  },
2369
- shouldRethrow: isBleStaleBondHardwareError,
2831
+ shouldRethrow: shouldRethrowProtocolProbeError,
2370
2832
  });
2371
2833
  if (!detected) {
2372
2834
  this.clearProbeProtocol(uuid, 'V2');
@@ -2513,6 +2975,9 @@ export default class ReactNativeBleTransport {
2513
2975
  this.rememberStaleBondError(uuid, bondError);
2514
2976
  throw bondError;
2515
2977
  }
2978
+ if (isNativeBleDisconnectError(error)) {
2979
+ throw toBleDisconnectHardwareError(error);
2980
+ }
2516
2981
  if (
2517
2982
  getFirmwareUploadWriteRetryType(error) !== 'congested' ||
2518
2983
  attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
@@ -2573,6 +3038,7 @@ export default class ReactNativeBleTransport {
2573
3038
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
2574
3039
  }
2575
3040
 
3041
+ const isProtocolProbe = this.probingProtocols.get(uuid) === 'V2';
2576
3042
  const callOptions = options;
2577
3043
  const highThroughputWrite = isProtocolV2HighThroughputCall(name);
2578
3044
 
@@ -2624,6 +3090,19 @@ export default class ReactNativeBleTransport {
2624
3090
  );
2625
3091
  } catch (e) {
2626
3092
  Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
3093
+ if (
3094
+ !isProtocolProbe &&
3095
+ e?.errorCode === HardwareErrorCode.BleTimeoutError &&
3096
+ !this.monitorTokens.has(uuid)
3097
+ ) {
3098
+ // The failed link has finished invalidating. Disconnect outside that
3099
+ // callback to avoid waiting on its own invalidation or acquire lock.
3100
+ await this.runLifecycleOperation(uuid, async () => {
3101
+ // A queued timeout leaves its active monitor intact; a newer acquire
3102
+ // may also have installed one while cleanup waited for the lifecycle lock.
3103
+ if (!this.monitorTokens.has(uuid)) await this.disconnectUnlocked(uuid);
3104
+ });
3105
+ }
2627
3106
  throw e;
2628
3107
  } finally {
2629
3108
  if (highThroughputWrite) {
@@ -2636,7 +3115,12 @@ export default class ReactNativeBleTransport {
2636
3115
  const transport = this.getCachedTransport(uuid);
2637
3116
  if (!shouldRefreshNegotiatedMtu(transport.mtuSize)) return;
2638
3117
 
2639
- const refreshedDevice = await requestNegotiatedMtu(transport.device, 'highThroughput', 1);
3118
+ const { device: refreshedDevice } = await requestNegotiatedMtu(
3119
+ transport.device,
3120
+ 'highThroughput',
3121
+ 1,
3122
+ transactionId => this.blePlxManager?.cancelTransaction(transactionId)
3123
+ );
2640
3124
  transport.device = refreshedDevice;
2641
3125
  transport.mtuSize =
2642
3126
  typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;