@onekeyfe/hd-transport-react-native 1.2.2-alpha.116 → 1.2.2-alpha.117

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
@@ -185,10 +185,7 @@ const shouldRethrowProtocolProbeError = (error: unknown): boolean => {
185
185
  code === HardwareErrorCode.BleDeviceDisconnected ||
186
186
  code === HardwareErrorCode.BleCharacteristicNotifyError ||
187
187
  code === HardwareErrorCode.BleCharacteristicNotifyChangeFailure ||
188
- code === HardwareErrorCode.BleWriteCharacteristicError ||
189
- // A link stuck at the default MTU cannot answer Protocol V2 at all; reporting it as a
190
- // probe miss would send detection on to report a protocol mismatch instead.
191
- isProtocolV2DefaultMtuError(error)
188
+ code === HardwareErrorCode.BleWriteCharacteristicError
192
189
  );
193
190
  };
194
191
  /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
@@ -259,28 +256,16 @@ const connectOptions: Record<string, unknown> = {
259
256
  refreshGatt: 'OnConnected',
260
257
  };
261
258
 
262
- /** 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. */
263
260
  const fallbackConnectOptions: Record<string, unknown> = {
264
261
  timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
265
262
  };
266
263
 
267
264
  /**
268
- * Android connects with the bare native budget: no MTU request and no GATT cache refresh
269
- * inside that timer. ble-plx runs establishConnection -> refreshGatt -> requestMtu under one
270
- * timeout, and refreshGatt makes the Android stack rediscover every service at the default
271
- * 23-byte MTU before the MTU request can leave the ATT queue. A Pro 2 needs 2-5s for that
272
- * rediscovery, so the budget expired with the request still unsent. Closing the client then
273
- * left the exchange marked in progress for the whole LE link, and the stack parked every
274
- * later MTU request on it ("Put conn_id on wait list"; btsnoop showed no Exchange MTU
275
- * Request on the air). The MTU is negotiated as a separate first step instead.
276
- */
277
- const androidConnectOptions: Record<string, unknown> = {
278
- timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
279
- };
280
-
281
- /**
282
- * Only used when the cached GATT table is known or likely to be stale; the rediscovery it
283
- * starts is then allowed to finish before the MTU exchange instead of racing it.
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.
284
269
  */
285
270
  const androidRefreshGattConnectOptions: Record<string, unknown> = {
286
271
  timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
@@ -288,61 +273,27 @@ const androidRefreshGattConnectOptions: Record<string, unknown> = {
288
273
  };
289
274
 
290
275
  /**
291
- * Bound for the Android MTU exchange. A healthy exchange with a Pro 2 completes in ~50ms.
292
- * When the phone has no cached GATT table (first connect after bonding, or a connect after
293
- * an aborted discovery) the stack runs its own discovery first and only executes the MTU
294
- * request after it; single passes on a Pro 2 at MTU 23 measured 1.7-4.8s and a refresh
295
- * plus restart 7.7s, so the bound has to sit well above those. An exchange the stack holds
296
- * as already in progress never completes, so a longer bound costs nothing on that link.
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.
297
278
  */
298
279
  export const ANDROID_MTU_EXCHANGE_TIMEOUT_MS = 12_000;
299
280
 
300
281
  /**
301
- * Android keeps an LE link up for its GATT link idle timer (4s) after the last client
302
- * closes, and per-link ATT state such as a pending MTU exchange lives as long as the link.
303
- * A reconnect inside that window attaches to the same link. The only connectivity signal
304
- * reachable from here, BluetoothManager.getConnectedDevices(GATT), is built from the
305
- * per-app GATT client registry rather than the LE link: it empties the moment this client
306
- * closes on phones with no resident GATT server, and stays populated for the life of the
307
- * link on phones that have one. So an unusable link is held for at least the quiet period
308
- * (past the idle timer), and additionally until that registry no longer lists the device,
309
- * within the overall bound.
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.
310
284
  */
311
285
  export const ANDROID_LINK_DROP_QUIET_MS = 5000;
312
- export const ANDROID_LINK_DROP_TIMEOUT_MS = 8000;
313
286
  const ANDROID_LINK_DROP_POLL_MS = 250;
314
287
 
315
288
  /**
316
- * Nothing works at the default 23-byte ATT MTU on Android. Protocol V1 writes 192-byte
317
- * packets whatever the MTU, and a Pro 2 answers Protocol V2 with a single ATT_MTU-3
318
- * notification and never sends the rest of the frame, so the 29-byte reply to a probe Ping
319
- * arrives as 20 bytes declaring 29 and the call hangs. Only a known default MTU counts; an
320
- * unknown value keeps the existing conservative-packet behaviour.
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.
321
292
  */
322
293
  const isKnownDefaultMtu = (mtu: unknown): boolean =>
323
294
  typeof mtu === 'number' && Number.isFinite(mtu) && mtu <= 23;
324
- const DEFAULT_MTU_LINK_MESSAGE = 'BLE link stayed at the default MTU';
325
- const createDefaultMtuLinkError = (mtu: unknown) =>
326
- ERRORS.TypedError(
327
- HardwareErrorCode.BleConnectedError,
328
- `${DEFAULT_MTU_LINK_MESSAGE} ${String(mtu)}, reconnecting on a fresh link`
329
- );
330
- const PROTOCOL_V2_DEFAULT_MTU_MESSAGE = 'Protocol V2 needs a negotiated BLE MTU';
331
- const isProtocolV2DefaultMtuError = (error: unknown): boolean =>
332
- (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
333
- typeof (error as { message?: unknown })?.message === 'string' &&
334
- (error as { message: string }).message.startsWith(PROTOCOL_V2_DEFAULT_MTU_MESSAGE);
335
- const createProtocolV2DefaultMtuError = (mtu: unknown) =>
336
- ERRORS.TypedError(
337
- HardwareErrorCode.BleConnectedError,
338
- `${PROTOCOL_V2_DEFAULT_MTU_MESSAGE}, current MTU ${String(mtu)}`
339
- );
340
295
 
341
- /**
342
- * Symptoms of a cached GATT table that no longer matches the device. The UUIDs still
343
- * resolve from the cache in the firmware-upgrade case, so the stale handles surface at
344
- * discovery (missing or mis-typed characteristic) or when notifications are enabled.
345
- */
296
+ /** Discovery found no OneKey service, or a characteristic of the wrong shape: the cached GATT table may be stale. */
346
297
  const isMissingGattShapeError = (error: unknown): boolean => {
347
298
  const code = (error as { errorCode?: unknown })?.errorCode;
348
299
  const message = (error as { message?: unknown })?.message;
@@ -358,9 +309,9 @@ const isMissingGattShapeError = (error: unknown): boolean => {
358
309
  const isStaleGattTableNotifyReason = (reason: string | null | undefined): boolean =>
359
310
  !!reason &&
360
311
  (reason.includes('Cannot write client characteristic config descriptor') ||
361
- reason.includes('Cannot find client characteristic config descriptor') ||
312
+ reason.includes('Cannot find client characteristic config descriptor') || // pro firmware 2.3.0 upgrade
362
313
  reason.includes('The handle is invalid') ||
363
- reason.includes('Writing is not permitted'));
314
+ reason.includes('Writing is not permitted')); // pro firmware 2.3.4 upgrade
364
315
 
365
316
  /**
366
317
  * JS backstop for connect. The native adapter applies its own 3s budget, but it
@@ -401,22 +352,16 @@ const shouldRethrowBleSetupError = (error: unknown): boolean =>
401
352
  isConnectTimeoutError(error) || isWedgedBleSetupError(error);
402
353
  const isNativeOperationTimeoutError = (error: unknown): boolean =>
403
354
  (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
404
- /**
405
- * ble-plx reports its own expired budget as OperationCancelled, not OperationTimedOut:
406
- * safeConnectToDevice disposes the operation chain and its doFinally reports a
407
- * cancellation. Either code means the native layer abandoned work that still owned
408
- * connection/GATT state, so both have to go through the stalled-connection cleanup.
409
- * Only used where nothing else recovers the link — the connect path does its own
410
- * ordered teardown before retrying, and routing it here too would both duplicate that
411
- * and spend the manager-reset budget on a connect that is about to be retried.
412
- */
413
- const isAbandonedNativeOperationError = (error: unknown): boolean => {
355
+ const isMtuOrCancelledConnectError = (error: unknown): boolean => {
414
356
  const errorCode = (error as { errorCode?: unknown })?.errorCode;
415
357
  return (
416
- errorCode === BleErrorCode.OperationTimedOut || errorCode === BleErrorCode.OperationCancelled
358
+ errorCode === BleErrorCode.DeviceMTUChangeFailed ||
359
+ errorCode === BleErrorCode.OperationCancelled
417
360
  );
418
361
  };
419
362
 
363
+ type NegotiatedMtuResult = { device: Device; timedOut: boolean };
364
+
420
365
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
421
366
 
422
367
  const tryToGetConfiguration = (device: Device) => {
@@ -428,26 +373,13 @@ const tryToGetConfiguration = (device: Device) => {
428
373
  return infos;
429
374
  };
430
375
 
431
- type NegotiatedMtuResult = {
432
- device: Device;
433
- /**
434
- * True when the bounded wait gave up while the native MTU exchange was still
435
- * outstanding. `cancelTransaction` only disposes the JS-side subscription:
436
- * RxAndroidBle releases a connection's serial operation queue from onComplete /
437
- * onError alone, so an abandoned exchange keeps that queue until its own native
438
- * timeout. Any GATT work issued before then — service discovery above all —
439
- * queues behind it and cannot settle inside its own budget.
440
- */
441
- abandoned: boolean;
442
- };
443
-
444
376
  const requestNegotiatedMtu = async (
445
377
  device: Device,
446
378
  stage: 'connected' | 'highThroughput',
447
379
  attempt: number,
448
380
  cancelTransaction?: (transactionId: string) => Promise<void> | void
449
381
  ): Promise<NegotiatedMtuResult> => {
450
- if (Platform.OS !== 'ios' && Platform.OS !== 'android') return { device, abandoned: false };
382
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') return { device, timedOut: false };
451
383
 
452
384
  const transactionId = `${device.id}:mtu:${stage}:${attempt}:${Date.now()}`;
453
385
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
@@ -470,7 +402,7 @@ const requestNegotiatedMtu = async (
470
402
  }, BLE_MTU_REQUEST_TIMEOUT_MS);
471
403
  }),
472
404
  ]);
473
- return { device: mtuDevice, abandoned: false };
405
+ return { device: mtuDevice, timedOut: false };
474
406
  } catch (error) {
475
407
  if (timedOut && cancelTransaction) {
476
408
  try {
@@ -496,9 +428,10 @@ const requestNegotiatedMtu = async (
496
428
  stage,
497
429
  attempt,
498
430
  actual: device.mtu,
431
+ timedOut,
499
432
  error: error instanceof Error ? error.message : String(error),
500
433
  });
501
- return { device, abandoned: timedOut };
434
+ return { device, timedOut };
502
435
  } finally {
503
436
  if (timeoutId) clearTimeout(timeoutId);
504
437
  }
@@ -510,7 +443,7 @@ const resolveNegotiatedMtu = (
510
443
  ): Promise<NegotiatedMtuResult> =>
511
444
  shouldRefreshNegotiatedMtu(device.mtu)
512
445
  ? requestNegotiatedMtu(device, 'connected', 0, cancelTransaction)
513
- : Promise.resolve({ device, abandoned: false });
446
+ : Promise.resolve({ device, timedOut: false });
514
447
 
515
448
  type IOBleErrorRemap = Error | BleError | null | undefined;
516
449
 
@@ -594,31 +527,12 @@ export default class ReactNativeBleTransport {
594
527
  /** Consecutive detections that failed while trusting sessionProtocols. */
595
528
  private protocolReprobeFailures: Map<string, number> = new Map();
596
529
 
597
- /** Endpoints whose last detection got no answer on any protocol. */
598
- private silentDetections = new Set<string>();
530
+ /** Endpoints whose last detection got no answer; 'woken' once their Initialize wake is spent. */
531
+ private silentDetections = new Map<string, 'silent' | 'woken'>();
599
532
 
600
- /**
601
- * Android endpoints whose cached GATT table is suspect and must be rediscovered on the
602
- * next connect. The cache is no longer refreshed on every connect, so a missing OneKey
603
- * service or characteristic marks the endpoint instead.
604
- */
533
+ /** Android endpoints whose cached GATT table is suspect; the next connect refreshes it. */
605
534
  private androidGattCacheRefreshes = new Set<string>();
606
535
 
607
- /** Instance copies of the Android timing bounds so tests can shorten real-time waits. */
608
- androidMtuExchangeTimeoutMs = ANDROID_MTU_EXCHANGE_TIMEOUT_MS;
609
-
610
- androidLinkDropQuietMs = ANDROID_LINK_DROP_QUIET_MS;
611
-
612
- androidLinkDropTimeoutMs = ANDROID_LINK_DROP_TIMEOUT_MS;
613
-
614
- /**
615
- * Endpoints already sent a Protocol V1 wake. The wake starts a fresh wallet session
616
- * on the device, and a failed detection tears the link down and reconnects on every
617
- * poll, so neither set is cleared on teardown: one wake is spent per endpoint until a
618
- * detection succeeds, which re-arms it for the next time the device sleeps.
619
- */
620
- private protocolWakeAttempts = new Set<string>();
621
-
622
536
  /**
623
537
  * Native encryption/pairing failures seen before Protocol V2 probe starts.
624
538
  * Pro2/Neo GATT connect can succeed on a stale iOS bond; the CCCD write then
@@ -889,13 +803,9 @@ export default class ReactNativeBleTransport {
889
803
 
890
804
  let { device } = transport;
891
805
  const isConnected = await device.isConnected().catch(() => false);
892
- // A firmware upload reconnect keeps the GATT refresh; on Android it uses the same
893
- // order as acquire: bare connect, discovery, then the MTU exchange on its own.
894
- const reconnectOptions =
895
- Platform.OS === 'android' ? androidRefreshGattConnectOptions : connectOptions;
896
806
  if (!isConnected) {
897
807
  try {
898
- device = await this.connectWithTimeout(uuid, () => device.connect(reconnectOptions));
808
+ device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
899
809
  } catch (e) {
900
810
  if (
901
811
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
@@ -910,17 +820,8 @@ export default class ReactNativeBleTransport {
910
820
 
911
821
  const { writeCharacteristic, notifyCharacteristic } =
912
822
  await this.resolveCharacteristicsWithTimeout(uuid, device);
913
- if (Platform.OS === 'android') {
914
- const manager = await this.getPlxManager();
915
- device = await this.negotiateAndroidMtu(uuid, manager, device);
916
- if (isKnownDefaultMtu(device.mtu)) {
917
- await this.dropAndroidLink(uuid, manager, device, 'firmware reconnect default mtu');
918
- throw createDefaultMtuLinkError(device.mtu);
919
- }
920
- }
921
823
 
922
824
  transport.device = device;
923
- transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
924
825
  transport.writeCharacteristic = writeCharacteristic;
925
826
  transport.notifyCharacteristic = notifyCharacteristic;
926
827
  const monitorToken = this.nextMonitorToken;
@@ -1106,7 +1007,7 @@ export default class ReactNativeBleTransport {
1106
1007
  characteristics?: ResolvedBleCharacteristics
1107
1008
  ) {
1108
1009
  const { writeCharacteristic, notifyCharacteristic } =
1109
- characteristics ?? (await this.resolveCharacteristicsForAcquire(uuid, device));
1010
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
1110
1011
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1111
1012
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
1112
1013
  transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
@@ -1209,13 +1110,9 @@ export default class ReactNativeBleTransport {
1209
1110
  const isCachedDeviceConnected = await cachedTransport.device
1210
1111
  .isConnected()
1211
1112
  .catch(() => false);
1212
- // A cached Android link at the default MTU, or one whose GATT table is suspect,
1213
- // has to go through the full acquire again so the MTU exchange and the refresh
1214
- // connect actually run instead of every retry reusing the same unusable link.
1113
+ // A suspect GATT table is only refreshed through a new connect.
1215
1114
  const isCachedAndroidLinkUsable =
1216
- Platform.OS !== 'android' ||
1217
- (!isKnownDefaultMtu(cachedTransport.mtuSize) &&
1218
- !this.androidGattCacheRefreshes.has(uuid));
1115
+ Platform.OS !== 'android' || !this.androidGattCacheRefreshes.has(uuid);
1219
1116
  if (
1220
1117
  isCachedDeviceConnected &&
1221
1118
  isCachedAndroidLinkUsable &&
@@ -1238,19 +1135,16 @@ export default class ReactNativeBleTransport {
1238
1135
 
1239
1136
  let device: Device | null = null;
1240
1137
  const isAndroid = Platform.OS === 'android';
1241
- // A firmware-install reconnect keeps the per-connect GATT refresh it always had: the
1242
- // install loader may expose a different table than the firmware that was cached.
1138
+ // A firmware-install reconnect always refreshes: the new firmware may expose a different table.
1243
1139
  const refreshAndroidGattCache =
1244
1140
  isAndroid && (!!skipProtocolProbe || this.androidGattCacheRefreshes.has(uuid));
1245
1141
  let nativeConnectOptions = connectOptions;
1246
1142
  if (isAndroid) {
1247
1143
  nativeConnectOptions = refreshAndroidGattCache
1248
1144
  ? androidRefreshGattConnectOptions
1249
- : androidConnectOptions;
1145
+ : fallbackConnectOptions;
1250
1146
  }
1251
- // Only a connect that actually carried refreshGatt consumes a pending refresh; the
1252
- // fallback connects below drop that option, so the marker then survives for the next
1253
- // attempt instead of being cleared by a connect that never reached the cache.
1147
+ // Only a connect that carried refreshGatt clears the marker; the fallback connects drop it.
1254
1148
  let androidRefreshConnectRan = false;
1255
1149
 
1256
1150
  if (forceCleanRunPromise && this.runPromise) {
@@ -1262,6 +1156,7 @@ export default class ReactNativeBleTransport {
1262
1156
  }
1263
1157
 
1264
1158
  const blePlxManager = await this.getPlxManager();
1159
+ let skipPostConnectMtu = false;
1265
1160
  try {
1266
1161
  await subscribeBleOn(blePlxManager);
1267
1162
  } catch (error) {
@@ -1314,17 +1209,9 @@ export default class ReactNativeBleTransport {
1314
1209
  if (shouldRethrowBleSetupError(e)) {
1315
1210
  throw e;
1316
1211
  }
1317
- if (
1318
- e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
1319
- e.errorCode === BleErrorCode.OperationCancelled
1320
- ) {
1212
+ if (isMtuOrCancelledConnectError(e)) {
1213
+ skipPostConnectMtu = true;
1321
1214
  Log?.debug('first try to reconnect without params');
1322
- // The disposed chain can still own native connection state, and the retry
1323
- // would otherwise run on top of a half-open GATT client.
1324
- await this.runBestEffortNativeOperation(
1325
- 'connect cancelled: cancel manager connection',
1326
- () => blePlxManager.cancelDeviceConnection(uuid)
1327
- );
1328
1215
  device = await this.connectWithTimeout(uuid, () =>
1329
1216
  blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
1330
1217
  );
@@ -1341,7 +1228,11 @@ export default class ReactNativeBleTransport {
1341
1228
  throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'unable to connect to device');
1342
1229
  }
1343
1230
 
1344
- if (isAndroid && refreshAndroidGattCache && (await device.isConnected().catch(() => false))) {
1231
+ if (
1232
+ refreshAndroidGattCache &&
1233
+ !androidRefreshConnectRan &&
1234
+ (await device.isConnected().catch(() => false))
1235
+ ) {
1345
1236
  // refreshGatt only reaches the stack through a connect. A link that is still up would
1346
1237
  // skip the connect below and keep serving the stale table, so it is dropped first.
1347
1238
  await this.dropAndroidLink(uuid, blePlxManager, device, 'gatt cache refresh');
@@ -1362,17 +1253,9 @@ export default class ReactNativeBleTransport {
1362
1253
  if (shouldRethrowBleSetupError(e)) {
1363
1254
  throw e;
1364
1255
  }
1365
- if (
1366
- e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
1367
- e.errorCode === BleErrorCode.OperationCancelled
1368
- ) {
1256
+ if (isMtuOrCancelledConnectError(e)) {
1257
+ skipPostConnectMtu = true;
1369
1258
  Log?.debug('second try to reconnect without params');
1370
- // The disposed chain can still own native connection state, and the retry
1371
- // would otherwise run on top of a half-open GATT client.
1372
- await this.runBestEffortNativeOperation(
1373
- 'connect cancelled: cancel device connection',
1374
- () => disconnectedDevice.cancelConnection()
1375
- );
1376
1259
  try {
1377
1260
  device = await this.connectWithTimeout(uuid, () =>
1378
1261
  disconnectedDevice.connect(fallbackConnectOptions)
@@ -1418,43 +1301,38 @@ export default class ReactNativeBleTransport {
1418
1301
  if (refreshAndroidGattCache) {
1419
1302
  // refreshGatt has already started a full rediscovery; let it finish before the MTU
1420
1303
  // exchange so the request is not queued behind it.
1421
- characteristics = await this.resolveCharacteristicsForAcquire(uuid, device);
1304
+ characteristics = await this.resolveCharacteristicsWithTimeout(uuid, device);
1422
1305
  if (androidRefreshConnectRan) this.androidGattCacheRefreshes.delete(uuid);
1423
1306
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1424
1307
  }
1425
1308
  device = await this.negotiateAndroidMtu(uuid, blePlxManager, device);
1426
- if (isKnownDefaultMtu(device.mtu)) {
1427
- // Neither protocol works at the default MTU on Android, so the link is not worth
1428
- // keeping for any device family. Counted like a setup timeout so a device that keeps
1429
- // ending here reaches the wedged-link guard instead of the full retry ladder.
1430
- const resetManager = this.abandonStalledConnection(uuid, 'mtu-default');
1431
- await this.dropAndroidLink(uuid, blePlxManager, device, 'default mtu');
1432
- if (resetManager) throw this.createWedgedBleSetupError();
1433
- throw createDefaultMtuLinkError(device.mtu);
1434
- }
1435
- } else {
1436
- const negotiatedMtu = await resolveNegotiatedMtu(device, transactionId =>
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 =>
1437
1314
  blePlxManager.cancelTransaction(transactionId)
1438
1315
  );
1439
- device = negotiatedMtu.device;
1440
- if (negotiatedMtu.abandoned) {
1441
- // Service discovery would queue behind the abandoned exchange and miss its own
1442
- // budget. Drop the link so the next attempt starts on a queue nothing holds.
1443
- await this.runNativeTeardown(uuid, blePlxManager, async () => {
1444
- await this.runBestEffortNativeOperation('mtu abandoned: cancel manager connection', () =>
1445
- blePlxManager.cancelDeviceConnection(uuid)
1446
- );
1447
- });
1448
- throw ERRORS.TypedError(
1449
- HardwareErrorCode.BleConnectedError,
1450
- 'BLE MTU negotiation abandoned, reconnecting'
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
+ await this.runBestEffortNativeOperation('mtu timeout: cancel device connection', () =>
1324
+ timedOutDevice.cancelConnection()
1325
+ );
1326
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1327
+ device = await this.connectWithTimeout(uuid, () =>
1328
+ timedOutDevice.connect(fallbackConnectOptions)
1451
1329
  );
1452
1330
  }
1453
1331
  }
1454
1332
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1455
1333
  const acquiredDevice = device;
1456
1334
  const { writeCharacteristic, notifyCharacteristic } =
1457
- characteristics ?? (await this.resolveCharacteristicsForAcquire(uuid, acquiredDevice));
1335
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice));
1458
1336
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1459
1337
 
1460
1338
  const protocolHint = expectedProtocol
@@ -1565,6 +1443,9 @@ export default class ReactNativeBleTransport {
1565
1443
  this.rememberStaleBondError(uuid, toBleStaleBondHardwareError(error));
1566
1444
  return;
1567
1445
  }
1446
+ if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
1447
+ this.androidGattCacheRefreshes.add(uuid);
1448
+ }
1568
1449
  if (this.getActiveProtocol(uuid) === 'V2') {
1569
1450
  let errorCode:
1570
1451
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -1574,17 +1455,11 @@ export default class ReactNativeBleTransport {
1574
1455
  if (error.reason?.includes('The connection has timed out unexpectedly')) {
1575
1456
  errorCode = HardwareErrorCode.BleTimeoutError;
1576
1457
  } else if (
1577
- error.reason?.includes('Cannot write client characteristic config descriptor') ||
1578
- error.reason?.includes('Cannot find client characteristic config descriptor') ||
1579
- error.reason?.includes('The handle is invalid') ||
1580
- error.reason?.includes('Writing is not permitted') ||
1458
+ isStaleGattTableNotifyReason(error.reason) ||
1581
1459
  error.reason?.includes('notify change failed for device')
1582
1460
  ) {
1583
1461
  errorCode = HardwareErrorCode.BleCharacteristicNotifyChangeFailure;
1584
1462
  }
1585
- if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
1586
- this.androidGattCacheRefreshes.add(uuid);
1587
- }
1588
1463
  this.rejectProtocolV2Frames(uuid, ERRORS.TypedError(errorCode));
1589
1464
  return;
1590
1465
  }
@@ -1597,18 +1472,12 @@ export default class ReactNativeBleTransport {
1597
1472
  ERROR = HardwareErrorCode.BleTimeoutError;
1598
1473
  }
1599
1474
  if (
1600
- error.reason?.includes('Cannot write client characteristic config descriptor') ||
1601
- error.reason?.includes('Cannot find client characteristic config descriptor') || // pro firmware 2.3.0 upgrade
1602
- error.reason?.includes('The handle is invalid') ||
1603
- error.reason?.includes('Writing is not permitted') || // pro firmware 2.3.4 upgrade
1475
+ isStaleGattTableNotifyReason(error.reason) ||
1604
1476
  error.reason?.includes('notify change failed for device')
1605
1477
  ) {
1606
1478
  const notifyError = ERRORS.TypedError(
1607
1479
  HardwareErrorCode.BleCharacteristicNotifyChangeFailure
1608
1480
  );
1609
- if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
1610
- this.androidGattCacheRefreshes.add(uuid);
1611
- }
1612
1481
  this.runPromise.reject(notifyError);
1613
1482
  Log?.debug(
1614
1483
  `${HardwareErrorCode.BleCharacteristicNotifyChangeFailure} ${error.message} ${error.reason}`
@@ -2033,13 +1902,14 @@ export default class ReactNativeBleTransport {
2033
1902
  const jsonData = ProtocolV1.decodeMessage(messages, response);
2034
1903
  return check.call(jsonData);
2035
1904
  } catch (e) {
2036
- if (name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
2037
- Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe call failed:', e);
1905
+ const isProbeTimeout =
1906
+ options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS &&
1907
+ (name === 'GetFeatures' || name === 'Initialize');
1908
+ if (isProbeTimeout) {
1909
+ Log?.debug(`[ReactNativeBleTransport] Protocol V1 ${name} probe call failed:`, e);
2038
1910
  } else {
2039
1911
  Log?.error('call error: ', e);
2040
1912
  }
2041
- const isProbeTimeout =
2042
- name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
2043
1913
  // A call that has been superseded (forceRun) or cleaned up no longer owns the
2044
1914
  // transport; its late timeout must not tear down the connection the current
2045
1915
  // call is actively using.
@@ -2273,10 +2143,8 @@ export default class ReactNativeBleTransport {
2273
2143
  }
2274
2144
 
2275
2145
  /**
2276
- * Negotiate the ATT MTU as the first request on an Android GATT client, with nothing queued
2277
- * ahead of it, and never close the client while it is outstanding: an exchange abandoned
2278
- * mid-flight stays marked in progress for the whole LE link. A link that already negotiated
2279
- * an MTU answers at once with the recorded value.
2146
+ * Android: request the MTU with nothing queued ahead and never close the client while it is
2147
+ * outstanding; a link that times out or stays at MTU 23 is dropped.
2280
2148
  */
2281
2149
  private async negotiateAndroidMtu(
2282
2150
  uuid: string,
@@ -2286,61 +2154,58 @@ export default class ReactNativeBleTransport {
2286
2154
  if (!shouldRefreshNegotiatedMtu(device.mtu)) return device;
2287
2155
 
2288
2156
  const startedAt = Date.now();
2289
- const transactionId = `${device.id}:mtu:connected:0:${startedAt}`;
2290
2157
  let timer: ReturnType<typeof setTimeout> | undefined;
2291
2158
  let timedOut = false;
2292
- const request = device.requestMTU(ANDROID_REQUEST_MTU, transactionId);
2293
- request.catch(() => undefined);
2159
+ let negotiated = device;
2160
+ let failure: string | undefined;
2294
2161
  try {
2295
- const negotiated = await Promise.race([
2296
- request,
2162
+ negotiated = await Promise.race([
2163
+ device.requestMTU(ANDROID_REQUEST_MTU, `${device.id}:mtu:connected:0:${startedAt}`),
2297
2164
  new Promise<never>((_, reject) => {
2298
2165
  timer = setTimeout(() => {
2299
2166
  timedOut = true;
2300
2167
  reject(
2301
- new Error(`BLE MTU exchange timeout after ${this.androidMtuExchangeTimeoutMs}ms`)
2168
+ new Error(`BLE MTU exchange timeout after ${ANDROID_MTU_EXCHANGE_TIMEOUT_MS}ms`)
2302
2169
  );
2303
- }, this.androidMtuExchangeTimeoutMs);
2170
+ }, ANDROID_MTU_EXCHANGE_TIMEOUT_MS);
2304
2171
  }),
2305
2172
  ]);
2306
- Log?.debug('[ReactNativeBleTransport] BLE MTU exchange completed', {
2307
- connectIdSuffix: uuid.slice(-8),
2308
- elapsedMs: Date.now() - startedAt,
2309
- actual: negotiated.mtu,
2310
- });
2311
- return negotiated;
2312
2173
  } catch (error) {
2313
- Log?.debug('[ReactNativeBleTransport] BLE MTU exchange failed', {
2314
- connectIdSuffix: uuid.slice(-8),
2315
- elapsedMs: Date.now() - startedAt,
2316
- timedOut,
2317
- actual: device.mtu,
2318
- error: error instanceof Error ? error.message : String(error),
2319
- });
2320
- if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
2321
- // A request the stack rejected is not left pending; the caller's MTU check decides.
2322
- if (!timedOut) return device;
2323
- // The JS-side subscription is released for hygiene only: the native operation, and
2324
- // the per-link state it may already hold, end with the link below.
2325
- await Promise.resolve(manager.cancelTransaction(transactionId)).catch(() => undefined);
2326
- const resetManager = this.abandonStalledConnection(uuid, 'mtu-backstop');
2327
- await this.dropAndroidLink(uuid, manager, device, 'mtu exchange timeout');
2328
- if (resetManager) throw this.createWedgedBleSetupError();
2329
- throw ERRORS.TypedError(
2330
- HardwareErrorCode.BleConnectedError,
2331
- 'BLE MTU exchange did not complete, reconnecting on a fresh link'
2332
- );
2174
+ failure = error instanceof Error ? error.message : String(error);
2333
2175
  } finally {
2334
2176
  if (timer) clearTimeout(timer);
2335
2177
  }
2178
+ Log?.debug(`[ReactNativeBleTransport] BLE MTU exchange ${failure ? 'failed' : 'completed'}`, {
2179
+ connectIdSuffix: uuid.slice(-8),
2180
+ elapsedMs: Date.now() - startedAt,
2181
+ timedOut,
2182
+ actual: negotiated.mtu,
2183
+ error: failure,
2184
+ });
2185
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
2186
+ if (!timedOut && !isKnownDefaultMtu(negotiated.mtu)) return negotiated;
2187
+
2188
+ // Counted like a setup timeout so a link that keeps failing reaches the wedged-link guard.
2189
+ const resetManager = this.abandonStalledConnection(
2190
+ uuid,
2191
+ timedOut ? 'mtu-backstop' : 'mtu-default'
2192
+ );
2193
+ await this.dropAndroidLink(
2194
+ uuid,
2195
+ manager,
2196
+ negotiated,
2197
+ timedOut ? 'mtu exchange timeout' : 'default mtu'
2198
+ );
2199
+ if (resetManager) throw this.createWedgedBleSetupError();
2200
+ throw ERRORS.TypedError(
2201
+ HardwareErrorCode.BleConnectedError,
2202
+ timedOut
2203
+ ? 'BLE MTU exchange did not complete, reconnecting on a fresh link'
2204
+ : `BLE link stayed at the default MTU ${negotiated.mtu}, reconnecting on a fresh link`
2205
+ );
2336
2206
  }
2337
2207
 
2338
- /**
2339
- * Abandon an Android LE link whose per-link GATT state is unusable. Closing the GATT
2340
- * client alone keeps the link up for the stack's idle timer, and Core's retry arrives
2341
- * inside that window, so this holds for at least ANDROID_LINK_DROP_QUIET_MS and until the
2342
- * GATT registry no longer lists the device, capped at ANDROID_LINK_DROP_TIMEOUT_MS.
2343
- */
2208
+ /** Close the client and wait out the link idle timer so the next connect gets a fresh link. */
2344
2209
  private async dropAndroidLink(
2345
2210
  uuid: string,
2346
2211
  manager: BlePlxManager,
@@ -2359,55 +2224,16 @@ export default class ReactNativeBleTransport {
2359
2224
  });
2360
2225
 
2361
2226
  const startedAt = Date.now();
2362
- const target = uuid.toUpperCase();
2363
- let registryClear = false;
2364
- let quietPeriodElapsed = false;
2365
- while (!this.stopped) {
2366
- const elapsed = Date.now() - startedAt;
2367
- quietPeriodElapsed = elapsed >= this.androidLinkDropQuietMs;
2368
- if (!registryClear) {
2369
- const connected: Array<{ id?: unknown }> | undefined = await getConnectedDeviceIds(
2370
- []
2371
- ).catch(() => undefined);
2372
- registryClear =
2373
- !!connected &&
2374
- !connected.some(peripheral => String(peripheral?.id ?? '').toUpperCase() === target);
2375
- }
2376
- if ((registryClear && quietPeriodElapsed) || elapsed >= this.androidLinkDropTimeoutMs) {
2377
- break;
2378
- }
2227
+ while (!this.stopped && Date.now() - startedAt < ANDROID_LINK_DROP_QUIET_MS) {
2379
2228
  await delay(ANDROID_LINK_DROP_POLL_MS);
2380
2229
  }
2381
2230
  Log?.debug('[ReactNativeBleTransport] Android BLE link drop', {
2382
2231
  connectIdSuffix: uuid.slice(-8),
2383
2232
  reason,
2384
- registryClear,
2385
- quietPeriodElapsed,
2386
2233
  stopped: this.stopped,
2387
- elapsedMs: Date.now() - startedAt,
2388
2234
  });
2389
2235
  }
2390
2236
 
2391
- private async resolveCharacteristicsForAcquire(
2392
- uuid: string,
2393
- device: Device
2394
- ): Promise<ResolvedBleCharacteristics> {
2395
- try {
2396
- return await this.resolveCharacteristicsWithTimeout(uuid, device);
2397
- } catch (error) {
2398
- if (Platform.OS === 'android' && isMissingGattShapeError(error)) {
2399
- this.androidGattCacheRefreshes.add(uuid);
2400
- // The refresh only reaches the stack through a connect, and a link that is still
2401
- // up would let the next acquire skip that connect and serve the same stale table.
2402
- const manager = this.blePlxManager;
2403
- if (manager) {
2404
- await this.dropAndroidLink(uuid, manager, device, 'stale gatt table');
2405
- }
2406
- }
2407
- throw error;
2408
- }
2409
- }
2410
-
2411
2237
  /** Run a native connect under the JS backstop budget. */
2412
2238
  private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
2413
2239
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
@@ -2488,7 +2314,7 @@ export default class ReactNativeBleTransport {
2488
2314
  succeeded = true;
2489
2315
  return result;
2490
2316
  } catch (error) {
2491
- if (timedOut || isAbandonedNativeOperationError(error)) {
2317
+ if (timedOut || isNativeOperationTimeoutError(error)) {
2492
2318
  const resetManager = this.abandonStalledConnection(
2493
2319
  uuid,
2494
2320
  timedOut ? 'gatt-backstop' : 'gatt-native'
@@ -2497,6 +2323,9 @@ export default class ReactNativeBleTransport {
2497
2323
  throw this.createWedgedBleSetupError();
2498
2324
  }
2499
2325
  }
2326
+ if (Platform.OS === 'android' && isMissingGattShapeError(error)) {
2327
+ this.androidGattCacheRefreshes.add(uuid);
2328
+ }
2500
2329
  throw error;
2501
2330
  } finally {
2502
2331
  if (timer) clearTimeout(timer);
@@ -2699,7 +2528,6 @@ export default class ReactNativeBleTransport {
2699
2528
  // firmware reconnect after the native BLE manager is recreated.
2700
2529
  this.protocolReprobeFailures.clear();
2701
2530
  this.silentDetections.clear();
2702
- this.protocolWakeAttempts.clear();
2703
2531
  this.writeTimeoutCounts.clear();
2704
2532
  this.connectionSetupTimeoutCounts.clear();
2705
2533
  this.monitorTokens.clear();
@@ -2835,7 +2663,6 @@ export default class ReactNativeBleTransport {
2835
2663
  }
2836
2664
  this.protocolReprobeFailures.delete(uuid);
2837
2665
  this.silentDetections.delete(uuid);
2838
- this.protocolWakeAttempts.delete(uuid);
2839
2666
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
2840
2667
  deviceId: uuid,
2841
2668
  protocol,
@@ -2845,9 +2672,8 @@ export default class ReactNativeBleTransport {
2845
2672
  }
2846
2673
  }
2847
2674
 
2848
- // Nothing answered on any probed protocol. Remember it so the next detection, which
2849
- // the caller reaches on a freshly reconnected link, can spend a wake first.
2850
- this.silentDetections.add(uuid);
2675
+ // Arms the wake for the next detection.
2676
+ if (!this.silentDetections.has(uuid)) this.silentDetections.set(uuid, 'silent');
2851
2677
 
2852
2678
  if (trustSessionProtocol) {
2853
2679
  // Still silent on its own protocol: count it, and let the streak expire the
@@ -2863,29 +2689,19 @@ export default class ReactNativeBleTransport {
2863
2689
  }
2864
2690
 
2865
2691
  /**
2866
- * Sent once before probing when the previous detection on this endpoint got no answer
2867
- * at all. On Classic-family firmware that silence is also what a sleeping device looks
2868
- * like: its BLE co-processor keeps serving connect, GATT and MTU while the main MCU
2869
- * sits in the screensaver loop, whose host-message filter accepts only Initialize and
2870
- * the *Ack messages. GetFeatures and Ping are dropped there without a reply, so no
2871
- * amount of probing can bring the device back.
2872
- *
2873
- * Initialize is the one message that breaks that loop, but it also starts a fresh
2874
- * wallet session, which is why it is not the probe itself — a bare Initialize on every
2875
- * acquire would drop a hidden-wallet session before Core can restore it. Requiring a
2876
- * fully silent previous detection keeps it to devices with no session left to protect.
2877
- *
2878
- * The firmware consumes the wake to leave its loop and does not reliably answer it, so
2879
- * the result is ignored: the probes that follow are what decide the protocol.
2692
+ * A sleeping Classic drops GetFeatures/Ping and only leaves its screensaver on Initialize, which
2693
+ * resets the wallet session, so it is sent once after a fully silent detection. The firmware does
2694
+ * not reliably answer it, so its timeout must not drop the link.
2880
2695
  */
2881
2696
  private async wakeSilentProtocolV1Device(uuid: string, probeOrder: ProtocolType[]) {
2882
- if (!this._messages) return;
2883
- if (!probeOrder.includes('V1')) return;
2884
- if (!this.silentDetections.has(uuid)) return;
2885
- if (this.protocolWakeAttempts.has(uuid)) return;
2886
- if (!transportCache[uuid]) return;
2887
- this.protocolWakeAttempts.add(uuid);
2888
-
2697
+ if (
2698
+ Platform.OS !== 'android' ||
2699
+ !probeOrder.includes('V1') ||
2700
+ this.silentDetections.get(uuid) !== 'silent'
2701
+ ) {
2702
+ return;
2703
+ }
2704
+ this.silentDetections.set(uuid, 'woken');
2889
2705
  Log?.debug('[ReactNativeBleTransport] sending Protocol V1 Initialize wake', {
2890
2706
  connectIdSuffix: uuid.slice(-8),
2891
2707
  });
@@ -2893,11 +2709,7 @@ export default class ReactNativeBleTransport {
2893
2709
  this.probingProtocols.set(uuid, 'V1');
2894
2710
  await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2895
2711
  } catch (error) {
2896
- if (shouldRethrowProtocolProbeError(error)) {
2897
- this.clearProbeProtocol(uuid, 'V1');
2898
- throw error;
2899
- }
2900
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize wake did not answer:', error);
2712
+ if (shouldRethrowProtocolProbeError(error)) throw error;
2901
2713
  } finally {
2902
2714
  this.clearProbeProtocol(uuid, 'V1');
2903
2715
  }
@@ -3205,13 +3017,6 @@ export default class ReactNativeBleTransport {
3205
3017
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
3206
3018
  }
3207
3019
 
3208
- if (Platform.OS === 'android') {
3209
- const activeTransport = transportCache[uuid];
3210
- if (activeTransport && isKnownDefaultMtu(activeTransport.mtuSize)) {
3211
- throw createProtocolV2DefaultMtuError(activeTransport.mtuSize);
3212
- }
3213
- }
3214
-
3215
3020
  const isProtocolProbe = this.probingProtocols.get(uuid) === 'V2';
3216
3021
  const callOptions = options;
3217
3022
  const highThroughputWrite = isProtocolV2HighThroughputCall(name);
@@ -3289,7 +3094,7 @@ export default class ReactNativeBleTransport {
3289
3094
  const transport = this.getCachedTransport(uuid);
3290
3095
  if (!shouldRefreshNegotiatedMtu(transport.mtuSize)) return;
3291
3096
 
3292
- const { device: refreshedDevice, abandoned } = await requestNegotiatedMtu(
3097
+ const { device: refreshedDevice } = await requestNegotiatedMtu(
3293
3098
  transport.device,
3294
3099
  'highThroughput',
3295
3100
  1,
@@ -3299,7 +3104,7 @@ export default class ReactNativeBleTransport {
3299
3104
  transport.mtuSize =
3300
3105
  typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
3301
3106
 
3302
- if (abandoned || shouldRefreshNegotiatedMtu(transport.mtuSize)) {
3107
+ if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
3303
3108
  throw ERRORS.TypedError(
3304
3109
  HardwareErrorCode.BleConnectedError,
3305
3110
  `Protocol V2 high-throughput BLE MTU unavailable: ${String(transport.mtuSize)}`