@onekeyfe/hd-transport-react-native 1.2.3-alpha.1 → 1.2.3-alpha.11
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 +1 -1
- package/dist/BleManager.d.ts.map +1 -1
- package/dist/bleNativeDisconnect.d.ts +3 -0
- package/dist/bleNativeDisconnect.d.ts.map +1 -0
- package/dist/index.d.ts +44 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +714 -147
- package/package.json +6 -6
- package/src/BleManager.ts +73 -5
- package/src/__tests__/bleNativeDisconnect.test.ts +33 -0
- package/src/__tests__/connectTimeout.test.ts +427 -3
- package/src/__tests__/protocolReprobe.test.ts +93 -1
- package/src/__tests__/protocolV2Link.test.ts +976 -62
- package/src/__tests__/staleCallTimeout.test.ts +34 -5
- package/src/bleNativeDisconnect.ts +40 -0
- package/src/index.ts +806 -161
package/src/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
getInfosForServiceUuid,
|
|
48
48
|
isSameBleUuid,
|
|
49
49
|
} from './constants';
|
|
50
|
+
import { isNativeBleDisconnectError, toBleDisconnectHardwareError } from './bleNativeDisconnect';
|
|
50
51
|
import {
|
|
51
52
|
isBleStaleBondHardwareError,
|
|
52
53
|
isNativeBleStaleBondError,
|
|
@@ -76,6 +77,8 @@ const { check, ProtocolV1, parseConfigure } = transport;
|
|
|
76
77
|
const Log = bleLogger;
|
|
77
78
|
|
|
78
79
|
const transportCache: Record<string, BleTransport> = {};
|
|
80
|
+
// ble-plx shares one manager across transport instances in this JS runtime.
|
|
81
|
+
let bleManagerResetPromise: Promise<void> | undefined;
|
|
79
82
|
const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
|
|
80
83
|
const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
|
|
81
84
|
const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
|
|
@@ -168,6 +171,69 @@ const isWedgedWriteError = (error: unknown): boolean =>
|
|
|
168
171
|
(error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
|
|
169
172
|
typeof (error as { message?: unknown })?.message === 'string' &&
|
|
170
173
|
(error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
|
|
174
|
+
const shouldRethrowProtocolProbeError = (error: unknown): boolean => {
|
|
175
|
+
const code = (error as { errorCode?: unknown })?.errorCode;
|
|
176
|
+
// Bonding and GATT failures are not evidence of a protocol mismatch. Preserve
|
|
177
|
+
// them instead of probing another protocol on an unusable connection.
|
|
178
|
+
// Native PLX disconnects (errorCode 201 / iOS 7) must match before they are
|
|
179
|
+
// mapped: Protocol V2 writes rethrow them unchanged unless normalized first.
|
|
180
|
+
return (
|
|
181
|
+
isBleStaleBondHardwareError(error) ||
|
|
182
|
+
isNativeBleDisconnectError(error) ||
|
|
183
|
+
code === HardwareErrorCode.BleDeviceNotBonded ||
|
|
184
|
+
code === HardwareErrorCode.BleDeviceBondedCanceled ||
|
|
185
|
+
code === HardwareErrorCode.BleDeviceDisconnected ||
|
|
186
|
+
code === HardwareErrorCode.BleCharacteristicNotifyError ||
|
|
187
|
+
code === HardwareErrorCode.BleCharacteristicNotifyChangeFailure ||
|
|
188
|
+
code === HardwareErrorCode.BleWriteCharacteristicError
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const logIosBleBondDiagnostic = (
|
|
193
|
+
phase: string,
|
|
194
|
+
error: unknown,
|
|
195
|
+
outcome: 'fallback' | 'rethrow'
|
|
196
|
+
) => {
|
|
197
|
+
if (Platform.OS !== 'ios') return;
|
|
198
|
+
|
|
199
|
+
const nativeError = (error ?? {}) as {
|
|
200
|
+
name?: unknown;
|
|
201
|
+
errorCode?: unknown;
|
|
202
|
+
attErrorCode?: unknown;
|
|
203
|
+
iosErrorCode?: unknown;
|
|
204
|
+
androidErrorCode?: unknown;
|
|
205
|
+
};
|
|
206
|
+
const scalar = (value: unknown) =>
|
|
207
|
+
typeof value === 'string' || typeof value === 'number' ? value : undefined;
|
|
208
|
+
|
|
209
|
+
Log?.debug('[ReactNativeBleTransport][BleBondDiagnostic] probe error', {
|
|
210
|
+
phase,
|
|
211
|
+
outcome,
|
|
212
|
+
errorName: scalar(nativeError.name),
|
|
213
|
+
errorCode: scalar(nativeError.errorCode),
|
|
214
|
+
attErrorCode: scalar(nativeError.attErrorCode),
|
|
215
|
+
iosErrorCode: scalar(nativeError.iosErrorCode),
|
|
216
|
+
androidErrorCode: scalar(nativeError.androidErrorCode),
|
|
217
|
+
nativeStaleBond: isNativeBleStaleBondError(error),
|
|
218
|
+
hardwareStaleBond: isBleStaleBondHardwareError(error),
|
|
219
|
+
nativeDisconnect: isNativeBleDisconnectError(error),
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
type IosProbeDisconnectEvidence = {
|
|
224
|
+
phase: 'protocol-v1-get-features' | 'protocol-v2-ping';
|
|
225
|
+
errorCode?: string | number;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const isIosProbeDisconnectError = (error: unknown): boolean => {
|
|
229
|
+
if (Platform.OS !== 'ios') return false;
|
|
230
|
+
const code = (error as { errorCode?: unknown })?.errorCode;
|
|
231
|
+
return (
|
|
232
|
+
isNativeBleDisconnectError(error) ||
|
|
233
|
+
code === HardwareErrorCode.BleDeviceNotBonded ||
|
|
234
|
+
code === HardwareErrorCode.BleDeviceDisconnected
|
|
235
|
+
);
|
|
236
|
+
};
|
|
171
237
|
/** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
|
|
172
238
|
export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
|
|
173
239
|
const DEVICE_SCAN_TIMEOUT_MS = 3000;
|
|
@@ -222,13 +288,13 @@ function getDeviceDisplayName(device?: Device | null) {
|
|
|
222
288
|
|
|
223
289
|
const IOS_REQUEST_MTU = 247;
|
|
224
290
|
const ANDROID_REQUEST_MTU = 517;
|
|
225
|
-
const BLE_MTU_REFRESH_RETRY_DELAY_MS = 200;
|
|
226
291
|
const ANDROID_HIGH_PRIORITY_IDLE_MS = 1000;
|
|
227
292
|
|
|
228
293
|
const getRequestedBleMtu = () =>
|
|
229
294
|
Platform.OS === 'android' ? ANDROID_REQUEST_MTU : IOS_REQUEST_MTU;
|
|
230
295
|
|
|
231
296
|
const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
|
|
297
|
+
const BLE_MTU_REQUEST_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS;
|
|
232
298
|
|
|
233
299
|
const connectOptions: Record<string, unknown> = {
|
|
234
300
|
requestMTU: getRequestedBleMtu(),
|
|
@@ -236,11 +302,63 @@ const connectOptions: Record<string, unknown> = {
|
|
|
236
302
|
refreshGatt: 'OnConnected',
|
|
237
303
|
};
|
|
238
304
|
|
|
239
|
-
/**
|
|
305
|
+
/** Connect options without requestMTU: the iOS fallback and every bare Android connect. */
|
|
240
306
|
const fallbackConnectOptions: Record<string, unknown> = {
|
|
241
307
|
timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
|
|
242
308
|
};
|
|
243
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Android never requests the MTU inside the native connect budget: refreshGatt makes the stack
|
|
312
|
+
* rediscover first, and a budget that expires with the MTU request unsent parks every later MTU
|
|
313
|
+
* request on that LE link. refreshGatt itself is only added after a firmware install or a
|
|
314
|
+
* stale-table symptom, and discovery finishes before the MTU exchange.
|
|
315
|
+
*/
|
|
316
|
+
const androidRefreshGattConnectOptions: Record<string, unknown> = {
|
|
317
|
+
timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
|
|
318
|
+
refreshGatt: 'OnConnected',
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* With no cached GATT table the stack runs its own discovery (up to ~8s) before the MTU
|
|
323
|
+
* exchange, so the bound sits above that; a stuck exchange never completes.
|
|
324
|
+
*/
|
|
325
|
+
export const ANDROID_MTU_EXCHANGE_TIMEOUT_MS = 12_000;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Android keeps an LE link, with per-link ATT state such as a pending MTU exchange, for its
|
|
329
|
+
* 4s GATT link idle timer after the last client closes; a reconnect inside it reuses the link.
|
|
330
|
+
*/
|
|
331
|
+
export const ANDROID_LINK_DROP_QUIET_MS = 5000;
|
|
332
|
+
const ANDROID_LINK_DROP_POLL_MS = 250;
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Android cannot use a link at the default 23-byte ATT MTU: Protocol V1 writes 192-byte
|
|
336
|
+
* packets regardless, and a Pro 2 sends only the first ATT_MTU-3 bytes of a V2 reply.
|
|
337
|
+
* An unknown MTU is not treated as default.
|
|
338
|
+
*/
|
|
339
|
+
const isKnownDefaultMtu = (mtu: unknown): boolean =>
|
|
340
|
+
typeof mtu === 'number' && Number.isFinite(mtu) && mtu <= 23;
|
|
341
|
+
|
|
342
|
+
/** Discovery found no OneKey service, or a characteristic of the wrong shape: the cached GATT table may be stale. */
|
|
343
|
+
const isMissingGattShapeError = (error: unknown): boolean => {
|
|
344
|
+
const code = (error as { errorCode?: unknown })?.errorCode;
|
|
345
|
+
const message = (error as { message?: unknown })?.message;
|
|
346
|
+
return (
|
|
347
|
+
code === HardwareErrorCode.BleServiceNotFound ||
|
|
348
|
+
code === HardwareErrorCode.BleCharacteristicNotFound ||
|
|
349
|
+
(typeof message === 'string' &&
|
|
350
|
+
(message.includes('BLECharacteristicNotFound') ||
|
|
351
|
+
message.includes('BLECharacteristicNotWritable') ||
|
|
352
|
+
message.includes('BLECharacteristicNotNotifiable')))
|
|
353
|
+
);
|
|
354
|
+
};
|
|
355
|
+
const isStaleGattTableNotifyReason = (reason: string | null | undefined): boolean =>
|
|
356
|
+
!!reason &&
|
|
357
|
+
(reason.includes('Cannot write client characteristic config descriptor') ||
|
|
358
|
+
reason.includes('Cannot find client characteristic config descriptor') || // pro firmware 2.3.0 upgrade
|
|
359
|
+
reason.includes('The handle is invalid') ||
|
|
360
|
+
reason.includes('Writing is not permitted')); // pro firmware 2.3.4 upgrade
|
|
361
|
+
|
|
244
362
|
/**
|
|
245
363
|
* JS backstop for connect. The native adapter applies its own 3s budget, but it
|
|
246
364
|
* schedules that timeout on its serial queue, so a busy queue (e.g. right after a
|
|
@@ -280,6 +398,15 @@ const shouldRethrowBleSetupError = (error: unknown): boolean =>
|
|
|
280
398
|
isConnectTimeoutError(error) || isWedgedBleSetupError(error);
|
|
281
399
|
const isNativeOperationTimeoutError = (error: unknown): boolean =>
|
|
282
400
|
(error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
|
|
401
|
+
const isMtuOrCancelledConnectError = (error: unknown): boolean => {
|
|
402
|
+
const errorCode = (error as { errorCode?: unknown })?.errorCode;
|
|
403
|
+
return (
|
|
404
|
+
errorCode === BleErrorCode.DeviceMTUChangeFailed ||
|
|
405
|
+
errorCode === BleErrorCode.OperationCancelled
|
|
406
|
+
);
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
type NegotiatedMtuResult = { device: Device; timedOut: boolean };
|
|
283
410
|
|
|
284
411
|
export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
|
|
285
412
|
|
|
@@ -294,29 +421,75 @@ const tryToGetConfiguration = (device: Device) => {
|
|
|
294
421
|
|
|
295
422
|
const requestNegotiatedMtu = async (
|
|
296
423
|
device: Device,
|
|
297
|
-
stage: 'connected' | '
|
|
298
|
-
attempt: number
|
|
299
|
-
) =>
|
|
300
|
-
|
|
424
|
+
stage: 'connected' | 'highThroughput',
|
|
425
|
+
attempt: number,
|
|
426
|
+
cancelTransaction?: (transactionId: string) => Promise<void> | void
|
|
427
|
+
): Promise<NegotiatedMtuResult> => {
|
|
428
|
+
if (Platform.OS !== 'ios' && Platform.OS !== 'android') return { device, timedOut: false };
|
|
429
|
+
|
|
430
|
+
const transactionId = `${device.id}:mtu:${stage}:${attempt}:${Date.now()}`;
|
|
431
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
432
|
+
let timedOut = false;
|
|
301
433
|
|
|
302
434
|
try {
|
|
303
435
|
// iOS ignores the requested value but react-native-ble-plx returns a fresh
|
|
304
436
|
// Device snapshot whose MTU is derived from CoreBluetooth's maximum write length.
|
|
305
|
-
const
|
|
306
|
-
|
|
437
|
+
const request = device.requestMTU(getRequestedBleMtu(), transactionId);
|
|
438
|
+
// The timeout race may settle before the native request does. Attach a
|
|
439
|
+
// rejection handler so a late native cancellation cannot become an
|
|
440
|
+
// unhandled rejection after we continue with the current MTU.
|
|
441
|
+
request.catch(() => undefined);
|
|
442
|
+
const mtuDevice = await Promise.race([
|
|
443
|
+
request,
|
|
444
|
+
new Promise<never>((_, reject) => {
|
|
445
|
+
timeoutId = setTimeout(() => {
|
|
446
|
+
timedOut = true;
|
|
447
|
+
reject(new Error(`BLE MTU request timeout after ${BLE_MTU_REQUEST_TIMEOUT_MS}ms`));
|
|
448
|
+
}, BLE_MTU_REQUEST_TIMEOUT_MS);
|
|
449
|
+
}),
|
|
450
|
+
]);
|
|
451
|
+
return { device: mtuDevice, timedOut: false };
|
|
307
452
|
} catch (error) {
|
|
453
|
+
if (timedOut && cancelTransaction) {
|
|
454
|
+
try {
|
|
455
|
+
Promise.resolve(cancelTransaction(transactionId)).catch(cancelError => {
|
|
456
|
+
Log?.debug('[ReactNativeBleTransport] MTU cancellation failed', {
|
|
457
|
+
platform: Platform.OS,
|
|
458
|
+
stage,
|
|
459
|
+
attempt,
|
|
460
|
+
error: cancelError instanceof Error ? cancelError.message : String(cancelError),
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
} catch (cancelError) {
|
|
464
|
+
Log?.debug('[ReactNativeBleTransport] MTU cancellation failed', {
|
|
465
|
+
platform: Platform.OS,
|
|
466
|
+
stage,
|
|
467
|
+
attempt,
|
|
468
|
+
error: cancelError instanceof Error ? cancelError.message : String(cancelError),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
308
472
|
Log?.debug('[ReactNativeBleTransport] MTU refresh failed, continuing with current value', {
|
|
309
473
|
platform: Platform.OS,
|
|
310
474
|
stage,
|
|
311
475
|
attempt,
|
|
312
476
|
actual: device.mtu,
|
|
477
|
+
timedOut,
|
|
313
478
|
error: error instanceof Error ? error.message : String(error),
|
|
314
479
|
});
|
|
315
|
-
return device;
|
|
480
|
+
return { device, timedOut };
|
|
481
|
+
} finally {
|
|
482
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
316
483
|
}
|
|
317
484
|
};
|
|
318
485
|
|
|
319
|
-
const resolveNegotiatedMtu = (
|
|
486
|
+
const resolveNegotiatedMtu = (
|
|
487
|
+
device: Device,
|
|
488
|
+
cancelTransaction?: (transactionId: string) => Promise<void> | void
|
|
489
|
+
): Promise<NegotiatedMtuResult> =>
|
|
490
|
+
shouldRefreshNegotiatedMtu(device.mtu)
|
|
491
|
+
? requestNegotiatedMtu(device, 'connected', 0, cancelTransaction)
|
|
492
|
+
: Promise.resolve({ device, timedOut: false });
|
|
320
493
|
|
|
321
494
|
type IOBleErrorRemap = Error | BleError | null | undefined;
|
|
322
495
|
|
|
@@ -360,6 +533,8 @@ export default class ReactNativeBleTransport {
|
|
|
360
533
|
|
|
361
534
|
stopped = false;
|
|
362
535
|
|
|
536
|
+
private readonly bondAbortController = new AbortController();
|
|
537
|
+
|
|
363
538
|
scanTimeout = DEVICE_SCAN_TIMEOUT_MS;
|
|
364
539
|
|
|
365
540
|
runPromise: Deferred<any> | null = null;
|
|
@@ -398,6 +573,20 @@ export default class ReactNativeBleTransport {
|
|
|
398
573
|
/** Consecutive detections that failed while trusting sessionProtocols. */
|
|
399
574
|
private protocolReprobeFailures: Map<string, number> = new Map();
|
|
400
575
|
|
|
576
|
+
/** Endpoints whose last detection got no answer; 'woken' once their Initialize wake is spent. */
|
|
577
|
+
private silentDetections = new Map<string, 'silent' | 'woken'>();
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* iOS can report a reset Pro2 as a generic peripheral disconnect instead of
|
|
581
|
+
* CBErrorPeerRemovedPairingInformation. Keep that first probe failure across
|
|
582
|
+
* the automatic cold reconnect and require a second failed probe before
|
|
583
|
+
* classifying the OS bond as invalid.
|
|
584
|
+
*/
|
|
585
|
+
private iosProbeDisconnectEvidence = new Map<string, IosProbeDisconnectEvidence>();
|
|
586
|
+
|
|
587
|
+
/** Android endpoints whose cached GATT table is suspect; the next connect refreshes it. */
|
|
588
|
+
private androidGattCacheRefreshes = new Set<string>();
|
|
589
|
+
|
|
401
590
|
/**
|
|
402
591
|
* Native encryption/pairing failures seen before Protocol V2 probe starts.
|
|
403
592
|
* Pro2/Neo GATT connect can succeed on a stale iOS bond; the CCCD write then
|
|
@@ -430,6 +619,14 @@ export default class ReactNativeBleTransport {
|
|
|
430
619
|
this.rejectProtocolV2Frames(uuid, new Error(reason));
|
|
431
620
|
Log?.debug('[ReactNativeBleTransport] Protocol V2 link invalidated:', uuid, reason);
|
|
432
621
|
if (reason.startsWith('Protocol V2 link-fatal error:')) {
|
|
622
|
+
if (this.probingProtocols.get(uuid) !== 'V2') {
|
|
623
|
+
const transport = transportCache[uuid];
|
|
624
|
+
try {
|
|
625
|
+
this.emitDeviceDisconnect(uuid, transport?.device?.name, transport?.monitorToken);
|
|
626
|
+
} catch {
|
|
627
|
+
Log?.error('[ReactNativeBleTransport] Protocol V2 disconnect listener failed');
|
|
628
|
+
}
|
|
629
|
+
}
|
|
433
630
|
await this.releaseNative(uuid, true);
|
|
434
631
|
}
|
|
435
632
|
},
|
|
@@ -450,6 +647,10 @@ export default class ReactNativeBleTransport {
|
|
|
450
647
|
/** Serializes transport lifecycle changes for the same physical device. */
|
|
451
648
|
private lifecycleOperations: Map<string, Promise<void>> = new Map();
|
|
452
649
|
|
|
650
|
+
private stopPromise?: Promise<void>;
|
|
651
|
+
|
|
652
|
+
private scanCleanups = new Set<() => Promise<void>>();
|
|
653
|
+
|
|
453
654
|
constructor(options: TransportOptions) {
|
|
454
655
|
this.scanTimeout = options.scanTimeout ?? DEVICE_SCAN_TIMEOUT_MS;
|
|
455
656
|
}
|
|
@@ -485,10 +686,34 @@ export default class ReactNativeBleTransport {
|
|
|
485
686
|
// empty
|
|
486
687
|
}
|
|
487
688
|
|
|
488
|
-
getPlxManager(): Promise<BlePlxManager> {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
689
|
+
async getPlxManager(): Promise<BlePlxManager> {
|
|
690
|
+
while (bleManagerResetPromise) {
|
|
691
|
+
await this.waitForManagerReset();
|
|
692
|
+
}
|
|
693
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
694
|
+
if (!this.blePlxManager) this.blePlxManager = new BlePlxManager();
|
|
695
|
+
return this.blePlxManager;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private async waitForManagerReset(): Promise<void> {
|
|
699
|
+
if (!bleManagerResetPromise) return;
|
|
700
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
701
|
+
try {
|
|
702
|
+
await Promise.race([
|
|
703
|
+
bleManagerResetPromise,
|
|
704
|
+
new Promise<never>((_, reject) => {
|
|
705
|
+
timeout = setTimeout(
|
|
706
|
+
() => reject(this.createWedgedBleSetupError()),
|
|
707
|
+
BLE_CONNECT_TIMEOUT_MS
|
|
708
|
+
);
|
|
709
|
+
}),
|
|
710
|
+
]);
|
|
711
|
+
} catch {
|
|
712
|
+
// A timeout or failed destroy is not permission to reuse the old singleton.
|
|
713
|
+
throw this.createWedgedBleSetupError();
|
|
714
|
+
} finally {
|
|
715
|
+
if (timeout) clearTimeout(timeout);
|
|
716
|
+
}
|
|
492
717
|
}
|
|
493
718
|
|
|
494
719
|
async resolveCharacteristics(device: Device): Promise<ResolvedBleCharacteristics> {
|
|
@@ -677,35 +902,58 @@ export default class ReactNativeBleTransport {
|
|
|
677
902
|
* @returns
|
|
678
903
|
*/
|
|
679
904
|
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...');
|
|
905
|
+
const scanStartedAt = Date.now();
|
|
906
|
+
let firstDeviceMs: number | undefined;
|
|
907
|
+
const blePlxManager = await this.getPlxManager();
|
|
908
|
+
await subscribeBleOn(blePlxManager);
|
|
909
|
+
if (Platform.OS === 'android' && Platform.Version >= 31) {
|
|
910
|
+
Log?.debug('requesting permissions, please wait...');
|
|
694
911
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
912
|
+
const resultConnect = await PermissionsAndroid.requestMultiple([
|
|
913
|
+
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
|
|
914
|
+
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
|
|
915
|
+
]);
|
|
699
916
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
return;
|
|
707
|
-
}
|
|
917
|
+
Log?.debug('requesting permissions, result: ', resultConnect);
|
|
918
|
+
if (
|
|
919
|
+
resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] !== 'granted' ||
|
|
920
|
+
resultConnect[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] !== 'granted'
|
|
921
|
+
) {
|
|
922
|
+
throw ERRORS.TypedError(HardwareErrorCode.BlePermissionError);
|
|
708
923
|
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
927
|
+
return new Promise<IOneKeyDevice[]>((resolve, reject) => {
|
|
928
|
+
const deviceList: IOneKeyDevice[] = [];
|
|
929
|
+
let finished = false;
|
|
930
|
+
let scanCleanup: Promise<void> | undefined;
|
|
931
|
+
const finishScan = (error?: unknown) => {
|
|
932
|
+
if (scanCleanup) return scanCleanup;
|
|
933
|
+
finished = true;
|
|
934
|
+
clearScanTimer();
|
|
935
|
+
scanCleanup = this.runNativeTeardown('scan', blePlxManager, async () => {
|
|
936
|
+
await blePlxManager.stopDeviceScan();
|
|
937
|
+
}).then(() => {
|
|
938
|
+
this.scanCleanups.delete(cancelScan);
|
|
939
|
+
if (error) reject(error);
|
|
940
|
+
else resolve(deviceList);
|
|
941
|
+
});
|
|
942
|
+
return scanCleanup;
|
|
943
|
+
};
|
|
944
|
+
const cancelScan = () =>
|
|
945
|
+
finishScan(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
|
|
946
|
+
this.scanCleanups.add(cancelScan);
|
|
947
|
+
|
|
948
|
+
const clearScanTimer = timer.timeout(() => {
|
|
949
|
+
Log?.debug('[ReactNativeBleTransport] scan completed', {
|
|
950
|
+
elapsedMs: Date.now() - scanStartedAt,
|
|
951
|
+
firstDeviceMs,
|
|
952
|
+
deviceCount: deviceList.length,
|
|
953
|
+
scanWindowMs: this.scanTimeout,
|
|
954
|
+
});
|
|
955
|
+
finishScan();
|
|
956
|
+
}, this.scanTimeout);
|
|
709
957
|
|
|
710
958
|
blePlxManager.startDeviceScan(
|
|
711
959
|
getBluetoothServiceUuids(),
|
|
@@ -721,18 +969,17 @@ export default class ReactNativeBleTransport {
|
|
|
721
969
|
error.errorCode
|
|
722
970
|
)
|
|
723
971
|
) {
|
|
724
|
-
|
|
972
|
+
finishScan(ERRORS.TypedError(HardwareErrorCode.BlePermissionError));
|
|
725
973
|
} else if (error.errorCode === BleErrorCode.BluetoothUnauthorized) {
|
|
726
|
-
|
|
974
|
+
finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationError));
|
|
727
975
|
} else if (error.errorCode === BleErrorCode.LocationServicesDisabled) {
|
|
728
|
-
|
|
976
|
+
finishScan(ERRORS.TypedError(HardwareErrorCode.BleLocationServicesDisabled));
|
|
729
977
|
} else if (error.errorCode === BleErrorCode.ScanStartFailed) {
|
|
730
978
|
// Android Bluetooth will report an error when the search frequency is too fast,
|
|
731
979
|
// then nothing is processed and an empty array of devices is returned.
|
|
732
980
|
// Then the next search will be back to normal
|
|
733
|
-
timer.timeout(() => {}, this.scanTimeout);
|
|
734
981
|
} else {
|
|
735
|
-
|
|
982
|
+
finishScan(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.reason ?? ''));
|
|
736
983
|
}
|
|
737
984
|
return;
|
|
738
985
|
}
|
|
@@ -764,6 +1011,7 @@ export default class ReactNativeBleTransport {
|
|
|
764
1011
|
}
|
|
765
1012
|
);
|
|
766
1013
|
|
|
1014
|
+
if (finished) return;
|
|
767
1015
|
getConnectedDeviceIds(Platform.OS === 'ios' ? getBluetoothServiceUuids() : []).then(
|
|
768
1016
|
devices => {
|
|
769
1017
|
for (const device of devices) {
|
|
@@ -783,11 +1031,13 @@ export default class ReactNativeBleTransport {
|
|
|
783
1031
|
addDevice(device as unknown as Device);
|
|
784
1032
|
}
|
|
785
1033
|
}
|
|
786
|
-
}
|
|
1034
|
+
},
|
|
1035
|
+
error => Log?.debug('search connected peripheral failed:', error)
|
|
787
1036
|
);
|
|
788
1037
|
|
|
789
1038
|
const addDevice = (device: Device) => {
|
|
790
|
-
if (deviceList.every(d => d.id !== device.id)) {
|
|
1039
|
+
if (!finished && deviceList.every(d => d.id !== device.id)) {
|
|
1040
|
+
firstDeviceMs ??= Date.now() - scanStartedAt;
|
|
791
1041
|
const displayName = getDeviceDisplayName(device) ?? 'Unknown BLE Device';
|
|
792
1042
|
|
|
793
1043
|
deviceList.push({
|
|
@@ -802,11 +1052,6 @@ export default class ReactNativeBleTransport {
|
|
|
802
1052
|
});
|
|
803
1053
|
}
|
|
804
1054
|
};
|
|
805
|
-
|
|
806
|
-
timer.timeout(() => {
|
|
807
|
-
blePlxManager.stopDeviceScan();
|
|
808
|
-
resolve(deviceList);
|
|
809
|
-
}, this.scanTimeout);
|
|
810
1055
|
});
|
|
811
1056
|
}
|
|
812
1057
|
|
|
@@ -817,6 +1062,7 @@ export default class ReactNativeBleTransport {
|
|
|
817
1062
|
) {
|
|
818
1063
|
const { writeCharacteristic, notifyCharacteristic } =
|
|
819
1064
|
characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
|
|
1065
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
820
1066
|
const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
|
|
821
1067
|
transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
|
|
822
1068
|
const monitorToken = this.nextMonitorToken;
|
|
@@ -845,39 +1091,12 @@ export default class ReactNativeBleTransport {
|
|
|
845
1091
|
} else if (Platform.OS === 'android') {
|
|
846
1092
|
await delay(ANDROID_NOTIFY_READY_DELAY_MS);
|
|
847
1093
|
}
|
|
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
|
-
}
|
|
1094
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
874
1095
|
|
|
875
1096
|
Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
|
|
876
1097
|
platform: Platform.OS,
|
|
877
1098
|
requested: getRequestedBleMtu(),
|
|
878
|
-
initial: initialMtu,
|
|
879
1099
|
actual: transport.mtuSize,
|
|
880
|
-
refreshAttempts,
|
|
881
1100
|
});
|
|
882
1101
|
|
|
883
1102
|
return transport;
|
|
@@ -894,6 +1113,7 @@ export default class ReactNativeBleTransport {
|
|
|
894
1113
|
}
|
|
895
1114
|
|
|
896
1115
|
private async acquireUnlocked(input: FirmwareInstallBleAcquireInput) {
|
|
1116
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
897
1117
|
const { uuid, forceCleanRunPromise, expectedProtocol, skipProtocolProbe } = input;
|
|
898
1118
|
const shouldMapProtocolV2StaleBond = expectedProtocol
|
|
899
1119
|
? expectedProtocol === 'V2'
|
|
@@ -944,11 +1164,16 @@ export default class ReactNativeBleTransport {
|
|
|
944
1164
|
const isCachedDeviceConnected = await cachedTransport.device
|
|
945
1165
|
.isConnected()
|
|
946
1166
|
.catch(() => false);
|
|
1167
|
+
// A suspect GATT table is only refreshed through a new connect.
|
|
1168
|
+
const isCachedAndroidLinkUsable =
|
|
1169
|
+
Platform.OS !== 'android' || !this.androidGattCacheRefreshes.has(uuid);
|
|
947
1170
|
if (
|
|
948
1171
|
isCachedDeviceConnected &&
|
|
1172
|
+
isCachedAndroidLinkUsable &&
|
|
949
1173
|
cachedProtocol &&
|
|
950
1174
|
(!expectedProtocol || cachedProtocol === expectedProtocol)
|
|
951
1175
|
) {
|
|
1176
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
952
1177
|
Log?.debug('[ReactNativeBleTransport] reuse cached BLE transport:', uuid, cachedProtocol);
|
|
953
1178
|
return { uuid, protocolType: cachedProtocol };
|
|
954
1179
|
}
|
|
@@ -963,6 +1188,18 @@ export default class ReactNativeBleTransport {
|
|
|
963
1188
|
}
|
|
964
1189
|
|
|
965
1190
|
let device: Device | null = null;
|
|
1191
|
+
const isAndroid = Platform.OS === 'android';
|
|
1192
|
+
// A firmware-install reconnect always refreshes: the new firmware may expose a different table.
|
|
1193
|
+
const refreshAndroidGattCache =
|
|
1194
|
+
isAndroid && (!!skipProtocolProbe || this.androidGattCacheRefreshes.has(uuid));
|
|
1195
|
+
let nativeConnectOptions = connectOptions;
|
|
1196
|
+
if (isAndroid) {
|
|
1197
|
+
nativeConnectOptions = refreshAndroidGattCache
|
|
1198
|
+
? androidRefreshGattConnectOptions
|
|
1199
|
+
: fallbackConnectOptions;
|
|
1200
|
+
}
|
|
1201
|
+
// Only a connect that carried refreshGatt clears the marker; the fallback connects drop it.
|
|
1202
|
+
let androidRefreshConnectRan = false;
|
|
966
1203
|
|
|
967
1204
|
if (forceCleanRunPromise && this.runPromise) {
|
|
968
1205
|
const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
|
|
@@ -973,6 +1210,7 @@ export default class ReactNativeBleTransport {
|
|
|
973
1210
|
}
|
|
974
1211
|
|
|
975
1212
|
const blePlxManager = await this.getPlxManager();
|
|
1213
|
+
let skipPostConnectMtu = false;
|
|
976
1214
|
try {
|
|
977
1215
|
await subscribeBleOn(blePlxManager);
|
|
978
1216
|
} catch (error) {
|
|
@@ -980,6 +1218,27 @@ export default class ReactNativeBleTransport {
|
|
|
980
1218
|
throw error;
|
|
981
1219
|
}
|
|
982
1220
|
|
|
1221
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1222
|
+
if (Platform.OS === 'android') {
|
|
1223
|
+
// Initiate bonding locally before GATT can trigger peripheral-initiated pairing.
|
|
1224
|
+
try {
|
|
1225
|
+
const bondState = await pairDevice(uuid);
|
|
1226
|
+
if (bondState.bonding) {
|
|
1227
|
+
await onDeviceBondState(uuid, this.bondAbortController.signal);
|
|
1228
|
+
} else if (!bondState.bonded) {
|
|
1229
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'device is not bonded');
|
|
1230
|
+
}
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
await this.runNativeTeardown(uuid, blePlxManager, async () => {
|
|
1233
|
+
await this.runBestEffortNativeOperation('bond failure: cancel manager connection', () =>
|
|
1234
|
+
blePlxManager.cancelDeviceConnection(uuid)
|
|
1235
|
+
);
|
|
1236
|
+
});
|
|
1237
|
+
throw error;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1241
|
+
|
|
983
1242
|
if (!device) {
|
|
984
1243
|
const devices = await blePlxManager.devices([uuid]);
|
|
985
1244
|
[device] = devices;
|
|
@@ -996,17 +1255,16 @@ export default class ReactNativeBleTransport {
|
|
|
996
1255
|
Log?.debug('try to connect to device: ', uuid);
|
|
997
1256
|
try {
|
|
998
1257
|
device = await this.connectWithTimeout(uuid, () =>
|
|
999
|
-
blePlxManager.connectToDevice(uuid,
|
|
1258
|
+
blePlxManager.connectToDevice(uuid, nativeConnectOptions)
|
|
1000
1259
|
);
|
|
1260
|
+
androidRefreshConnectRan = refreshAndroidGattCache;
|
|
1001
1261
|
} catch (e) {
|
|
1002
1262
|
Log?.debug('try to connect to device has error: ', e);
|
|
1003
1263
|
if (shouldRethrowBleSetupError(e)) {
|
|
1004
1264
|
throw e;
|
|
1005
1265
|
}
|
|
1006
|
-
if (
|
|
1007
|
-
|
|
1008
|
-
e.errorCode === BleErrorCode.OperationCancelled
|
|
1009
|
-
) {
|
|
1266
|
+
if (isMtuOrCancelledConnectError(e)) {
|
|
1267
|
+
skipPostConnectMtu = true;
|
|
1010
1268
|
Log?.debug('first try to reconnect without params');
|
|
1011
1269
|
device = await this.connectWithTimeout(uuid, () =>
|
|
1012
1270
|
blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
|
|
@@ -1024,23 +1282,33 @@ export default class ReactNativeBleTransport {
|
|
|
1024
1282
|
throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'unable to connect to device');
|
|
1025
1283
|
}
|
|
1026
1284
|
|
|
1285
|
+
if (
|
|
1286
|
+
refreshAndroidGattCache &&
|
|
1287
|
+
!androidRefreshConnectRan &&
|
|
1288
|
+
(await device.isConnected().catch(() => false))
|
|
1289
|
+
) {
|
|
1290
|
+
// refreshGatt only reaches the stack through a connect. A link that is still up would
|
|
1291
|
+
// skip the connect below and keep serving the stale table, so it is dropped first.
|
|
1292
|
+
await this.dropAndroidLink(uuid, blePlxManager, device, 'gatt cache refresh');
|
|
1293
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1027
1296
|
if (!(await device.isConnected())) {
|
|
1028
1297
|
Log?.debug('not connected, try to connect to device: ', uuid);
|
|
1029
1298
|
const disconnectedDevice = device;
|
|
1030
1299
|
|
|
1031
1300
|
try {
|
|
1032
1301
|
device = await this.connectWithTimeout(uuid, () =>
|
|
1033
|
-
disconnectedDevice.connect(
|
|
1302
|
+
disconnectedDevice.connect(nativeConnectOptions)
|
|
1034
1303
|
);
|
|
1304
|
+
androidRefreshConnectRan = refreshAndroidGattCache;
|
|
1035
1305
|
} catch (e) {
|
|
1036
1306
|
Log?.debug('not connected, try to connect to device has error: ', e);
|
|
1037
1307
|
if (shouldRethrowBleSetupError(e)) {
|
|
1038
1308
|
throw e;
|
|
1039
1309
|
}
|
|
1040
|
-
if (
|
|
1041
|
-
|
|
1042
|
-
e.errorCode === BleErrorCode.OperationCancelled
|
|
1043
|
-
) {
|
|
1310
|
+
if (isMtuOrCancelledConnectError(e)) {
|
|
1311
|
+
skipPostConnectMtu = true;
|
|
1044
1312
|
Log?.debug('second try to reconnect without params');
|
|
1045
1313
|
try {
|
|
1046
1314
|
device = await this.connectWithTimeout(uuid, () =>
|
|
@@ -1066,42 +1334,80 @@ export default class ReactNativeBleTransport {
|
|
|
1066
1334
|
}
|
|
1067
1335
|
}
|
|
1068
1336
|
|
|
1069
|
-
if (
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1337
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1338
|
+
if (Platform.OS === 'android' && !(await device.isConnected().catch(() => false))) {
|
|
1339
|
+
const disconnectedDevice = device;
|
|
1340
|
+
await this.runNativeTeardown(uuid, blePlxManager, async () => {
|
|
1341
|
+
await Promise.all([
|
|
1342
|
+
this.runBestEffortNativeOperation('connect failure: cancel manager connection', () =>
|
|
1343
|
+
blePlxManager.cancelDeviceConnection(uuid)
|
|
1344
|
+
),
|
|
1345
|
+
this.runBestEffortNativeOperation('connect failure: cancel device connection', () =>
|
|
1346
|
+
disconnectedDevice.cancelConnection()
|
|
1347
|
+
),
|
|
1348
|
+
]);
|
|
1349
|
+
});
|
|
1350
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'device is not connected');
|
|
1351
|
+
}
|
|
1352
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1353
|
+
let characteristics: ResolvedBleCharacteristics | undefined;
|
|
1354
|
+
if (isAndroid) {
|
|
1355
|
+
if (refreshAndroidGattCache) {
|
|
1356
|
+
// refreshGatt has already started a full rediscovery; let it finish before the MTU
|
|
1357
|
+
// exchange so the request is not queued behind it.
|
|
1358
|
+
characteristics = await this.resolveCharacteristicsWithTimeout(uuid, device);
|
|
1359
|
+
if (androidRefreshConnectRan) this.androidGattCacheRefreshes.delete(uuid);
|
|
1360
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1361
|
+
}
|
|
1362
|
+
device = await this.negotiateAndroidMtu(uuid, blePlxManager, device);
|
|
1363
|
+
} else if (!skipPostConnectMtu) {
|
|
1364
|
+
// Match 1.1.31: MTU is a connect() best-effort. If connect already fell back
|
|
1365
|
+
// without requestMTU, do not put another requestMTU on the native serial
|
|
1366
|
+
// queue — that is what wedges GATT after Account#2 reconnect.
|
|
1367
|
+
const mtuResult = await resolveNegotiatedMtu(device, transactionId =>
|
|
1368
|
+
blePlxManager.cancelTransaction(transactionId)
|
|
1369
|
+
);
|
|
1370
|
+
device = mtuResult.device;
|
|
1371
|
+
if (mtuResult.timedOut) {
|
|
1372
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1373
|
+
Log?.debug(
|
|
1374
|
+
'[ReactNativeBleTransport] post-connect MTU timed out, reconnecting without requesting MTU'
|
|
1375
|
+
);
|
|
1376
|
+
const timedOutDevice = device;
|
|
1377
|
+
let mtuTeardownSettled = false;
|
|
1378
|
+
await this.runNativeTeardown(uuid, blePlxManager, async () => {
|
|
1379
|
+
await this.runBestEffortNativeOperation('mtu timeout: cancel device connection', () =>
|
|
1380
|
+
timedOutDevice.cancelConnection()
|
|
1078
1381
|
);
|
|
1382
|
+
mtuTeardownSettled = true;
|
|
1383
|
+
});
|
|
1384
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1385
|
+
if (!mtuTeardownSettled || this.blePlxManager !== blePlxManager) {
|
|
1386
|
+
// The teardown budget expired and the manager that owned timedOutDevice was reset.
|
|
1387
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleTimeoutError, 'BLE MTU cleanup timed out');
|
|
1079
1388
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1389
|
+
try {
|
|
1390
|
+
device = await this.connectWithTimeout(uuid, () =>
|
|
1391
|
+
timedOutDevice.connect(fallbackConnectOptions)
|
|
1392
|
+
);
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
if (shouldRethrowBleSetupError(error)) throw error;
|
|
1395
|
+
if (
|
|
1396
|
+
(error as { errorCode?: unknown })?.errorCode === BleErrorCode.DeviceAlreadyConnected
|
|
1397
|
+
) {
|
|
1398
|
+
// GATT resolution and the protocol probe below still validate the retained link.
|
|
1399
|
+
device = timedOutDevice;
|
|
1400
|
+
} else {
|
|
1401
|
+
remapError(error);
|
|
1402
|
+
}
|
|
1085
1403
|
}
|
|
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
1404
|
}
|
|
1099
1405
|
}
|
|
1100
|
-
|
|
1101
|
-
device = await resolveNegotiatedMtu(device);
|
|
1406
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1102
1407
|
const acquiredDevice = device;
|
|
1103
1408
|
const { writeCharacteristic, notifyCharacteristic } =
|
|
1104
|
-
await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
|
|
1409
|
+
characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice));
|
|
1410
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1105
1411
|
|
|
1106
1412
|
const protocolHint = expectedProtocol
|
|
1107
1413
|
? undefined
|
|
@@ -1163,6 +1469,7 @@ export default class ReactNativeBleTransport {
|
|
|
1163
1469
|
await this.installTransportForAcquire(uuid, acquiredDevice);
|
|
1164
1470
|
}
|
|
1165
1471
|
);
|
|
1472
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
1166
1473
|
const currentTransport = transportCache[uuid];
|
|
1167
1474
|
if (!currentTransport) {
|
|
1168
1475
|
throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
|
|
@@ -1170,11 +1477,11 @@ export default class ReactNativeBleTransport {
|
|
|
1170
1477
|
this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
|
|
1171
1478
|
return { uuid, protocolType };
|
|
1172
1479
|
} catch (error) {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
}
|
|
1480
|
+
// A failed acquire must retire the physical link before Core retries. Logical
|
|
1481
|
+
// release leaves GATT connected even when neither protocol receives a response.
|
|
1482
|
+
await this.disconnectUnlocked(uuid, {
|
|
1483
|
+
preserveIosProbeDisconnect: this.iosProbeDisconnectEvidence.has(uuid),
|
|
1484
|
+
});
|
|
1178
1485
|
throw error;
|
|
1179
1486
|
} finally {
|
|
1180
1487
|
this.acquiringProtocolV2.delete(uuid);
|
|
@@ -1212,6 +1519,9 @@ export default class ReactNativeBleTransport {
|
|
|
1212
1519
|
this.rememberStaleBondError(uuid, toBleStaleBondHardwareError(error));
|
|
1213
1520
|
return;
|
|
1214
1521
|
}
|
|
1522
|
+
if (Platform.OS === 'android' && isStaleGattTableNotifyReason(error.reason)) {
|
|
1523
|
+
this.androidGattCacheRefreshes.add(uuid);
|
|
1524
|
+
}
|
|
1215
1525
|
if (this.getActiveProtocol(uuid) === 'V2') {
|
|
1216
1526
|
let errorCode:
|
|
1217
1527
|
| typeof HardwareErrorCode.BleCharacteristicNotifyError
|
|
@@ -1221,10 +1531,7 @@ export default class ReactNativeBleTransport {
|
|
|
1221
1531
|
if (error.reason?.includes('The connection has timed out unexpectedly')) {
|
|
1222
1532
|
errorCode = HardwareErrorCode.BleTimeoutError;
|
|
1223
1533
|
} 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') ||
|
|
1534
|
+
isStaleGattTableNotifyReason(error.reason) ||
|
|
1228
1535
|
error.reason?.includes('notify change failed for device')
|
|
1229
1536
|
) {
|
|
1230
1537
|
errorCode = HardwareErrorCode.BleCharacteristicNotifyChangeFailure;
|
|
@@ -1241,10 +1548,7 @@ export default class ReactNativeBleTransport {
|
|
|
1241
1548
|
ERROR = HardwareErrorCode.BleTimeoutError;
|
|
1242
1549
|
}
|
|
1243
1550
|
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
|
|
1551
|
+
isStaleGattTableNotifyReason(error.reason) ||
|
|
1248
1552
|
error.reason?.includes('notify change failed for device')
|
|
1249
1553
|
) {
|
|
1250
1554
|
const notifyError = ERRORS.TypedError(
|
|
@@ -1674,13 +1978,14 @@ export default class ReactNativeBleTransport {
|
|
|
1674
1978
|
const jsonData = ProtocolV1.decodeMessage(messages, response);
|
|
1675
1979
|
return check.call(jsonData);
|
|
1676
1980
|
} catch (e) {
|
|
1677
|
-
|
|
1678
|
-
|
|
1981
|
+
const isProbeTimeout =
|
|
1982
|
+
options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS &&
|
|
1983
|
+
(name === 'GetFeatures' || name === 'Initialize');
|
|
1984
|
+
if (isProbeTimeout) {
|
|
1985
|
+
Log?.debug(`[ReactNativeBleTransport] Protocol V1 ${name} probe call failed:`, e);
|
|
1679
1986
|
} else {
|
|
1680
1987
|
Log?.error('call error: ', e);
|
|
1681
1988
|
}
|
|
1682
|
-
const isProbeTimeout =
|
|
1683
|
-
name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
|
|
1684
1989
|
// A call that has been superseded (forceRun) or cleaned up no longer owns the
|
|
1685
1990
|
// transport; its late timeout must not tear down the connection the current
|
|
1686
1991
|
// call is actively using.
|
|
@@ -1703,14 +2008,60 @@ export default class ReactNativeBleTransport {
|
|
|
1703
2008
|
}
|
|
1704
2009
|
|
|
1705
2010
|
stop() {
|
|
2011
|
+
if (this.stopPromise) return this.stopPromise;
|
|
1706
2012
|
this.stopped = true;
|
|
2013
|
+
// Bonding precedes GATT, so cancelDeviceConnection cannot end this wait.
|
|
2014
|
+
this.bondAbortController.abort();
|
|
2015
|
+
const deviceIds = new Set([
|
|
2016
|
+
...this.monitorTokens.keys(),
|
|
2017
|
+
...this.sessionProtocols.keys(),
|
|
2018
|
+
...this.lifecycleOperations.keys(),
|
|
2019
|
+
...(this.runPromiseDeviceId ? [this.runPromiseDeviceId] : []),
|
|
2020
|
+
]);
|
|
2021
|
+
const scans = Array.from(this.scanCleanups, cleanup => cleanup());
|
|
2022
|
+
this.androidPriorityResetTimers.forEach(timeout => clearTimeout(timeout));
|
|
2023
|
+
this.androidPriorityResetTimers.clear();
|
|
2024
|
+
this.androidHighPriorityDevices.clear();
|
|
2025
|
+
this.iosProbeDisconnectEvidence.clear();
|
|
2026
|
+
const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
2027
|
+
this.runPromise?.reject(error);
|
|
2028
|
+
this.runPromise = null;
|
|
2029
|
+
this.runPromiseDeviceId = null;
|
|
2030
|
+
deviceIds.forEach(uuid => this.rejectProtocolV2Frames(uuid, error));
|
|
2031
|
+
const manager = this.blePlxManager;
|
|
2032
|
+
// Cancel native setup before waiting for its lifecycle lock. Otherwise stop
|
|
2033
|
+
// waits for the very connect/MTU/GATT operation it needs to interrupt.
|
|
2034
|
+
const pendingConnections = manager
|
|
2035
|
+
? Array.from(this.lifecycleOperations.keys(), uuid =>
|
|
2036
|
+
this.runNativeTeardown(uuid, manager, async () => {
|
|
2037
|
+
await this.runBestEffortNativeOperation('stop: cancel pending device connection', () =>
|
|
2038
|
+
manager.cancelDeviceConnection(uuid)
|
|
2039
|
+
);
|
|
2040
|
+
})
|
|
2041
|
+
)
|
|
2042
|
+
: [];
|
|
2043
|
+
// Release only this transport's endpoints; other connectors may share ble-plx.
|
|
2044
|
+
this.stopPromise = Promise.all([
|
|
2045
|
+
...scans,
|
|
2046
|
+
...pendingConnections,
|
|
2047
|
+
...Array.from(deviceIds, uuid => this.disconnect(uuid)),
|
|
2048
|
+
]).then(async () => {
|
|
2049
|
+
await this.protocolV2Links.invalidateAllLinks('React Native BLE transport stopped');
|
|
2050
|
+
await this.waitForManagerReset();
|
|
2051
|
+
this.blePlxManager = undefined;
|
|
2052
|
+
this.emitter = undefined;
|
|
2053
|
+
});
|
|
2054
|
+
return this.stopPromise;
|
|
1707
2055
|
}
|
|
1708
2056
|
|
|
1709
2057
|
async disconnect(session: string) {
|
|
1710
2058
|
return this.runLifecycleOperation(session, () => this.disconnectUnlocked(session));
|
|
1711
2059
|
}
|
|
1712
2060
|
|
|
1713
|
-
private async disconnectUnlocked(
|
|
2061
|
+
private async disconnectUnlocked(
|
|
2062
|
+
session: string,
|
|
2063
|
+
options?: { preserveIosProbeDisconnect?: boolean }
|
|
2064
|
+
) {
|
|
1714
2065
|
await this.protocolV2Links.invalidateLink(session, 'React Native BLE transport disconnected');
|
|
1715
2066
|
const transport = transportCache[session];
|
|
1716
2067
|
const manager = this.blePlxManager;
|
|
@@ -1752,6 +2103,9 @@ export default class ReactNativeBleTransport {
|
|
|
1752
2103
|
this.deviceProtocolHints.delete(session);
|
|
1753
2104
|
this.sessionProtocols.delete(session);
|
|
1754
2105
|
this.protocolReprobeFailures.delete(session);
|
|
2106
|
+
if (!options?.preserveIosProbeDisconnect) {
|
|
2107
|
+
this.iosProbeDisconnectEvidence.delete(session);
|
|
2108
|
+
}
|
|
1755
2109
|
this.protocolV2Assemblers.delete(session);
|
|
1756
2110
|
this.resetProtocolV2Frames(session);
|
|
1757
2111
|
|
|
@@ -1855,17 +2209,119 @@ export default class ReactNativeBleTransport {
|
|
|
1855
2209
|
}
|
|
1856
2210
|
}
|
|
1857
2211
|
|
|
1858
|
-
cancel() {
|
|
2212
|
+
async cancel() {
|
|
1859
2213
|
Log?.debug('transport-react-native transport cancel');
|
|
1860
|
-
|
|
1861
|
-
|
|
2214
|
+
const pending = this.runPromise;
|
|
2215
|
+
const deviceId = this.runPromiseDeviceId;
|
|
2216
|
+
if (pending) {
|
|
2217
|
+
pending.reject(ERRORS.TypedError(HardwareErrorCode.CallQueueActionCancelled));
|
|
2218
|
+
if (this.runPromise === pending) {
|
|
2219
|
+
this.runPromise = null;
|
|
2220
|
+
this.runPromiseDeviceId = null;
|
|
2221
|
+
}
|
|
2222
|
+
// A V1 read cannot be safely reused after abandoning its response.
|
|
2223
|
+
// Drain native teardown before DeviceCommands releases the operation.
|
|
2224
|
+
if (deviceId) await this.disconnect(deviceId);
|
|
1862
2225
|
}
|
|
1863
|
-
|
|
1864
|
-
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/**
|
|
2229
|
+
* Android: request the MTU with nothing queued ahead and never close the client while it is
|
|
2230
|
+
* outstanding; a link that times out or stays at MTU 23 is dropped.
|
|
2231
|
+
*/
|
|
2232
|
+
private async negotiateAndroidMtu(
|
|
2233
|
+
uuid: string,
|
|
2234
|
+
manager: BlePlxManager,
|
|
2235
|
+
device: Device
|
|
2236
|
+
): Promise<Device> {
|
|
2237
|
+
if (!shouldRefreshNegotiatedMtu(device.mtu)) return device;
|
|
2238
|
+
|
|
2239
|
+
const startedAt = Date.now();
|
|
2240
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
2241
|
+
let timedOut = false;
|
|
2242
|
+
let negotiated = device;
|
|
2243
|
+
let failure: string | undefined;
|
|
2244
|
+
try {
|
|
2245
|
+
negotiated = await Promise.race([
|
|
2246
|
+
device.requestMTU(ANDROID_REQUEST_MTU, `${device.id}:mtu:connected:0:${startedAt}`),
|
|
2247
|
+
new Promise<never>((_, reject) => {
|
|
2248
|
+
timer = setTimeout(() => {
|
|
2249
|
+
timedOut = true;
|
|
2250
|
+
reject(
|
|
2251
|
+
new Error(`BLE MTU exchange timeout after ${ANDROID_MTU_EXCHANGE_TIMEOUT_MS}ms`)
|
|
2252
|
+
);
|
|
2253
|
+
}, ANDROID_MTU_EXCHANGE_TIMEOUT_MS);
|
|
2254
|
+
}),
|
|
2255
|
+
]);
|
|
2256
|
+
} catch (error) {
|
|
2257
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
2258
|
+
} finally {
|
|
2259
|
+
if (timer) clearTimeout(timer);
|
|
2260
|
+
}
|
|
2261
|
+
Log?.debug(`[ReactNativeBleTransport] BLE MTU exchange ${failure ? 'failed' : 'completed'}`, {
|
|
2262
|
+
connectIdSuffix: uuid.slice(-8),
|
|
2263
|
+
elapsedMs: Date.now() - startedAt,
|
|
2264
|
+
timedOut,
|
|
2265
|
+
actual: negotiated.mtu,
|
|
2266
|
+
error: failure,
|
|
2267
|
+
});
|
|
2268
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
2269
|
+
if (!timedOut && !isKnownDefaultMtu(negotiated.mtu)) return negotiated;
|
|
2270
|
+
|
|
2271
|
+
// Counted like a setup timeout so a link that keeps failing reaches the wedged-link guard.
|
|
2272
|
+
const resetManager = this.abandonStalledConnection(
|
|
2273
|
+
uuid,
|
|
2274
|
+
timedOut ? 'mtu-backstop' : 'mtu-default'
|
|
2275
|
+
);
|
|
2276
|
+
await this.dropAndroidLink(
|
|
2277
|
+
uuid,
|
|
2278
|
+
manager,
|
|
2279
|
+
negotiated,
|
|
2280
|
+
timedOut ? 'mtu exchange timeout' : 'default mtu'
|
|
2281
|
+
);
|
|
2282
|
+
if (resetManager) throw this.createWedgedBleSetupError();
|
|
2283
|
+
throw ERRORS.TypedError(
|
|
2284
|
+
HardwareErrorCode.BleConnectedError,
|
|
2285
|
+
timedOut
|
|
2286
|
+
? 'BLE MTU exchange did not complete, reconnecting on a fresh link'
|
|
2287
|
+
: `BLE link stayed at the default MTU ${negotiated.mtu}, reconnecting on a fresh link`
|
|
2288
|
+
);
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
/** Close the client and wait out the link idle timer so the next connect gets a fresh link. */
|
|
2292
|
+
private async dropAndroidLink(
|
|
2293
|
+
uuid: string,
|
|
2294
|
+
manager: BlePlxManager,
|
|
2295
|
+
device: Device,
|
|
2296
|
+
reason: string
|
|
2297
|
+
) {
|
|
2298
|
+
await this.runNativeTeardown(uuid, manager, async () => {
|
|
2299
|
+
await Promise.all([
|
|
2300
|
+
this.runBestEffortNativeOperation(`${reason}: cancel manager connection`, () =>
|
|
2301
|
+
manager.cancelDeviceConnection(uuid)
|
|
2302
|
+
),
|
|
2303
|
+
this.runBestEffortNativeOperation(`${reason}: cancel device connection`, () =>
|
|
2304
|
+
device.cancelConnection()
|
|
2305
|
+
),
|
|
2306
|
+
]);
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
const startedAt = Date.now();
|
|
2310
|
+
while (!this.stopped && Date.now() - startedAt < ANDROID_LINK_DROP_QUIET_MS) {
|
|
2311
|
+
await delay(ANDROID_LINK_DROP_POLL_MS);
|
|
2312
|
+
}
|
|
2313
|
+
Log?.debug('[ReactNativeBleTransport] Android BLE link drop', {
|
|
2314
|
+
connectIdSuffix: uuid.slice(-8),
|
|
2315
|
+
reason,
|
|
2316
|
+
stopped: this.stopped,
|
|
2317
|
+
});
|
|
1865
2318
|
}
|
|
1866
2319
|
|
|
1867
2320
|
/** Run a native connect under the JS backstop budget. */
|
|
1868
2321
|
private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
|
|
2322
|
+
if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
2323
|
+
const startedAt = Date.now();
|
|
2324
|
+
let succeeded = false;
|
|
1869
2325
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1870
2326
|
let timedOut = false;
|
|
1871
2327
|
const pending = connect();
|
|
@@ -1887,6 +2343,7 @@ export default class ReactNativeBleTransport {
|
|
|
1887
2343
|
}, BLE_CONNECT_TIMEOUT_MS);
|
|
1888
2344
|
}),
|
|
1889
2345
|
]);
|
|
2346
|
+
succeeded = true;
|
|
1890
2347
|
return result;
|
|
1891
2348
|
} catch (error) {
|
|
1892
2349
|
if (timedOut || isNativeOperationTimeoutError(error)) {
|
|
@@ -1901,6 +2358,12 @@ export default class ReactNativeBleTransport {
|
|
|
1901
2358
|
throw error;
|
|
1902
2359
|
} finally {
|
|
1903
2360
|
if (timer) clearTimeout(timer);
|
|
2361
|
+
Log?.debug('[ReactNativeBleTransport] connect completed', {
|
|
2362
|
+
connectIdSuffix: uuid.slice(-8),
|
|
2363
|
+
elapsedMs: Date.now() - startedAt,
|
|
2364
|
+
succeeded,
|
|
2365
|
+
backstopExpired: timedOut,
|
|
2366
|
+
});
|
|
1904
2367
|
}
|
|
1905
2368
|
}
|
|
1906
2369
|
|
|
@@ -1909,6 +2372,8 @@ export default class ReactNativeBleTransport {
|
|
|
1909
2372
|
uuid: string,
|
|
1910
2373
|
device: Device
|
|
1911
2374
|
): Promise<ResolvedBleCharacteristics> {
|
|
2375
|
+
const startedAt = Date.now();
|
|
2376
|
+
let succeeded = false;
|
|
1912
2377
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1913
2378
|
let timedOut = false;
|
|
1914
2379
|
const pending = this.resolveCharacteristics(device);
|
|
@@ -1929,6 +2394,7 @@ export default class ReactNativeBleTransport {
|
|
|
1929
2394
|
}),
|
|
1930
2395
|
]);
|
|
1931
2396
|
this.connectionSetupTimeoutCounts.delete(uuid);
|
|
2397
|
+
succeeded = true;
|
|
1932
2398
|
return result;
|
|
1933
2399
|
} catch (error) {
|
|
1934
2400
|
if (timedOut || isNativeOperationTimeoutError(error)) {
|
|
@@ -1940,9 +2406,18 @@ export default class ReactNativeBleTransport {
|
|
|
1940
2406
|
throw this.createWedgedBleSetupError();
|
|
1941
2407
|
}
|
|
1942
2408
|
}
|
|
2409
|
+
if (Platform.OS === 'android' && isMissingGattShapeError(error)) {
|
|
2410
|
+
this.androidGattCacheRefreshes.add(uuid);
|
|
2411
|
+
}
|
|
1943
2412
|
throw error;
|
|
1944
2413
|
} finally {
|
|
1945
2414
|
if (timer) clearTimeout(timer);
|
|
2415
|
+
Log?.debug('[ReactNativeBleTransport] GATT setup completed', {
|
|
2416
|
+
connectIdSuffix: uuid.slice(-8),
|
|
2417
|
+
elapsedMs: Date.now() - startedAt,
|
|
2418
|
+
succeeded,
|
|
2419
|
+
backstopExpired: timedOut,
|
|
2420
|
+
});
|
|
1946
2421
|
}
|
|
1947
2422
|
}
|
|
1948
2423
|
|
|
@@ -1954,7 +2429,13 @@ export default class ReactNativeBleTransport {
|
|
|
1954
2429
|
*/
|
|
1955
2430
|
private abandonStalledConnection(
|
|
1956
2431
|
uuid: string,
|
|
1957
|
-
stage:
|
|
2432
|
+
stage:
|
|
2433
|
+
| 'connect-backstop'
|
|
2434
|
+
| 'connect-native'
|
|
2435
|
+
| 'gatt-backstop'
|
|
2436
|
+
| 'gatt-native'
|
|
2437
|
+
| 'mtu-backstop'
|
|
2438
|
+
| 'mtu-default'
|
|
1958
2439
|
): boolean {
|
|
1959
2440
|
const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
|
|
1960
2441
|
this.connectionSetupTimeoutCounts.set(uuid, timeouts);
|
|
@@ -1973,6 +2454,7 @@ export default class ReactNativeBleTransport {
|
|
|
1973
2454
|
this.deviceProtocol.delete(uuid);
|
|
1974
2455
|
this.probingProtocols.delete(uuid);
|
|
1975
2456
|
this.staleBondErrors.delete(uuid);
|
|
2457
|
+
this.iosProbeDisconnectEvidence.delete(uuid);
|
|
1976
2458
|
this.acquiringProtocolV2.delete(uuid);
|
|
1977
2459
|
this.protocolV2Assemblers.delete(uuid);
|
|
1978
2460
|
this.resetProtocolV2Frames(uuid);
|
|
@@ -2071,6 +2553,7 @@ export default class ReactNativeBleTransport {
|
|
|
2071
2553
|
this.deviceProtocol.delete(uuid);
|
|
2072
2554
|
this.probingProtocols.delete(uuid);
|
|
2073
2555
|
this.staleBondErrors.delete(uuid);
|
|
2556
|
+
this.iosProbeDisconnectEvidence.delete(uuid);
|
|
2074
2557
|
this.acquiringProtocolV2.delete(uuid);
|
|
2075
2558
|
this.protocolV2Assemblers.delete(uuid);
|
|
2076
2559
|
this.resetProtocolV2Frames(uuid);
|
|
@@ -2086,6 +2569,7 @@ export default class ReactNativeBleTransport {
|
|
|
2086
2569
|
}
|
|
2087
2570
|
|
|
2088
2571
|
private resetPlxManager() {
|
|
2572
|
+
if (bleManagerResetPromise) return;
|
|
2089
2573
|
const manager = this.blePlxManager;
|
|
2090
2574
|
this.blePlxManager = undefined;
|
|
2091
2575
|
const reason = 'React Native BLE manager reset';
|
|
@@ -2123,20 +2607,32 @@ export default class ReactNativeBleTransport {
|
|
|
2123
2607
|
this.deviceProtocol.clear();
|
|
2124
2608
|
this.probingProtocols.clear();
|
|
2125
2609
|
this.staleBondErrors.clear();
|
|
2610
|
+
this.iosProbeDisconnectEvidence.clear();
|
|
2126
2611
|
this.acquiringProtocolV2.clear();
|
|
2127
2612
|
this.sessionProtocols.clear();
|
|
2128
2613
|
// Keep transport-lifetime V2 proof so the same endpoint can finish a no-probe
|
|
2129
2614
|
// firmware reconnect after the native BLE manager is recreated.
|
|
2130
2615
|
this.protocolReprobeFailures.clear();
|
|
2616
|
+
this.silentDetections.clear();
|
|
2131
2617
|
this.writeTimeoutCounts.clear();
|
|
2132
2618
|
this.connectionSetupTimeoutCounts.clear();
|
|
2133
2619
|
this.monitorTokens.clear();
|
|
2134
2620
|
this.protocolV2Assemblers.clear();
|
|
2621
|
+
let reset: Promise<void>;
|
|
2135
2622
|
try {
|
|
2136
|
-
manager?.destroy();
|
|
2623
|
+
reset = Promise.resolve(manager?.destroy());
|
|
2137
2624
|
} catch (error) {
|
|
2138
|
-
|
|
2625
|
+
reset = Promise.reject(error);
|
|
2139
2626
|
}
|
|
2627
|
+
bleManagerResetPromise = reset;
|
|
2628
|
+
reset.then(
|
|
2629
|
+
() => {
|
|
2630
|
+
if (bleManagerResetPromise === reset) bleManagerResetPromise = undefined;
|
|
2631
|
+
},
|
|
2632
|
+
error => {
|
|
2633
|
+
Log?.error('[ReactNativeBleTransport] BLE manager destroy failed:', error);
|
|
2634
|
+
}
|
|
2635
|
+
);
|
|
2140
2636
|
}
|
|
2141
2637
|
|
|
2142
2638
|
private createProtocolMismatchError(expected: ProtocolType) {
|
|
@@ -2155,6 +2651,47 @@ export default class ReactNativeBleTransport {
|
|
|
2155
2651
|
);
|
|
2156
2652
|
}
|
|
2157
2653
|
|
|
2654
|
+
private rememberIosProbeDisconnect(
|
|
2655
|
+
uuid: string,
|
|
2656
|
+
phase: IosProbeDisconnectEvidence['phase'],
|
|
2657
|
+
error: unknown
|
|
2658
|
+
): Error | undefined {
|
|
2659
|
+
if (!isIosProbeDisconnectError(error)) return undefined;
|
|
2660
|
+
|
|
2661
|
+
const previous = this.iosProbeDisconnectEvidence.get(uuid);
|
|
2662
|
+
const errorCode = (error as { errorCode?: unknown })?.errorCode;
|
|
2663
|
+
const evidence: IosProbeDisconnectEvidence = {
|
|
2664
|
+
phase,
|
|
2665
|
+
...(typeof errorCode === 'string' || typeof errorCode === 'number' ? { errorCode } : {}),
|
|
2666
|
+
};
|
|
2667
|
+
this.iosProbeDisconnectEvidence.set(uuid, evidence);
|
|
2668
|
+
Log?.debug('[ReactNativeBleTransport][BleBondDiagnostic] probe disconnect recorded', {
|
|
2669
|
+
phase,
|
|
2670
|
+
errorCode: evidence.errorCode,
|
|
2671
|
+
repeated: Boolean(previous),
|
|
2672
|
+
});
|
|
2673
|
+
|
|
2674
|
+
if (!previous) return undefined;
|
|
2675
|
+
return this.consumeIosProbeDisconnect(uuid, 'repeated-probe-disconnect');
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
private consumeIosProbeDisconnect(
|
|
2679
|
+
uuid: string,
|
|
2680
|
+
confirmation: 'repeated-probe-disconnect' | 'follow-up-no-response'
|
|
2681
|
+
): Error | undefined {
|
|
2682
|
+
const evidence = this.iosProbeDisconnectEvidence.get(uuid);
|
|
2683
|
+
if (!evidence) return undefined;
|
|
2684
|
+
|
|
2685
|
+
this.iosProbeDisconnectEvidence.delete(uuid);
|
|
2686
|
+
Log?.debug('[ReactNativeBleTransport][BleBondDiagnostic] stale bond confirmed', {
|
|
2687
|
+
confirmation,
|
|
2688
|
+
triggerPhase: evidence.phase,
|
|
2689
|
+
triggerErrorCode: evidence.errorCode,
|
|
2690
|
+
finalErrorCode: HardwareErrorCode.BleBondInvalid,
|
|
2691
|
+
});
|
|
2692
|
+
return ERRORS.TypedError(HardwareErrorCode.BleBondInvalid);
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2158
2695
|
private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
|
|
2159
2696
|
if (this.probingProtocols.get(uuid) === protocol) {
|
|
2160
2697
|
this.probingProtocols.delete(uuid);
|
|
@@ -2186,6 +2723,7 @@ export default class ReactNativeBleTransport {
|
|
|
2186
2723
|
// received no response. skipProtocolProbe does not need it either — its only
|
|
2187
2724
|
// caller is the V2 firmware-install reconnect.
|
|
2188
2725
|
if (expectedProtocol === 'V1') {
|
|
2726
|
+
this.iosProbeDisconnectEvidence.delete(uuid);
|
|
2189
2727
|
this.deviceProtocol.set(uuid, 'V1');
|
|
2190
2728
|
Log?.debug('[ReactNativeBleTransport] protocol selected', {
|
|
2191
2729
|
deviceId: uuid,
|
|
@@ -2201,6 +2739,7 @@ export default class ReactNativeBleTransport {
|
|
|
2201
2739
|
|
|
2202
2740
|
if (expectedProtocol === 'V2') {
|
|
2203
2741
|
if (await this.probeProtocolV2(uuid)) {
|
|
2742
|
+
this.iosProbeDisconnectEvidence.delete(uuid);
|
|
2204
2743
|
this.deviceProtocol.set(uuid, 'V2');
|
|
2205
2744
|
this.sessionProtocols.set(uuid, 'V2');
|
|
2206
2745
|
this.confirmedProtocolV2.add(uuid);
|
|
@@ -2211,6 +2750,18 @@ export default class ReactNativeBleTransport {
|
|
|
2211
2750
|
});
|
|
2212
2751
|
return 'V2';
|
|
2213
2752
|
}
|
|
2753
|
+
const staleBondError = this.consumeIosProbeDisconnect(uuid, 'follow-up-no-response');
|
|
2754
|
+
if (Platform.OS === 'ios') {
|
|
2755
|
+
Log?.debug('[ReactNativeBleTransport][BleBondDiagnostic] protocol detection ended', {
|
|
2756
|
+
outcome: 'expected-v2-no-response',
|
|
2757
|
+
expectedProtocol,
|
|
2758
|
+
protocolHint: protocolHint ?? 'none',
|
|
2759
|
+
finalErrorCode: staleBondError
|
|
2760
|
+
? HardwareErrorCode.BleBondInvalid
|
|
2761
|
+
: HardwareErrorCode.RuntimeError,
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
if (staleBondError) throw staleBondError;
|
|
2214
2765
|
throw this.createProtocolMismatchError(expectedProtocol);
|
|
2215
2766
|
}
|
|
2216
2767
|
|
|
@@ -2229,6 +2780,8 @@ export default class ReactNativeBleTransport {
|
|
|
2229
2780
|
reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
|
|
2230
2781
|
const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
|
|
2231
2782
|
|
|
2783
|
+
await this.wakeSilentProtocolV1Device(uuid, probeOrder);
|
|
2784
|
+
|
|
2232
2785
|
for (let i = 0; i < probeOrder.length; i += 1) {
|
|
2233
2786
|
const protocol = probeOrder[i];
|
|
2234
2787
|
if (i > 0) {
|
|
@@ -2244,12 +2797,14 @@ export default class ReactNativeBleTransport {
|
|
|
2244
2797
|
const detected =
|
|
2245
2798
|
protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
|
|
2246
2799
|
if (detected) {
|
|
2800
|
+
this.iosProbeDisconnectEvidence.delete(uuid);
|
|
2247
2801
|
this.deviceProtocol.set(uuid, protocol);
|
|
2248
2802
|
this.sessionProtocols.set(uuid, protocol);
|
|
2249
2803
|
if (protocol === 'V2') {
|
|
2250
2804
|
this.confirmedProtocolV2.add(uuid);
|
|
2251
2805
|
}
|
|
2252
2806
|
this.protocolReprobeFailures.delete(uuid);
|
|
2807
|
+
this.silentDetections.delete(uuid);
|
|
2253
2808
|
Log?.debug('[ReactNativeBleTransport] protocol detected', {
|
|
2254
2809
|
deviceId: uuid,
|
|
2255
2810
|
protocol,
|
|
@@ -2259,6 +2814,9 @@ export default class ReactNativeBleTransport {
|
|
|
2259
2814
|
}
|
|
2260
2815
|
}
|
|
2261
2816
|
|
|
2817
|
+
// Arms the wake for the next detection.
|
|
2818
|
+
if (!this.silentDetections.has(uuid)) this.silentDetections.set(uuid, 'silent');
|
|
2819
|
+
|
|
2262
2820
|
if (trustSessionProtocol) {
|
|
2263
2821
|
// Still silent on its own protocol: count it, and let the streak expire the
|
|
2264
2822
|
// shortcut so a device that genuinely switched protocols is found again.
|
|
@@ -2269,9 +2827,56 @@ export default class ReactNativeBleTransport {
|
|
|
2269
2827
|
|
|
2270
2828
|
this.deviceProtocol.delete(uuid);
|
|
2271
2829
|
this.probingProtocols.delete(uuid);
|
|
2830
|
+
const staleBondError = this.consumeIosProbeDisconnect(uuid, 'follow-up-no-response');
|
|
2831
|
+
if (Platform.OS === 'ios') {
|
|
2832
|
+
Log?.debug('[ReactNativeBleTransport][BleBondDiagnostic] protocol detection ended', {
|
|
2833
|
+
outcome: 'all-probes-no-response',
|
|
2834
|
+
expectedProtocol: expectedProtocol ?? 'none',
|
|
2835
|
+
protocolHint: protocolHint ?? 'none',
|
|
2836
|
+
sessionProtocol: sessionProtocol ?? 'none',
|
|
2837
|
+
probeOrder: probeOrder.join(','),
|
|
2838
|
+
trustSessionProtocol,
|
|
2839
|
+
reprobeFailures,
|
|
2840
|
+
transportCached: Boolean(transportCache[uuid]),
|
|
2841
|
+
notifyActive: Boolean(transportCache[uuid]?.notifySubscription),
|
|
2842
|
+
staleBondRecorded: this.staleBondErrors.has(uuid),
|
|
2843
|
+
finalErrorCode: staleBondError
|
|
2844
|
+
? HardwareErrorCode.BleBondInvalid
|
|
2845
|
+
: HardwareErrorCode.BleTimeoutError,
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
if (staleBondError) throw staleBondError;
|
|
2272
2849
|
throw this.createProtocolDetectionError();
|
|
2273
2850
|
}
|
|
2274
2851
|
|
|
2852
|
+
/**
|
|
2853
|
+
* A sleeping Classic drops GetFeatures/Ping and only leaves its screensaver on Initialize, which
|
|
2854
|
+
* resets the wallet session, so it is sent once after a fully silent detection. The firmware does
|
|
2855
|
+
* not reliably answer it, so its timeout must not drop the link. It only runs when V1 is probed
|
|
2856
|
+
* first, so a late reply lands on the V1 probe rather than on a V2 one.
|
|
2857
|
+
*/
|
|
2858
|
+
private async wakeSilentProtocolV1Device(uuid: string, probeOrder: ProtocolType[]) {
|
|
2859
|
+
if (
|
|
2860
|
+
Platform.OS !== 'android' ||
|
|
2861
|
+
probeOrder[0] !== 'V1' ||
|
|
2862
|
+
this.silentDetections.get(uuid) !== 'silent'
|
|
2863
|
+
) {
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
this.silentDetections.set(uuid, 'woken');
|
|
2867
|
+
Log?.debug('[ReactNativeBleTransport] sending Protocol V1 Initialize wake', {
|
|
2868
|
+
connectIdSuffix: uuid.slice(-8),
|
|
2869
|
+
});
|
|
2870
|
+
try {
|
|
2871
|
+
this.probingProtocols.set(uuid, 'V1');
|
|
2872
|
+
await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
|
|
2873
|
+
} catch (error) {
|
|
2874
|
+
if (shouldRethrowProtocolProbeError(error)) throw error;
|
|
2875
|
+
} finally {
|
|
2876
|
+
this.clearProbeProtocol(uuid, 'V1');
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2275
2880
|
private async resetProbeStateAfterProtocolProbe(uuid: string, protocol: ProtocolType) {
|
|
2276
2881
|
const transport = transportCache[uuid];
|
|
2277
2882
|
await this.protocolV2Links.invalidateLink(
|
|
@@ -2339,9 +2944,19 @@ export default class ReactNativeBleTransport {
|
|
|
2339
2944
|
} catch (error) {
|
|
2340
2945
|
this.clearProbeProtocol(uuid, 'V1');
|
|
2341
2946
|
Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2947
|
+
const shouldRethrow = shouldRethrowProtocolProbeError(error);
|
|
2948
|
+
const staleBondError = this.rememberIosProbeDisconnect(
|
|
2949
|
+
uuid,
|
|
2950
|
+
'protocol-v1-get-features',
|
|
2951
|
+
error
|
|
2952
|
+
);
|
|
2953
|
+
logIosBleBondDiagnostic(
|
|
2954
|
+
'protocol-v1-get-features',
|
|
2955
|
+
error,
|
|
2956
|
+
shouldRethrow ? 'rethrow' : 'fallback'
|
|
2957
|
+
);
|
|
2958
|
+
if (staleBondError) throw staleBondError;
|
|
2959
|
+
if (shouldRethrow) {
|
|
2345
2960
|
throw error;
|
|
2346
2961
|
}
|
|
2347
2962
|
return false;
|
|
@@ -2356,18 +2971,26 @@ export default class ReactNativeBleTransport {
|
|
|
2356
2971
|
this.probingProtocols.set(uuid, 'V2');
|
|
2357
2972
|
this.protocolV2Assemblers.get(uuid)?.reset();
|
|
2358
2973
|
this.throwIfStaleBondError(uuid);
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2974
|
+
let detected: boolean;
|
|
2975
|
+
try {
|
|
2976
|
+
detected = await probeProtocolV2Helper({
|
|
2977
|
+
call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
|
|
2978
|
+
this.callProtocolV2(uuid, name, data, options),
|
|
2979
|
+
timeoutMs: PROTOCOL_V2_PROBE_TIMEOUT_MS,
|
|
2980
|
+
logger: Log,
|
|
2981
|
+
logPrefix: 'ProtocolV2 RN-BLE',
|
|
2982
|
+
onProbeFailed: () => {
|
|
2983
|
+
this.protocolV2Assemblers.get(uuid)?.reset();
|
|
2984
|
+
this.resetProtocolV2Frames(uuid);
|
|
2985
|
+
},
|
|
2986
|
+
shouldRethrow: shouldRethrowProtocolProbeError,
|
|
2987
|
+
});
|
|
2988
|
+
} catch (error) {
|
|
2989
|
+
const staleBondError = this.rememberIosProbeDisconnect(uuid, 'protocol-v2-ping', error);
|
|
2990
|
+
logIosBleBondDiagnostic('protocol-v2-ping', error, 'rethrow');
|
|
2991
|
+
if (staleBondError) throw staleBondError;
|
|
2992
|
+
throw error;
|
|
2993
|
+
}
|
|
2371
2994
|
if (!detected) {
|
|
2372
2995
|
this.clearProbeProtocol(uuid, 'V2');
|
|
2373
2996
|
} else {
|
|
@@ -2513,6 +3136,9 @@ export default class ReactNativeBleTransport {
|
|
|
2513
3136
|
this.rememberStaleBondError(uuid, bondError);
|
|
2514
3137
|
throw bondError;
|
|
2515
3138
|
}
|
|
3139
|
+
if (isNativeBleDisconnectError(error)) {
|
|
3140
|
+
throw toBleDisconnectHardwareError(error);
|
|
3141
|
+
}
|
|
2516
3142
|
if (
|
|
2517
3143
|
getFirmwareUploadWriteRetryType(error) !== 'congested' ||
|
|
2518
3144
|
attempt >= FIRMWARE_UPLOAD_WRITE_MAX_RETRIES
|
|
@@ -2573,6 +3199,7 @@ export default class ReactNativeBleTransport {
|
|
|
2573
3199
|
throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
|
|
2574
3200
|
}
|
|
2575
3201
|
|
|
3202
|
+
const isProtocolProbe = this.probingProtocols.get(uuid) === 'V2';
|
|
2576
3203
|
const callOptions = options;
|
|
2577
3204
|
const highThroughputWrite = isProtocolV2HighThroughputCall(name);
|
|
2578
3205
|
|
|
@@ -2624,6 +3251,19 @@ export default class ReactNativeBleTransport {
|
|
|
2624
3251
|
);
|
|
2625
3252
|
} catch (e) {
|
|
2626
3253
|
Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
|
|
3254
|
+
if (
|
|
3255
|
+
!isProtocolProbe &&
|
|
3256
|
+
e?.errorCode === HardwareErrorCode.BleTimeoutError &&
|
|
3257
|
+
!this.monitorTokens.has(uuid)
|
|
3258
|
+
) {
|
|
3259
|
+
// The failed link has finished invalidating. Disconnect outside that
|
|
3260
|
+
// callback to avoid waiting on its own invalidation or acquire lock.
|
|
3261
|
+
await this.runLifecycleOperation(uuid, async () => {
|
|
3262
|
+
// A queued timeout leaves its active monitor intact; a newer acquire
|
|
3263
|
+
// may also have installed one while cleanup waited for the lifecycle lock.
|
|
3264
|
+
if (!this.monitorTokens.has(uuid)) await this.disconnectUnlocked(uuid);
|
|
3265
|
+
});
|
|
3266
|
+
}
|
|
2627
3267
|
throw e;
|
|
2628
3268
|
} finally {
|
|
2629
3269
|
if (highThroughputWrite) {
|
|
@@ -2636,7 +3276,12 @@ export default class ReactNativeBleTransport {
|
|
|
2636
3276
|
const transport = this.getCachedTransport(uuid);
|
|
2637
3277
|
if (!shouldRefreshNegotiatedMtu(transport.mtuSize)) return;
|
|
2638
3278
|
|
|
2639
|
-
const refreshedDevice = await requestNegotiatedMtu(
|
|
3279
|
+
const { device: refreshedDevice } = await requestNegotiatedMtu(
|
|
3280
|
+
transport.device,
|
|
3281
|
+
'highThroughput',
|
|
3282
|
+
1,
|
|
3283
|
+
transactionId => this.blePlxManager?.cancelTransaction(transactionId)
|
|
3284
|
+
);
|
|
2640
3285
|
transport.device = refreshedDevice;
|
|
2641
3286
|
transport.mtuSize =
|
|
2642
3287
|
typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
|