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

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,7 +185,10 @@ const shouldRethrowProtocolProbeError = (error: unknown): boolean => {
185
185
  code === HardwareErrorCode.BleDeviceDisconnected ||
186
186
  code === HardwareErrorCode.BleCharacteristicNotifyError ||
187
187
  code === HardwareErrorCode.BleCharacteristicNotifyChangeFailure ||
188
- code === HardwareErrorCode.BleWriteCharacteristicError
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)
189
192
  );
190
193
  };
191
194
  /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
@@ -261,6 +264,104 @@ const fallbackConnectOptions: Record<string, unknown> = {
261
264
  timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
262
265
  };
263
266
 
267
+ /**
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.
284
+ */
285
+ const androidRefreshGattConnectOptions: Record<string, unknown> = {
286
+ timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
287
+ refreshGatt: 'OnConnected',
288
+ };
289
+
290
+ /**
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.
297
+ */
298
+ export const ANDROID_MTU_EXCHANGE_TIMEOUT_MS = 12_000;
299
+
300
+ /**
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.
310
+ */
311
+ export const ANDROID_LINK_DROP_QUIET_MS = 5000;
312
+ export const ANDROID_LINK_DROP_TIMEOUT_MS = 8000;
313
+ const ANDROID_LINK_DROP_POLL_MS = 250;
314
+
315
+ /**
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.
321
+ */
322
+ const isKnownDefaultMtu = (mtu: unknown): boolean =>
323
+ 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
+
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
+ */
346
+ const isMissingGattShapeError = (error: unknown): boolean => {
347
+ const code = (error as { errorCode?: unknown })?.errorCode;
348
+ const message = (error as { message?: unknown })?.message;
349
+ return (
350
+ code === HardwareErrorCode.BleServiceNotFound ||
351
+ code === HardwareErrorCode.BleCharacteristicNotFound ||
352
+ (typeof message === 'string' &&
353
+ (message.includes('BLECharacteristicNotFound') ||
354
+ message.includes('BLECharacteristicNotWritable') ||
355
+ message.includes('BLECharacteristicNotNotifiable')))
356
+ );
357
+ };
358
+ const isStaleGattTableNotifyReason = (reason: string | null | undefined): boolean =>
359
+ !!reason &&
360
+ (reason.includes('Cannot write client characteristic config descriptor') ||
361
+ reason.includes('Cannot find client characteristic config descriptor') ||
362
+ reason.includes('The handle is invalid') ||
363
+ reason.includes('Writing is not permitted'));
364
+
264
365
  /**
265
366
  * JS backstop for connect. The native adapter applies its own 3s budget, but it
266
367
  * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
@@ -300,16 +401,22 @@ const shouldRethrowBleSetupError = (error: unknown): boolean =>
300
401
  isConnectTimeoutError(error) || isWedgedBleSetupError(error);
301
402
  const isNativeOperationTimeoutError = (error: unknown): boolean =>
302
403
  (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
303
- const isMtuOrCancelledConnectError = (error: unknown): boolean => {
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 => {
304
414
  const errorCode = (error as { errorCode?: unknown })?.errorCode;
305
415
  return (
306
- errorCode === BleErrorCode.DeviceMTUChangeFailed ||
307
- errorCode === BleErrorCode.OperationCancelled
416
+ errorCode === BleErrorCode.OperationTimedOut || errorCode === BleErrorCode.OperationCancelled
308
417
  );
309
418
  };
310
419
 
311
- type NegotiatedMtuResult = { device: Device; timedOut: boolean };
312
-
313
420
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
314
421
 
315
422
  const tryToGetConfiguration = (device: Device) => {
@@ -321,13 +428,26 @@ const tryToGetConfiguration = (device: Device) => {
321
428
  return infos;
322
429
  };
323
430
 
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
+
324
444
  const requestNegotiatedMtu = async (
325
445
  device: Device,
326
446
  stage: 'connected' | 'highThroughput',
327
447
  attempt: number,
328
448
  cancelTransaction?: (transactionId: string) => Promise<void> | void
329
449
  ): Promise<NegotiatedMtuResult> => {
330
- if (Platform.OS !== 'ios' && Platform.OS !== 'android') return { device, timedOut: false };
450
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') return { device, abandoned: false };
331
451
 
332
452
  const transactionId = `${device.id}:mtu:${stage}:${attempt}:${Date.now()}`;
333
453
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
@@ -350,7 +470,7 @@ const requestNegotiatedMtu = async (
350
470
  }, BLE_MTU_REQUEST_TIMEOUT_MS);
351
471
  }),
352
472
  ]);
353
- return { device: mtuDevice, timedOut: false };
473
+ return { device: mtuDevice, abandoned: false };
354
474
  } catch (error) {
355
475
  if (timedOut && cancelTransaction) {
356
476
  try {
@@ -376,10 +496,9 @@ const requestNegotiatedMtu = async (
376
496
  stage,
377
497
  attempt,
378
498
  actual: device.mtu,
379
- timedOut,
380
499
  error: error instanceof Error ? error.message : String(error),
381
500
  });
382
- return { device, timedOut };
501
+ return { device, abandoned: timedOut };
383
502
  } finally {
384
503
  if (timeoutId) clearTimeout(timeoutId);
385
504
  }
@@ -391,7 +510,7 @@ const resolveNegotiatedMtu = (
391
510
  ): Promise<NegotiatedMtuResult> =>
392
511
  shouldRefreshNegotiatedMtu(device.mtu)
393
512
  ? requestNegotiatedMtu(device, 'connected', 0, cancelTransaction)
394
- : Promise.resolve({ device, timedOut: false });
513
+ : Promise.resolve({ device, abandoned: false });
395
514
 
396
515
  type IOBleErrorRemap = Error | BleError | null | undefined;
397
516
 
@@ -475,6 +594,31 @@ export default class ReactNativeBleTransport {
475
594
  /** Consecutive detections that failed while trusting sessionProtocols. */
476
595
  private protocolReprobeFailures: Map<string, number> = new Map();
477
596
 
597
+ /** Endpoints whose last detection got no answer on any protocol. */
598
+ private silentDetections = new Set<string>();
599
+
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
+ */
605
+ private androidGattCacheRefreshes = new Set<string>();
606
+
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
+
478
622
  /**
479
623
  * Native encryption/pairing failures seen before Protocol V2 probe starts.
480
624
  * Pro2/Neo GATT connect can succeed on a stale iOS bond; the CCCD write then
@@ -745,9 +889,13 @@ export default class ReactNativeBleTransport {
745
889
 
746
890
  let { device } = transport;
747
891
  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;
748
896
  if (!isConnected) {
749
897
  try {
750
- device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
898
+ device = await this.connectWithTimeout(uuid, () => device.connect(reconnectOptions));
751
899
  } catch (e) {
752
900
  if (
753
901
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
@@ -762,8 +910,17 @@ export default class ReactNativeBleTransport {
762
910
 
763
911
  const { writeCharacteristic, notifyCharacteristic } =
764
912
  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
+ }
765
921
 
766
922
  transport.device = device;
923
+ transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
767
924
  transport.writeCharacteristic = writeCharacteristic;
768
925
  transport.notifyCharacteristic = notifyCharacteristic;
769
926
  const monitorToken = this.nextMonitorToken;
@@ -949,7 +1106,7 @@ export default class ReactNativeBleTransport {
949
1106
  characteristics?: ResolvedBleCharacteristics
950
1107
  ) {
951
1108
  const { writeCharacteristic, notifyCharacteristic } =
952
- characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
1109
+ characteristics ?? (await this.resolveCharacteristicsForAcquire(uuid, device));
953
1110
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
954
1111
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
955
1112
  transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
@@ -1052,8 +1209,16 @@ export default class ReactNativeBleTransport {
1052
1209
  const isCachedDeviceConnected = await cachedTransport.device
1053
1210
  .isConnected()
1054
1211
  .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.
1215
+ const isCachedAndroidLinkUsable =
1216
+ Platform.OS !== 'android' ||
1217
+ (!isKnownDefaultMtu(cachedTransport.mtuSize) &&
1218
+ !this.androidGattCacheRefreshes.has(uuid));
1055
1219
  if (
1056
1220
  isCachedDeviceConnected &&
1221
+ isCachedAndroidLinkUsable &&
1057
1222
  cachedProtocol &&
1058
1223
  (!expectedProtocol || cachedProtocol === expectedProtocol)
1059
1224
  ) {
@@ -1072,6 +1237,21 @@ export default class ReactNativeBleTransport {
1072
1237
  }
1073
1238
 
1074
1239
  let device: Device | null = null;
1240
+ 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.
1243
+ const refreshAndroidGattCache =
1244
+ isAndroid && (!!skipProtocolProbe || this.androidGattCacheRefreshes.has(uuid));
1245
+ let nativeConnectOptions = connectOptions;
1246
+ if (isAndroid) {
1247
+ nativeConnectOptions = refreshAndroidGattCache
1248
+ ? androidRefreshGattConnectOptions
1249
+ : androidConnectOptions;
1250
+ }
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.
1254
+ let androidRefreshConnectRan = false;
1075
1255
 
1076
1256
  if (forceCleanRunPromise && this.runPromise) {
1077
1257
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
@@ -1082,7 +1262,6 @@ export default class ReactNativeBleTransport {
1082
1262
  }
1083
1263
 
1084
1264
  const blePlxManager = await this.getPlxManager();
1085
- let skipPostConnectMtu = false;
1086
1265
  try {
1087
1266
  await subscribeBleOn(blePlxManager);
1088
1267
  } catch (error) {
@@ -1127,16 +1306,25 @@ export default class ReactNativeBleTransport {
1127
1306
  Log?.debug('try to connect to device: ', uuid);
1128
1307
  try {
1129
1308
  device = await this.connectWithTimeout(uuid, () =>
1130
- blePlxManager.connectToDevice(uuid, connectOptions)
1309
+ blePlxManager.connectToDevice(uuid, nativeConnectOptions)
1131
1310
  );
1311
+ androidRefreshConnectRan = refreshAndroidGattCache;
1132
1312
  } catch (e) {
1133
1313
  Log?.debug('try to connect to device has error: ', e);
1134
1314
  if (shouldRethrowBleSetupError(e)) {
1135
1315
  throw e;
1136
1316
  }
1137
- if (isMtuOrCancelledConnectError(e)) {
1138
- skipPostConnectMtu = true;
1317
+ if (
1318
+ e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
1319
+ e.errorCode === BleErrorCode.OperationCancelled
1320
+ ) {
1139
1321
  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
+ );
1140
1328
  device = await this.connectWithTimeout(uuid, () =>
1141
1329
  blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
1142
1330
  );
@@ -1153,22 +1341,38 @@ export default class ReactNativeBleTransport {
1153
1341
  throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'unable to connect to device');
1154
1342
  }
1155
1343
 
1344
+ if (isAndroid && refreshAndroidGattCache && (await device.isConnected().catch(() => false))) {
1345
+ // refreshGatt only reaches the stack through a connect. A link that is still up would
1346
+ // skip the connect below and keep serving the stale table, so it is dropped first.
1347
+ await this.dropAndroidLink(uuid, blePlxManager, device, 'gatt cache refresh');
1348
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1349
+ }
1350
+
1156
1351
  if (!(await device.isConnected())) {
1157
1352
  Log?.debug('not connected, try to connect to device: ', uuid);
1158
1353
  const disconnectedDevice = device;
1159
1354
 
1160
1355
  try {
1161
1356
  device = await this.connectWithTimeout(uuid, () =>
1162
- disconnectedDevice.connect(connectOptions)
1357
+ disconnectedDevice.connect(nativeConnectOptions)
1163
1358
  );
1359
+ androidRefreshConnectRan = refreshAndroidGattCache;
1164
1360
  } catch (e) {
1165
1361
  Log?.debug('not connected, try to connect to device has error: ', e);
1166
1362
  if (shouldRethrowBleSetupError(e)) {
1167
1363
  throw e;
1168
1364
  }
1169
- if (isMtuOrCancelledConnectError(e)) {
1170
- skipPostConnectMtu = true;
1365
+ if (
1366
+ e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
1367
+ e.errorCode === BleErrorCode.OperationCancelled
1368
+ ) {
1171
1369
  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
+ );
1172
1376
  try {
1173
1377
  device = await this.connectWithTimeout(uuid, () =>
1174
1378
  disconnectedDevice.connect(fallbackConnectOptions)
@@ -1209,33 +1413,48 @@ export default class ReactNativeBleTransport {
1209
1413
  throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'device is not connected');
1210
1414
  }
1211
1415
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1212
- // Match 1.1.31: MTU is a connect() best-effort. If connect already fell back
1213
- // without requestMTU, do not put another requestMTU on the native serial
1214
- // queue — that is what wedges GATT after Account#2 reconnect.
1215
- if (!skipPostConnectMtu) {
1216
- const mtuResult = await resolveNegotiatedMtu(device, transactionId =>
1416
+ let characteristics: ResolvedBleCharacteristics | undefined;
1417
+ if (isAndroid) {
1418
+ if (refreshAndroidGattCache) {
1419
+ // refreshGatt has already started a full rediscovery; let it finish before the MTU
1420
+ // exchange so the request is not queued behind it.
1421
+ characteristics = await this.resolveCharacteristicsForAcquire(uuid, device);
1422
+ if (androidRefreshConnectRan) this.androidGattCacheRefreshes.delete(uuid);
1423
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1424
+ }
1425
+ 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 =>
1217
1437
  blePlxManager.cancelTransaction(transactionId)
1218
1438
  );
1219
- device = mtuResult.device;
1220
- if (mtuResult.timedOut) {
1221
- if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1222
- Log?.debug(
1223
- '[ReactNativeBleTransport] post-connect MTU timed out, reconnecting without requesting MTU'
1224
- );
1225
- const timedOutDevice = device;
1226
- await this.runBestEffortNativeOperation('mtu timeout: cancel device connection', () =>
1227
- timedOutDevice.cancelConnection()
1228
- );
1229
- if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1230
- device = await this.connectWithTimeout(uuid, () =>
1231
- timedOutDevice.connect(fallbackConnectOptions)
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'
1232
1451
  );
1233
1452
  }
1234
1453
  }
1235
1454
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1236
1455
  const acquiredDevice = device;
1237
1456
  const { writeCharacteristic, notifyCharacteristic } =
1238
- await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
1457
+ characteristics ?? (await this.resolveCharacteristicsForAcquire(uuid, acquiredDevice));
1239
1458
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1240
1459
 
1241
1460
  const protocolHint = expectedProtocol
@@ -1363,6 +1582,9 @@ export default class ReactNativeBleTransport {
1363
1582
  ) {
1364
1583
  errorCode = HardwareErrorCode.BleCharacteristicNotifyChangeFailure;
1365
1584
  }
1585
+ if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
1586
+ this.androidGattCacheRefreshes.add(uuid);
1587
+ }
1366
1588
  this.rejectProtocolV2Frames(uuid, ERRORS.TypedError(errorCode));
1367
1589
  return;
1368
1590
  }
@@ -1384,6 +1606,9 @@ export default class ReactNativeBleTransport {
1384
1606
  const notifyError = ERRORS.TypedError(
1385
1607
  HardwareErrorCode.BleCharacteristicNotifyChangeFailure
1386
1608
  );
1609
+ if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
1610
+ this.androidGattCacheRefreshes.add(uuid);
1611
+ }
1387
1612
  this.runPromise.reject(notifyError);
1388
1613
  Log?.debug(
1389
1614
  `${HardwareErrorCode.BleCharacteristicNotifyChangeFailure} ${error.message} ${error.reason}`
@@ -2047,6 +2272,142 @@ export default class ReactNativeBleTransport {
2047
2272
  }
2048
2273
  }
2049
2274
 
2275
+ /**
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.
2280
+ */
2281
+ private async negotiateAndroidMtu(
2282
+ uuid: string,
2283
+ manager: BlePlxManager,
2284
+ device: Device
2285
+ ): Promise<Device> {
2286
+ if (!shouldRefreshNegotiatedMtu(device.mtu)) return device;
2287
+
2288
+ const startedAt = Date.now();
2289
+ const transactionId = `${device.id}:mtu:connected:0:${startedAt}`;
2290
+ let timer: ReturnType<typeof setTimeout> | undefined;
2291
+ let timedOut = false;
2292
+ const request = device.requestMTU(ANDROID_REQUEST_MTU, transactionId);
2293
+ request.catch(() => undefined);
2294
+ try {
2295
+ const negotiated = await Promise.race([
2296
+ request,
2297
+ new Promise<never>((_, reject) => {
2298
+ timer = setTimeout(() => {
2299
+ timedOut = true;
2300
+ reject(
2301
+ new Error(`BLE MTU exchange timeout after ${this.androidMtuExchangeTimeoutMs}ms`)
2302
+ );
2303
+ }, this.androidMtuExchangeTimeoutMs);
2304
+ }),
2305
+ ]);
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
+ } 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
+ );
2333
+ } finally {
2334
+ if (timer) clearTimeout(timer);
2335
+ }
2336
+ }
2337
+
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
+ */
2344
+ private async dropAndroidLink(
2345
+ uuid: string,
2346
+ manager: BlePlxManager,
2347
+ device: Device,
2348
+ reason: string
2349
+ ) {
2350
+ await this.runNativeTeardown(uuid, manager, async () => {
2351
+ await Promise.all([
2352
+ this.runBestEffortNativeOperation(`${reason}: cancel manager connection`, () =>
2353
+ manager.cancelDeviceConnection(uuid)
2354
+ ),
2355
+ this.runBestEffortNativeOperation(`${reason}: cancel device connection`, () =>
2356
+ device.cancelConnection()
2357
+ ),
2358
+ ]);
2359
+ });
2360
+
2361
+ 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
+ }
2379
+ await delay(ANDROID_LINK_DROP_POLL_MS);
2380
+ }
2381
+ Log?.debug('[ReactNativeBleTransport] Android BLE link drop', {
2382
+ connectIdSuffix: uuid.slice(-8),
2383
+ reason,
2384
+ registryClear,
2385
+ quietPeriodElapsed,
2386
+ stopped: this.stopped,
2387
+ elapsedMs: Date.now() - startedAt,
2388
+ });
2389
+ }
2390
+
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
+
2050
2411
  /** Run a native connect under the JS backstop budget. */
2051
2412
  private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
2052
2413
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
@@ -2127,7 +2488,7 @@ export default class ReactNativeBleTransport {
2127
2488
  succeeded = true;
2128
2489
  return result;
2129
2490
  } catch (error) {
2130
- if (timedOut || isNativeOperationTimeoutError(error)) {
2491
+ if (timedOut || isAbandonedNativeOperationError(error)) {
2131
2492
  const resetManager = this.abandonStalledConnection(
2132
2493
  uuid,
2133
2494
  timedOut ? 'gatt-backstop' : 'gatt-native'
@@ -2156,7 +2517,13 @@ export default class ReactNativeBleTransport {
2156
2517
  */
2157
2518
  private abandonStalledConnection(
2158
2519
  uuid: string,
2159
- stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
2520
+ stage:
2521
+ | 'connect-backstop'
2522
+ | 'connect-native'
2523
+ | 'gatt-backstop'
2524
+ | 'gatt-native'
2525
+ | 'mtu-backstop'
2526
+ | 'mtu-default'
2160
2527
  ): boolean {
2161
2528
  const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
2162
2529
  this.connectionSetupTimeoutCounts.set(uuid, timeouts);
@@ -2331,6 +2698,8 @@ export default class ReactNativeBleTransport {
2331
2698
  // Keep transport-lifetime V2 proof so the same endpoint can finish a no-probe
2332
2699
  // firmware reconnect after the native BLE manager is recreated.
2333
2700
  this.protocolReprobeFailures.clear();
2701
+ this.silentDetections.clear();
2702
+ this.protocolWakeAttempts.clear();
2334
2703
  this.writeTimeoutCounts.clear();
2335
2704
  this.connectionSetupTimeoutCounts.clear();
2336
2705
  this.monitorTokens.clear();
@@ -2442,6 +2811,8 @@ export default class ReactNativeBleTransport {
2442
2811
  reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
2443
2812
  const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
2444
2813
 
2814
+ await this.wakeSilentProtocolV1Device(uuid, probeOrder);
2815
+
2445
2816
  for (let i = 0; i < probeOrder.length; i += 1) {
2446
2817
  const protocol = probeOrder[i];
2447
2818
  if (i > 0) {
@@ -2463,6 +2834,8 @@ export default class ReactNativeBleTransport {
2463
2834
  this.confirmedProtocolV2.add(uuid);
2464
2835
  }
2465
2836
  this.protocolReprobeFailures.delete(uuid);
2837
+ this.silentDetections.delete(uuid);
2838
+ this.protocolWakeAttempts.delete(uuid);
2466
2839
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
2467
2840
  deviceId: uuid,
2468
2841
  protocol,
@@ -2472,6 +2845,10 @@ export default class ReactNativeBleTransport {
2472
2845
  }
2473
2846
  }
2474
2847
 
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);
2851
+
2475
2852
  if (trustSessionProtocol) {
2476
2853
  // Still silent on its own protocol: count it, and let the streak expire the
2477
2854
  // shortcut so a device that genuinely switched protocols is found again.
@@ -2485,6 +2862,47 @@ export default class ReactNativeBleTransport {
2485
2862
  throw this.createProtocolDetectionError();
2486
2863
  }
2487
2864
 
2865
+ /**
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.
2880
+ */
2881
+ 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
+
2889
+ Log?.debug('[ReactNativeBleTransport] sending Protocol V1 Initialize wake', {
2890
+ connectIdSuffix: uuid.slice(-8),
2891
+ });
2892
+ try {
2893
+ this.probingProtocols.set(uuid, 'V1');
2894
+ await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2895
+ } 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);
2901
+ } finally {
2902
+ this.clearProbeProtocol(uuid, 'V1');
2903
+ }
2904
+ }
2905
+
2488
2906
  private async resetProbeStateAfterProtocolProbe(uuid: string, protocol: ProtocolType) {
2489
2907
  const transport = transportCache[uuid];
2490
2908
  await this.protocolV2Links.invalidateLink(
@@ -2787,6 +3205,13 @@ export default class ReactNativeBleTransport {
2787
3205
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
2788
3206
  }
2789
3207
 
3208
+ if (Platform.OS === 'android') {
3209
+ const activeTransport = transportCache[uuid];
3210
+ if (activeTransport && isKnownDefaultMtu(activeTransport.mtuSize)) {
3211
+ throw createProtocolV2DefaultMtuError(activeTransport.mtuSize);
3212
+ }
3213
+ }
3214
+
2790
3215
  const isProtocolProbe = this.probingProtocols.get(uuid) === 'V2';
2791
3216
  const callOptions = options;
2792
3217
  const highThroughputWrite = isProtocolV2HighThroughputCall(name);
@@ -2864,7 +3289,7 @@ export default class ReactNativeBleTransport {
2864
3289
  const transport = this.getCachedTransport(uuid);
2865
3290
  if (!shouldRefreshNegotiatedMtu(transport.mtuSize)) return;
2866
3291
 
2867
- const { device: refreshedDevice } = await requestNegotiatedMtu(
3292
+ const { device: refreshedDevice, abandoned } = await requestNegotiatedMtu(
2868
3293
  transport.device,
2869
3294
  'highThroughput',
2870
3295
  1,
@@ -2874,7 +3299,7 @@ export default class ReactNativeBleTransport {
2874
3299
  transport.mtuSize =
2875
3300
  typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
2876
3301
 
2877
- if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
3302
+ if (abandoned || shouldRefreshNegotiatedMtu(transport.mtuSize)) {
2878
3303
  throw ERRORS.TypedError(
2879
3304
  HardwareErrorCode.BleConnectedError,
2880
3305
  `Protocol V2 high-throughput BLE MTU unavailable: ${String(transport.mtuSize)}`