@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/dist/index.d.ts +29 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +265 -59
- package/package.json +5 -5
- package/src/__tests__/connectTimeout.test.ts +85 -1
- package/src/__tests__/protocolV2Link.test.ts +600 -25
- package/src/index.ts +366 -67
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
|
-
/**
|
|
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' | '
|
|
318
|
-
attempt: number
|
|
319
|
-
) =>
|
|
320
|
-
|
|
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
|
|
326
|
-
|
|
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 = (
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1769
|
-
|
|
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:
|
|
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(
|
|
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;
|