@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/dist/index.d.ts +24 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +181 -71
- package/package.json +5 -5
- package/src/__tests__/connectTimeout.test.ts +84 -0
- package/src/__tests__/protocolV2Link.test.ts +413 -30
- package/src/index.ts +261 -137
package/src/index.ts
CHANGED
|
@@ -256,11 +256,63 @@ const connectOptions: Record<string, unknown> = {
|
|
|
256
256
|
refreshGatt: 'OnConnected',
|
|
257
257
|
};
|
|
258
258
|
|
|
259
|
-
/**
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
497
|
-
private silentDetections = new
|
|
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
|
-
|
|
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
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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 =
|
|
1276
|
-
if (
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1867
|
-
|
|
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 ||
|
|
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:
|
|
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
|
-
//
|
|
2537
|
-
|
|
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
|
-
*
|
|
2555
|
-
*
|
|
2556
|
-
*
|
|
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 (
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
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
|
|
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 (
|
|
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)}`
|