@onekeyfe/hd-transport-react-native 1.2.2-alpha.115 → 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
@@ -496,6 +597,20 @@ export default class ReactNativeBleTransport {
496
597
  /** Endpoints whose last detection got no answer on any protocol. */
497
598
  private silentDetections = new Set<string>();
498
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
+
499
614
  /**
500
615
  * Endpoints already sent a Protocol V1 wake. The wake starts a fresh wallet session
501
616
  * on the device, and a failed detection tears the link down and reconnects on every
@@ -774,9 +889,13 @@ export default class ReactNativeBleTransport {
774
889
 
775
890
  let { device } = transport;
776
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;
777
896
  if (!isConnected) {
778
897
  try {
779
- device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
898
+ device = await this.connectWithTimeout(uuid, () => device.connect(reconnectOptions));
780
899
  } catch (e) {
781
900
  if (
782
901
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
@@ -791,8 +910,17 @@ export default class ReactNativeBleTransport {
791
910
 
792
911
  const { writeCharacteristic, notifyCharacteristic } =
793
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
+ }
794
921
 
795
922
  transport.device = device;
923
+ transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
796
924
  transport.writeCharacteristic = writeCharacteristic;
797
925
  transport.notifyCharacteristic = notifyCharacteristic;
798
926
  const monitorToken = this.nextMonitorToken;
@@ -978,7 +1106,7 @@ export default class ReactNativeBleTransport {
978
1106
  characteristics?: ResolvedBleCharacteristics
979
1107
  ) {
980
1108
  const { writeCharacteristic, notifyCharacteristic } =
981
- characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
1109
+ characteristics ?? (await this.resolveCharacteristicsForAcquire(uuid, device));
982
1110
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
983
1111
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
984
1112
  transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
@@ -1081,8 +1209,16 @@ export default class ReactNativeBleTransport {
1081
1209
  const isCachedDeviceConnected = await cachedTransport.device
1082
1210
  .isConnected()
1083
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));
1084
1219
  if (
1085
1220
  isCachedDeviceConnected &&
1221
+ isCachedAndroidLinkUsable &&
1086
1222
  cachedProtocol &&
1087
1223
  (!expectedProtocol || cachedProtocol === expectedProtocol)
1088
1224
  ) {
@@ -1101,14 +1237,21 @@ export default class ReactNativeBleTransport {
1101
1237
  }
1102
1238
 
1103
1239
  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;
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;
1112
1255
 
1113
1256
  if (forceCleanRunPromise && this.runPromise) {
1114
1257
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
@@ -1163,8 +1306,9 @@ export default class ReactNativeBleTransport {
1163
1306
  Log?.debug('try to connect to device: ', uuid);
1164
1307
  try {
1165
1308
  device = await this.connectWithTimeout(uuid, () =>
1166
- blePlxManager.connectToDevice(uuid, connectOptions)
1309
+ blePlxManager.connectToDevice(uuid, nativeConnectOptions)
1167
1310
  );
1311
+ androidRefreshConnectRan = refreshAndroidGattCache;
1168
1312
  } catch (e) {
1169
1313
  Log?.debug('try to connect to device has error: ', e);
1170
1314
  if (shouldRethrowBleSetupError(e)) {
@@ -1175,7 +1319,6 @@ export default class ReactNativeBleTransport {
1175
1319
  e.errorCode === BleErrorCode.OperationCancelled
1176
1320
  ) {
1177
1321
  Log?.debug('first try to reconnect without params');
1178
- mtuHandshakeRefused = true;
1179
1322
  // The disposed chain can still own native connection state, and the retry
1180
1323
  // would otherwise run on top of a half-open GATT client.
1181
1324
  await this.runBestEffortNativeOperation(
@@ -1198,14 +1341,22 @@ export default class ReactNativeBleTransport {
1198
1341
  throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'unable to connect to device');
1199
1342
  }
1200
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
+
1201
1351
  if (!(await device.isConnected())) {
1202
1352
  Log?.debug('not connected, try to connect to device: ', uuid);
1203
1353
  const disconnectedDevice = device;
1204
1354
 
1205
1355
  try {
1206
1356
  device = await this.connectWithTimeout(uuid, () =>
1207
- disconnectedDevice.connect(connectOptions)
1357
+ disconnectedDevice.connect(nativeConnectOptions)
1208
1358
  );
1359
+ androidRefreshConnectRan = refreshAndroidGattCache;
1209
1360
  } catch (e) {
1210
1361
  Log?.debug('not connected, try to connect to device has error: ', e);
1211
1362
  if (shouldRethrowBleSetupError(e)) {
@@ -1216,7 +1367,6 @@ export default class ReactNativeBleTransport {
1216
1367
  e.errorCode === BleErrorCode.OperationCancelled
1217
1368
  ) {
1218
1369
  Log?.debug('second try to reconnect without params');
1219
- mtuHandshakeRefused = true;
1220
1370
  // The disposed chain can still own native connection state, and the retry
1221
1371
  // would otherwise run on top of a half-open GATT client.
1222
1372
  await this.runBestEffortNativeOperation(
@@ -1263,11 +1413,25 @@ export default class ReactNativeBleTransport {
1263
1413
  throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'device is not connected');
1264
1414
  }
1265
1415
  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
- });
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
+ }
1271
1435
  } else {
1272
1436
  const negotiatedMtu = await resolveNegotiatedMtu(device, transactionId =>
1273
1437
  blePlxManager.cancelTransaction(transactionId)
@@ -1290,7 +1454,7 @@ export default class ReactNativeBleTransport {
1290
1454
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1291
1455
  const acquiredDevice = device;
1292
1456
  const { writeCharacteristic, notifyCharacteristic } =
1293
- await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
1457
+ characteristics ?? (await this.resolveCharacteristicsForAcquire(uuid, acquiredDevice));
1294
1458
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1295
1459
 
1296
1460
  const protocolHint = expectedProtocol
@@ -1418,6 +1582,9 @@ export default class ReactNativeBleTransport {
1418
1582
  ) {
1419
1583
  errorCode = HardwareErrorCode.BleCharacteristicNotifyChangeFailure;
1420
1584
  }
1585
+ if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
1586
+ this.androidGattCacheRefreshes.add(uuid);
1587
+ }
1421
1588
  this.rejectProtocolV2Frames(uuid, ERRORS.TypedError(errorCode));
1422
1589
  return;
1423
1590
  }
@@ -1439,6 +1606,9 @@ export default class ReactNativeBleTransport {
1439
1606
  const notifyError = ERRORS.TypedError(
1440
1607
  HardwareErrorCode.BleCharacteristicNotifyChangeFailure
1441
1608
  );
1609
+ if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
1610
+ this.androidGattCacheRefreshes.add(uuid);
1611
+ }
1442
1612
  this.runPromise.reject(notifyError);
1443
1613
  Log?.debug(
1444
1614
  `${HardwareErrorCode.BleCharacteristicNotifyChangeFailure} ${error.message} ${error.reason}`
@@ -2102,6 +2272,142 @@ export default class ReactNativeBleTransport {
2102
2272
  }
2103
2273
  }
2104
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
+
2105
2411
  /** Run a native connect under the JS backstop budget. */
2106
2412
  private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
2107
2413
  if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
@@ -2211,7 +2517,13 @@ export default class ReactNativeBleTransport {
2211
2517
  */
2212
2518
  private abandonStalledConnection(
2213
2519
  uuid: string,
2214
- 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'
2215
2527
  ): boolean {
2216
2528
  const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
2217
2529
  this.connectionSetupTimeoutCounts.set(uuid, timeouts);
@@ -2893,6 +3205,13 @@ export default class ReactNativeBleTransport {
2893
3205
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
2894
3206
  }
2895
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
+
2896
3215
  const isProtocolProbe = this.probingProtocols.get(uuid) === 'V2';
2897
3216
  const callOptions = options;
2898
3217
  const highThroughputWrite = isProtocolV2HighThroughputCall(name);