@onekeyfe/hd-transport-react-native 1.2.0-alpha.56 → 1.2.0-alpha.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +59 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +245 -84
- package/jest.config.js +5 -0
- package/package.json +5 -5
- package/src/BleTransport.ts +1 -1
- package/src/__tests__/BleTransport.test.ts +2 -2
- package/src/__tests__/connectTimeout.test.ts +192 -0
- package/src/__tests__/protocolV2Link.test.ts +41 -241
- package/src/__tests__/staleCallTimeout.test.ts +211 -0
- package/src/__tests__/writePacketTimeout.test.ts +306 -0
- package/src/index.ts +357 -83
- package/src/__tests__/enumerate.test.ts +0 -132
package/src/index.ts
CHANGED
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
HardwareErrorCode,
|
|
29
29
|
createDeferred,
|
|
30
30
|
isOnekeyBluetoothDevice,
|
|
31
|
-
isPro2FindMyAdvertisementName,
|
|
32
31
|
} from '@onekeyfe/hd-shared';
|
|
33
32
|
|
|
34
33
|
import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
|
|
@@ -61,7 +60,6 @@ const FIRMWARE_UPLOAD_WRITE_BURST_SIZE = Platform.OS === 'ios' ? 4 : 5;
|
|
|
61
60
|
const FIRMWARE_UPLOAD_WRITE_PAUSE_MS = Platform.OS === 'ios' ? 8 : 10;
|
|
62
61
|
const FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS = Platform.OS === 'ios' ? 24 : 30;
|
|
63
62
|
const FIRMWARE_UPLOAD_WRITE_MAX_RETRIES = 8;
|
|
64
|
-
const IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS = 5;
|
|
65
63
|
const ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH = 192;
|
|
66
64
|
const FIRMWARE_UPLOAD_WRITE_PACKET_CAPACITY =
|
|
67
65
|
Platform.OS === 'ios' ? IOS_PACKET_LENGTH : ANDROID_FIRMWARE_UPLOAD_PACKET_LENGTH;
|
|
@@ -108,6 +106,22 @@ const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, max
|
|
|
108
106
|
Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
109
107
|
const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
|
|
110
108
|
const PROTOCOL_V2_PROBE_TIMEOUT_MS = 10_000;
|
|
109
|
+
/**
|
|
110
|
+
* Per-packet write budget. iOS only resolves writeWithoutResponse once CoreBluetooth
|
|
111
|
+
* reports the peripheral ready again; a peripheral wedged by its own firmware reboot
|
|
112
|
+
* stops reporting ready while staying connected, so the write promise never settles.
|
|
113
|
+
* Response timeouts cannot cover that — they are armed after the writes complete —
|
|
114
|
+
* and an unbounded write leaves the whole transport unusable until the process dies.
|
|
115
|
+
* A healthy packet completes in milliseconds, so this only fires on a dead link.
|
|
116
|
+
*/
|
|
117
|
+
export const BLE_WRITE_PACKET_TIMEOUT_MS = 10_000;
|
|
118
|
+
const WEDGED_WRITE_MESSAGE = 'BLE write timeout after';
|
|
119
|
+
const isWedgedWriteError = (error: unknown): boolean =>
|
|
120
|
+
(error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleWriteCharacteristicError &&
|
|
121
|
+
typeof (error as { message?: unknown })?.message === 'string' &&
|
|
122
|
+
(error as { message: string }).message.startsWith(WEDGED_WRITE_MESSAGE);
|
|
123
|
+
/** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
|
|
124
|
+
export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
|
|
111
125
|
const DEVICE_SCAN_TIMEOUT_MS = 3000;
|
|
112
126
|
const IOS_NOTIFY_READY_DELAY_MS = 150;
|
|
113
127
|
const ANDROID_NOTIFY_READY_DELAY_MS = 300;
|
|
@@ -164,12 +178,35 @@ function getDeviceDisplayName(device?: Device | null) {
|
|
|
164
178
|
|
|
165
179
|
const ANDROID_REQUEST_MTU = 256;
|
|
166
180
|
|
|
181
|
+
const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
|
|
182
|
+
|
|
167
183
|
const connectOptions: Record<string, unknown> = {
|
|
168
184
|
requestMTU: ANDROID_REQUEST_MTU,
|
|
169
|
-
timeout:
|
|
185
|
+
timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
|
|
170
186
|
refreshGatt: 'OnConnected',
|
|
171
187
|
};
|
|
172
188
|
|
|
189
|
+
/** Fallback connect options: drops requestMTU (the thing being worked around) but keeps the native budget. */
|
|
190
|
+
const fallbackConnectOptions: Record<string, unknown> = {
|
|
191
|
+
timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* JS backstop for connect. The native adapter applies its own 3s budget, but it
|
|
196
|
+
* schedules that timeout on its serial queue, so a busy queue (e.g. right after a
|
|
197
|
+
* firmware install tears the link down) can leave the promise unsettled — observed
|
|
198
|
+
* blocking a reconnect for 61s until the app-level timeout. Healthy connects finish
|
|
199
|
+
* inside the native budget, so this only fires when the native timeout did not.
|
|
200
|
+
*/
|
|
201
|
+
export const BLE_CONNECT_TIMEOUT_MS = BLE_NATIVE_CONNECT_TIMEOUT_MS * 2 + 2000;
|
|
202
|
+
/** Consecutive connect timeouts on one device before the BLE manager itself is recreated. */
|
|
203
|
+
export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
|
|
204
|
+
const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
|
|
205
|
+
const isConnectTimeoutError = (error: unknown): boolean =>
|
|
206
|
+
(error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
|
|
207
|
+
typeof (error as { message?: unknown })?.message === 'string' &&
|
|
208
|
+
(error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
|
|
209
|
+
|
|
173
210
|
export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
|
|
174
211
|
|
|
175
212
|
const tryToGetConfiguration = (device: Device) => {
|
|
@@ -258,6 +295,20 @@ export default class ReactNativeBleTransport {
|
|
|
258
295
|
/** Per-device protocol type detected by active wire-level probe after connect. */
|
|
259
296
|
private deviceProtocol: Map<string, ProtocolType> = new Map();
|
|
260
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Protocol a probe is currently trying, before the device has confirmed it. Calls
|
|
300
|
+
* must route with it, but acquire() must not treat it as a detected protocol: a
|
|
301
|
+
* probe that never answers would otherwise leave the reuse fast path handing out a
|
|
302
|
+
* transport that was never validated.
|
|
303
|
+
*/
|
|
304
|
+
private probingProtocols: Map<string, ProtocolType> = new Map();
|
|
305
|
+
|
|
306
|
+
/** Consecutive write timeouts per device; reset by any write that completes. */
|
|
307
|
+
private writeTimeoutCounts: Map<string, number> = new Map();
|
|
308
|
+
|
|
309
|
+
/** Consecutive connect timeouts per device; reset by any connect that settles. */
|
|
310
|
+
private connectTimeoutCounts: Map<string, number> = new Map();
|
|
311
|
+
|
|
261
312
|
private deviceProtocolHints: Map<string, ProtocolType> = new Map();
|
|
262
313
|
|
|
263
314
|
private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
|
|
@@ -477,13 +528,13 @@ export default class ReactNativeBleTransport {
|
|
|
477
528
|
const isConnected = await device.isConnected().catch(() => false);
|
|
478
529
|
if (!isConnected) {
|
|
479
530
|
try {
|
|
480
|
-
device = await device.connect(connectOptions);
|
|
531
|
+
device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
|
|
481
532
|
} catch (e) {
|
|
482
533
|
if (
|
|
483
534
|
e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
|
|
484
535
|
e.errorCode === BleErrorCode.OperationCancelled
|
|
485
536
|
) {
|
|
486
|
-
device = await device.connect();
|
|
537
|
+
device = await this.connectWithTimeout(uuid, () => device.connect());
|
|
487
538
|
} else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
|
|
488
539
|
throw e;
|
|
489
540
|
}
|
|
@@ -582,21 +633,12 @@ export default class ReactNativeBleTransport {
|
|
|
582
633
|
}
|
|
583
634
|
|
|
584
635
|
const displayName = getDeviceDisplayName(device);
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
const isOneKey =
|
|
592
|
-
!isUnnamedIOSPeripheral &&
|
|
593
|
-
!isFindMyPeripheral &&
|
|
594
|
-
isOnekeyBluetoothDevice({
|
|
595
|
-
id: device?.id,
|
|
596
|
-
name: device?.name,
|
|
597
|
-
localName: device?.localName,
|
|
598
|
-
serviceUuids: device?.serviceUUIDs,
|
|
599
|
-
});
|
|
636
|
+
const isOneKey = isOnekeyBluetoothDevice({
|
|
637
|
+
id: device?.id,
|
|
638
|
+
name: device?.name,
|
|
639
|
+
localName: device?.localName,
|
|
640
|
+
serviceUuids: device?.serviceUUIDs,
|
|
641
|
+
});
|
|
600
642
|
if (isOneKey) {
|
|
601
643
|
addDevice(device as unknown as Device);
|
|
602
644
|
} else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
|
|
@@ -617,12 +659,7 @@ export default class ReactNativeBleTransport {
|
|
|
617
659
|
'localName' in device && typeof device.localName === 'string'
|
|
618
660
|
? device.localName
|
|
619
661
|
: null;
|
|
620
|
-
const isFindMyPeripheral =
|
|
621
|
-
isPro2FindMyAdvertisementName(device.name) ||
|
|
622
|
-
isPro2FindMyAdvertisementName(localName);
|
|
623
|
-
|
|
624
662
|
if (
|
|
625
|
-
!isFindMyPeripheral &&
|
|
626
663
|
isOnekeyBluetoothDevice({
|
|
627
664
|
id: device.id,
|
|
628
665
|
name: device.name,
|
|
@@ -775,15 +812,22 @@ export default class ReactNativeBleTransport {
|
|
|
775
812
|
if (!device) {
|
|
776
813
|
Log?.debug('try to connect to device: ', uuid);
|
|
777
814
|
try {
|
|
778
|
-
device = await
|
|
815
|
+
device = await this.connectWithTimeout(uuid, () =>
|
|
816
|
+
blePlxManager.connectToDevice(uuid, connectOptions)
|
|
817
|
+
);
|
|
779
818
|
} catch (e) {
|
|
780
819
|
Log?.debug('try to connect to device has error: ', e);
|
|
820
|
+
if (isConnectTimeoutError(e)) {
|
|
821
|
+
throw e;
|
|
822
|
+
}
|
|
781
823
|
if (
|
|
782
824
|
e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
|
|
783
825
|
e.errorCode === BleErrorCode.OperationCancelled
|
|
784
826
|
) {
|
|
785
827
|
Log?.debug('first try to reconnect without params');
|
|
786
|
-
device = await
|
|
828
|
+
device = await this.connectWithTimeout(uuid, () =>
|
|
829
|
+
blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
|
|
830
|
+
);
|
|
787
831
|
} else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
|
|
788
832
|
Log?.debug('device already connected');
|
|
789
833
|
throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
|
|
@@ -799,26 +843,36 @@ export default class ReactNativeBleTransport {
|
|
|
799
843
|
|
|
800
844
|
if (!(await device.isConnected())) {
|
|
801
845
|
Log?.debug('not connected, try to connect to device: ', uuid);
|
|
846
|
+
const disconnectedDevice = device;
|
|
802
847
|
|
|
803
848
|
try {
|
|
804
|
-
device = await
|
|
849
|
+
device = await this.connectWithTimeout(uuid, () =>
|
|
850
|
+
disconnectedDevice.connect(connectOptions)
|
|
851
|
+
);
|
|
805
852
|
} catch (e) {
|
|
806
853
|
Log?.debug('not connected, try to connect to device has error: ', e);
|
|
854
|
+
if (isConnectTimeoutError(e)) {
|
|
855
|
+
throw e;
|
|
856
|
+
}
|
|
807
857
|
if (
|
|
808
858
|
e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
|
|
809
859
|
e.errorCode === BleErrorCode.OperationCancelled
|
|
810
860
|
) {
|
|
811
861
|
Log?.debug('second try to reconnect without params');
|
|
812
862
|
try {
|
|
813
|
-
device = await
|
|
863
|
+
device = await this.connectWithTimeout(uuid, () =>
|
|
864
|
+
disconnectedDevice.connect(fallbackConnectOptions)
|
|
865
|
+
);
|
|
814
866
|
} catch (e) {
|
|
815
867
|
Log?.debug('last try to reconnect error: ', e);
|
|
816
868
|
// last try to reconnect device if this issue exists
|
|
817
869
|
// https://github.com/dotintent/react-native-ble-plx/issues/426
|
|
818
870
|
if (e.errorCode === BleErrorCode.OperationCancelled) {
|
|
819
871
|
Log?.debug('last try to reconnect');
|
|
820
|
-
await
|
|
821
|
-
device = await
|
|
872
|
+
await disconnectedDevice.cancelConnection();
|
|
873
|
+
device = await this.connectWithTimeout(uuid, () =>
|
|
874
|
+
disconnectedDevice.connect(fallbackConnectOptions)
|
|
875
|
+
);
|
|
822
876
|
}
|
|
823
877
|
}
|
|
824
878
|
} else {
|
|
@@ -895,7 +949,7 @@ export default class ReactNativeBleTransport {
|
|
|
895
949
|
Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
|
|
896
950
|
return;
|
|
897
951
|
}
|
|
898
|
-
if (this.
|
|
952
|
+
if (this.getActiveProtocol(uuid) === 'V2') {
|
|
899
953
|
let errorCode:
|
|
900
954
|
| typeof HardwareErrorCode.BleDeviceBondError
|
|
901
955
|
| typeof HardwareErrorCode.BleCharacteristicNotifyError
|
|
@@ -965,7 +1019,7 @@ export default class ReactNativeBleTransport {
|
|
|
965
1019
|
|
|
966
1020
|
try {
|
|
967
1021
|
const data = Buffer.from(c.value as string, 'base64');
|
|
968
|
-
const protocol = this.
|
|
1022
|
+
const protocol = this.getActiveProtocol(uuid);
|
|
969
1023
|
if (!protocol) {
|
|
970
1024
|
Log?.debug('monitor data ignored before protocol detection: ', uuid);
|
|
971
1025
|
return;
|
|
@@ -999,7 +1053,7 @@ export default class ReactNativeBleTransport {
|
|
|
999
1053
|
} catch (error) {
|
|
1000
1054
|
Log?.debug('monitor data error: ', error);
|
|
1001
1055
|
const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
|
|
1002
|
-
if (this.
|
|
1056
|
+
if (this.getActiveProtocol(uuid) === 'V2') {
|
|
1003
1057
|
this.rejectProtocolV2Frames(uuid, notifyError);
|
|
1004
1058
|
} else if (this.runPromiseDeviceId === uuid) {
|
|
1005
1059
|
this.runPromise?.reject(notifyError);
|
|
@@ -1063,6 +1117,7 @@ export default class ReactNativeBleTransport {
|
|
|
1063
1117
|
}
|
|
1064
1118
|
|
|
1065
1119
|
this.deviceProtocol.delete(uuid);
|
|
1120
|
+
this.probingProtocols.delete(uuid);
|
|
1066
1121
|
// Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
|
|
1067
1122
|
this.protocolV2Assemblers.get(uuid)?.reset();
|
|
1068
1123
|
this.protocolV2Assemblers.delete(uuid);
|
|
@@ -1129,8 +1184,25 @@ export default class ReactNativeBleTransport {
|
|
|
1129
1184
|
const transport = this.getCachedTransport(uuid);
|
|
1130
1185
|
const runPromise = createDeferred<string>();
|
|
1131
1186
|
runPromise.promise.catch(() => undefined);
|
|
1187
|
+
const supersededRunPromise = this.runPromise;
|
|
1188
|
+
if (supersededRunPromise) {
|
|
1189
|
+
// Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
|
|
1190
|
+
// the superseded deferred now so its response race resolves and its finally block
|
|
1191
|
+
// clears its timeout timer; an orphaned timer would otherwise fire much later and
|
|
1192
|
+
// tear down the shared connection while another call is using it.
|
|
1193
|
+
supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
|
|
1194
|
+
}
|
|
1132
1195
|
this.runPromise = runPromise;
|
|
1133
1196
|
this.runPromiseDeviceId = uuid;
|
|
1197
|
+
// A superseded call's late write failure must not clear the successor's ownership;
|
|
1198
|
+
// only the call that still owns the slot may release it.
|
|
1199
|
+
const releaseOwnershipIfCurrent = () => {
|
|
1200
|
+
if (this.runPromise === runPromise) {
|
|
1201
|
+
this.runPromise = null;
|
|
1202
|
+
this.runPromiseDeviceId = null;
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
const isCurrentOwner = () => this.runPromise === runPromise;
|
|
1134
1206
|
const messages = this._messages;
|
|
1135
1207
|
const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
|
|
1136
1208
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -1156,6 +1228,9 @@ export default class ReactNativeBleTransport {
|
|
|
1156
1228
|
chunk = ByteBuffer.allocate(packetCapacity);
|
|
1157
1229
|
} catch (e) {
|
|
1158
1230
|
onError(e);
|
|
1231
|
+
if (isWedgedWriteError(e)) {
|
|
1232
|
+
throw e;
|
|
1233
|
+
}
|
|
1159
1234
|
throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
|
|
1160
1235
|
}
|
|
1161
1236
|
}
|
|
@@ -1187,6 +1262,9 @@ export default class ReactNativeBleTransport {
|
|
|
1187
1262
|
}
|
|
1188
1263
|
} catch (e) {
|
|
1189
1264
|
onError(e);
|
|
1265
|
+
if (isWedgedWriteError(e)) {
|
|
1266
|
+
throw e;
|
|
1267
|
+
}
|
|
1190
1268
|
throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
|
|
1191
1269
|
}
|
|
1192
1270
|
}
|
|
@@ -1200,9 +1278,15 @@ export default class ReactNativeBleTransport {
|
|
|
1200
1278
|
if (name === 'EmmcFileWrite') {
|
|
1201
1279
|
await writeChunkedData(
|
|
1202
1280
|
buffers,
|
|
1203
|
-
data =>
|
|
1281
|
+
data =>
|
|
1282
|
+
this.writeBlePacket(
|
|
1283
|
+
uuid,
|
|
1284
|
+
data,
|
|
1285
|
+
payload => transport.writeWithRetry(payload),
|
|
1286
|
+
isCurrentOwner
|
|
1287
|
+
),
|
|
1204
1288
|
e => {
|
|
1205
|
-
|
|
1289
|
+
releaseOwnershipIfCurrent();
|
|
1206
1290
|
Log?.error('writeCharacteristic write error: ', e);
|
|
1207
1291
|
}
|
|
1208
1292
|
);
|
|
@@ -1223,7 +1307,12 @@ export default class ReactNativeBleTransport {
|
|
|
1223
1307
|
// eslint-disable-next-line no-constant-condition
|
|
1224
1308
|
while (true) {
|
|
1225
1309
|
try {
|
|
1226
|
-
await
|
|
1310
|
+
await this.writeBlePacket(
|
|
1311
|
+
uuid,
|
|
1312
|
+
data,
|
|
1313
|
+
payload => transport.writeCharacteristic.writeWithoutResponse(payload),
|
|
1314
|
+
isCurrentOwner
|
|
1315
|
+
);
|
|
1227
1316
|
return;
|
|
1228
1317
|
} catch (error) {
|
|
1229
1318
|
const retryType = getFirmwareUploadWriteRetryType(error);
|
|
@@ -1242,7 +1331,7 @@ export default class ReactNativeBleTransport {
|
|
|
1242
1331
|
}
|
|
1243
1332
|
},
|
|
1244
1333
|
e => {
|
|
1245
|
-
|
|
1334
|
+
releaseOwnershipIfCurrent();
|
|
1246
1335
|
Log?.error('writeCharacteristic write error: ', e);
|
|
1247
1336
|
}
|
|
1248
1337
|
);
|
|
@@ -1251,16 +1340,18 @@ export default class ReactNativeBleTransport {
|
|
|
1251
1340
|
const outData = o.toString('base64');
|
|
1252
1341
|
// Upload resources on low-end phones may OOM
|
|
1253
1342
|
try {
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
}
|
|
1343
|
+
await this.writeBlePacket(
|
|
1344
|
+
uuid,
|
|
1345
|
+
outData,
|
|
1346
|
+
payload => transport.writeCharacteristic.writeWithoutResponse(payload),
|
|
1347
|
+
isCurrentOwner
|
|
1348
|
+
);
|
|
1261
1349
|
} catch (e) {
|
|
1262
1350
|
Log?.debug('writeCharacteristic write error: ', e);
|
|
1263
|
-
|
|
1351
|
+
releaseOwnershipIfCurrent();
|
|
1352
|
+
if (isWedgedWriteError(e)) {
|
|
1353
|
+
throw e;
|
|
1354
|
+
}
|
|
1264
1355
|
if (e.errorCode === BleErrorCode.DeviceDisconnected) {
|
|
1265
1356
|
throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
|
|
1266
1357
|
} else if (e.errorCode === BleErrorCode.OperationStartFailed) {
|
|
@@ -1303,8 +1394,13 @@ export default class ReactNativeBleTransport {
|
|
|
1303
1394
|
}
|
|
1304
1395
|
const isProbeTimeout =
|
|
1305
1396
|
name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
|
|
1397
|
+
// A call that has been superseded (forceRun) or cleaned up no longer owns the
|
|
1398
|
+
// transport; its late timeout must not tear down the connection the current
|
|
1399
|
+
// call is actively using.
|
|
1400
|
+
const isStaleCall = this.runPromise !== runPromise;
|
|
1306
1401
|
if (
|
|
1307
1402
|
!isProbeTimeout &&
|
|
1403
|
+
!isStaleCall &&
|
|
1308
1404
|
(e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
|
|
1309
1405
|
) {
|
|
1310
1406
|
await this.disconnect(uuid);
|
|
@@ -1384,6 +1480,7 @@ export default class ReactNativeBleTransport {
|
|
|
1384
1480
|
delete transportCache[session];
|
|
1385
1481
|
}
|
|
1386
1482
|
this.deviceProtocol.delete(session);
|
|
1483
|
+
this.probingProtocols.delete(session);
|
|
1387
1484
|
this.deviceProtocolHints.delete(session);
|
|
1388
1485
|
this.protocolV2Assemblers.delete(session);
|
|
1389
1486
|
this.resetProtocolV2Frames(session);
|
|
@@ -1410,6 +1507,75 @@ export default class ReactNativeBleTransport {
|
|
|
1410
1507
|
this.runPromiseDeviceId = null;
|
|
1411
1508
|
}
|
|
1412
1509
|
|
|
1510
|
+
/** Run a native connect under the JS backstop budget. */
|
|
1511
|
+
private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
|
|
1512
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1513
|
+
let timedOut = false;
|
|
1514
|
+
const pending = connect();
|
|
1515
|
+
// The abandoned attempt keeps running; swallow its late outcome so it cannot
|
|
1516
|
+
// surface as an unhandled rejection after we have already given up on it.
|
|
1517
|
+
pending.catch(() => undefined);
|
|
1518
|
+
try {
|
|
1519
|
+
const result = await Promise.race([
|
|
1520
|
+
pending,
|
|
1521
|
+
new Promise<never>((_, reject) => {
|
|
1522
|
+
timer = setTimeout(() => {
|
|
1523
|
+
timedOut = true;
|
|
1524
|
+
reject(
|
|
1525
|
+
ERRORS.TypedError(
|
|
1526
|
+
HardwareErrorCode.BleConnectedError,
|
|
1527
|
+
`BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
|
|
1528
|
+
)
|
|
1529
|
+
);
|
|
1530
|
+
}, BLE_CONNECT_TIMEOUT_MS);
|
|
1531
|
+
}),
|
|
1532
|
+
]);
|
|
1533
|
+
this.connectTimeoutCounts.delete(uuid);
|
|
1534
|
+
return result;
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
if (timedOut) {
|
|
1537
|
+
this.abandonStalledConnect(uuid);
|
|
1538
|
+
}
|
|
1539
|
+
throw error;
|
|
1540
|
+
} finally {
|
|
1541
|
+
if (timer) clearTimeout(timer);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Give up on a connect the native layer never settled. The abandoned attempt still
|
|
1547
|
+
* holds a native "connecting" entry that would cancel the NEXT attempt out from under
|
|
1548
|
+
* itself, so it is cleared here — fire and forget, because that call talks to the very
|
|
1549
|
+
* queue that just stopped responding.
|
|
1550
|
+
*/
|
|
1551
|
+
private abandonStalledConnect(uuid: string) {
|
|
1552
|
+
const timeouts = (this.connectTimeoutCounts.get(uuid) ?? 0) + 1;
|
|
1553
|
+
this.connectTimeoutCounts.set(uuid, timeouts);
|
|
1554
|
+
Log?.error('[ReactNativeBleTransport] BLE connect timed out:', uuid, {
|
|
1555
|
+
consecutiveConnectTimeouts: timeouts,
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
|
|
1559
|
+
// Rejects with "Operation was cancelled" while merely connecting — expected.
|
|
1560
|
+
});
|
|
1561
|
+
const stalled = transportCache[uuid];
|
|
1562
|
+
if (stalled) {
|
|
1563
|
+
delete transportCache[uuid];
|
|
1564
|
+
}
|
|
1565
|
+
this.deviceProtocol.delete(uuid);
|
|
1566
|
+
this.probingProtocols.delete(uuid);
|
|
1567
|
+
this.protocolV2Assemblers.delete(uuid);
|
|
1568
|
+
this.resetProtocolV2Frames(uuid);
|
|
1569
|
+
|
|
1570
|
+
if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
|
|
1571
|
+
// BleManager.destroy() force-rejects every promise the native queue abandoned —
|
|
1572
|
+
// the only JS-reachable way to settle them — and drops all cached peripherals.
|
|
1573
|
+
Log?.error('[ReactNativeBleTransport] BLE connects wedged repeatedly, resetting BLE manager');
|
|
1574
|
+
this.resetPlxManager();
|
|
1575
|
+
this.connectTimeoutCounts.delete(uuid);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1413
1579
|
private getCachedTransport(uuid: string) {
|
|
1414
1580
|
const transport = transportCache[uuid];
|
|
1415
1581
|
if (!transport) {
|
|
@@ -1418,6 +1584,105 @@ export default class ReactNativeBleTransport {
|
|
|
1418
1584
|
return transport;
|
|
1419
1585
|
}
|
|
1420
1586
|
|
|
1587
|
+
/**
|
|
1588
|
+
* Write one packet under a bounded budget. A write that never settles means the
|
|
1589
|
+
* peripheral is wedged even though the GATT link still reports connected, so the
|
|
1590
|
+
* link is torn down: releasing JS state alone would leave the poisoned peripheral
|
|
1591
|
+
* cached and every later call would hang on it again.
|
|
1592
|
+
*/
|
|
1593
|
+
private async writeBlePacket(
|
|
1594
|
+
uuid: string,
|
|
1595
|
+
data: string,
|
|
1596
|
+
write: (payload: string) => Promise<unknown>,
|
|
1597
|
+
isCurrentOwner?: () => boolean
|
|
1598
|
+
) {
|
|
1599
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1600
|
+
let timedOut = false;
|
|
1601
|
+
try {
|
|
1602
|
+
await Promise.race([
|
|
1603
|
+
write(data),
|
|
1604
|
+
new Promise<never>((_, reject) => {
|
|
1605
|
+
timer = setTimeout(() => {
|
|
1606
|
+
timedOut = true;
|
|
1607
|
+
reject(
|
|
1608
|
+
ERRORS.TypedError(
|
|
1609
|
+
HardwareErrorCode.BleWriteCharacteristicError,
|
|
1610
|
+
`BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
|
|
1611
|
+
)
|
|
1612
|
+
);
|
|
1613
|
+
}, BLE_WRITE_PACKET_TIMEOUT_MS);
|
|
1614
|
+
}),
|
|
1615
|
+
]);
|
|
1616
|
+
this.writeTimeoutCounts.delete(uuid);
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
if (timedOut) {
|
|
1619
|
+
// A superseded call's late write must not tear down the link the current
|
|
1620
|
+
// call is using; only the owner of the transport may declare it dead.
|
|
1621
|
+
if (isCurrentOwner && !isCurrentOwner()) {
|
|
1622
|
+
Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
|
|
1623
|
+
} else {
|
|
1624
|
+
this.tearDownWedgedLink(uuid);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
throw error;
|
|
1628
|
+
} finally {
|
|
1629
|
+
if (timer) clearTimeout(timer);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Drop a link whose writes stopped completing. The JS state is purged synchronously
|
|
1635
|
+
* so the next acquire() cannot reuse the dead transport, while the native teardown is
|
|
1636
|
+
* intentionally NOT awaited: it talks to the very layer that just stopped settling
|
|
1637
|
+
* promises, so awaiting it could hang exactly like the write it is recovering from.
|
|
1638
|
+
*/
|
|
1639
|
+
private tearDownWedgedLink(uuid: string) {
|
|
1640
|
+
const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
|
|
1641
|
+
this.writeTimeoutCounts.set(uuid, timeouts);
|
|
1642
|
+
Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
|
|
1643
|
+
consecutiveWriteTimeouts: timeouts,
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
const wedged = transportCache[uuid];
|
|
1647
|
+
this.disconnect(uuid).catch(error => {
|
|
1648
|
+
Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
|
|
1649
|
+
});
|
|
1650
|
+
if (wedged && transportCache[uuid] === wedged) {
|
|
1651
|
+
delete transportCache[uuid];
|
|
1652
|
+
}
|
|
1653
|
+
this.deviceProtocol.delete(uuid);
|
|
1654
|
+
this.probingProtocols.delete(uuid);
|
|
1655
|
+
this.protocolV2Assemblers.delete(uuid);
|
|
1656
|
+
this.resetProtocolV2Frames(uuid);
|
|
1657
|
+
|
|
1658
|
+
if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
|
|
1659
|
+
// Reconnecting reuses the same native peripheral object. When it stays wedged
|
|
1660
|
+
// across attempts the poison lives in the BLE manager itself, and only a fresh
|
|
1661
|
+
// manager drops every cached peripheral — the JS equivalent of restarting the app.
|
|
1662
|
+
Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
|
|
1663
|
+
this.resetPlxManager();
|
|
1664
|
+
this.writeTimeoutCounts.delete(uuid);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
private resetPlxManager() {
|
|
1669
|
+
const manager = this.blePlxManager;
|
|
1670
|
+
this.blePlxManager = undefined;
|
|
1671
|
+
// Every cached transport belongs to the destroyed manager's peripherals.
|
|
1672
|
+
Object.keys(transportCache).forEach(key => {
|
|
1673
|
+
delete transportCache[key];
|
|
1674
|
+
});
|
|
1675
|
+
this.deviceProtocol.clear();
|
|
1676
|
+
this.probingProtocols.clear();
|
|
1677
|
+
this.monitorTokens.clear();
|
|
1678
|
+
this.protocolV2Assemblers.clear();
|
|
1679
|
+
try {
|
|
1680
|
+
manager?.destroy();
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1421
1686
|
private createProtocolMismatchError(expected: ProtocolType) {
|
|
1422
1687
|
return ERRORS.TypedError(
|
|
1423
1688
|
HardwareErrorCode.RuntimeError,
|
|
@@ -1433,34 +1698,25 @@ export default class ReactNativeBleTransport {
|
|
|
1433
1698
|
}
|
|
1434
1699
|
|
|
1435
1700
|
private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
|
|
1701
|
+
if (this.probingProtocols.get(uuid) === protocol) {
|
|
1702
|
+
this.probingProtocols.delete(uuid);
|
|
1703
|
+
}
|
|
1436
1704
|
if (this.deviceProtocol.get(uuid) === protocol) {
|
|
1437
1705
|
this.deviceProtocol.delete(uuid);
|
|
1438
1706
|
}
|
|
1439
1707
|
}
|
|
1440
1708
|
|
|
1709
|
+
/** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
|
|
1710
|
+
private getActiveProtocol(uuid: string): ProtocolType | undefined {
|
|
1711
|
+
return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1441
1714
|
private async detectProtocol(
|
|
1442
1715
|
uuid: string,
|
|
1443
1716
|
expectedProtocol?: ProtocolType,
|
|
1444
1717
|
protocolHint?: ProtocolType,
|
|
1445
1718
|
rebuildTransport?: () => Promise<void>
|
|
1446
1719
|
): Promise<ProtocolType> {
|
|
1447
|
-
if (Platform.OS === 'ios') {
|
|
1448
|
-
const protocol = expectedProtocol ?? protocolHint ?? 'V1';
|
|
1449
|
-
let source = 'ios-legacy-default';
|
|
1450
|
-
if (expectedProtocol) {
|
|
1451
|
-
source = 'expected';
|
|
1452
|
-
} else if (protocolHint) {
|
|
1453
|
-
source = 'hint';
|
|
1454
|
-
}
|
|
1455
|
-
this.deviceProtocol.set(uuid, protocol);
|
|
1456
|
-
Log?.debug('[ReactNativeBleTransport] protocol selected', {
|
|
1457
|
-
deviceId: uuid,
|
|
1458
|
-
protocol,
|
|
1459
|
-
source,
|
|
1460
|
-
});
|
|
1461
|
-
return protocol;
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
1720
|
if (expectedProtocol === 'V1') {
|
|
1465
1721
|
if (await this.probeProtocolV1(uuid)) {
|
|
1466
1722
|
this.deviceProtocol.set(uuid, 'V1');
|
|
@@ -1518,6 +1774,7 @@ export default class ReactNativeBleTransport {
|
|
|
1518
1774
|
}
|
|
1519
1775
|
|
|
1520
1776
|
this.deviceProtocol.delete(uuid);
|
|
1777
|
+
this.probingProtocols.delete(uuid);
|
|
1521
1778
|
throw this.createProtocolDetectionError();
|
|
1522
1779
|
}
|
|
1523
1780
|
|
|
@@ -1579,14 +1836,20 @@ export default class ReactNativeBleTransport {
|
|
|
1579
1836
|
}
|
|
1580
1837
|
|
|
1581
1838
|
try {
|
|
1582
|
-
this.
|
|
1839
|
+
this.probingProtocols.set(uuid, 'V1');
|
|
1583
1840
|
// GetFeatures identifies Protocol V1 without resetting an existing wallet
|
|
1584
1841
|
// session before Core has a chance to restore a hidden wallet.
|
|
1585
1842
|
await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
|
|
1843
|
+
this.probingProtocols.delete(uuid);
|
|
1586
1844
|
return true;
|
|
1587
1845
|
} catch (error) {
|
|
1588
1846
|
this.clearProbeProtocol(uuid, 'V1');
|
|
1589
1847
|
Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
|
|
1848
|
+
// A wedged write already dropped the link, so probing another protocol on it
|
|
1849
|
+
// would only fail against a torn-down transport: surface the real cause.
|
|
1850
|
+
if (isWedgedWriteError(error)) {
|
|
1851
|
+
throw error;
|
|
1852
|
+
}
|
|
1590
1853
|
return false;
|
|
1591
1854
|
}
|
|
1592
1855
|
}
|
|
@@ -1596,7 +1859,7 @@ export default class ReactNativeBleTransport {
|
|
|
1596
1859
|
return false;
|
|
1597
1860
|
}
|
|
1598
1861
|
|
|
1599
|
-
this.
|
|
1862
|
+
this.probingProtocols.set(uuid, 'V2');
|
|
1600
1863
|
this.protocolV2Assemblers.get(uuid)?.reset();
|
|
1601
1864
|
const detected = await probeProtocolV2Helper({
|
|
1602
1865
|
call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
|
|
@@ -1611,6 +1874,8 @@ export default class ReactNativeBleTransport {
|
|
|
1611
1874
|
});
|
|
1612
1875
|
if (!detected) {
|
|
1613
1876
|
this.clearProbeProtocol(uuid, 'V2');
|
|
1877
|
+
} else {
|
|
1878
|
+
this.probingProtocols.delete(uuid);
|
|
1614
1879
|
}
|
|
1615
1880
|
return detected;
|
|
1616
1881
|
}
|
|
@@ -1692,14 +1957,12 @@ export default class ReactNativeBleTransport {
|
|
|
1692
1957
|
}
|
|
1693
1958
|
|
|
1694
1959
|
private async writeProtocolV2Packet(
|
|
1960
|
+
uuid: string,
|
|
1695
1961
|
transport: BleTransport,
|
|
1696
1962
|
base64: string,
|
|
1697
1963
|
context: ProtocolV2CallContext,
|
|
1698
1964
|
assertCurrentGeneration: () => void
|
|
1699
1965
|
) {
|
|
1700
|
-
const shouldUseWriteWithResponse =
|
|
1701
|
-
transport.writeCharacteristic.isWritableWithResponse &&
|
|
1702
|
-
(context.writeWithResponse === true || (Platform.OS === 'ios' && !context.highVolume));
|
|
1703
1966
|
let attempt = 0;
|
|
1704
1967
|
for (;;) {
|
|
1705
1968
|
assertCurrentGeneration();
|
|
@@ -1707,11 +1970,21 @@ export default class ReactNativeBleTransport {
|
|
|
1707
1970
|
throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
|
|
1708
1971
|
}
|
|
1709
1972
|
try {
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1973
|
+
await this.writeBlePacket(
|
|
1974
|
+
uuid,
|
|
1975
|
+
base64,
|
|
1976
|
+
payload => transport.writeCharacteristic.writeWithoutResponse(payload),
|
|
1977
|
+
// Same rule as Protocol V1: a write from a superseded generation must not
|
|
1978
|
+
// tear down the link that the current generation is using.
|
|
1979
|
+
() => {
|
|
1980
|
+
try {
|
|
1981
|
+
assertCurrentGeneration();
|
|
1982
|
+
return !context.signal.aborted;
|
|
1983
|
+
} catch {
|
|
1984
|
+
return false;
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
);
|
|
1715
1988
|
assertCurrentGeneration();
|
|
1716
1989
|
return;
|
|
1717
1990
|
} catch (error) {
|
|
@@ -1734,6 +2007,7 @@ export default class ReactNativeBleTransport {
|
|
|
1734
2007
|
}
|
|
1735
2008
|
|
|
1736
2009
|
private async writeProtocolV2Frame(
|
|
2010
|
+
uuid: string,
|
|
1737
2011
|
transport: BleTransport,
|
|
1738
2012
|
frame: Uint8Array,
|
|
1739
2013
|
context: ProtocolV2CallContext,
|
|
@@ -1746,25 +2020,19 @@ export default class ReactNativeBleTransport {
|
|
|
1746
2020
|
androidPacketLength: tuning.androidPacketLength,
|
|
1747
2021
|
mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
|
|
1748
2022
|
});
|
|
1749
|
-
// Match Desktop BLE pacing so Pro2 firmware can finish the previous response
|
|
1750
|
-
// before the next single-packet control command is written.
|
|
1751
|
-
const initialDelayMs =
|
|
1752
|
-
Platform.OS === 'ios' && !context.highVolume && frame.length <= packetCapacity
|
|
1753
|
-
? IOS_PROTOCOL_V2_CONTROL_WRITE_DELAY_MS
|
|
1754
|
-
: 0;
|
|
1755
2023
|
await writeProtocolV2BleFrame({
|
|
1756
2024
|
frame,
|
|
1757
2025
|
packetCapacity,
|
|
1758
2026
|
assertActive: assertCurrentGeneration,
|
|
1759
2027
|
signal: context.signal,
|
|
1760
2028
|
abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
|
|
1761
|
-
initialDelayMs,
|
|
1762
2029
|
burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
|
|
1763
2030
|
burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
|
|
1764
2031
|
flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
|
|
1765
2032
|
wait: delay,
|
|
1766
2033
|
writePacket: packet =>
|
|
1767
2034
|
this.writeProtocolV2Packet(
|
|
2035
|
+
uuid,
|
|
1768
2036
|
transport,
|
|
1769
2037
|
Buffer.from(packet).toString('base64'),
|
|
1770
2038
|
context,
|
|
@@ -1790,7 +2058,7 @@ export default class ReactNativeBleTransport {
|
|
|
1790
2058
|
const tuning = getProtocolV2BleTuning();
|
|
1791
2059
|
Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
|
|
1792
2060
|
name,
|
|
1793
|
-
writeMode:
|
|
2061
|
+
writeMode: 'withoutResponse',
|
|
1794
2062
|
packetCapacity: Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
|
|
1795
2063
|
});
|
|
1796
2064
|
}
|
|
@@ -1829,7 +2097,13 @@ export default class ReactNativeBleTransport {
|
|
|
1829
2097
|
writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
|
|
1830
2098
|
assertCurrentGeneration();
|
|
1831
2099
|
const currentTransport = this.getCachedTransport(uuid);
|
|
1832
|
-
await this.writeProtocolV2Frame(
|
|
2100
|
+
await this.writeProtocolV2Frame(
|
|
2101
|
+
uuid,
|
|
2102
|
+
currentTransport,
|
|
2103
|
+
frame,
|
|
2104
|
+
context,
|
|
2105
|
+
assertCurrentGeneration
|
|
2106
|
+
);
|
|
1833
2107
|
},
|
|
1834
2108
|
readFrame: async () => {
|
|
1835
2109
|
assertCurrentGeneration();
|
|
@@ -1854,6 +2128,6 @@ export default class ReactNativeBleTransport {
|
|
|
1854
2128
|
}
|
|
1855
2129
|
|
|
1856
2130
|
getProtocolType(path: string): ProtocolType | undefined {
|
|
1857
|
-
return this.
|
|
2131
|
+
return this.getActiveProtocol(path);
|
|
1858
2132
|
}
|
|
1859
2133
|
}
|