@onekeyfe/hd-transport-react-native 1.2.2-alpha.120 → 1.2.2-alpha.122

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
@@ -242,13 +242,13 @@ function getDeviceDisplayName(device?: Device | null) {
242
242
 
243
243
  const IOS_REQUEST_MTU = 247;
244
244
  const ANDROID_REQUEST_MTU = 517;
245
- const BLE_MTU_REFRESH_RETRY_DELAY_MS = 200;
246
245
  const ANDROID_HIGH_PRIORITY_IDLE_MS = 1000;
247
246
 
248
247
  const getRequestedBleMtu = () =>
249
248
  Platform.OS === 'android' ? ANDROID_REQUEST_MTU : IOS_REQUEST_MTU;
250
249
 
251
250
  const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
251
+ const BLE_MTU_REQUEST_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS;
252
252
 
253
253
  const connectOptions: Record<string, unknown> = {
254
254
  requestMTU: getRequestedBleMtu(),
@@ -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,6 +352,15 @@ 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;
355
+ const isMtuOrCancelledConnectError = (error: unknown): boolean => {
356
+ const errorCode = (error as { errorCode?: unknown })?.errorCode;
357
+ return (
358
+ errorCode === BleErrorCode.DeviceMTUChangeFailed ||
359
+ errorCode === BleErrorCode.OperationCancelled
360
+ );
361
+ };
362
+
363
+ type NegotiatedMtuResult = { device: Device; timedOut: boolean };
303
364
 
304
365
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
305
366
 
@@ -314,29 +375,75 @@ const tryToGetConfiguration = (device: Device) => {
314
375
 
315
376
  const requestNegotiatedMtu = async (
316
377
  device: Device,
317
- stage: 'connected' | 'servicesAndNotifyReady' | 'highThroughput',
318
- attempt: number
319
- ) => {
320
- if (Platform.OS !== 'ios' && Platform.OS !== 'android') return device;
378
+ stage: 'connected' | 'highThroughput',
379
+ attempt: number,
380
+ cancelTransaction?: (transactionId: string) => Promise<void> | void
381
+ ): Promise<NegotiatedMtuResult> => {
382
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') return { device, timedOut: false };
383
+
384
+ const transactionId = `${device.id}:mtu:${stage}:${attempt}:${Date.now()}`;
385
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
386
+ let timedOut = false;
321
387
 
322
388
  try {
323
389
  // iOS ignores the requested value but react-native-ble-plx returns a fresh
324
390
  // Device snapshot whose MTU is derived from CoreBluetooth's maximum write length.
325
- const mtuDevice = await device.requestMTU(getRequestedBleMtu());
326
- return mtuDevice;
391
+ const request = device.requestMTU(getRequestedBleMtu(), transactionId);
392
+ // The timeout race may settle before the native request does. Attach a
393
+ // rejection handler so a late native cancellation cannot become an
394
+ // unhandled rejection after we continue with the current MTU.
395
+ request.catch(() => undefined);
396
+ const mtuDevice = await Promise.race([
397
+ request,
398
+ new Promise<never>((_, reject) => {
399
+ timeoutId = setTimeout(() => {
400
+ timedOut = true;
401
+ reject(new Error(`BLE MTU request timeout after ${BLE_MTU_REQUEST_TIMEOUT_MS}ms`));
402
+ }, BLE_MTU_REQUEST_TIMEOUT_MS);
403
+ }),
404
+ ]);
405
+ return { device: mtuDevice, timedOut: false };
327
406
  } catch (error) {
407
+ if (timedOut && cancelTransaction) {
408
+ try {
409
+ Promise.resolve(cancelTransaction(transactionId)).catch(cancelError => {
410
+ Log?.debug('[ReactNativeBleTransport] MTU cancellation failed', {
411
+ platform: Platform.OS,
412
+ stage,
413
+ attempt,
414
+ error: cancelError instanceof Error ? cancelError.message : String(cancelError),
415
+ });
416
+ });
417
+ } catch (cancelError) {
418
+ Log?.debug('[ReactNativeBleTransport] MTU cancellation failed', {
419
+ platform: Platform.OS,
420
+ stage,
421
+ attempt,
422
+ error: cancelError instanceof Error ? cancelError.message : String(cancelError),
423
+ });
424
+ }
425
+ }
328
426
  Log?.debug('[ReactNativeBleTransport] MTU refresh failed, continuing with current value', {
329
427
  platform: Platform.OS,
330
428
  stage,
331
429
  attempt,
332
430
  actual: device.mtu,
431
+ timedOut,
333
432
  error: error instanceof Error ? error.message : String(error),
334
433
  });
335
- return device;
434
+ return { device, timedOut };
435
+ } finally {
436
+ if (timeoutId) clearTimeout(timeoutId);
336
437
  }
337
438
  };
338
439
 
339
- const resolveNegotiatedMtu = (device: Device) => requestNegotiatedMtu(device, 'connected', 0);
440
+ const resolveNegotiatedMtu = (
441
+ device: Device,
442
+ cancelTransaction?: (transactionId: string) => Promise<void> | void
443
+ ): Promise<NegotiatedMtuResult> =>
444
+ shouldRefreshNegotiatedMtu(device.mtu)
445
+ ? requestNegotiatedMtu(device, 'connected', 0, cancelTransaction)
446
+ : Promise.resolve({ device, timedOut: false });
340
447
 
341
448
  type IOBleErrorRemap = Error | BleError | null | undefined;
342
449
 
@@ -420,6 +527,12 @@ export default class ReactNativeBleTransport {
420
527
  /** Consecutive detections that failed while trusting sessionProtocols. */
421
528
  private protocolReprobeFailures: Map<string, number> = new Map();
422
529
 
530
+ /** Endpoints whose last detection got no answer; 'woken' once their Initialize wake is spent. */
531
+ private silentDetections = new Map<string, 'silent' | 'woken'>();
532
+
533
+ /** Android endpoints whose cached GATT table is suspect; the next connect refreshes it. */
534
+ private androidGattCacheRefreshes = new Set<string>();
535
+
423
536
  /**
424
537
  * Native encryption/pairing failures seen before Protocol V2 probe starts.
425
538
  * Pro2/Neo GATT connect can succeed on a stale iOS bond; the CCCD write then
@@ -926,41 +1039,10 @@ export default class ReactNativeBleTransport {
926
1039
  }
927
1040
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
928
1041
 
929
- const initialMtu = transport.mtuSize;
930
- let refreshAttempts = 0;
931
- if (
932
- (Platform.OS === 'ios' || Platform.OS === 'android') &&
933
- shouldRefreshNegotiatedMtu(transport.mtuSize)
934
- ) {
935
- refreshAttempts += 1;
936
- let refreshedDevice = await requestNegotiatedMtu(
937
- transport.device,
938
- 'servicesAndNotifyReady',
939
- 1
940
- );
941
- if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
942
- transport.device = refreshedDevice;
943
- transport.mtuSize =
944
- typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
945
-
946
- if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
947
- await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
948
- if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
949
- refreshAttempts += 1;
950
- refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
951
- transport.device = refreshedDevice;
952
- transport.mtuSize =
953
- typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
954
- }
955
- }
956
-
957
- if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
958
1042
  Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
959
1043
  platform: Platform.OS,
960
1044
  requested: getRequestedBleMtu(),
961
- initial: initialMtu,
962
1045
  actual: transport.mtuSize,
963
- refreshAttempts,
964
1046
  });
965
1047
 
966
1048
  return transport;
@@ -1028,8 +1110,12 @@ export default class ReactNativeBleTransport {
1028
1110
  const isCachedDeviceConnected = await cachedTransport.device
1029
1111
  .isConnected()
1030
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);
1031
1116
  if (
1032
1117
  isCachedDeviceConnected &&
1118
+ isCachedAndroidLinkUsable &&
1033
1119
  cachedProtocol &&
1034
1120
  (!expectedProtocol || cachedProtocol === expectedProtocol)
1035
1121
  ) {
@@ -1048,6 +1134,18 @@ export default class ReactNativeBleTransport {
1048
1134
  }
1049
1135
 
1050
1136
  let device: Device | null = null;
1137
+ const isAndroid = Platform.OS === 'android';
1138
+ // A firmware-install reconnect always refreshes: the new firmware may expose a different table.
1139
+ const refreshAndroidGattCache =
1140
+ isAndroid && (!!skipProtocolProbe || this.androidGattCacheRefreshes.has(uuid));
1141
+ let nativeConnectOptions = connectOptions;
1142
+ if (isAndroid) {
1143
+ nativeConnectOptions = refreshAndroidGattCache
1144
+ ? androidRefreshGattConnectOptions
1145
+ : fallbackConnectOptions;
1146
+ }
1147
+ // Only a connect that carried refreshGatt clears the marker; the fallback connects drop it.
1148
+ let androidRefreshConnectRan = false;
1051
1149
 
1052
1150
  if (forceCleanRunPromise && this.runPromise) {
1053
1151
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
@@ -1058,6 +1156,7 @@ export default class ReactNativeBleTransport {
1058
1156
  }
1059
1157
 
1060
1158
  const blePlxManager = await this.getPlxManager();
1159
+ let skipPostConnectMtu = false;
1061
1160
  try {
1062
1161
  await subscribeBleOn(blePlxManager);
1063
1162
  } catch (error) {
@@ -1102,17 +1201,16 @@ export default class ReactNativeBleTransport {
1102
1201
  Log?.debug('try to connect to device: ', uuid);
1103
1202
  try {
1104
1203
  device = await this.connectWithTimeout(uuid, () =>
1105
- blePlxManager.connectToDevice(uuid, connectOptions)
1204
+ blePlxManager.connectToDevice(uuid, nativeConnectOptions)
1106
1205
  );
1206
+ androidRefreshConnectRan = refreshAndroidGattCache;
1107
1207
  } catch (e) {
1108
1208
  Log?.debug('try to connect to device has error: ', e);
1109
1209
  if (shouldRethrowBleSetupError(e)) {
1110
1210
  throw e;
1111
1211
  }
1112
- if (
1113
- e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
1114
- e.errorCode === BleErrorCode.OperationCancelled
1115
- ) {
1212
+ if (isMtuOrCancelledConnectError(e)) {
1213
+ skipPostConnectMtu = true;
1116
1214
  Log?.debug('first try to reconnect without params');
1117
1215
  device = await this.connectWithTimeout(uuid, () =>
1118
1216
  blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
@@ -1130,23 +1228,33 @@ export default class ReactNativeBleTransport {
1130
1228
  throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'unable to connect to device');
1131
1229
  }
1132
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
+
1133
1242
  if (!(await device.isConnected())) {
1134
1243
  Log?.debug('not connected, try to connect to device: ', uuid);
1135
1244
  const disconnectedDevice = device;
1136
1245
 
1137
1246
  try {
1138
1247
  device = await this.connectWithTimeout(uuid, () =>
1139
- disconnectedDevice.connect(connectOptions)
1248
+ disconnectedDevice.connect(nativeConnectOptions)
1140
1249
  );
1250
+ androidRefreshConnectRan = refreshAndroidGattCache;
1141
1251
  } catch (e) {
1142
1252
  Log?.debug('not connected, try to connect to device has error: ', e);
1143
1253
  if (shouldRethrowBleSetupError(e)) {
1144
1254
  throw e;
1145
1255
  }
1146
- if (
1147
- e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
1148
- e.errorCode === BleErrorCode.OperationCancelled
1149
- ) {
1256
+ if (isMtuOrCancelledConnectError(e)) {
1257
+ skipPostConnectMtu = true;
1150
1258
  Log?.debug('second try to reconnect without params');
1151
1259
  try {
1152
1260
  device = await this.connectWithTimeout(uuid, () =>
@@ -1188,11 +1296,63 @@ export default class ReactNativeBleTransport {
1188
1296
  throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'device is not connected');
1189
1297
  }
1190
1298
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1191
- device = await resolveNegotiatedMtu(device);
1299
+ let characteristics: ResolvedBleCharacteristics | undefined;
1300
+ if (isAndroid) {
1301
+ if (refreshAndroidGattCache) {
1302
+ // refreshGatt has already started a full rediscovery; let it finish before the MTU
1303
+ // exchange so the request is not queued behind it.
1304
+ characteristics = await this.resolveCharacteristicsWithTimeout(uuid, device);
1305
+ if (androidRefreshConnectRan) this.androidGattCacheRefreshes.delete(uuid);
1306
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1307
+ }
1308
+ device = await this.negotiateAndroidMtu(uuid, blePlxManager, device);
1309
+ } else if (!skipPostConnectMtu) {
1310
+ // Match 1.1.31: MTU is a connect() best-effort. If connect already fell back
1311
+ // without requestMTU, do not put another requestMTU on the native serial
1312
+ // queue — that is what wedges GATT after Account#2 reconnect.
1313
+ const mtuResult = await resolveNegotiatedMtu(device, transactionId =>
1314
+ blePlxManager.cancelTransaction(transactionId)
1315
+ );
1316
+ device = mtuResult.device;
1317
+ if (mtuResult.timedOut) {
1318
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1319
+ Log?.debug(
1320
+ '[ReactNativeBleTransport] post-connect MTU timed out, reconnecting without requesting MTU'
1321
+ );
1322
+ const timedOutDevice = device;
1323
+ let mtuTeardownSettled = false;
1324
+ await this.runNativeTeardown(uuid, blePlxManager, async () => {
1325
+ await this.runBestEffortNativeOperation('mtu timeout: cancel device connection', () =>
1326
+ timedOutDevice.cancelConnection()
1327
+ );
1328
+ mtuTeardownSettled = true;
1329
+ });
1330
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1331
+ if (!mtuTeardownSettled || this.blePlxManager !== blePlxManager) {
1332
+ // The teardown budget expired and the manager that owned timedOutDevice was reset.
1333
+ throw ERRORS.TypedError(HardwareErrorCode.BleTimeoutError, 'BLE MTU cleanup timed out');
1334
+ }
1335
+ try {
1336
+ device = await this.connectWithTimeout(uuid, () =>
1337
+ timedOutDevice.connect(fallbackConnectOptions)
1338
+ );
1339
+ } catch (error) {
1340
+ if (shouldRethrowBleSetupError(error)) throw error;
1341
+ if (
1342
+ (error as { errorCode?: unknown })?.errorCode === BleErrorCode.DeviceAlreadyConnected
1343
+ ) {
1344
+ // GATT resolution and the protocol probe below still validate the retained link.
1345
+ device = timedOutDevice;
1346
+ } else {
1347
+ remapError(error);
1348
+ }
1349
+ }
1350
+ }
1351
+ }
1192
1352
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1193
1353
  const acquiredDevice = device;
1194
1354
  const { writeCharacteristic, notifyCharacteristic } =
1195
- await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
1355
+ characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice));
1196
1356
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1197
1357
 
1198
1358
  const protocolHint = expectedProtocol
@@ -1303,6 +1463,9 @@ export default class ReactNativeBleTransport {
1303
1463
  this.rememberStaleBondError(uuid, toBleStaleBondHardwareError(error));
1304
1464
  return;
1305
1465
  }
1466
+ if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
1467
+ this.androidGattCacheRefreshes.add(uuid);
1468
+ }
1306
1469
  if (this.getActiveProtocol(uuid) === 'V2') {
1307
1470
  let errorCode:
1308
1471
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -1312,10 +1475,7 @@ export default class ReactNativeBleTransport {
1312
1475
  if (error.reason?.includes('The connection has timed out unexpectedly')) {
1313
1476
  errorCode = HardwareErrorCode.BleTimeoutError;
1314
1477
  } else if (
1315
- error.reason?.includes('Cannot write client characteristic config descriptor') ||
1316
- error.reason?.includes('Cannot find client characteristic config descriptor') ||
1317
- error.reason?.includes('The handle is invalid') ||
1318
- error.reason?.includes('Writing is not permitted') ||
1478
+ isStaleGattTableNotifyReason(error.reason) ||
1319
1479
  error.reason?.includes('notify change failed for device')
1320
1480
  ) {
1321
1481
  errorCode = HardwareErrorCode.BleCharacteristicNotifyChangeFailure;
@@ -1332,10 +1492,7 @@ export default class ReactNativeBleTransport {
1332
1492
  ERROR = HardwareErrorCode.BleTimeoutError;
1333
1493
  }
1334
1494
  if (
1335
- error.reason?.includes('Cannot write client characteristic config descriptor') ||
1336
- error.reason?.includes('Cannot find client characteristic config descriptor') || // pro firmware 2.3.0 upgrade
1337
- error.reason?.includes('The handle is invalid') ||
1338
- error.reason?.includes('Writing is not permitted') || // pro firmware 2.3.4 upgrade
1495
+ isStaleGattTableNotifyReason(error.reason) ||
1339
1496
  error.reason?.includes('notify change failed for device')
1340
1497
  ) {
1341
1498
  const notifyError = ERRORS.TypedError(
@@ -1765,13 +1922,14 @@ export default class ReactNativeBleTransport {
1765
1922
  const jsonData = ProtocolV1.decodeMessage(messages, response);
1766
1923
  return check.call(jsonData);
1767
1924
  } catch (e) {
1768
- if (name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS) {
1769
- Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe call failed:', e);
1925
+ const isProbeTimeout =
1926
+ options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS &&
1927
+ (name === 'GetFeatures' || name === 'Initialize');
1928
+ if (isProbeTimeout) {
1929
+ Log?.debug(`[ReactNativeBleTransport] Protocol V1 ${name} probe call failed:`, e);
1770
1930
  } else {
1771
1931
  Log?.error('call error: ', e);
1772
1932
  }
1773
- const isProbeTimeout =
1774
- name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1775
1933
  // A call that has been superseded (forceRun) or cleaned up no longer owns the
1776
1934
  // transport; its late timeout must not tear down the connection the current
1777
1935
  // call is actively using.
@@ -2004,6 +2162,98 @@ export default class ReactNativeBleTransport {
2004
2162
  }
2005
2163
  }
2006
2164
 
2165
+ /**
2166
+ * Android: request the MTU with nothing queued ahead and never close the client while it is
2167
+ * outstanding; a link that times out or stays at MTU 23 is dropped.
2168
+ */
2169
+ private async negotiateAndroidMtu(
2170
+ uuid: string,
2171
+ manager: BlePlxManager,
2172
+ device: Device
2173
+ ): Promise<Device> {
2174
+ if (!shouldRefreshNegotiatedMtu(device.mtu)) return device;
2175
+
2176
+ const startedAt = Date.now();
2177
+ let timer: ReturnType<typeof setTimeout> | undefined;
2178
+ let timedOut = false;
2179
+ let negotiated = device;
2180
+ let failure: string | undefined;
2181
+ try {
2182
+ negotiated = await Promise.race([
2183
+ device.requestMTU(ANDROID_REQUEST_MTU, `${device.id}:mtu:connected:0:${startedAt}`),
2184
+ new Promise<never>((_, reject) => {
2185
+ timer = setTimeout(() => {
2186
+ timedOut = true;
2187
+ reject(
2188
+ new Error(`BLE MTU exchange timeout after ${ANDROID_MTU_EXCHANGE_TIMEOUT_MS}ms`)
2189
+ );
2190
+ }, ANDROID_MTU_EXCHANGE_TIMEOUT_MS);
2191
+ }),
2192
+ ]);
2193
+ } catch (error) {
2194
+ failure = error instanceof Error ? error.message : String(error);
2195
+ } finally {
2196
+ if (timer) clearTimeout(timer);
2197
+ }
2198
+ Log?.debug(`[ReactNativeBleTransport] BLE MTU exchange ${failure ? 'failed' : 'completed'}`, {
2199
+ connectIdSuffix: uuid.slice(-8),
2200
+ elapsedMs: Date.now() - startedAt,
2201
+ timedOut,
2202
+ actual: negotiated.mtu,
2203
+ error: failure,
2204
+ });
2205
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
2206
+ if (!timedOut && !isKnownDefaultMtu(negotiated.mtu)) return negotiated;
2207
+
2208
+ // Counted like a setup timeout so a link that keeps failing reaches the wedged-link guard.
2209
+ const resetManager = this.abandonStalledConnection(
2210
+ uuid,
2211
+ timedOut ? 'mtu-backstop' : 'mtu-default'
2212
+ );
2213
+ await this.dropAndroidLink(
2214
+ uuid,
2215
+ manager,
2216
+ negotiated,
2217
+ timedOut ? 'mtu exchange timeout' : 'default mtu'
2218
+ );
2219
+ if (resetManager) throw this.createWedgedBleSetupError();
2220
+ throw ERRORS.TypedError(
2221
+ HardwareErrorCode.BleConnectedError,
2222
+ timedOut
2223
+ ? 'BLE MTU exchange did not complete, reconnecting on a fresh link'
2224
+ : `BLE link stayed at the default MTU ${negotiated.mtu}, reconnecting on a fresh link`
2225
+ );
2226
+ }
2227
+
2228
+ /** Close the client and wait out the link idle timer so the next connect gets a fresh link. */
2229
+ private async dropAndroidLink(
2230
+ uuid: string,
2231
+ manager: BlePlxManager,
2232
+ device: Device,
2233
+ reason: string
2234
+ ) {
2235
+ await this.runNativeTeardown(uuid, manager, async () => {
2236
+ await Promise.all([
2237
+ this.runBestEffortNativeOperation(`${reason}: cancel manager connection`, () =>
2238
+ manager.cancelDeviceConnection(uuid)
2239
+ ),
2240
+ this.runBestEffortNativeOperation(`${reason}: cancel device connection`, () =>
2241
+ device.cancelConnection()
2242
+ ),
2243
+ ]);
2244
+ });
2245
+
2246
+ const startedAt = Date.now();
2247
+ while (!this.stopped && Date.now() - startedAt < ANDROID_LINK_DROP_QUIET_MS) {
2248
+ await delay(ANDROID_LINK_DROP_POLL_MS);
2249
+ }
2250
+ Log?.debug('[ReactNativeBleTransport] Android BLE link drop', {
2251
+ connectIdSuffix: uuid.slice(-8),
2252
+ reason,
2253
+ stopped: this.stopped,
2254
+ });
2255
+ }
2256
+
2007
2257
  /** Run a native connect under the JS backstop budget. */
2008
2258
  private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
2009
2259
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
@@ -2093,6 +2343,9 @@ export default class ReactNativeBleTransport {
2093
2343
  throw this.createWedgedBleSetupError();
2094
2344
  }
2095
2345
  }
2346
+ if (Platform.OS === 'android' && isMissingGattShapeError(error)) {
2347
+ this.androidGattCacheRefreshes.add(uuid);
2348
+ }
2096
2349
  throw error;
2097
2350
  } finally {
2098
2351
  if (timer) clearTimeout(timer);
@@ -2113,7 +2366,13 @@ export default class ReactNativeBleTransport {
2113
2366
  */
2114
2367
  private abandonStalledConnection(
2115
2368
  uuid: string,
2116
- stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
2369
+ stage:
2370
+ | 'connect-backstop'
2371
+ | 'connect-native'
2372
+ | 'gatt-backstop'
2373
+ | 'gatt-native'
2374
+ | 'mtu-backstop'
2375
+ | 'mtu-default'
2117
2376
  ): boolean {
2118
2377
  const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
2119
2378
  this.connectionSetupTimeoutCounts.set(uuid, timeouts);
@@ -2288,6 +2547,7 @@ export default class ReactNativeBleTransport {
2288
2547
  // Keep transport-lifetime V2 proof so the same endpoint can finish a no-probe
2289
2548
  // firmware reconnect after the native BLE manager is recreated.
2290
2549
  this.protocolReprobeFailures.clear();
2550
+ this.silentDetections.clear();
2291
2551
  this.writeTimeoutCounts.clear();
2292
2552
  this.connectionSetupTimeoutCounts.clear();
2293
2553
  this.monitorTokens.clear();
@@ -2399,6 +2659,8 @@ export default class ReactNativeBleTransport {
2399
2659
  reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
2400
2660
  const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
2401
2661
 
2662
+ await this.wakeSilentProtocolV1Device(uuid, probeOrder);
2663
+
2402
2664
  for (let i = 0; i < probeOrder.length; i += 1) {
2403
2665
  const protocol = probeOrder[i];
2404
2666
  if (i > 0) {
@@ -2420,6 +2682,7 @@ export default class ReactNativeBleTransport {
2420
2682
  this.confirmedProtocolV2.add(uuid);
2421
2683
  }
2422
2684
  this.protocolReprobeFailures.delete(uuid);
2685
+ this.silentDetections.delete(uuid);
2423
2686
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
2424
2687
  deviceId: uuid,
2425
2688
  protocol,
@@ -2429,6 +2692,9 @@ export default class ReactNativeBleTransport {
2429
2692
  }
2430
2693
  }
2431
2694
 
2695
+ // Arms the wake for the next detection.
2696
+ if (!this.silentDetections.has(uuid)) this.silentDetections.set(uuid, 'silent');
2697
+
2432
2698
  if (trustSessionProtocol) {
2433
2699
  // Still silent on its own protocol: count it, and let the streak expire the
2434
2700
  // shortcut so a device that genuinely switched protocols is found again.
@@ -2442,6 +2708,34 @@ export default class ReactNativeBleTransport {
2442
2708
  throw this.createProtocolDetectionError();
2443
2709
  }
2444
2710
 
2711
+ /**
2712
+ * A sleeping Classic drops GetFeatures/Ping and only leaves its screensaver on Initialize, which
2713
+ * resets the wallet session, so it is sent once after a fully silent detection. The firmware does
2714
+ * not reliably answer it, so its timeout must not drop the link. It only runs when V1 is probed
2715
+ * first, so a late reply lands on the V1 probe rather than on a V2 one.
2716
+ */
2717
+ private async wakeSilentProtocolV1Device(uuid: string, probeOrder: ProtocolType[]) {
2718
+ if (
2719
+ Platform.OS !== 'android' ||
2720
+ probeOrder[0] !== 'V1' ||
2721
+ this.silentDetections.get(uuid) !== 'silent'
2722
+ ) {
2723
+ return;
2724
+ }
2725
+ this.silentDetections.set(uuid, 'woken');
2726
+ Log?.debug('[ReactNativeBleTransport] sending Protocol V1 Initialize wake', {
2727
+ connectIdSuffix: uuid.slice(-8),
2728
+ });
2729
+ try {
2730
+ this.probingProtocols.set(uuid, 'V1');
2731
+ await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
2732
+ } catch (error) {
2733
+ if (shouldRethrowProtocolProbeError(error)) throw error;
2734
+ } finally {
2735
+ this.clearProbeProtocol(uuid, 'V1');
2736
+ }
2737
+ }
2738
+
2445
2739
  private async resetProbeStateAfterProtocolProbe(uuid: string, protocol: ProtocolType) {
2446
2740
  const transport = transportCache[uuid];
2447
2741
  await this.protocolV2Links.invalidateLink(
@@ -2821,7 +3115,12 @@ export default class ReactNativeBleTransport {
2821
3115
  const transport = this.getCachedTransport(uuid);
2822
3116
  if (!shouldRefreshNegotiatedMtu(transport.mtuSize)) return;
2823
3117
 
2824
- const refreshedDevice = await requestNegotiatedMtu(transport.device, 'highThroughput', 1);
3118
+ const { device: refreshedDevice } = await requestNegotiatedMtu(
3119
+ transport.device,
3120
+ 'highThroughput',
3121
+ 1,
3122
+ transactionId => this.blePlxManager?.cancelTransaction(transactionId)
3123
+ );
2825
3124
  transport.device = refreshedDevice;
2826
3125
  transport.mtuSize =
2827
3126
  typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;