@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/dist/index.d.ts +48 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +212 -14
- package/package.json +5 -5
- package/src/__tests__/connectTimeout.test.ts +104 -0
- package/src/__tests__/protocolV2Link.test.ts +450 -24
- package/src/index.ts +341 -22
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(
|
|
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.
|
|
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
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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.
|
|
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:
|
|
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);
|