@onekeyfe/hd-transport-react-native 1.2.2-alpha.115 → 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
@@ -256,11 +256,63 @@ const connectOptions: Record<string, unknown> = {
256
256
  refreshGatt: 'OnConnected',
257
257
  };
258
258
 
259
- /** 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. */
260
260
  const fallbackConnectOptions: Record<string, unknown> = {
261
261
  timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
262
262
  };
263
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
+
264
316
  /**
265
317
  * JS backstop for connect. The native adapter applies its own 3s budget, but it
266
318
  * schedules that timeout on its serial queue, so a busy queue (e.g. right after a
@@ -300,22 +352,16 @@ const shouldRethrowBleSetupError = (error: unknown): boolean =>
300
352
  isConnectTimeoutError(error) || isWedgedBleSetupError(error);
301
353
  const isNativeOperationTimeoutError = (error: unknown): boolean =>
302
354
  (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
303
- /**
304
- * ble-plx reports its own expired budget as OperationCancelled, not OperationTimedOut:
305
- * safeConnectToDevice disposes the operation chain and its doFinally reports a
306
- * cancellation. Either code means the native layer abandoned work that still owned
307
- * connection/GATT state, so both have to go through the stalled-connection cleanup.
308
- * Only used where nothing else recovers the link — the connect path does its own
309
- * ordered teardown before retrying, and routing it here too would both duplicate that
310
- * and spend the manager-reset budget on a connect that is about to be retried.
311
- */
312
- const isAbandonedNativeOperationError = (error: unknown): boolean => {
355
+ const isMtuOrCancelledConnectError = (error: unknown): boolean => {
313
356
  const errorCode = (error as { errorCode?: unknown })?.errorCode;
314
357
  return (
315
- errorCode === BleErrorCode.OperationTimedOut || errorCode === BleErrorCode.OperationCancelled
358
+ errorCode === BleErrorCode.DeviceMTUChangeFailed ||
359
+ errorCode === BleErrorCode.OperationCancelled
316
360
  );
317
361
  };
318
362
 
363
+ type NegotiatedMtuResult = { device: Device; timedOut: boolean };
364
+
319
365
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
320
366
 
321
367
  const tryToGetConfiguration = (device: Device) => {
@@ -327,26 +373,13 @@ const tryToGetConfiguration = (device: Device) => {
327
373
  return infos;
328
374
  };
329
375
 
330
- type NegotiatedMtuResult = {
331
- device: Device;
332
- /**
333
- * True when the bounded wait gave up while the native MTU exchange was still
334
- * outstanding. `cancelTransaction` only disposes the JS-side subscription:
335
- * RxAndroidBle releases a connection's serial operation queue from onComplete /
336
- * onError alone, so an abandoned exchange keeps that queue until its own native
337
- * timeout. Any GATT work issued before then — service discovery above all —
338
- * queues behind it and cannot settle inside its own budget.
339
- */
340
- abandoned: boolean;
341
- };
342
-
343
376
  const requestNegotiatedMtu = async (
344
377
  device: Device,
345
378
  stage: 'connected' | 'highThroughput',
346
379
  attempt: number,
347
380
  cancelTransaction?: (transactionId: string) => Promise<void> | void
348
381
  ): Promise<NegotiatedMtuResult> => {
349
- if (Platform.OS !== 'ios' && Platform.OS !== 'android') return { device, abandoned: false };
382
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') return { device, timedOut: false };
350
383
 
351
384
  const transactionId = `${device.id}:mtu:${stage}:${attempt}:${Date.now()}`;
352
385
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
@@ -369,7 +402,7 @@ const requestNegotiatedMtu = async (
369
402
  }, BLE_MTU_REQUEST_TIMEOUT_MS);
370
403
  }),
371
404
  ]);
372
- return { device: mtuDevice, abandoned: false };
405
+ return { device: mtuDevice, timedOut: false };
373
406
  } catch (error) {
374
407
  if (timedOut && cancelTransaction) {
375
408
  try {
@@ -395,9 +428,10 @@ const requestNegotiatedMtu = async (
395
428
  stage,
396
429
  attempt,
397
430
  actual: device.mtu,
431
+ timedOut,
398
432
  error: error instanceof Error ? error.message : String(error),
399
433
  });
400
- return { device, abandoned: timedOut };
434
+ return { device, timedOut };
401
435
  } finally {
402
436
  if (timeoutId) clearTimeout(timeoutId);
403
437
  }
@@ -409,7 +443,7 @@ const resolveNegotiatedMtu = (
409
443
  ): Promise<NegotiatedMtuResult> =>
410
444
  shouldRefreshNegotiatedMtu(device.mtu)
411
445
  ? requestNegotiatedMtu(device, 'connected', 0, cancelTransaction)
412
- : Promise.resolve({ device, abandoned: false });
446
+ : Promise.resolve({ device, timedOut: false });
413
447
 
414
448
  type IOBleErrorRemap = Error | BleError | null | undefined;
415
449
 
@@ -493,16 +527,11 @@ export default class ReactNativeBleTransport {
493
527
  /** Consecutive detections that failed while trusting sessionProtocols. */
494
528
  private protocolReprobeFailures: Map<string, number> = new Map();
495
529
 
496
- /** Endpoints whose last detection got no answer on any protocol. */
497
- 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'>();
498
532
 
499
- /**
500
- * Endpoints already sent a Protocol V1 wake. The wake starts a fresh wallet session
501
- * on the device, and a failed detection tears the link down and reconnects on every
502
- * poll, so neither set is cleared on teardown: one wake is spent per endpoint until a
503
- * detection succeeds, which re-arms it for the next time the device sleeps.
504
- */
505
- private protocolWakeAttempts = new Set<string>();
533
+ /** Android endpoints whose cached GATT table is suspect; the next connect refreshes it. */
534
+ private androidGattCacheRefreshes = new Set<string>();
506
535
 
507
536
  /**
508
537
  * Native encryption/pairing failures seen before Protocol V2 probe starts.
@@ -1081,8 +1110,12 @@ export default class ReactNativeBleTransport {
1081
1110
  const isCachedDeviceConnected = await cachedTransport.device
1082
1111
  .isConnected()
1083
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);
1084
1116
  if (
1085
1117
  isCachedDeviceConnected &&
1118
+ isCachedAndroidLinkUsable &&
1086
1119
  cachedProtocol &&
1087
1120
  (!expectedProtocol || cachedProtocol === expectedProtocol)
1088
1121
  ) {
@@ -1101,14 +1134,18 @@ export default class ReactNativeBleTransport {
1101
1134
  }
1102
1135
 
1103
1136
  let device: Device | null = null;
1104
- /**
1105
- * Set when an MTU-bearing connect had to fall back to `fallbackConnectOptions`.
1106
- * ble-plx applies `connectOptions.timeout` to the whole establishConnection ->
1107
- * refreshGatt -> requestMtu chain, so that fallback means the peripheral did not
1108
- * finish the MTU exchange on this link. Asking again below would only park a
1109
- * second abandoned exchange on the connection's serial queue.
1110
- */
1111
- let mtuHandshakeRefused = false;
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;
1112
1149
 
1113
1150
  if (forceCleanRunPromise && this.runPromise) {
1114
1151
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
@@ -1119,6 +1156,7 @@ export default class ReactNativeBleTransport {
1119
1156
  }
1120
1157
 
1121
1158
  const blePlxManager = await this.getPlxManager();
1159
+ let skipPostConnectMtu = false;
1122
1160
  try {
1123
1161
  await subscribeBleOn(blePlxManager);
1124
1162
  } catch (error) {
@@ -1163,25 +1201,17 @@ export default class ReactNativeBleTransport {
1163
1201
  Log?.debug('try to connect to device: ', uuid);
1164
1202
  try {
1165
1203
  device = await this.connectWithTimeout(uuid, () =>
1166
- blePlxManager.connectToDevice(uuid, connectOptions)
1204
+ blePlxManager.connectToDevice(uuid, nativeConnectOptions)
1167
1205
  );
1206
+ androidRefreshConnectRan = refreshAndroidGattCache;
1168
1207
  } catch (e) {
1169
1208
  Log?.debug('try to connect to device has error: ', e);
1170
1209
  if (shouldRethrowBleSetupError(e)) {
1171
1210
  throw e;
1172
1211
  }
1173
- if (
1174
- e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
1175
- e.errorCode === BleErrorCode.OperationCancelled
1176
- ) {
1212
+ if (isMtuOrCancelledConnectError(e)) {
1213
+ skipPostConnectMtu = true;
1177
1214
  Log?.debug('first try to reconnect without params');
1178
- mtuHandshakeRefused = true;
1179
- // The disposed chain can still own native connection state, and the retry
1180
- // would otherwise run on top of a half-open GATT client.
1181
- await this.runBestEffortNativeOperation(
1182
- 'connect cancelled: cancel manager connection',
1183
- () => blePlxManager.cancelDeviceConnection(uuid)
1184
- );
1185
1215
  device = await this.connectWithTimeout(uuid, () =>
1186
1216
  blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
1187
1217
  );
@@ -1198,31 +1228,34 @@ export default class ReactNativeBleTransport {
1198
1228
  throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'unable to connect to device');
1199
1229
  }
1200
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
+
1201
1242
  if (!(await device.isConnected())) {
1202
1243
  Log?.debug('not connected, try to connect to device: ', uuid);
1203
1244
  const disconnectedDevice = device;
1204
1245
 
1205
1246
  try {
1206
1247
  device = await this.connectWithTimeout(uuid, () =>
1207
- disconnectedDevice.connect(connectOptions)
1248
+ disconnectedDevice.connect(nativeConnectOptions)
1208
1249
  );
1250
+ androidRefreshConnectRan = refreshAndroidGattCache;
1209
1251
  } catch (e) {
1210
1252
  Log?.debug('not connected, try to connect to device has error: ', e);
1211
1253
  if (shouldRethrowBleSetupError(e)) {
1212
1254
  throw e;
1213
1255
  }
1214
- if (
1215
- e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
1216
- e.errorCode === BleErrorCode.OperationCancelled
1217
- ) {
1256
+ if (isMtuOrCancelledConnectError(e)) {
1257
+ skipPostConnectMtu = true;
1218
1258
  Log?.debug('second try to reconnect without params');
1219
- mtuHandshakeRefused = true;
1220
- // The disposed chain can still own native connection state, and the retry
1221
- // would otherwise run on top of a half-open GATT client.
1222
- await this.runBestEffortNativeOperation(
1223
- 'connect cancelled: cancel device connection',
1224
- () => disconnectedDevice.cancelConnection()
1225
- );
1226
1259
  try {
1227
1260
  device = await this.connectWithTimeout(uuid, () =>
1228
1261
  disconnectedDevice.connect(fallbackConnectOptions)
@@ -1263,34 +1296,43 @@ export default class ReactNativeBleTransport {
1263
1296
  throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'device is not connected');
1264
1297
  }
1265
1298
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1266
- if (mtuHandshakeRefused) {
1267
- Log?.debug('[ReactNativeBleTransport] skipping MTU refresh after cancelled MTU connect', {
1268
- connectIdSuffix: uuid.slice(-8),
1269
- actual: device.mtu,
1270
- });
1271
- } else {
1272
- const negotiatedMtu = await resolveNegotiatedMtu(device, transactionId =>
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 =>
1273
1314
  blePlxManager.cancelTransaction(transactionId)
1274
1315
  );
1275
- device = negotiatedMtu.device;
1276
- if (negotiatedMtu.abandoned) {
1277
- // Service discovery would queue behind the abandoned exchange and miss its own
1278
- // budget. Drop the link so the next attempt starts on a queue nothing holds.
1279
- await this.runNativeTeardown(uuid, blePlxManager, async () => {
1280
- await this.runBestEffortNativeOperation('mtu abandoned: cancel manager connection', () =>
1281
- blePlxManager.cancelDeviceConnection(uuid)
1282
- );
1283
- });
1284
- throw ERRORS.TypedError(
1285
- HardwareErrorCode.BleConnectedError,
1286
- '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)
1287
1329
  );
1288
1330
  }
1289
1331
  }
1290
1332
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1291
1333
  const acquiredDevice = device;
1292
1334
  const { writeCharacteristic, notifyCharacteristic } =
1293
- await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
1335
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice));
1294
1336
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1295
1337
 
1296
1338
  const protocolHint = expectedProtocol
@@ -1401,6 +1443,9 @@ export default class ReactNativeBleTransport {
1401
1443
  this.rememberStaleBondError(uuid, toBleStaleBondHardwareError(error));
1402
1444
  return;
1403
1445
  }
1446
+ if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
1447
+ this.androidGattCacheRefreshes.add(uuid);
1448
+ }
1404
1449
  if (this.getActiveProtocol(uuid) === 'V2') {
1405
1450
  let errorCode:
1406
1451
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -1410,10 +1455,7 @@ export default class ReactNativeBleTransport {
1410
1455
  if (error.reason?.includes('The connection has timed out unexpectedly')) {
1411
1456
  errorCode = HardwareErrorCode.BleTimeoutError;
1412
1457
  } else if (
1413
- error.reason?.includes('Cannot write client characteristic config descriptor') ||
1414
- error.reason?.includes('Cannot find client characteristic config descriptor') ||
1415
- error.reason?.includes('The handle is invalid') ||
1416
- error.reason?.includes('Writing is not permitted') ||
1458
+ isStaleGattTableNotifyReason(error.reason) ||
1417
1459
  error.reason?.includes('notify change failed for device')
1418
1460
  ) {
1419
1461
  errorCode = HardwareErrorCode.BleCharacteristicNotifyChangeFailure;
@@ -1430,10 +1472,7 @@ export default class ReactNativeBleTransport {
1430
1472
  ERROR = HardwareErrorCode.BleTimeoutError;
1431
1473
  }
1432
1474
  if (
1433
- error.reason?.includes('Cannot write client characteristic config descriptor') ||
1434
- error.reason?.includes('Cannot find client characteristic config descriptor') || // pro firmware 2.3.0 upgrade
1435
- error.reason?.includes('The handle is invalid') ||
1436
- error.reason?.includes('Writing is not permitted') || // pro firmware 2.3.4 upgrade
1475
+ isStaleGattTableNotifyReason(error.reason) ||
1437
1476
  error.reason?.includes('notify change failed for device')
1438
1477
  ) {
1439
1478
  const notifyError = ERRORS.TypedError(
@@ -1863,13 +1902,14 @@ export default class ReactNativeBleTransport {
1863
1902
  const jsonData = ProtocolV1.decodeMessage(messages, response);
1864
1903
  return check.call(jsonData);
1865
1904
  } catch (e) {
1866
- if (name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1867
- 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);
1868
1910
  } else {
1869
1911
  Log?.error('call error: ', e);
1870
1912
  }
1871
- const isProbeTimeout =
1872
- name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1873
1913
  // A call that has been superseded (forceRun) or cleaned up no longer owns the
1874
1914
  // transport; its late timeout must not tear down the connection the current
1875
1915
  // call is actively using.
@@ -2102,6 +2142,98 @@ export default class ReactNativeBleTransport {
2102
2142
  }
2103
2143
  }
2104
2144
 
2145
+ /**
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.
2148
+ */
2149
+ private async negotiateAndroidMtu(
2150
+ uuid: string,
2151
+ manager: BlePlxManager,
2152
+ device: Device
2153
+ ): Promise<Device> {
2154
+ if (!shouldRefreshNegotiatedMtu(device.mtu)) return device;
2155
+
2156
+ const startedAt = Date.now();
2157
+ let timer: ReturnType<typeof setTimeout> | undefined;
2158
+ let timedOut = false;
2159
+ let negotiated = device;
2160
+ let failure: string | undefined;
2161
+ try {
2162
+ negotiated = await Promise.race([
2163
+ device.requestMTU(ANDROID_REQUEST_MTU, `${device.id}:mtu:connected:0:${startedAt}`),
2164
+ new Promise<never>((_, reject) => {
2165
+ timer = setTimeout(() => {
2166
+ timedOut = true;
2167
+ reject(
2168
+ new Error(`BLE MTU exchange timeout after ${ANDROID_MTU_EXCHANGE_TIMEOUT_MS}ms`)
2169
+ );
2170
+ }, ANDROID_MTU_EXCHANGE_TIMEOUT_MS);
2171
+ }),
2172
+ ]);
2173
+ } catch (error) {
2174
+ failure = error instanceof Error ? error.message : String(error);
2175
+ } finally {
2176
+ if (timer) clearTimeout(timer);
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
+ );
2206
+ }
2207
+
2208
+ /** Close the client and wait out the link idle timer so the next connect gets a fresh link. */
2209
+ private async dropAndroidLink(
2210
+ uuid: string,
2211
+ manager: BlePlxManager,
2212
+ device: Device,
2213
+ reason: string
2214
+ ) {
2215
+ await this.runNativeTeardown(uuid, manager, async () => {
2216
+ await Promise.all([
2217
+ this.runBestEffortNativeOperation(`${reason}: cancel manager connection`, () =>
2218
+ manager.cancelDeviceConnection(uuid)
2219
+ ),
2220
+ this.runBestEffortNativeOperation(`${reason}: cancel device connection`, () =>
2221
+ device.cancelConnection()
2222
+ ),
2223
+ ]);
2224
+ });
2225
+
2226
+ const startedAt = Date.now();
2227
+ while (!this.stopped && Date.now() - startedAt < ANDROID_LINK_DROP_QUIET_MS) {
2228
+ await delay(ANDROID_LINK_DROP_POLL_MS);
2229
+ }
2230
+ Log?.debug('[ReactNativeBleTransport] Android BLE link drop', {
2231
+ connectIdSuffix: uuid.slice(-8),
2232
+ reason,
2233
+ stopped: this.stopped,
2234
+ });
2235
+ }
2236
+
2105
2237
  /** Run a native connect under the JS backstop budget. */
2106
2238
  private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
2107
2239
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
@@ -2182,7 +2314,7 @@ export default class ReactNativeBleTransport {
2182
2314
  succeeded = true;
2183
2315
  return result;
2184
2316
  } catch (error) {
2185
- if (timedOut || isAbandonedNativeOperationError(error)) {
2317
+ if (timedOut || isNativeOperationTimeoutError(error)) {
2186
2318
  const resetManager = this.abandonStalledConnection(
2187
2319
  uuid,
2188
2320
  timedOut ? 'gatt-backstop' : 'gatt-native'
@@ -2191,6 +2323,9 @@ export default class ReactNativeBleTransport {
2191
2323
  throw this.createWedgedBleSetupError();
2192
2324
  }
2193
2325
  }
2326
+ if (Platform.OS === 'android' && isMissingGattShapeError(error)) {
2327
+ this.androidGattCacheRefreshes.add(uuid);
2328
+ }
2194
2329
  throw error;
2195
2330
  } finally {
2196
2331
  if (timer) clearTimeout(timer);
@@ -2211,7 +2346,13 @@ export default class ReactNativeBleTransport {
2211
2346
  */
2212
2347
  private abandonStalledConnection(
2213
2348
  uuid: string,
2214
- stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
2349
+ stage:
2350
+ | 'connect-backstop'
2351
+ | 'connect-native'
2352
+ | 'gatt-backstop'
2353
+ | 'gatt-native'
2354
+ | 'mtu-backstop'
2355
+ | 'mtu-default'
2215
2356
  ): boolean {
2216
2357
  const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
2217
2358
  this.connectionSetupTimeoutCounts.set(uuid, timeouts);
@@ -2387,7 +2528,6 @@ export default class ReactNativeBleTransport {
2387
2528
  // firmware reconnect after the native BLE manager is recreated.
2388
2529
  this.protocolReprobeFailures.clear();
2389
2530
  this.silentDetections.clear();
2390
- this.protocolWakeAttempts.clear();
2391
2531
  this.writeTimeoutCounts.clear();
2392
2532
  this.connectionSetupTimeoutCounts.clear();
2393
2533
  this.monitorTokens.clear();
@@ -2523,7 +2663,6 @@ export default class ReactNativeBleTransport {
2523
2663
  }
2524
2664
  this.protocolReprobeFailures.delete(uuid);
2525
2665
  this.silentDetections.delete(uuid);
2526
- this.protocolWakeAttempts.delete(uuid);
2527
2666
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
2528
2667
  deviceId: uuid,
2529
2668
  protocol,
@@ -2533,9 +2672,8 @@ export default class ReactNativeBleTransport {
2533
2672
  }
2534
2673
  }
2535
2674
 
2536
- // Nothing answered on any probed protocol. Remember it so the next detection, which
2537
- // the caller reaches on a freshly reconnected link, can spend a wake first.
2538
- this.silentDetections.add(uuid);
2675
+ // Arms the wake for the next detection.
2676
+ if (!this.silentDetections.has(uuid)) this.silentDetections.set(uuid, 'silent');
2539
2677
 
2540
2678
  if (trustSessionProtocol) {
2541
2679
  // Still silent on its own protocol: count it, and let the streak expire the
@@ -2551,29 +2689,19 @@ export default class ReactNativeBleTransport {
2551
2689
  }
2552
2690
 
2553
2691
  /**
2554
- * Sent once before probing when the previous detection on this endpoint got no answer
2555
- * at all. On Classic-family firmware that silence is also what a sleeping device looks
2556
- * like: its BLE co-processor keeps serving connect, GATT and MTU while the main MCU
2557
- * sits in the screensaver loop, whose host-message filter accepts only Initialize and
2558
- * the *Ack messages. GetFeatures and Ping are dropped there without a reply, so no
2559
- * amount of probing can bring the device back.
2560
- *
2561
- * Initialize is the one message that breaks that loop, but it also starts a fresh
2562
- * wallet session, which is why it is not the probe itself — a bare Initialize on every
2563
- * acquire would drop a hidden-wallet session before Core can restore it. Requiring a
2564
- * fully silent previous detection keeps it to devices with no session left to protect.
2565
- *
2566
- * The firmware consumes the wake to leave its loop and does not reliably answer it, so
2567
- * 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.
2568
2695
  */
2569
2696
  private async wakeSilentProtocolV1Device(uuid: string, probeOrder: ProtocolType[]) {
2570
- if (!this._messages) return;
2571
- if (!probeOrder.includes('V1')) return;
2572
- if (!this.silentDetections.has(uuid)) return;
2573
- if (this.protocolWakeAttempts.has(uuid)) return;
2574
- if (!transportCache[uuid]) return;
2575
- this.protocolWakeAttempts.add(uuid);
2576
-
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');
2577
2705
  Log?.debug('[ReactNativeBleTransport] sending Protocol V1 Initialize wake', {
2578
2706
  connectIdSuffix: uuid.slice(-8),
2579
2707
  });
@@ -2581,11 +2709,7 @@ export default class ReactNativeBleTransport {
2581
2709
  this.probingProtocols.set(uuid, 'V1');
2582
2710
  await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2583
2711
  } catch (error) {
2584
- if (shouldRethrowProtocolProbeError(error)) {
2585
- this.clearProbeProtocol(uuid, 'V1');
2586
- throw error;
2587
- }
2588
- Log?.debug('[ReactNativeBleTransport] Protocol V1 Initialize wake did not answer:', error);
2712
+ if (shouldRethrowProtocolProbeError(error)) throw error;
2589
2713
  } finally {
2590
2714
  this.clearProbeProtocol(uuid, 'V1');
2591
2715
  }
@@ -2970,7 +3094,7 @@ export default class ReactNativeBleTransport {
2970
3094
  const transport = this.getCachedTransport(uuid);
2971
3095
  if (!shouldRefreshNegotiatedMtu(transport.mtuSize)) return;
2972
3096
 
2973
- const { device: refreshedDevice, abandoned } = await requestNegotiatedMtu(
3097
+ const { device: refreshedDevice } = await requestNegotiatedMtu(
2974
3098
  transport.device,
2975
3099
  'highThroughput',
2976
3100
  1,
@@ -2980,7 +3104,7 @@ export default class ReactNativeBleTransport {
2980
3104
  transport.mtuSize =
2981
3105
  typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
2982
3106
 
2983
- if (abandoned || shouldRefreshNegotiatedMtu(transport.mtuSize)) {
3107
+ if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
2984
3108
  throw ERRORS.TypedError(
2985
3109
  HardwareErrorCode.BleConnectedError,
2986
3110
  `Protocol V2 high-throughput BLE MTU unavailable: ${String(transport.mtuSize)}`