@onekeyfe/hd-transport-react-native 1.2.3-alpha.1 → 1.2.3-alpha.10
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/README.md +1 -1
- package/dist/BleManager.d.ts +6 -5
- package/dist/BleManager.d.ts.map +1 -1
- package/dist/bleEncryption.d.ts +17 -0
- package/dist/bleEncryption.d.ts.map +1 -0
- package/dist/bleKeyMissing.d.ts +7 -0
- package/dist/bleKeyMissing.d.ts.map +1 -0
- package/dist/bleNativeDisconnect.d.ts +3 -0
- package/dist/bleNativeDisconnect.d.ts.map +1 -0
- package/dist/index.d.ts +69 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +951 -139
- package/package.json +6 -6
- package/src/BleManager.ts +113 -5
- package/src/__tests__/androidEncryption.test.ts +482 -0
- package/src/__tests__/androidKeyMissing.test.ts +307 -0
- package/src/__tests__/bleNativeDisconnect.test.ts +33 -0
- package/src/__tests__/connectTimeout.test.ts +427 -3
- package/src/__tests__/enumerate.test.ts +15 -0
- package/src/__tests__/protocolV2Link.test.ts +991 -62
- package/src/__tests__/staleCallTimeout.test.ts +34 -5
- package/src/bleEncryption.ts +267 -0
- package/src/bleKeyMissing.ts +106 -0
- package/src/bleNativeDisconnect.ts +40 -0
- package/src/index.ts +785 -156
package/src/index.ts
CHANGED
|
@@ -47,6 +47,19 @@ import {
|
|
|
47
47
|
getInfosForServiceUuid,
|
|
48
48
|
isSameBleUuid,
|
|
49
49
|
} from './constants';
|
|
50
|
+
import {
|
|
51
|
+
isBleKeyMissingSupported,
|
|
52
|
+
startBleKeyMissingTracking,
|
|
53
|
+
stopBleKeyMissingTracking,
|
|
54
|
+
waitForBleKeyMissing,
|
|
55
|
+
} from './bleKeyMissing';
|
|
56
|
+
import {
|
|
57
|
+
markBleLinkEncrypted,
|
|
58
|
+
startBleEncryptionTracking,
|
|
59
|
+
stopBleEncryptionTracking,
|
|
60
|
+
waitForAndroidLinkEncryption,
|
|
61
|
+
} from './bleEncryption';
|
|
62
|
+
import { isNativeBleDisconnectError, toBleDisconnectHardwareError } from './bleNativeDisconnect';
|
|
50
63
|
import {
|
|
51
64
|
isBleStaleBondHardwareError,
|
|
52
65
|
isNativeBleStaleBondError,
|
|
@@ -61,6 +74,7 @@ import type { Deferred } from '@onekeyfe/hd-shared';
|
|
|
61
74
|
import type { Characteristic, Device, Subscription } from 'react-native-ble-plx';
|
|
62
75
|
import type EventEmitter from 'events';
|
|
63
76
|
import type { BleAcquireInput, TransportOptions } from './types';
|
|
77
|
+
import type { AndroidLinkEncryption } from './bleEncryption';
|
|
64
78
|
|
|
65
79
|
type FirmwareInstallBleAcquireInput = BleAcquireInput & {
|
|
66
80
|
/**
|
|
@@ -76,6 +90,8 @@ const { check, ProtocolV1, parseConfigure } = transport;
|
|
|
76
90
|
const Log = bleLogger;
|
|
77
91
|
|
|
78
92
|
const transportCache: Record<string, BleTransport> = {};
|
|
93
|
+
// ble-plx shares one manager across transport instances in this JS runtime.
|
|
94
|
+
let bleManagerResetPromise: Promise<void> | undefined;
|
|
79
95
|
const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
|
|
80
96
|
const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
|
|
81
97
|
const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
|
|
@@ -168,6 +184,55 @@ const isWedgedWriteError = (error: unknown): boolean =>
|
|
|
168
184
|
(error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
|
|
169
185
|
typeof (error as { message?: unknown })?.message === 'string' &&
|
|
170
186
|
(error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
|
|
187
|
+
const shouldRethrowProtocolProbeError = (error: unknown): boolean => {
|
|
188
|
+
const code = (error as { errorCode?: unknown })?.errorCode;
|
|
189
|
+
// Bonding and GATT failures are not evidence of a protocol mismatch. Preserve
|
|
190
|
+
// them instead of probing another protocol on an unusable connection.
|
|
191
|
+
// Native PLX disconnects (errorCode 201 / iOS 7) must match before they are
|
|
192
|
+
// mapped: Protocol V2 writes rethrow them unchanged unless normalized first.
|
|
193
|
+
return (
|
|
194
|
+
isBleStaleBondHardwareError(error) ||
|
|
195
|
+
isNativeBleDisconnectError(error) ||
|
|
196
|
+
code === HardwareErrorCode.BleDeviceNotBonded ||
|
|
197
|
+
code === HardwareErrorCode.BleDeviceBondedCanceled ||
|
|
198
|
+
code === HardwareErrorCode.BleDeviceDisconnected ||
|
|
199
|
+
code === HardwareErrorCode.BleCharacteristicNotifyError ||
|
|
200
|
+
code === HardwareErrorCode.BleCharacteristicNotifyChangeFailure ||
|
|
201
|
+
code === HardwareErrorCode.BleWriteCharacteristicError
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* Android reports a lost bond (ACTION_KEY_MISSING) while it encrypts a new link, so only
|
|
206
|
+
* a failure this soon after the link started can be explained by it. Later disconnects,
|
|
207
|
+
* such as a firmware-update reboot, must keep their own meaning.
|
|
208
|
+
*/
|
|
209
|
+
export const ANDROID_KEY_MISSING_LINK_WINDOW_MS = 10_000;
|
|
210
|
+
/** The broadcast and the GATT disconnect it explains travel separately; either can land first. */
|
|
211
|
+
export const ANDROID_KEY_MISSING_GRACE_MS = 500;
|
|
212
|
+
/**
|
|
213
|
+
* Android reports a bonded link's first encryption result within about half a second of
|
|
214
|
+
* connecting. Past this, the result is treated as unknown and the link is used as before.
|
|
215
|
+
*/
|
|
216
|
+
export const ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS = 1500;
|
|
217
|
+
/**
|
|
218
|
+
* A system re-pairing needs the user to accept a pairing request and confirm the code on the
|
|
219
|
+
* device. The Bluetooth stack gives up after 30s and then reports key missing.
|
|
220
|
+
*/
|
|
221
|
+
export const ANDROID_SYSTEM_REPAIR_TIMEOUT_MS = 35_000;
|
|
222
|
+
/**
|
|
223
|
+
* How a link dropped by a device that refuses a stale bond reaches JS. A wedged write is
|
|
224
|
+
* excluded: the system is still re-pairing then and has not reported key missing yet.
|
|
225
|
+
*/
|
|
226
|
+
const isAndroidLinkLossError = (error: unknown): boolean => {
|
|
227
|
+
const code = (error as { errorCode?: unknown })?.errorCode;
|
|
228
|
+
return (
|
|
229
|
+
isNativeBleDisconnectError(error) ||
|
|
230
|
+
code === HardwareErrorCode.BleDeviceNotBonded ||
|
|
231
|
+
code === HardwareErrorCode.BleDeviceDisconnected ||
|
|
232
|
+
code === HardwareErrorCode.BleCharacteristicNotifyError ||
|
|
233
|
+
code === HardwareErrorCode.BleConnectedError
|
|
234
|
+
);
|
|
235
|
+
};
|
|
171
236
|
/** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
|
|
172
237
|
export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
|
|
173
238
|
const DEVICE_SCAN_TIMEOUT_MS = 3000;
|
|
@@ -222,13 +287,13 @@ function getDeviceDisplayName(device?: Device | null) {
|
|
|
222
287
|
|
|
223
288
|
const IOS_REQUEST_MTU = 247;
|
|
224
289
|
const ANDROID_REQUEST_MTU = 517;
|
|
225
|
-
const BLE_MTU_REFRESH_RETRY_DELAY_MS = 200;
|
|
226
290
|
const ANDROID_HIGH_PRIORITY_IDLE_MS = 1000;
|
|
227
291
|
|
|
228
292
|
const getRequestedBleMtu = () =>
|
|
229
293
|
Platform.OS === 'android' ? ANDROID_REQUEST_MTU : IOS_REQUEST_MTU;
|
|
230
294
|
|
|
231
295
|
const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
|
|
296
|
+
const BLE_MTU_REQUEST_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS;
|
|
232
297
|
|
|
233
298
|
const connectOptions: Record<string, unknown> = {
|
|
234
299
|
requestMTU: getRequestedBleMtu(),
|
|
@@ -236,11 +301,63 @@ const connectOptions: Record<string, unknown> = {
|
|
|
236
301
|
refreshGatt: 'OnConnected',
|
|
237
302
|
};
|
|
238
303
|
|
|
239
|
-
/**
|
|
304
|
+
/** Connect options without requestMTU: the iOS fallback and every bare Android connect. */
|
|
240
305
|
const fallbackConnectOptions: Record<string, unknown> = {
|
|
241
306
|
timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
|
|
242
307
|
};
|
|
243
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Android never requests the MTU inside the native connect budget: refreshGatt makes the stack
|
|
311
|
+
* rediscover first, and a budget that expires with the MTU request unsent parks every later MTU
|
|
312
|
+
* request on that LE link. refreshGatt itself is only added after a firmware install or a
|
|
313
|
+
* stale-table symptom, and discovery finishes before the MTU exchange.
|
|
314
|
+
*/
|
|
315
|
+
const androidRefreshGattConnectOptions: Record<string, unknown> = {
|
|
316
|
+
timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
|
|
317
|
+
refreshGatt: 'OnConnected',
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* With no cached GATT table the stack runs its own discovery (up to ~8s) before the MTU
|
|
322
|
+
* exchange, so the bound sits above that; a stuck exchange never completes.
|
|
323
|
+
*/
|
|
324
|
+
export const ANDROID_MTU_EXCHANGE_TIMEOUT_MS = 12_000;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Android keeps an LE link, with per-link ATT state such as a pending MTU exchange, for its
|
|
328
|
+
* 4s GATT link idle timer after the last client closes; a reconnect inside it reuses the link.
|
|
329
|
+
*/
|
|
330
|
+
export const ANDROID_LINK_DROP_QUIET_MS = 5000;
|
|
331
|
+
const ANDROID_LINK_DROP_POLL_MS = 250;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Android cannot use a link at the default 23-byte ATT MTU: Protocol V1 writes 192-byte
|
|
335
|
+
* packets regardless, and a Pro 2 sends only the first ATT_MTU-3 bytes of a V2 reply.
|
|
336
|
+
* An unknown MTU is not treated as default.
|
|
337
|
+
*/
|
|
338
|
+
const isKnownDefaultMtu = (mtu: unknown): boolean =>
|
|
339
|
+
typeof mtu === 'number' && Number.isFinite(mtu) && mtu <= 23;
|
|
340
|
+
|
|
341
|
+
/** Discovery found no OneKey service, or a characteristic of the wrong shape: the cached GATT table may be stale. */
|
|
342
|
+
const isMissingGattShapeError = (error: unknown): boolean => {
|
|
343
|
+
const code = (error as { errorCode?: unknown })?.errorCode;
|
|
344
|
+
const message = (error as { message?: unknown })?.message;
|
|
345
|
+
return (
|
|
346
|
+
code === HardwareErrorCode.BleServiceNotFound ||
|
|
347
|
+
code === HardwareErrorCode.BleCharacteristicNotFound ||
|
|
348
|
+
(typeof message === 'string' &&
|
|
349
|
+
(message.includes('BLECharacteristicNotFound') ||
|
|
350
|
+
message.includes('BLECharacteristicNotWritable') ||
|
|
351
|
+
message.includes('BLECharacteristicNotNotifiable')))
|
|
352
|
+
);
|
|
353
|
+
};
|
|
354
|
+
const isStaleGattTableNotifyReason = (reason: string | null | undefined): boolean =>
|
|
355
|
+
!!reason &&
|
|
356
|
+
(reason.includes('Cannot write client characteristic config descriptor') ||
|
|
357
|
+
reason.includes('Cannot find client characteristic config descriptor') || // pro firmware 2.3.0 upgrade
|
|
358
|
+
reason.includes('The handle is invalid') ||
|
|
359
|
+
reason.includes('Writing is not permitted')); // pro firmware 2.3.4 upgrade
|
|
360
|
+
|
|
244
361
|
/**
|
|
245
362
|
* JS backstop for connect. The native adapter applies its own 3s budget, but it
|
|
246
363
|
* schedules that timeout on its serial queue, so a busy queue (e.g. right after a
|
|
@@ -280,6 +397,15 @@ const shouldRethrowBleSetupError = (error: unknown): boolean =>
|
|
|
280
397
|
isConnectTimeoutError(error) || isWedgedBleSetupError(error);
|
|
281
398
|
const isNativeOperationTimeoutError = (error: unknown): boolean =>
|
|
282
399
|
(error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
|
|
400
|
+
const isMtuOrCancelledConnectError = (error: unknown): boolean => {
|
|
401
|
+
const errorCode = (error as { errorCode?: unknown })?.errorCode;
|
|
402
|
+
return (
|
|
403
|
+
errorCode === BleErrorCode.DeviceMTUChangeFailed ||
|
|
404
|
+
errorCode === BleErrorCode.OperationCancelled
|
|
405
|
+
);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
type NegotiatedMtuResult = { device: Device; timedOut: boolean };
|
|
283
409
|
|
|
284
410
|
export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
|
|
285
411
|
|
|
@@ -294,29 +420,75 @@ const tryToGetConfiguration = (device: Device) => {
|
|
|
294
420
|
|
|
295
421
|
const requestNegotiatedMtu = async (
|
|
296
422
|
device: Device,
|
|
297
|
-
stage: 'connected' | '
|
|
298
|
-
attempt: number
|
|
299
|
-
) =>
|
|
300
|
-
|
|
423
|
+
stage: 'connected' | 'highThroughput',
|
|
424
|
+
attempt: number,
|
|
425
|
+
cancelTransaction?: (transactionId: string) => Promise<void> | void
|
|
426
|
+
): Promise<NegotiatedMtuResult> => {
|
|
427
|
+
if (Platform.OS !== 'ios' && Platform.OS !== 'android') return { device, timedOut: false };
|
|
428
|
+
|
|
429
|
+
const transactionId = `${device.id}:mtu:${stage}:${attempt}:${Date.now()}`;
|
|
430
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
431
|
+
let timedOut = false;
|
|
301
432
|
|
|
302
433
|
try {
|
|
303
434
|
// iOS ignores the requested value but react-native-ble-plx returns a fresh
|
|
304
435
|
// Device snapshot whose MTU is derived from CoreBluetooth's maximum write length.
|
|
305
|
-
const
|
|
306
|
-
|
|
436
|
+
const request = device.requestMTU(getRequestedBleMtu(), transactionId);
|
|
437
|
+
// The timeout race may settle before the native request does. Attach a
|
|
438
|
+
// rejection handler so a late native cancellation cannot become an
|
|
439
|
+
// unhandled rejection after we continue with the current MTU.
|
|
440
|
+
request.catch(() => undefined);
|
|
441
|
+
const mtuDevice = await Promise.race([
|
|
442
|
+
request,
|
|
443
|
+
new Promise<never>((_, reject) => {
|
|
444
|
+
timeoutId = setTimeout(() => {
|
|
445
|
+
timedOut = true;
|
|
446
|
+
reject(new Error(`BLE MTU request timeout after ${BLE_MTU_REQUEST_TIMEOUT_MS}ms`));
|
|
447
|
+
}, BLE_MTU_REQUEST_TIMEOUT_MS);
|
|
448
|
+
}),
|
|
449
|
+
]);
|
|
450
|
+
return { device: mtuDevice, timedOut: false };
|
|
307
451
|
} catch (error) {
|
|
452
|
+
if (timedOut && cancelTransaction) {
|
|
453
|
+
try {
|
|
454
|
+
Promise.resolve(cancelTransaction(transactionId)).catch(cancelError => {
|
|
455
|
+
Log?.debug('[ReactNativeBleTransport] MTU cancellation failed', {
|
|
456
|
+
platform: Platform.OS,
|
|
457
|
+
stage,
|
|
458
|
+
attempt,
|
|
459
|
+
error: cancelError instanceof Error ? cancelError.message : String(cancelError),
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
} catch (cancelError) {
|
|
463
|
+
Log?.debug('[ReactNativeBleTransport] MTU cancellation failed', {
|
|
464
|
+
platform: Platform.OS,
|
|
465
|
+
stage,
|
|
466
|
+
attempt,
|
|
467
|
+
error: cancelError instanceof Error ? cancelError.message : String(cancelError),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
308
471
|
Log?.debug('[ReactNativeBleTransport] MTU refresh failed, continuing with current value', {
|
|
309
472
|
platform: Platform.OS,
|
|
310
473
|
stage,
|
|
311
474
|
attempt,
|
|
312
475
|
actual: device.mtu,
|
|
476
|
+
timedOut,
|
|
313
477
|
error: error instanceof Error ? error.message : String(error),
|
|
314
478
|
});
|
|
315
|
-
return device;
|
|
479
|
+
return { device, timedOut };
|
|
480
|
+
} finally {
|
|
481
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
316
482
|
}
|
|
317
483
|
};
|
|
318
484
|
|
|
319
|
-
const resolveNegotiatedMtu = (
|
|
485
|
+
const resolveNegotiatedMtu = (
|
|
486
|
+
device: Device,
|
|
487
|
+
cancelTransaction?: (transactionId: string) => Promise<void> | void
|
|
488
|
+
): Promise<NegotiatedMtuResult> =>
|
|
489
|
+
shouldRefreshNegotiatedMtu(device.mtu)
|
|
490
|
+
? requestNegotiatedMtu(device, 'connected', 0, cancelTransaction)
|
|
491
|
+
: Promise.resolve({ device, timedOut: false });
|
|
320
492
|
|
|
321
493
|
type IOBleErrorRemap = Error | BleError | null | undefined;
|
|
322
494
|
|
|
@@ -360,6 +532,8 @@ export default class ReactNativeBleTransport {
|
|
|
360
532
|
|
|
361
533
|
stopped = false;
|
|
362
534
|
|
|
535
|
+
private readonly bondAbortController = new AbortController();
|
|
536
|
+
|
|
363
537
|
scanTimeout = DEVICE_SCAN_TIMEOUT_MS;
|
|
364
538
|
|
|
365
539
|
runPromise: Deferred<any> | null = null;
|
|
@@ -398,6 +572,12 @@ export default class ReactNativeBleTransport {
|
|
|
398
572
|
/** Consecutive detections that failed while trusting sessionProtocols. */
|
|
399
573
|
private protocolReprobeFailures: Map<string, number> = new Map();
|
|
400
574
|
|
|
575
|
+
/** Endpoints whose last detection got no answer; 'woken' once their Initialize wake is spent. */
|
|
576
|
+
private silentDetections = new Map<string, 'silent' | 'woken'>();
|
|
577
|
+
|
|
578
|
+
/** Android endpoints whose cached GATT table is suspect; the next connect refreshes it. */
|
|
579
|
+
private androidGattCacheRefreshes = new Set<string>();
|
|
580
|
+
|
|
401
581
|
/**
|
|
402
582
|
* Native encryption/pairing failures seen before Protocol V2 probe starts.
|
|
403
583
|
* Pro2/Neo GATT connect can succeed on a stale iOS bond; the CCCD write then
|
|
@@ -405,6 +585,9 @@ export default class ReactNativeBleTransport {
|
|
|
405
585
|
*/
|
|
406
586
|
private staleBondErrors: Map<string, Error> = new Map();
|
|
407
587
|
|
|
588
|
+
/** When the current Android link attempt began; bounds which key-missing signals apply. */
|
|
589
|
+
private androidLinkStartedAt: Map<string, number> = new Map();
|
|
590
|
+
|
|
408
591
|
/** Strict or previously confirmed V2 target while acquire installs notifications. */
|
|
409
592
|
private acquiringProtocolV2 = new Set<string>();
|
|
410
593
|
|
|
@@ -430,6 +613,14 @@ export default class ReactNativeBleTransport {
|
|
|
430
613
|
this.rejectProtocolV2Frames(uuid, new Error(reason));
|
|
431
614
|
Log?.debug('[ReactNativeBleTransport] Protocol V2 link invalidated:', uuid, reason);
|
|
432
615
|
if (reason.startsWith('Protocol V2 link-fatal error:')) {
|
|
616
|
+
if (this.probingProtocols.get(uuid) !== 'V2') {
|
|
617
|
+
const transport = transportCache[uuid];
|
|
618
|
+
try {
|
|
619
|
+
this.emitDeviceDisconnect(uuid, transport?.device?.name, transport?.monitorToken);
|
|
620
|
+
} catch {
|
|
621
|
+
Log?.error('[ReactNativeBleTransport] Protocol V2 disconnect listener failed');
|
|
622
|
+
}
|
|
623
|
+
}
|
|
433
624
|
await this.releaseNative(uuid, true);
|
|
434
625
|
}
|
|
435
626
|
},
|
|
@@ -450,6 +641,10 @@ export default class ReactNativeBleTransport {
|
|
|
450
641
|
/** Serializes transport lifecycle changes for the same physical device. */
|
|
451
642
|
private lifecycleOperations: Map<string, Promise<void>> = new Map();
|
|
452
643
|
|
|
644
|
+
private stopPromise?: Promise<void>;
|
|
645
|
+
|
|
646
|
+
private scanCleanups = new Set<() => Promise<void>>();
|
|
647
|
+
|
|
453
648
|
constructor(options: TransportOptions) {
|
|
454
649
|
this.scanTimeout = options.scanTimeout ?? DEVICE_SCAN_TIMEOUT_MS;
|
|
455
650
|
}
|
|
@@ -457,6 +652,11 @@ export default class ReactNativeBleTransport {
|
|
|
457
652
|
init(logger: any, emitter: EventEmitter) {
|
|
458
653
|
setBleLogger(logger);
|
|
459
654
|
this.emitter = emitter;
|
|
655
|
+
if (Platform.OS === 'android') {
|
|
656
|
+
// Link security events are only meaningful if they were observed since the link came up.
|
|
657
|
+
startBleKeyMissingTracking();
|
|
658
|
+
startBleEncryptionTracking();
|
|
659
|
+
}
|
|
460
660
|
}
|
|
461
661
|
|
|
462
662
|
configure(signedData: any) {
|
|
@@ -485,10 +685,34 @@ export default class ReactNativeBleTransport {
|
|
|
485
685
|
// empty
|
|
486
686
|
}
|
|
487
687
|
|
|
488
|
-
getPlxManager(): Promise<BlePlxManager> {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
688
|
+
async getPlxManager(): Promise<BlePlxManager> {
|
|
689
|
+
while (bleManagerResetPromise) {
|
|
690
|
+
await this.waitForManagerReset();
|
|
691
|
+
}
|
|
692
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
693
|
+
if (!this.blePlxManager) this.blePlxManager = new BlePlxManager();
|
|
694
|
+
return this.blePlxManager;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private async waitForManagerReset(): Promise<void> {
|
|
698
|
+
if (!bleManagerResetPromise) return;
|
|
699
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
700
|
+
try {
|
|
701
|
+
await Promise.race([
|
|
702
|
+
bleManagerResetPromise,
|
|
703
|
+
new Promise<never>((_, reject) => {
|
|
704
|
+
timeout = setTimeout(
|
|
705
|
+
() => reject(this.createWedgedBleSetupError()),
|
|
706
|
+
BLE_CONNECT_TIMEOUT_MS
|
|
707
|
+
);
|
|
708
|
+
}),
|
|
709
|
+
]);
|
|
710
|
+
} catch {
|
|
711
|
+
// A timeout or failed destroy is not permission to reuse the old singleton.
|
|
712
|
+
throw this.createWedgedBleSetupError();
|
|
713
|
+
} finally {
|
|
714
|
+
if (timeout) clearTimeout(timeout);
|
|
715
|
+
}
|
|
492
716
|
}
|
|
493
717
|
|
|
494
718
|
async resolveCharacteristics(device: Device): Promise<ResolvedBleCharacteristics> {
|
|
@@ -677,35 +901,58 @@ export default class ReactNativeBleTransport {
|
|
|
677
901
|
* @returns
|
|
678
902
|
*/
|
|
679
903
|
async enumerate() {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
} catch (error) {
|
|
687
|
-
Log?.debug('subscribeBleOn error: ', error);
|
|
688
|
-
reject(error);
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
if (Platform.OS === 'android' && Platform.Version >= 31) {
|
|
693
|
-
Log?.debug('requesting permissions, please wait...');
|
|
904
|
+
const scanStartedAt = Date.now();
|
|
905
|
+
let firstDeviceMs: number | undefined;
|
|
906
|
+
const blePlxManager = await this.getPlxManager();
|
|
907
|
+
await subscribeBleOn(blePlxManager);
|
|
908
|
+
if (Platform.OS === 'android' && Platform.Version >= 31) {
|
|
909
|
+
Log?.debug('requesting permissions, please wait...');
|
|
694
910
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
911
|
+
const resultConnect = await PermissionsAndroid.requestMultiple([
|
|
912
|
+
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
|
|
913
|
+
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
|
|
914
|
+
]);
|
|
699
915
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
return;
|
|
707
|
-
}
|
|
916
|
+
Log?.debug('requesting permissions, result: ', resultConnect);
|
|
917
|
+
if (
|
|
918
|
+
resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] !== 'granted' ||
|
|
919
|
+
resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] !== 'granted'
|
|
920
|
+
) {
|
|
921
|
+
throw ERRORS.TypedError(HardwareErrorCode.BlePermissionError);
|
|
708
922
|
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
926
|
+
return new Promise<IOneKeyDevice[]>((resolve, reject) => {
|
|
927
|
+
const deviceList: IOneKeyDevice[] = [];
|
|
928
|
+
let finished = false;
|
|
929
|
+
let scanCleanup: Promise<void> | undefined;
|
|
930
|
+
const finishScan = (error?: unknown) => {
|
|
931
|
+
if (scanCleanup) return scanCleanup;
|
|
932
|
+
finished = true;
|
|
933
|
+
clearScanTimer();
|
|
934
|
+
scanCleanup = this.runNativeTeardown('scan', blePlxManager, async () => {
|
|
935
|
+
await blePlxManager.stopDeviceScan();
|
|
936
|
+
}).then(() => {
|
|
937
|
+
this.scanCleanups.delete(cancelScan);
|
|
938
|
+
if (error) reject(error);
|
|
939
|
+
else resolve(deviceList);
|
|
940
|
+
});
|
|
941
|
+
return scanCleanup;
|
|
942
|
+
};
|
|
943
|
+
const cancelScan = () =>
|
|
944
|
+
finishScan(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
|
|
945
|
+
this.scanCleanups.add(cancelScan);
|
|
946
|
+
|
|
947
|
+
const clearScanTimer = timer.timeout(() => {
|
|
948
|
+
Log?.debug('[ReactNativeBleTransport] scan completed', {
|
|
949
|
+
elapsedMs: Date.now() - scanStartedAt,
|
|
950
|
+
firstDeviceMs,
|
|
951
|
+
deviceCount: deviceList.length,
|
|
952
|
+
scanWindowMs: this.scanTimeout,
|
|
953
|
+
});
|
|
954
|
+
finishScan();
|
|
955
|
+
}, this.scanTimeout);
|
|
709
956
|
|
|
710
957
|
blePlxManager.startDeviceScan(
|
|
711
958
|
getBluetoothServiceUuids(),
|
|
@@ -721,18 +968,17 @@ export default class ReactNativeBleTransport {
|
|
|
721
968
|
error.errorCode
|
|
722
969
|
)
|
|
723
970
|
) {
|
|
724
|
-
|
|
971
|
+
finishScan(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
|
|
725
972
|
} else if (error.errorCode === BleErrorCode.BluetoothUnauthorized) {
|
|
726
|
-
|
|
973
|
+
finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationError));
|
|
727
974
|
} else if (error.errorCode === BleErrorCode.LocationServicesDisabled) {
|
|
728
|
-
|
|
975
|
+
finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationServicesDisabled));
|
|
729
976
|
} else if (error.errorCode === BleErrorCode.ScanStartFailed) {
|
|
730
977
|
// Android Bluetooth will report an error when the search frequency is too fast,
|
|
731
978
|
// then nothing is processed and an empty array of devices is returned.
|
|
732
979
|
// Then the next search will be back to normal
|
|
733
|
-
timer.timeout(() => {}, this.scanTimeout);
|
|
734
980
|
} else {
|
|
735
|
-
|
|
981
|
+
finishScan(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.reason ?? ''));
|
|
736
982
|
}
|
|
737
983
|
return;
|
|
738
984
|
}
|
|
@@ -764,6 +1010,7 @@ export default class ReactNativeBleTransport {
|
|
|
764
1010
|
}
|
|
765
1011
|
);
|
|
766
1012
|
|
|
1013
|
+
if (finished) return;
|
|
767
1014
|
getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
|
|
768
1015
|
devices => {
|
|
769
1016
|
for (const device of devices) {
|
|
@@ -783,11 +1030,13 @@ export default class ReactNativeBleTransport {
|
|
|
783
1030
|
addDevice(device as unknown as Device);
|
|
784
1031
|
}
|
|
785
1032
|
}
|
|
786
|
-
}
|
|
1033
|
+
},
|
|
1034
|
+
error => Log?.debug('search connected peripheral failed:', error)
|
|
787
1035
|
);
|
|
788
1036
|
|
|
789
1037
|
const addDevice = (device: Device) => {
|
|
790
|
-
if (deviceList.every(d => d.id !== device.id)) {
|
|
1038
|
+
if (!finished && deviceList.every(d => d.id !== device.id)) {
|
|
1039
|
+
firstDeviceMs ??= Date.now() - scanStartedAt;
|
|
791
1040
|
const displayName = getDeviceDisplayName(device) ?? 'Unknown BLE Device';
|
|
792
1041
|
|
|
793
1042
|
deviceList.push({
|
|
@@ -802,11 +1051,6 @@ export default class ReactNativeBleTransport {
|
|
|
802
1051
|
});
|
|
803
1052
|
}
|
|
804
1053
|
};
|
|
805
|
-
|
|
806
|
-
timer.timeout(() => {
|
|
807
|
-
blePlxManager.stopDeviceScan();
|
|
808
|
-
resolve(deviceList);
|
|
809
|
-
}, this.scanTimeout);
|
|
810
1054
|
});
|
|
811
1055
|
}
|
|
812
1056
|
|
|
@@ -817,6 +1061,7 @@ export default class ReactNativeBleTransport {
|
|
|
817
1061
|
) {
|
|
818
1062
|
const { writeCharacteristic, notifyCharacteristic } =
|
|
819
1063
|
characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
|
|
1064
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
820
1065
|
const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
|
|
821
1066
|
transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
|
|
822
1067
|
const monitorToken = this.nextMonitorToken;
|
|
@@ -845,39 +1090,12 @@ export default class ReactNativeBleTransport {
|
|
|
845
1090
|
} else if (Platform.OS === 'android') {
|
|
846
1091
|
await delay(ANDROID_NOTIFY_READY_DELAY_MS);
|
|
847
1092
|
}
|
|
848
|
-
|
|
849
|
-
const initialMtu = transport.mtuSize;
|
|
850
|
-
let refreshAttempts = 0;
|
|
851
|
-
if (
|
|
852
|
-
(Platform.OS === 'ios' || Platform.OS === 'android') &&
|
|
853
|
-
shouldRefreshNegotiatedMtu(transport.mtuSize)
|
|
854
|
-
) {
|
|
855
|
-
refreshAttempts += 1;
|
|
856
|
-
let refreshedDevice = await requestNegotiatedMtu(
|
|
857
|
-
transport.device,
|
|
858
|
-
'servicesAndNotifyReady',
|
|
859
|
-
1
|
|
860
|
-
);
|
|
861
|
-
transport.device = refreshedDevice;
|
|
862
|
-
transport.mtuSize =
|
|
863
|
-
typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
|
|
864
|
-
|
|
865
|
-
if (shouldRefreshNegotiatedMtu(transport.mtuSize)) {
|
|
866
|
-
await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
|
|
867
|
-
refreshAttempts += 1;
|
|
868
|
-
refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
|
|
869
|
-
transport.device = refreshedDevice;
|
|
870
|
-
transport.mtuSize =
|
|
871
|
-
typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
|
|
872
|
-
}
|
|
873
|
-
}
|
|
1093
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
874
1094
|
|
|
875
1095
|
Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
|
|
876
1096
|
platform: Platform.OS,
|
|
877
1097
|
requested: getRequestedBleMtu(),
|
|
878
|
-
initial: initialMtu,
|
|
879
1098
|
actual: transport.mtuSize,
|
|
880
|
-
refreshAttempts,
|
|
881
1099
|
});
|
|
882
1100
|
|
|
883
1101
|
return transport;
|
|
@@ -890,10 +1108,17 @@ export default class ReactNativeBleTransport {
|
|
|
890
1108
|
throw ERRORS.TypedError(HardwareErrorCode.BleRequiredUUID);
|
|
891
1109
|
}
|
|
892
1110
|
|
|
893
|
-
return this.runLifecycleOperation(uuid, () =>
|
|
1111
|
+
return this.runLifecycleOperation(uuid, async () => {
|
|
1112
|
+
try {
|
|
1113
|
+
return await this.acquireUnlocked(input);
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
throw await this.resolveAndroidBondInvalid(uuid, error);
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
894
1118
|
}
|
|
895
1119
|
|
|
896
1120
|
private async acquireUnlocked(input: FirmwareInstallBleAcquireInput) {
|
|
1121
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
897
1122
|
const { uuid, forceCleanRunPromise, expectedProtocol, skipProtocolProbe } = input;
|
|
898
1123
|
const shouldMapProtocolV2StaleBond = expectedProtocol
|
|
899
1124
|
? expectedProtocol === 'V2'
|
|
@@ -944,11 +1169,16 @@ export default class ReactNativeBleTransport {
|
|
|
944
1169
|
const isCachedDeviceConnected = await cachedTransport.device
|
|
945
1170
|
.isConnected()
|
|
946
1171
|
.catch(() => false);
|
|
1172
|
+
// A suspect GATT table is only refreshed through a new connect.
|
|
1173
|
+
const isCachedAndroidLinkUsable =
|
|
1174
|
+
Platform.OS !== 'android' || !this.androidGattCacheRefreshes.has(uuid);
|
|
947
1175
|
if (
|
|
948
1176
|
isCachedDeviceConnected &&
|
|
1177
|
+
isCachedAndroidLinkUsable &&
|
|
949
1178
|
cachedProtocol &&
|
|
950
1179
|
(!expectedProtocol || cachedProtocol === expectedProtocol)
|
|
951
1180
|
) {
|
|
1181
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
952
1182
|
Log?.debug('[ReactNativeBleTransport] reuse cached BLE transport:', uuid, cachedProtocol);
|
|
953
1183
|
return { uuid, protocolType: cachedProtocol };
|
|
954
1184
|
}
|
|
@@ -963,6 +1193,20 @@ export default class ReactNativeBleTransport {
|
|
|
963
1193
|
}
|
|
964
1194
|
|
|
965
1195
|
let device: Device | null = null;
|
|
1196
|
+
const isAndroid = Platform.OS === 'android';
|
|
1197
|
+
// Only a bond that existed before this acquire can have been lost by the device.
|
|
1198
|
+
let androidBondedBeforeConnect = false;
|
|
1199
|
+
// A firmware-install reconnect always refreshes: the new firmware may expose a different table.
|
|
1200
|
+
const refreshAndroidGattCache =
|
|
1201
|
+
isAndroid && (!!skipProtocolProbe || this.androidGattCacheRefreshes.has(uuid));
|
|
1202
|
+
let nativeConnectOptions = connectOptions;
|
|
1203
|
+
if (isAndroid) {
|
|
1204
|
+
nativeConnectOptions = refreshAndroidGattCache
|
|
1205
|
+
? androidRefreshGattConnectOptions
|
|
1206
|
+
: fallbackConnectOptions;
|
|
1207
|
+
}
|
|
1208
|
+
// Only a connect that carried refreshGatt clears the marker; the fallback connects drop it.
|
|
1209
|
+
let androidRefreshConnectRan = false;
|
|
966
1210
|
|
|
967
1211
|
if (forceCleanRunPromise && this.runPromise) {
|
|
968
1212
|
const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
|
|
@@ -973,6 +1217,7 @@ export default class ReactNativeBleTransport {
|
|
|
973
1217
|
}
|
|
974
1218
|
|
|
975
1219
|
const blePlxManager = await this.getPlxManager();
|
|
1220
|
+
let skipPostConnectMtu = false;
|
|
976
1221
|
try {
|
|
977
1222
|
await subscribeBleOn(blePlxManager);
|
|
978
1223
|
} catch (error) {
|
|
@@ -980,6 +1225,36 @@ export default class ReactNativeBleTransport {
|
|
|
980
1225
|
throw error;
|
|
981
1226
|
}
|
|
982
1227
|
|
|
1228
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1229
|
+
if (Platform.OS === 'android') {
|
|
1230
|
+
// Subscribe before the link exists: key missing is broadcast while Android encrypts it.
|
|
1231
|
+
startBleKeyMissingTracking();
|
|
1232
|
+
startBleEncryptionTracking();
|
|
1233
|
+
// Failures before the new link starts must not be read against the previous link.
|
|
1234
|
+
this.androidLinkStartedAt.delete(uuid);
|
|
1235
|
+
// Initiate bonding locally before GATT can trigger peripheral-initiated pairing.
|
|
1236
|
+
try {
|
|
1237
|
+
const bondState = await pairDevice(uuid);
|
|
1238
|
+
androidBondedBeforeConnect = !bondState.bonding && bondState.bonded;
|
|
1239
|
+
if (bondState.bonding) {
|
|
1240
|
+
await onDeviceBondState(uuid, this.bondAbortController.signal, {
|
|
1241
|
+
systemInitiated: bondState.initiated === false,
|
|
1242
|
+
});
|
|
1243
|
+
} else if (!bondState.bonded) {
|
|
1244
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded');
|
|
1245
|
+
}
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
await this.runNativeTeardown(uuid, blePlxManager, async () => {
|
|
1248
|
+
await this.runBestEffortNativeOperation('bond failure: cancel manager connection', () =>
|
|
1249
|
+
blePlxManager.cancelDeviceConnection(uuid)
|
|
1250
|
+
);
|
|
1251
|
+
});
|
|
1252
|
+
throw error;
|
|
1253
|
+
}
|
|
1254
|
+
this.androidLinkStartedAt.set(uuid, Date.now());
|
|
1255
|
+
}
|
|
1256
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1257
|
+
|
|
983
1258
|
if (!device) {
|
|
984
1259
|
const devices = await blePlxManager.devices([uuid]);
|
|
985
1260
|
[device] = devices;
|
|
@@ -996,17 +1271,16 @@ export default class ReactNativeBleTransport {
|
|
|
996
1271
|
Log?.debug('try to connect to device: ', uuid);
|
|
997
1272
|
try {
|
|
998
1273
|
device = await this.connectWithTimeout(uuid, () =>
|
|
999
|
-
blePlxManager.connectToDevice(uuid,
|
|
1274
|
+
blePlxManager.connectToDevice(uuid, nativeConnectOptions)
|
|
1000
1275
|
);
|
|
1276
|
+
androidRefreshConnectRan = refreshAndroidGattCache;
|
|
1001
1277
|
} catch (e) {
|
|
1002
1278
|
Log?.debug('try to connect to device has error: ', e);
|
|
1003
1279
|
if (shouldRethrowBleSetupError(e)) {
|
|
1004
1280
|
throw e;
|
|
1005
1281
|
}
|
|
1006
|
-
if (
|
|
1007
|
-
|
|
1008
|
-
e.errorCode === BleErrorCode.OperationCancelled
|
|
1009
|
-
) {
|
|
1282
|
+
if (isMtuOrCancelledConnectError(e)) {
|
|
1283
|
+
skipPostConnectMtu = true;
|
|
1010
1284
|
Log?.debug('first try to reconnect without params');
|
|
1011
1285
|
device = await this.connectWithTimeout(uuid, () =>
|
|
1012
1286
|
blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
|
|
@@ -1024,23 +1298,33 @@ export default class ReactNativeBleTransport {
|
|
|
1024
1298
|
throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'unable to connect to device');
|
|
1025
1299
|
}
|
|
1026
1300
|
|
|
1301
|
+
if (
|
|
1302
|
+
refreshAndroidGattCache &&
|
|
1303
|
+
!androidRefreshConnectRan &&
|
|
1304
|
+
(await device.isConnected().catch(() => false))
|
|
1305
|
+
) {
|
|
1306
|
+
// refreshGatt only reaches the stack through a connect. A link that is still up would
|
|
1307
|
+
// skip the connect below and keep serving the stale table, so it is dropped first.
|
|
1308
|
+
await this.dropAndroidLink(uuid, blePlxManager, device, 'gatt cache refresh');
|
|
1309
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1027
1312
|
if (!(await device.isConnected())) {
|
|
1028
1313
|
Log?.debug('not connected, try to connect to device: ', uuid);
|
|
1029
1314
|
const disconnectedDevice = device;
|
|
1030
1315
|
|
|
1031
1316
|
try {
|
|
1032
1317
|
device = await this.connectWithTimeout(uuid, () =>
|
|
1033
|
-
disconnectedDevice.connect(
|
|
1318
|
+
disconnectedDevice.connect(nativeConnectOptions)
|
|
1034
1319
|
);
|
|
1320
|
+
androidRefreshConnectRan = refreshAndroidGattCache;
|
|
1035
1321
|
} catch (e) {
|
|
1036
1322
|
Log?.debug('not connected, try to connect to device has error: ', e);
|
|
1037
1323
|
if (shouldRethrowBleSetupError(e)) {
|
|
1038
1324
|
throw e;
|
|
1039
1325
|
}
|
|
1040
|
-
if (
|
|
1041
|
-
|
|
1042
|
-
e.errorCode === BleErrorCode.OperationCancelled
|
|
1043
|
-
) {
|
|
1326
|
+
if (isMtuOrCancelledConnectError(e)) {
|
|
1327
|
+
skipPostConnectMtu = true;
|
|
1044
1328
|
Log?.debug('second try to reconnect without params');
|
|
1045
1329
|
try {
|
|
1046
1330
|
device = await this.connectWithTimeout(uuid, () =>
|
|
@@ -1066,42 +1350,80 @@ export default class ReactNativeBleTransport {
|
|
|
1066
1350
|
}
|
|
1067
1351
|
}
|
|
1068
1352
|
|
|
1069
|
-
if (
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1353
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1354
|
+
if (Platform.OS === 'android' && !(await device.isConnected().catch(() => false))) {
|
|
1355
|
+
const disconnectedDevice = device;
|
|
1356
|
+
await this.runNativeTeardown(uuid, blePlxManager, async () => {
|
|
1357
|
+
await Promise.all([
|
|
1358
|
+
this.runBestEffortNativeOperation('connect failure: cancel manager connection', () =>
|
|
1359
|
+
blePlxManager.cancelDeviceConnection(uuid)
|
|
1360
|
+
),
|
|
1361
|
+
this.runBestEffortNativeOperation('connect failure: cancel device connection', () =>
|
|
1362
|
+
disconnectedDevice.cancelConnection()
|
|
1363
|
+
),
|
|
1364
|
+
]);
|
|
1365
|
+
});
|
|
1366
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'device is not connected');
|
|
1367
|
+
}
|
|
1368
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1369
|
+
let characteristics: ResolvedBleCharacteristics | undefined;
|
|
1370
|
+
if (isAndroid) {
|
|
1371
|
+
if (refreshAndroidGattCache) {
|
|
1372
|
+
// refreshGatt has already started a full rediscovery; let it finish before the MTU
|
|
1373
|
+
// exchange so the request is not queued behind it.
|
|
1374
|
+
characteristics = await this.resolveCharacteristicsWithTimeout(uuid, device);
|
|
1375
|
+
if (androidRefreshConnectRan) this.androidGattCacheRefreshes.delete(uuid);
|
|
1376
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1377
|
+
}
|
|
1378
|
+
device = await this.negotiateAndroidMtu(uuid, blePlxManager, device);
|
|
1379
|
+
} else if (!skipPostConnectMtu) {
|
|
1380
|
+
// Match 1.1.31: MTU is a connect() best-effort. If connect already fell back
|
|
1381
|
+
// without requestMTU, do not put another requestMTU on the native serial
|
|
1382
|
+
// queue — that is what wedges GATT after Account#2 reconnect.
|
|
1383
|
+
const mtuResult = await resolveNegotiatedMtu(device, transactionId =>
|
|
1384
|
+
blePlxManager.cancelTransaction(transactionId)
|
|
1385
|
+
);
|
|
1386
|
+
device = mtuResult.device;
|
|
1387
|
+
if (mtuResult.timedOut) {
|
|
1388
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1389
|
+
Log?.debug(
|
|
1390
|
+
'[ReactNativeBleTransport] post-connect MTU timed out, reconnecting without requesting MTU'
|
|
1391
|
+
);
|
|
1392
|
+
const timedOutDevice = device;
|
|
1393
|
+
let mtuTeardownSettled = false;
|
|
1394
|
+
await this.runNativeTeardown(uuid, blePlxManager, async () => {
|
|
1395
|
+
await this.runBestEffortNativeOperation('mtu timeout: cancel device connection', () =>
|
|
1396
|
+
timedOutDevice.cancelConnection()
|
|
1078
1397
|
);
|
|
1398
|
+
mtuTeardownSettled = true;
|
|
1399
|
+
});
|
|
1400
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1401
|
+
if (!mtuTeardownSettled || this.blePlxManager !== blePlxManager) {
|
|
1402
|
+
// The teardown budget expired and the manager that owned timedOutDevice was reset.
|
|
1403
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleTimeoutError, 'BLE MTU cleanup timed out');
|
|
1079
1404
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1405
|
+
try {
|
|
1406
|
+
device = await this.connectWithTimeout(uuid, () =>
|
|
1407
|
+
timedOutDevice.connect(fallbackConnectOptions)
|
|
1408
|
+
);
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
if (shouldRethrowBleSetupError(error)) throw error;
|
|
1411
|
+
if (
|
|
1412
|
+
(error as { errorCode?: unknown })?.errorCode === BleErrorCode.DeviceAlreadyConnected
|
|
1413
|
+
) {
|
|
1414
|
+
// GATT resolution and the protocol probe below still validate the retained link.
|
|
1415
|
+
device = timedOutDevice;
|
|
1416
|
+
} else {
|
|
1417
|
+
remapError(error);
|
|
1418
|
+
}
|
|
1085
1419
|
}
|
|
1086
|
-
} catch (error) {
|
|
1087
|
-
await this.runNativeTeardown(uuid, blePlxManager, async () => {
|
|
1088
|
-
await Promise.all([
|
|
1089
|
-
this.runBestEffortNativeOperation('bond failure: cancel manager connection', () =>
|
|
1090
|
-
blePlxManager.cancelDeviceConnection(uuid)
|
|
1091
|
-
),
|
|
1092
|
-
this.runBestEffortNativeOperation('bond failure: cancel device connection', () =>
|
|
1093
|
-
connectedDevice.cancelConnection()
|
|
1094
|
-
),
|
|
1095
|
-
]);
|
|
1096
|
-
});
|
|
1097
|
-
throw error;
|
|
1098
1420
|
}
|
|
1099
1421
|
}
|
|
1100
|
-
|
|
1101
|
-
device = await resolveNegotiatedMtu(device);
|
|
1422
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1102
1423
|
const acquiredDevice = device;
|
|
1103
1424
|
const { writeCharacteristic, notifyCharacteristic } =
|
|
1104
|
-
await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
|
|
1425
|
+
characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice));
|
|
1426
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1105
1427
|
|
|
1106
1428
|
const protocolHint = expectedProtocol
|
|
1107
1429
|
? undefined
|
|
@@ -1117,7 +1439,13 @@ export default class ReactNativeBleTransport {
|
|
|
1117
1439
|
this.acquiringProtocolV2.add(uuid);
|
|
1118
1440
|
}
|
|
1119
1441
|
|
|
1442
|
+
let linkEncryption: AndroidLinkEncryption | undefined;
|
|
1120
1443
|
try {
|
|
1444
|
+
// A firmware-install reconnect keeps its existing sequence.
|
|
1445
|
+
if (androidBondedBeforeConnect && !skipProtocolProbe) {
|
|
1446
|
+
linkEncryption = await this.waitForAndroidLinkSecurity(uuid);
|
|
1447
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1448
|
+
}
|
|
1121
1449
|
await this.installTransportForAcquire(uuid, acquiredDevice, {
|
|
1122
1450
|
writeCharacteristic,
|
|
1123
1451
|
notifyCharacteristic,
|
|
@@ -1163,18 +1491,22 @@ export default class ReactNativeBleTransport {
|
|
|
1163
1491
|
await this.installTransportForAcquire(uuid, acquiredDevice);
|
|
1164
1492
|
}
|
|
1165
1493
|
);
|
|
1494
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1166
1495
|
const currentTransport = transportCache[uuid];
|
|
1167
1496
|
if (!currentTransport) {
|
|
1168
1497
|
throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
|
|
1169
1498
|
}
|
|
1170
1499
|
this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
|
|
1500
|
+
if (linkEncryption === 'unresolved') {
|
|
1501
|
+
// Notifications and the probe only work on an encrypted link, so the next reuse of this
|
|
1502
|
+
// link needs no result that was reported before tracking started.
|
|
1503
|
+
markBleLinkEncrypted(uuid);
|
|
1504
|
+
}
|
|
1171
1505
|
return { uuid, protocolType };
|
|
1172
1506
|
} catch (error) {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
await this.releaseUnlocked(uuid, true);
|
|
1177
|
-
}
|
|
1507
|
+
// A failed acquire must retire the physical link before Core retries. Logical
|
|
1508
|
+
// release leaves GATT connected even when neither protocol receives a response.
|
|
1509
|
+
await this.disconnectUnlocked(uuid);
|
|
1178
1510
|
throw error;
|
|
1179
1511
|
} finally {
|
|
1180
1512
|
this.acquiringProtocolV2.delete(uuid);
|
|
@@ -1212,6 +1544,9 @@ export default class ReactNativeBleTransport {
|
|
|
1212
1544
|
this.rememberStaleBondError(uuid, toBleStaleBondHardwareError(error));
|
|
1213
1545
|
return;
|
|
1214
1546
|
}
|
|
1547
|
+
if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
|
|
1548
|
+
this.androidGattCacheRefreshes.add(uuid);
|
|
1549
|
+
}
|
|
1215
1550
|
if (this.getActiveProtocol(uuid) === 'V2') {
|
|
1216
1551
|
let errorCode:
|
|
1217
1552
|
| typeof HardwareErrorCode.BleCharacteristicNotifyError
|
|
@@ -1221,10 +1556,7 @@ export default class ReactNativeBleTransport {
|
|
|
1221
1556
|
if (error.reason?.includes('The connection has timed out unexpectedly')) {
|
|
1222
1557
|
errorCode = HardwareErrorCode.BleTimeoutError;
|
|
1223
1558
|
} else if (
|
|
1224
|
-
error.reason
|
|
1225
|
-
error.reason?.includes('Cannot find client characteristic config descriptor') ||
|
|
1226
|
-
error.reason?.includes('The handle is invalid') ||
|
|
1227
|
-
error.reason?.includes('Writing is not permitted') ||
|
|
1559
|
+
isStaleGattTableNotifyReason(error.reason) ||
|
|
1228
1560
|
error.reason?.includes('notify change failed for device')
|
|
1229
1561
|
) {
|
|
1230
1562
|
errorCode = HardwareErrorCode.BleCharacteristicNotifyChangeFailure;
|
|
@@ -1241,10 +1573,7 @@ export default class ReactNativeBleTransport {
|
|
|
1241
1573
|
ERROR = HardwareErrorCode.BleTimeoutError;
|
|
1242
1574
|
}
|
|
1243
1575
|
if (
|
|
1244
|
-
error.reason
|
|
1245
|
-
error.reason?.includes('Cannot find client characteristic config descriptor') || // pro firmware 2.3.0 upgrade
|
|
1246
|
-
error.reason?.includes('The handle is invalid') ||
|
|
1247
|
-
error.reason?.includes('Writing is not permitted') || // pro firmware 2.3.4 upgrade
|
|
1576
|
+
isStaleGattTableNotifyReason(error.reason) ||
|
|
1248
1577
|
error.reason?.includes('notify change failed for device')
|
|
1249
1578
|
) {
|
|
1250
1579
|
const notifyError = ERRORS.TypedError(
|
|
@@ -1441,16 +1770,77 @@ export default class ReactNativeBleTransport {
|
|
|
1441
1770
|
`Device protocol has not been detected for ${uuid}`
|
|
1442
1771
|
);
|
|
1443
1772
|
}
|
|
1444
|
-
if (protocol === 'V2') {
|
|
1445
|
-
return this.callProtocolV2(uuid, name, data, options);
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
1773
|
const forceRun = name === 'Initialize' || name === 'Cancel';
|
|
1449
|
-
if (this.runPromise && !forceRun) {
|
|
1774
|
+
if (protocol !== 'V2' && this.runPromise && !forceRun) {
|
|
1450
1775
|
throw ERRORS.TypedError(HardwareErrorCode.TransportCallInProgress);
|
|
1451
1776
|
}
|
|
1452
1777
|
|
|
1453
|
-
|
|
1778
|
+
try {
|
|
1779
|
+
return protocol === 'V2'
|
|
1780
|
+
? await this.callProtocolV2(uuid, name, data, options)
|
|
1781
|
+
: await this.callProtocolV1(uuid, name, data, options);
|
|
1782
|
+
} catch (error) {
|
|
1783
|
+
// An expected-V1 acquire skips the probe, so the first call is what meets the dropped link.
|
|
1784
|
+
throw await this.resolveAndroidBondInvalid(uuid, error);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* Android 16+ keeps a bond the device no longer holds keys for. The device drops the link
|
|
1790
|
+
* and the only structured evidence is ACTION_KEY_MISSING, so a link loss right after
|
|
1791
|
+
* connect is re-read as an invalid bond when that signal belongs to the same attempt.
|
|
1792
|
+
*/
|
|
1793
|
+
private async resolveAndroidBondInvalid(uuid: string, error: unknown): Promise<unknown> {
|
|
1794
|
+
if (Platform.OS !== 'android' || !isBleKeyMissingSupported()) return error;
|
|
1795
|
+
if (isBleStaleBondHardwareError(error) || !isAndroidLinkLossError(error)) return error;
|
|
1796
|
+
const linkStartedAt = this.androidLinkStartedAt.get(uuid);
|
|
1797
|
+
if (
|
|
1798
|
+
linkStartedAt === undefined ||
|
|
1799
|
+
Date.now() - linkStartedAt > ANDROID_KEY_MISSING_LINK_WINDOW_MS
|
|
1800
|
+
) {
|
|
1801
|
+
return error;
|
|
1802
|
+
}
|
|
1803
|
+
if (!(await waitForBleKeyMissing(uuid, linkStartedAt, ANDROID_KEY_MISSING_GRACE_MS))) {
|
|
1804
|
+
return error;
|
|
1805
|
+
}
|
|
1806
|
+
Log?.debug('[ReactNativeBleTransport] Android key missing, bond is invalid:', uuid);
|
|
1807
|
+
return ERRORS.TypedError(HardwareErrorCode.BleBondInvalid, undefined, {
|
|
1808
|
+
phase: 'connect',
|
|
1809
|
+
reason: 'key_missing',
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Android 16+ encrypts a bonded link on its own right after connecting. Subscribing to
|
|
1815
|
+
* notifications before that finishes gets the CCCD write rejected, and the framework's retry
|
|
1816
|
+
* encrypts again with the same stale key; firmware that allows one failure per link then drops
|
|
1817
|
+
* it before the system can re-pair. Holding GATT until the result lets a lost bond be re-paired
|
|
1818
|
+
* in place. Without the platform signal this resolves immediately.
|
|
1819
|
+
*/
|
|
1820
|
+
private async waitForAndroidLinkSecurity(uuid: string): Promise<AndroidLinkEncryption> {
|
|
1821
|
+
const linkStartedAt = this.androidLinkStartedAt.get(uuid);
|
|
1822
|
+
if (linkStartedAt === undefined) return 'unresolved';
|
|
1823
|
+
const waitStartedAt = Date.now();
|
|
1824
|
+
const result = await waitForAndroidLinkEncryption({
|
|
1825
|
+
deviceId: uuid,
|
|
1826
|
+
linkStartedAt,
|
|
1827
|
+
resultTimeoutMs: ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS,
|
|
1828
|
+
repairTimeoutMs: ANDROID_SYSTEM_REPAIR_TIMEOUT_MS,
|
|
1829
|
+
keyMissingGraceMs: ANDROID_KEY_MISSING_GRACE_MS,
|
|
1830
|
+
signal: this.bondAbortController.signal,
|
|
1831
|
+
onRepairStarted: () => {
|
|
1832
|
+
Log?.debug(
|
|
1833
|
+
'[ReactNativeBleTransport] Android bond lost, waiting for system re-pairing:',
|
|
1834
|
+
uuid
|
|
1835
|
+
);
|
|
1836
|
+
},
|
|
1837
|
+
});
|
|
1838
|
+
Log?.debug('[ReactNativeBleTransport] Android link encryption', {
|
|
1839
|
+
connectIdSuffix: uuid.slice(-8),
|
|
1840
|
+
result,
|
|
1841
|
+
waitedMs: Date.now() - waitStartedAt,
|
|
1842
|
+
});
|
|
1843
|
+
return result;
|
|
1454
1844
|
}
|
|
1455
1845
|
|
|
1456
1846
|
private async callProtocolV1(
|
|
@@ -1674,13 +2064,14 @@ export default class ReactNativeBleTransport {
|
|
|
1674
2064
|
const jsonData = ProtocolV1.decodeMessage(messages, response);
|
|
1675
2065
|
return check.call(jsonData);
|
|
1676
2066
|
} catch (e) {
|
|
1677
|
-
|
|
1678
|
-
|
|
2067
|
+
const isProbeTimeout =
|
|
2068
|
+
options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS &&
|
|
2069
|
+
(name === 'GetFeatures' || name === 'Initialize');
|
|
2070
|
+
if (isProbeTimeout) {
|
|
2071
|
+
Log?.debug(`[ReactNativeBleTransport] Protocol V1 ${name} probe call failed:`, e);
|
|
1679
2072
|
} else {
|
|
1680
2073
|
Log?.error('call error: ', e);
|
|
1681
2074
|
}
|
|
1682
|
-
const isProbeTimeout =
|
|
1683
|
-
name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
|
|
1684
2075
|
// A call that has been superseded (forceRun) or cleaned up no longer owns the
|
|
1685
2076
|
// transport; its late timeout must not tear down the connection the current
|
|
1686
2077
|
// call is actively using.
|
|
@@ -1703,7 +2094,52 @@ export default class ReactNativeBleTransport {
|
|
|
1703
2094
|
}
|
|
1704
2095
|
|
|
1705
2096
|
stop() {
|
|
2097
|
+
if (this.stopPromise) return this.stopPromise;
|
|
1706
2098
|
this.stopped = true;
|
|
2099
|
+
// Bonding precedes GATT, so cancelDeviceConnection cannot end this wait.
|
|
2100
|
+
this.bondAbortController.abort();
|
|
2101
|
+
const deviceIds = new Set([
|
|
2102
|
+
...this.monitorTokens.keys(),
|
|
2103
|
+
...this.sessionProtocols.keys(),
|
|
2104
|
+
...this.lifecycleOperations.keys(),
|
|
2105
|
+
...(this.runPromiseDeviceId ? [this.runPromiseDeviceId] : []),
|
|
2106
|
+
]);
|
|
2107
|
+
const scans = Array.from(this.scanCleanups, cleanup => cleanup());
|
|
2108
|
+
this.androidPriorityResetTimers.forEach(timeout => clearTimeout(timeout));
|
|
2109
|
+
this.androidPriorityResetTimers.clear();
|
|
2110
|
+
this.androidHighPriorityDevices.clear();
|
|
2111
|
+
this.androidLinkStartedAt.clear();
|
|
2112
|
+
stopBleKeyMissingTracking();
|
|
2113
|
+
stopBleEncryptionTracking();
|
|
2114
|
+
const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
2115
|
+
this.runPromise?.reject(error);
|
|
2116
|
+
this.runPromise = null;
|
|
2117
|
+
this.runPromiseDeviceId = null;
|
|
2118
|
+
deviceIds.forEach(uuid => this.rejectProtocolV2Frames(uuid, error));
|
|
2119
|
+
const manager = this.blePlxManager;
|
|
2120
|
+
// Cancel native setup before waiting for its lifecycle lock. Otherwise stop
|
|
2121
|
+
// waits for the very connect/MTU/GATT operation it needs to interrupt.
|
|
2122
|
+
const pendingConnections = manager
|
|
2123
|
+
? Array.from(this.lifecycleOperations.keys(), uuid =>
|
|
2124
|
+
this.runNativeTeardown(uuid, manager, async () => {
|
|
2125
|
+
await this.runBestEffortNativeOperation('stop: cancel pending device connection', () =>
|
|
2126
|
+
manager.cancelDeviceConnection(uuid)
|
|
2127
|
+
);
|
|
2128
|
+
})
|
|
2129
|
+
)
|
|
2130
|
+
: [];
|
|
2131
|
+
// Release only this transport's endpoints; other connectors may share ble-plx.
|
|
2132
|
+
this.stopPromise = Promise.all([
|
|
2133
|
+
...scans,
|
|
2134
|
+
...pendingConnections,
|
|
2135
|
+
...Array.from(deviceIds, uuid => this.disconnect(uuid)),
|
|
2136
|
+
]).then(async () => {
|
|
2137
|
+
await this.protocolV2Links.invalidateAllLinks('React Native BLE transport stopped');
|
|
2138
|
+
await this.waitForManagerReset();
|
|
2139
|
+
this.blePlxManager = undefined;
|
|
2140
|
+
this.emitter = undefined;
|
|
2141
|
+
});
|
|
2142
|
+
return this.stopPromise;
|
|
1707
2143
|
}
|
|
1708
2144
|
|
|
1709
2145
|
async disconnect(session: string) {
|
|
@@ -1855,17 +2291,119 @@ export default class ReactNativeBleTransport {
|
|
|
1855
2291
|
}
|
|
1856
2292
|
}
|
|
1857
2293
|
|
|
1858
|
-
cancel() {
|
|
2294
|
+
async cancel() {
|
|
1859
2295
|
Log?.debug('transport-react-native transport cancel');
|
|
1860
|
-
|
|
1861
|
-
|
|
2296
|
+
const pending = this.runPromise;
|
|
2297
|
+
const deviceId = this.runPromiseDeviceId;
|
|
2298
|
+
if (pending) {
|
|
2299
|
+
pending.reject(ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled));
|
|
2300
|
+
if (this.runPromise === pending) {
|
|
2301
|
+
this.runPromise = null;
|
|
2302
|
+
this.runPromiseDeviceId = null;
|
|
2303
|
+
}
|
|
2304
|
+
// A V1 read cannot be safely reused after abandoning its response.
|
|
2305
|
+
// Drain native teardown before DeviceCommands releases the operation.
|
|
2306
|
+
if (deviceId) await this.disconnect(deviceId);
|
|
1862
2307
|
}
|
|
1863
|
-
|
|
1864
|
-
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/**
|
|
2311
|
+
* Android: request the MTU with nothing queued ahead and never close the client while it is
|
|
2312
|
+
* outstanding; a link that times out or stays at MTU 23 is dropped.
|
|
2313
|
+
*/
|
|
2314
|
+
private async negotiateAndroidMtu(
|
|
2315
|
+
uuid: string,
|
|
2316
|
+
manager: BlePlxManager,
|
|
2317
|
+
device: Device
|
|
2318
|
+
): Promise<Device> {
|
|
2319
|
+
if (!shouldRefreshNegotiatedMtu(device.mtu)) return device;
|
|
2320
|
+
|
|
2321
|
+
const startedAt = Date.now();
|
|
2322
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
2323
|
+
let timedOut = false;
|
|
2324
|
+
let negotiated = device;
|
|
2325
|
+
let failure: string | undefined;
|
|
2326
|
+
try {
|
|
2327
|
+
negotiated = await Promise.race([
|
|
2328
|
+
device.requestMTU(ANDROID_REQUEST_MTU, `${device.id}:mtu:connected:0:${startedAt}`),
|
|
2329
|
+
new Promise<never>((_, reject) => {
|
|
2330
|
+
timer = setTimeout(() => {
|
|
2331
|
+
timedOut = true;
|
|
2332
|
+
reject(
|
|
2333
|
+
new Error(`BLE MTU exchange timeout after ${ANDROID_MTU_EXCHANGE_TIMEOUT_MS}ms`)
|
|
2334
|
+
);
|
|
2335
|
+
}, ANDROID_MTU_EXCHANGE_TIMEOUT_MS);
|
|
2336
|
+
}),
|
|
2337
|
+
]);
|
|
2338
|
+
} catch (error) {
|
|
2339
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
2340
|
+
} finally {
|
|
2341
|
+
if (timer) clearTimeout(timer);
|
|
2342
|
+
}
|
|
2343
|
+
Log?.debug(`[ReactNativeBleTransport] BLE MTU exchange ${failure ? 'failed' : 'completed'}`, {
|
|
2344
|
+
connectIdSuffix: uuid.slice(-8),
|
|
2345
|
+
elapsedMs: Date.now() - startedAt,
|
|
2346
|
+
timedOut,
|
|
2347
|
+
actual: negotiated.mtu,
|
|
2348
|
+
error: failure,
|
|
2349
|
+
});
|
|
2350
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
2351
|
+
if (!timedOut && !isKnownDefaultMtu(negotiated.mtu)) return negotiated;
|
|
2352
|
+
|
|
2353
|
+
// Counted like a setup timeout so a link that keeps failing reaches the wedged-link guard.
|
|
2354
|
+
const resetManager = this.abandonStalledConnection(
|
|
2355
|
+
uuid,
|
|
2356
|
+
timedOut ? 'mtu-backstop' : 'mtu-default'
|
|
2357
|
+
);
|
|
2358
|
+
await this.dropAndroidLink(
|
|
2359
|
+
uuid,
|
|
2360
|
+
manager,
|
|
2361
|
+
negotiated,
|
|
2362
|
+
timedOut ? 'mtu exchange timeout' : 'default mtu'
|
|
2363
|
+
);
|
|
2364
|
+
if (resetManager) throw this.createWedgedBleSetupError();
|
|
2365
|
+
throw ERRORS.TypedError(
|
|
2366
|
+
HardwareErrorCode.BleConnectedError,
|
|
2367
|
+
timedOut
|
|
2368
|
+
? 'BLE MTU exchange did not complete, reconnecting on a fresh link'
|
|
2369
|
+
: `BLE link stayed at the default MTU ${negotiated.mtu}, reconnecting on a fresh link`
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/** Close the client and wait out the link idle timer so the next connect gets a fresh link. */
|
|
2374
|
+
private async dropAndroidLink(
|
|
2375
|
+
uuid: string,
|
|
2376
|
+
manager: BlePlxManager,
|
|
2377
|
+
device: Device,
|
|
2378
|
+
reason: string
|
|
2379
|
+
) {
|
|
2380
|
+
await this.runNativeTeardown(uuid, manager, async () => {
|
|
2381
|
+
await Promise.all([
|
|
2382
|
+
this.runBestEffortNativeOperation(`${reason}: cancel manager connection`, () =>
|
|
2383
|
+
manager.cancelDeviceConnection(uuid)
|
|
2384
|
+
),
|
|
2385
|
+
this.runBestEffortNativeOperation(`${reason}: cancel device connection`, () =>
|
|
2386
|
+
device.cancelConnection()
|
|
2387
|
+
),
|
|
2388
|
+
]);
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
const startedAt = Date.now();
|
|
2392
|
+
while (!this.stopped && Date.now() - startedAt < ANDROID_LINK_DROP_QUIET_MS) {
|
|
2393
|
+
await delay(ANDROID_LINK_DROP_POLL_MS);
|
|
2394
|
+
}
|
|
2395
|
+
Log?.debug('[ReactNativeBleTransport] Android BLE link drop', {
|
|
2396
|
+
connectIdSuffix: uuid.slice(-8),
|
|
2397
|
+
reason,
|
|
2398
|
+
stopped: this.stopped,
|
|
2399
|
+
});
|
|
1865
2400
|
}
|
|
1866
2401
|
|
|
1867
2402
|
/** Run a native connect under the JS backstop budget. */
|
|
1868
2403
|
private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
|
|
2404
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
2405
|
+
const startedAt = Date.now();
|
|
2406
|
+
let succeeded = false;
|
|
1869
2407
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1870
2408
|
let timedOut = false;
|
|
1871
2409
|
const pending = connect();
|
|
@@ -1887,6 +2425,7 @@ export default class ReactNativeBleTransport {
|
|
|
1887
2425
|
}, BLE_CONNECT_TIMEOUT_MS);
|
|
1888
2426
|
}),
|
|
1889
2427
|
]);
|
|
2428
|
+
succeeded = true;
|
|
1890
2429
|
return result;
|
|
1891
2430
|
} catch (error) {
|
|
1892
2431
|
if (timedOut || isNativeOperationTimeoutError(error)) {
|
|
@@ -1901,6 +2440,12 @@ export default class ReactNativeBleTransport {
|
|
|
1901
2440
|
throw error;
|
|
1902
2441
|
} finally {
|
|
1903
2442
|
if (timer) clearTimeout(timer);
|
|
2443
|
+
Log?.debug('[ReactNativeBleTransport] connect completed', {
|
|
2444
|
+
connectIdSuffix: uuid.slice(-8),
|
|
2445
|
+
elapsedMs: Date.now() - startedAt,
|
|
2446
|
+
succeeded,
|
|
2447
|
+
backstopExpired: timedOut,
|
|
2448
|
+
});
|
|
1904
2449
|
}
|
|
1905
2450
|
}
|
|
1906
2451
|
|
|
@@ -1909,6 +2454,8 @@ export default class ReactNativeBleTransport {
|
|
|
1909
2454
|
uuid: string,
|
|
1910
2455
|
device: Device
|
|
1911
2456
|
): Promise<ResolvedBleCharacteristics> {
|
|
2457
|
+
const startedAt = Date.now();
|
|
2458
|
+
let succeeded = false;
|
|
1912
2459
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1913
2460
|
let timedOut = false;
|
|
1914
2461
|
const pending = this.resolveCharacteristics(device);
|
|
@@ -1929,6 +2476,7 @@ export default class ReactNativeBleTransport {
|
|
|
1929
2476
|
}),
|
|
1930
2477
|
]);
|
|
1931
2478
|
this.connectionSetupTimeoutCounts.delete(uuid);
|
|
2479
|
+
succeeded = true;
|
|
1932
2480
|
return result;
|
|
1933
2481
|
} catch (error) {
|
|
1934
2482
|
if (timedOut || isNativeOperationTimeoutError(error)) {
|
|
@@ -1940,9 +2488,18 @@ export default class ReactNativeBleTransport {
|
|
|
1940
2488
|
throw this.createWedgedBleSetupError();
|
|
1941
2489
|
}
|
|
1942
2490
|
}
|
|
2491
|
+
if (Platform.OS === 'android' && isMissingGattShapeError(error)) {
|
|
2492
|
+
this.androidGattCacheRefreshes.add(uuid);
|
|
2493
|
+
}
|
|
1943
2494
|
throw error;
|
|
1944
2495
|
} finally {
|
|
1945
2496
|
if (timer) clearTimeout(timer);
|
|
2497
|
+
Log?.debug('[ReactNativeBleTransport] GATT setup completed', {
|
|
2498
|
+
connectIdSuffix: uuid.slice(-8),
|
|
2499
|
+
elapsedMs: Date.now() - startedAt,
|
|
2500
|
+
succeeded,
|
|
2501
|
+
backstopExpired: timedOut,
|
|
2502
|
+
});
|
|
1946
2503
|
}
|
|
1947
2504
|
}
|
|
1948
2505
|
|
|
@@ -1954,7 +2511,13 @@ export default class ReactNativeBleTransport {
|
|
|
1954
2511
|
*/
|
|
1955
2512
|
private abandonStalledConnection(
|
|
1956
2513
|
uuid: string,
|
|
1957
|
-
stage:
|
|
2514
|
+
stage:
|
|
2515
|
+
| 'connect-backstop'
|
|
2516
|
+
| 'connect-native'
|
|
2517
|
+
| 'gatt-backstop'
|
|
2518
|
+
| 'gatt-native'
|
|
2519
|
+
| 'mtu-backstop'
|
|
2520
|
+
| 'mtu-default'
|
|
1958
2521
|
): boolean {
|
|
1959
2522
|
const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
|
|
1960
2523
|
this.connectionSetupTimeoutCounts.set(uuid, timeouts);
|
|
@@ -2086,6 +2649,7 @@ export default class ReactNativeBleTransport {
|
|
|
2086
2649
|
}
|
|
2087
2650
|
|
|
2088
2651
|
private resetPlxManager() {
|
|
2652
|
+
if (bleManagerResetPromise) return;
|
|
2089
2653
|
const manager = this.blePlxManager;
|
|
2090
2654
|
this.blePlxManager = undefined;
|
|
2091
2655
|
const reason = 'React Native BLE manager reset';
|
|
@@ -2128,15 +2692,26 @@ export default class ReactNativeBleTransport {
|
|
|
2128
2692
|
// Keep transport-lifetime V2 proof so the same endpoint can finish a no-probe
|
|
2129
2693
|
// firmware reconnect after the native BLE manager is recreated.
|
|
2130
2694
|
this.protocolReprobeFailures.clear();
|
|
2695
|
+
this.silentDetections.clear();
|
|
2131
2696
|
this.writeTimeoutCounts.clear();
|
|
2132
2697
|
this.connectionSetupTimeoutCounts.clear();
|
|
2133
2698
|
this.monitorTokens.clear();
|
|
2134
2699
|
this.protocolV2Assemblers.clear();
|
|
2700
|
+
let reset: Promise<void>;
|
|
2135
2701
|
try {
|
|
2136
|
-
manager?.destroy();
|
|
2702
|
+
reset = Promise.resolve(manager?.destroy());
|
|
2137
2703
|
} catch (error) {
|
|
2138
|
-
|
|
2704
|
+
reset = Promise.reject(error);
|
|
2139
2705
|
}
|
|
2706
|
+
bleManagerResetPromise = reset;
|
|
2707
|
+
reset.then(
|
|
2708
|
+
() => {
|
|
2709
|
+
if (bleManagerResetPromise === reset) bleManagerResetPromise = undefined;
|
|
2710
|
+
},
|
|
2711
|
+
error => {
|
|
2712
|
+
Log?.error('[ReactNativeBleTransport] BLE manager destroy failed:', error);
|
|
2713
|
+
}
|
|
2714
|
+
);
|
|
2140
2715
|
}
|
|
2141
2716
|
|
|
2142
2717
|
private createProtocolMismatchError(expected: ProtocolType) {
|
|
@@ -2229,6 +2804,8 @@ export default class ReactNativeBleTransport {
|
|
|
2229
2804
|
reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
|
|
2230
2805
|
const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
|
|
2231
2806
|
|
|
2807
|
+
await this.wakeSilentProtocolV1Device(uuid, probeOrder);
|
|
2808
|
+
|
|
2232
2809
|
for (let i = 0; i < probeOrder.length; i += 1) {
|
|
2233
2810
|
const protocol = probeOrder[i];
|
|
2234
2811
|
if (i > 0) {
|
|
@@ -2250,6 +2827,7 @@ export default class ReactNativeBleTransport {
|
|
|
2250
2827
|
this.confirmedProtocolV2.add(uuid);
|
|
2251
2828
|
}
|
|
2252
2829
|
this.protocolReprobeFailures.delete(uuid);
|
|
2830
|
+
this.silentDetections.delete(uuid);
|
|
2253
2831
|
Log?.debug('[ReactNativeBleTransport] protocol detected', {
|
|
2254
2832
|
deviceId: uuid,
|
|
2255
2833
|
protocol,
|
|
@@ -2259,6 +2837,9 @@ export default class ReactNativeBleTransport {
|
|
|
2259
2837
|
}
|
|
2260
2838
|
}
|
|
2261
2839
|
|
|
2840
|
+
// Arms the wake for the next detection.
|
|
2841
|
+
if (!this.silentDetections.has(uuid)) this.silentDetections.set(uuid, 'silent');
|
|
2842
|
+
|
|
2262
2843
|
if (trustSessionProtocol) {
|
|
2263
2844
|
// Still silent on its own protocol: count it, and let the streak expire the
|
|
2264
2845
|
// shortcut so a device that genuinely switched protocols is found again.
|
|
@@ -2272,6 +2853,34 @@ export default class ReactNativeBleTransport {
|
|
|
2272
2853
|
throw this.createProtocolDetectionError();
|
|
2273
2854
|
}
|
|
2274
2855
|
|
|
2856
|
+
/**
|
|
2857
|
+
* A sleeping Classic drops GetFeatures/Ping and only leaves its screensaver on Initialize, which
|
|
2858
|
+
* resets the wallet session, so it is sent once after a fully silent detection. The firmware does
|
|
2859
|
+
* not reliably answer it, so its timeout must not drop the link. It only runs when V1 is probed
|
|
2860
|
+
* first, so a late reply lands on the V1 probe rather than on a V2 one.
|
|
2861
|
+
*/
|
|
2862
|
+
private async wakeSilentProtocolV1Device(uuid: string, probeOrder: ProtocolType[]) {
|
|
2863
|
+
if (
|
|
2864
|
+
Platform.OS !== 'android' ||
|
|
2865
|
+
probeOrder[0] !== 'V1' ||
|
|
2866
|
+
this.silentDetections.get(uuid) !== 'silent'
|
|
2867
|
+
) {
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
this.silentDetections.set(uuid, 'woken');
|
|
2871
|
+
Log?.debug('[ReactNativeBleTransport] sending Protocol V1 Initialize wake', {
|
|
2872
|
+
connectIdSuffix: uuid.slice(-8),
|
|
2873
|
+
});
|
|
2874
|
+
try {
|
|
2875
|
+
this.probingProtocols.set(uuid, 'V1');
|
|
2876
|
+
await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
|
|
2877
|
+
} catch (error) {
|
|
2878
|
+
if (shouldRethrowProtocolProbeError(error)) throw error;
|
|
2879
|
+
} finally {
|
|
2880
|
+
this.clearProbeProtocol(uuid, 'V1');
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2275
2884
|
private async resetProbeStateAfterProtocolProbe(uuid: string, protocol: ProtocolType) {
|
|
2276
2885
|
const transport = transportCache[uuid];
|
|
2277
2886
|
await this.protocolV2Links.invalidateLink(
|
|
@@ -2339,9 +2948,7 @@ export default class ReactNativeBleTransport {
|
|
|
2339
2948
|
} catch (error) {
|
|
2340
2949
|
this.clearProbeProtocol(uuid, 'V1');
|
|
2341
2950
|
Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
|
|
2342
|
-
|
|
2343
|
-
// would only fail against a torn-down transport: surface the real cause.
|
|
2344
|
-
if (isWedgedWriteError(error)) {
|
|
2951
|
+
if (shouldRethrowProtocolProbeError(error)) {
|
|
2345
2952
|
throw error;
|
|
2346
2953
|
}
|
|
2347
2954
|
return false;
|
|
@@ -2366,7 +2973,7 @@ export default class ReactNativeBleTransport {
|
|
|
2366
2973
|
this.protocolV2Assemblers.get(uuid)?.reset();
|
|
2367
2974
|
this.resetProtocolV2Frames(uuid);
|
|
2368
2975
|
},
|
|
2369
|
-
shouldRethrow:
|
|
2976
|
+
shouldRethrow: shouldRethrowProtocolProbeError,
|
|
2370
2977
|
});
|
|
2371
2978
|
if (!detected) {
|
|
2372
2979
|
this.clearProbeProtocol(uuid, 'V2');
|
|
@@ -2513,6 +3120,9 @@ export default class ReactNativeBleTransport {
|
|
|
2513
3120
|
this.rememberStaleBondError(uuid, bondError);
|
|
2514
3121
|
throw bondError;
|
|
2515
3122
|
}
|
|
3123
|
+
if (isNativeBleDisconnectError(error)) {
|
|
3124
|
+
throw toBleDisconnectHardwareError(error);
|
|
3125
|
+
}
|
|
2516
3126
|
if (
|
|
2517
3127
|
getFirmwareUploadWriteRetryType(error) !== 'congested' ||
|
|
2518
3128
|
attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
|
|
@@ -2573,6 +3183,7 @@ export default class ReactNativeBleTransport {
|
|
|
2573
3183
|
throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
|
|
2574
3184
|
}
|
|
2575
3185
|
|
|
3186
|
+
const isProtocolProbe = this.probingProtocols.get(uuid) === 'V2';
|
|
2576
3187
|
const callOptions = options;
|
|
2577
3188
|
const highThroughputWrite = isProtocolV2HighThroughputCall(name);
|
|
2578
3189
|
|
|
@@ -2624,6 +3235,19 @@ export default class ReactNativeBleTransport {
|
|
|
2624
3235
|
);
|
|
2625
3236
|
} catch (e) {
|
|
2626
3237
|
Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
|
|
3238
|
+
if (
|
|
3239
|
+
!isProtocolProbe &&
|
|
3240
|
+
e?.errorCode === HardwareErrorCode.BleTimeoutError &&
|
|
3241
|
+
!this.monitorTokens.has(uuid)
|
|
3242
|
+
) {
|
|
3243
|
+
// The failed link has finished invalidating. Disconnect outside that
|
|
3244
|
+
// callback to avoid waiting on its own invalidation or acquire lock.
|
|
3245
|
+
await this.runLifecycleOperation(uuid, async () => {
|
|
3246
|
+
// A queued timeout leaves its active monitor intact; a newer acquire
|
|
3247
|
+
// may also have installed one while cleanup waited for the lifecycle lock.
|
|
3248
|
+
if (!this.monitorTokens.has(uuid)) await this.disconnectUnlocked(uuid);
|
|
3249
|
+
});
|
|
3250
|
+
}
|
|
2627
3251
|
throw e;
|
|
2628
3252
|
} finally {
|
|
2629
3253
|
if (highThroughputWrite) {
|
|
@@ -2636,7 +3260,12 @@ export default class ReactNativeBleTransport {
|
|
|
2636
3260
|
const transport = this.getCachedTransport(uuid);
|
|
2637
3261
|
if (!shouldRefreshNegotiatedMtu(transport.mtuSize)) return;
|
|
2638
3262
|
|
|
2639
|
-
const refreshedDevice = await requestNegotiatedMtu(
|
|
3263
|
+
const { device: refreshedDevice } = await requestNegotiatedMtu(
|
|
3264
|
+
transport.device,
|
|
3265
|
+
'highThroughput',
|
|
3266
|
+
1,
|
|
3267
|
+
transactionId => this.blePlxManager?.cancelTransaction(transactionId)
|
|
3268
|
+
);
|
|
2640
3269
|
transport.device = refreshedDevice;
|
|
2641
3270
|
transport.mtuSize =
|
|
2642
3271
|
typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
|