@onekeyfe/hd-transport-react-native 1.2.0-alpha.67 → 1.2.0-alpha.69

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/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  BleError,
6
6
  BleErrorCode,
7
7
  BleManager as BlePlxManager,
8
+ ConnectionPriority,
8
9
  ScanMode,
9
10
  } from 'react-native-ble-plx';
10
11
  import ByteBuffer from 'bytebuffer';
@@ -28,14 +29,21 @@ import {
28
29
  HardwareErrorCode,
29
30
  createDeferred,
30
31
  isOnekeyBluetoothDevice,
32
+ isPro2FindMyAdvertisementName,
31
33
  } from '@onekeyfe/hd-shared';
32
34
 
33
35
  import { getConnectedDeviceIds, onDeviceBondState, pairDevice } from './BleManager';
34
- import { hasWritableCapability, resolveProtocolV2PacketCapacity } from './bleStrategy';
36
+ import {
37
+ hasWritableCapability,
38
+ resolveProtocolV2PacketCapacity,
39
+ shouldWriteProtocolV2WithResponse,
40
+ } from './bleStrategy';
35
41
  import { subscribeBleOn } from './subscribeBleOn';
36
42
  import {
37
43
  ANDROID_PACKET_LENGTH,
44
+ ANDROID_PROTOCOL_V2_PACKET_LENGTH,
38
45
  IOS_PACKET_LENGTH,
46
+ IOS_PROTOCOL_V2_PACKET_LENGTH,
39
47
  getBluetoothServiceUuids,
40
48
  getInfosForServiceUuid,
41
49
  isSameBleUuid,
@@ -71,6 +79,33 @@ type ResolvedBleCharacteristics = {
71
79
  notifyCharacteristic: Characteristic;
72
80
  };
73
81
 
82
+ const isAsciiWhitespace = (code: number) =>
83
+ code === 0x09 ||
84
+ code === 0x0a ||
85
+ code === 0x0b ||
86
+ code === 0x0c ||
87
+ code === 0x0d ||
88
+ code === 0x20;
89
+
90
+ const hasGattCongestedStatus = (text: string) => {
91
+ let searchFrom = 0;
92
+ while (searchFrom < text.length) {
93
+ const statusIndex = text.indexOf('status', searchFrom);
94
+ if (statusIndex < 0) return false;
95
+
96
+ let cursor = statusIndex + 'status'.length;
97
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
98
+ if (text[cursor] === ':' || text[cursor] === '=') {
99
+ cursor += 1;
100
+ while (cursor < text.length && isAsciiWhitespace(text.charCodeAt(cursor))) cursor += 1;
101
+ }
102
+ if (text.startsWith(String(ANDROID_GATT_CONGESTED_STATUS), cursor)) return true;
103
+
104
+ searchFrom = statusIndex + 'status'.length;
105
+ }
106
+ return false;
107
+ };
108
+
74
109
  const delay = (ms: number) =>
75
110
  new Promise<void>(resolve => {
76
111
  setTimeout(resolve, ms);
@@ -99,29 +134,13 @@ export const getFirmwareUploadWriteRetryType = (
99
134
  const text = [bleWriteError.reason, bleWriteError.message, bleWriteError.name]
100
135
  .filter(value => typeof value === 'string')
101
136
  .join(' ');
102
- return /GATT_CONGESTED|status\s*[:=]?\s*143/.test(text) ? 'congested' : null;
137
+ return text.includes('GATT_CONGESTED') || hasGattCongestedStatus(text) ? 'congested' : null;
103
138
  };
104
139
 
105
140
  const resolveFirmwareUploadRetryDelay = (attempt: number, baseDelayMs = 200, maxDelayMs = 1200) =>
106
141
  Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
107
142
  const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
108
143
  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;
125
144
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
126
145
  const IOS_NOTIFY_READY_DELAY_MS = 150;
127
146
  const ANDROID_NOTIFY_READY_DELAY_MS = 300;
@@ -133,8 +152,8 @@ export type ProtocolV2BleTuning = {
133
152
  type ResolvedProtocolV2BleTuning = Required<ProtocolV2BleTuning>;
134
153
 
135
154
  const DEFAULT_PROTOCOL_V2_BLE_TUNING: ResolvedProtocolV2BleTuning = {
136
- iosPacketLength: IOS_PACKET_LENGTH,
137
- androidPacketLength: ANDROID_PACKET_LENGTH,
155
+ iosPacketLength: IOS_PROTOCOL_V2_PACKET_LENGTH,
156
+ androidPacketLength: ANDROID_PROTOCOL_V2_PACKET_LENGTH,
138
157
  };
139
158
 
140
159
  let protocolV2BleTuning: ResolvedProtocolV2BleTuning = { ...DEFAULT_PROTOCOL_V2_BLE_TUNING };
@@ -176,54 +195,21 @@ function getDeviceDisplayName(device?: Device | null) {
176
195
  return device?.name || device?.localName || null;
177
196
  }
178
197
 
179
- const ANDROID_REQUEST_MTU = 256;
198
+ const IOS_REQUEST_MTU = 247;
199
+ const ANDROID_REQUEST_MTU = 517;
200
+ const BLE_MTU_REFRESH_THRESHOLD = 247;
201
+ const BLE_MTU_REFRESH_RETRY_DELAY_MS = 200;
202
+ const ANDROID_HIGH_PRIORITY_IDLE_MS = 1000;
180
203
 
181
- const BLE_NATIVE_CONNECT_TIMEOUT_MS = 3000;
204
+ const getRequestedBleMtu = () =>
205
+ Platform.OS === 'android' ? ANDROID_REQUEST_MTU : IOS_REQUEST_MTU;
182
206
 
183
207
  const connectOptions: Record<string, unknown> = {
184
- requestMTU: ANDROID_REQUEST_MTU,
185
- timeout: BLE_NATIVE_CONNECT_TIMEOUT_MS,
208
+ requestMTU: getRequestedBleMtu(),
209
+ timeout: 3000,
186
210
  refreshGatt: 'OnConnected',
187
211
  };
188
212
 
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
- /**
203
- * Service discovery and characteristic resolution run after connect() succeeds, but
204
- * CoreBluetooth schedules them on the same serial queue. If that queue is wedged by a
205
- * device reboot, these calls can remain pending forever unless they have their own
206
- * budget.
207
- */
208
- export const BLE_GATT_SETUP_TIMEOUT_MS = 10_000;
209
- /**
210
- * How many times a known device may fail its own protocol before we probe the others
211
- * again. Reconnect polling during a device reboot repeats this every few seconds, and
212
- * probing Protocol V2 costs a 10s Ping timeout, so paying it on every attempt for a
213
- * device we just spoke V1 to dominates the wait. A firmware update can legitimately
214
- * change a device's protocol, so the shortcut has to expire rather than stick.
215
- */
216
- export const PROTOCOL_REPROBE_FALLBACK_ATTEMPTS = 3;
217
- /** BLE setup timeouts since the last successful setup before the manager is recreated. */
218
- export const BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
219
- const CONNECT_TIMEOUT_MESSAGE = 'BLE connect timeout after';
220
- const isConnectTimeoutError = (error: unknown): boolean =>
221
- (error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleConnectedError &&
222
- typeof (error as { message?: unknown })?.message === 'string' &&
223
- (error as { message: string }).message.startsWith(CONNECT_TIMEOUT_MESSAGE);
224
- const isNativeOperationTimeoutError = (error: unknown): boolean =>
225
- (error as { errorCode?: unknown })?.errorCode === BleErrorCode.OperationTimedOut;
226
-
227
213
  export type IOneKeyDevice = OneKeyDeviceInfoBase & Device;
228
214
 
229
215
  const tryToGetConfiguration = (device: Device) => {
@@ -235,23 +221,32 @@ const tryToGetConfiguration = (device: Device) => {
235
221
  return infos;
236
222
  };
237
223
 
238
- const requestAndroidMtu = async (device: Device) => {
239
- if (Platform.OS !== 'android') return device;
224
+ const requestNegotiatedMtu = async (
225
+ device: Device,
226
+ stage: 'connected' | 'servicesAndNotifyReady',
227
+ attempt: number
228
+ ) => {
229
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') return device;
240
230
 
241
231
  try {
242
- const mtuDevice = await device.requestMTU(ANDROID_REQUEST_MTU);
243
- Log?.debug('[ReactNativeBleTransport] MTU configured', {
244
- deviceId: device.id,
245
- requested: ANDROID_REQUEST_MTU,
246
- actual: mtuDevice.mtu,
247
- });
232
+ // iOS ignores the requested value but react-native-ble-plx returns a fresh
233
+ // Device snapshot whose MTU is derived from CoreBluetooth's maximum write length.
234
+ const mtuDevice = await device.requestMTU(getRequestedBleMtu());
248
235
  return mtuDevice;
249
236
  } catch (error) {
250
- Log?.debug('[ReactNativeBleTransport] Android MTU request failed:', error);
237
+ Log?.debug('[ReactNativeBleTransport] MTU refresh failed, continuing with current value', {
238
+ platform: Platform.OS,
239
+ stage,
240
+ attempt,
241
+ actual: device.mtu,
242
+ error: error instanceof Error ? error.message : String(error),
243
+ });
251
244
  return device;
252
245
  }
253
246
  };
254
247
 
248
+ const resolveNegotiatedMtu = (device: Device) => requestNegotiatedMtu(device, 'connected', 0);
249
+
255
250
  type IOBleErrorRemap = Error | BleError | null | undefined;
256
251
 
257
252
  function remapError(error: IOBleErrorRemap) {
@@ -312,28 +307,8 @@ export default class ReactNativeBleTransport {
312
307
  /** Per-device protocol type detected by active wire-level probe after connect. */
313
308
  private deviceProtocol: Map<string, ProtocolType> = new Map();
314
309
 
315
- /**
316
- * Protocol a probe is currently trying, before the device has confirmed it. Calls
317
- * must route with it, but acquire() must not treat it as a detected protocol: a
318
- * probe that never answers would otherwise leave the reuse fast path handing out a
319
- * transport that was never validated.
320
- */
321
- private probingProtocols: Map<string, ProtocolType> = new Map();
322
-
323
- /** Consecutive write timeouts per device; reset by any write that completes. */
324
- private writeTimeoutCounts: Map<string, number> = new Map();
325
-
326
- /** BLE setup timeouts per device since the last complete characteristic resolution. */
327
- private connectionSetupTimeoutCounts: Map<string, number> = new Map();
328
-
329
310
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
330
311
 
331
- /** Protocol this device actually answered on, kept across reconnects of one session. */
332
- private sessionProtocols: Map<string, ProtocolType> = new Map();
333
-
334
- /** Consecutive detections that failed while trusting sessionProtocols. */
335
- private protocolReprobeFailures: Map<string, number> = new Map();
336
-
337
312
  private protocolV2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
338
313
 
339
314
  private protocolV2FrameQueues: Map<string, Uint8Array[]> = new Map();
@@ -365,6 +340,12 @@ export default class ReactNativeBleTransport {
365
340
 
366
341
  private disconnectEventTokens: Map<string, number> = new Map();
367
342
 
343
+ private protocolV2HighVolumeLogSignatures: Map<string, Set<string>> = new Map();
344
+
345
+ private androidHighPriorityDevices: Set<string> = new Set();
346
+
347
+ private androidPriorityResetTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
348
+
368
349
  private nextMonitorToken = 1;
369
350
 
370
351
  constructor(options: TransportOptions) {
@@ -551,21 +532,22 @@ export default class ReactNativeBleTransport {
551
532
  const isConnected = await device.isConnected().catch(() => false);
552
533
  if (!isConnected) {
553
534
  try {
554
- device = await this.connectWithTimeout(uuid, () => device.connect(connectOptions));
535
+ device = await device.connect(connectOptions);
555
536
  } catch (e) {
556
537
  if (
557
538
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
558
539
  e.errorCode === BleErrorCode.OperationCancelled
559
540
  ) {
560
- device = await this.connectWithTimeout(uuid, () => device.connect());
541
+ device = await device.connect();
561
542
  } else if (e.errorCode !== BleErrorCode.DeviceAlreadyConnected) {
562
543
  throw e;
563
544
  }
564
545
  }
565
546
  }
566
547
 
567
- const { writeCharacteristic, notifyCharacteristic } =
568
- await this.resolveCharacteristicsWithTimeout(uuid, device);
548
+ const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
549
+ device
550
+ );
569
551
 
570
552
  transport.device = device;
571
553
  transport.writeCharacteristic = writeCharacteristic;
@@ -655,12 +637,21 @@ export default class ReactNativeBleTransport {
655
637
  }
656
638
 
657
639
  const displayName = getDeviceDisplayName(device);
658
- const isOneKey = isOnekeyBluetoothDevice({
659
- id: device?.id,
660
- name: device?.name,
661
- localName: device?.localName,
662
- serviceUuids: device?.serviceUUIDs,
663
- });
640
+ // iOS may report a service-only advertisement before the named scan response.
641
+ // Do not cache that incomplete advertisement as an unknown device.
642
+ const isUnnamedIOSPeripheral = Platform.OS === 'ios' && !displayName?.trim();
643
+ const isFindMyPeripheral =
644
+ isPro2FindMyAdvertisementName(device?.name) ||
645
+ isPro2FindMyAdvertisementName(device?.localName);
646
+ const isOneKey =
647
+ !isUnnamedIOSPeripheral &&
648
+ !isFindMyPeripheral &&
649
+ isOnekeyBluetoothDevice({
650
+ id: device?.id,
651
+ name: device?.name,
652
+ localName: device?.localName,
653
+ serviceUuids: device?.serviceUUIDs,
654
+ });
664
655
  if (isOneKey) {
665
656
  addDevice(device as unknown as Device);
666
657
  } else if (displayName && /\bpro\s*2\b/i.test(displayName)) {
@@ -681,7 +672,12 @@ export default class ReactNativeBleTransport {
681
672
  'localName' in device && typeof device.localName === 'string'
682
673
  ? device.localName
683
674
  : null;
675
+ const isFindMyPeripheral =
676
+ isPro2FindMyAdvertisementName(device.name) ||
677
+ isPro2FindMyAdvertisementName(localName);
678
+
684
679
  if (
680
+ !isFindMyPeripheral &&
685
681
  isOnekeyBluetoothDevice({
686
682
  id: device.id,
687
683
  name: device.name,
@@ -730,11 +726,9 @@ export default class ReactNativeBleTransport {
730
726
  characteristics?: ResolvedBleCharacteristics
731
727
  ) {
732
728
  const { writeCharacteristic, notifyCharacteristic } =
733
- characteristics ?? (await this.resolveCharacteristicsWithTimeout(uuid, device));
729
+ characteristics ?? (await this.resolveCharacteristics(device));
734
730
  const transport = new BleTransport(device, writeCharacteristic, notifyCharacteristic);
735
- if (Platform.OS === 'android') {
736
- transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : transport.mtuSize;
737
- }
731
+ transport.mtuSize = typeof device.mtu === 'number' ? device.mtu : undefined;
738
732
  const monitorToken = this.nextMonitorToken;
739
733
  this.nextMonitorToken += 1;
740
734
  const notifyTransactionId = `${uuid}:notify:${monitorToken}`;
@@ -748,6 +742,7 @@ export default class ReactNativeBleTransport {
748
742
  notifyTransactionId
749
743
  );
750
744
  transportCache[uuid] = transport;
745
+ this.protocolV2HighVolumeLogSignatures.set(uuid, new Set());
751
746
  this.protocolV2Assemblers.set(
752
747
  uuid,
753
748
  new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES)
@@ -761,6 +756,40 @@ export default class ReactNativeBleTransport {
761
756
  await delay(ANDROID_NOTIFY_READY_DELAY_MS);
762
757
  }
763
758
 
759
+ const initialMtu = transport.mtuSize;
760
+ let refreshAttempts = 0;
761
+ if (
762
+ (Platform.OS === 'ios' || Platform.OS === 'android') &&
763
+ (typeof transport.mtuSize !== 'number' || transport.mtuSize < BLE_MTU_REFRESH_THRESHOLD)
764
+ ) {
765
+ refreshAttempts += 1;
766
+ let refreshedDevice = await requestNegotiatedMtu(
767
+ transport.device,
768
+ 'servicesAndNotifyReady',
769
+ 1
770
+ );
771
+ transport.device = refreshedDevice;
772
+ transport.mtuSize =
773
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
774
+
775
+ if (typeof transport.mtuSize !== 'number' || transport.mtuSize < BLE_MTU_REFRESH_THRESHOLD) {
776
+ await delay(BLE_MTU_REFRESH_RETRY_DELAY_MS);
777
+ refreshAttempts += 1;
778
+ refreshedDevice = await requestNegotiatedMtu(transport.device, 'servicesAndNotifyReady', 2);
779
+ transport.device = refreshedDevice;
780
+ transport.mtuSize =
781
+ typeof refreshedDevice.mtu === 'number' ? refreshedDevice.mtu : transport.mtuSize;
782
+ }
783
+ }
784
+
785
+ Log?.debug('[ReactNativeBleTransport] BLE MTU ready', {
786
+ platform: Platform.OS,
787
+ requested: getRequestedBleMtu(),
788
+ initial: initialMtu,
789
+ actual: transport.mtuSize,
790
+ refreshAttempts,
791
+ });
792
+
764
793
  return transport;
765
794
  }
766
795
 
@@ -834,22 +863,15 @@ export default class ReactNativeBleTransport {
834
863
  if (!device) {
835
864
  Log?.debug('try to connect to device: ', uuid);
836
865
  try {
837
- device = await this.connectWithTimeout(uuid, () =>
838
- blePlxManager.connectToDevice(uuid, connectOptions)
839
- );
866
+ device = await blePlxManager.connectToDevice(uuid, connectOptions);
840
867
  } catch (e) {
841
868
  Log?.debug('try to connect to device has error: ', e);
842
- if (isConnectTimeoutError(e)) {
843
- throw e;
844
- }
845
869
  if (
846
870
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
847
871
  e.errorCode === BleErrorCode.OperationCancelled
848
872
  ) {
849
873
  Log?.debug('first try to reconnect without params');
850
- device = await this.connectWithTimeout(uuid, () =>
851
- blePlxManager.connectToDevice(uuid, fallbackConnectOptions)
852
- );
874
+ device = await blePlxManager.connectToDevice(uuid);
853
875
  } else if (e.errorCode === BleErrorCode.DeviceAlreadyConnected) {
854
876
  Log?.debug('device already connected');
855
877
  throw ERRORS.TypedError(HardwareErrorCode.BleAlreadyConnected);
@@ -865,36 +887,26 @@ export default class ReactNativeBleTransport {
865
887
 
866
888
  if (!(await device.isConnected())) {
867
889
  Log?.debug('not connected, try to connect to device: ', uuid);
868
- const disconnectedDevice = device;
869
890
 
870
891
  try {
871
- device = await this.connectWithTimeout(uuid, () =>
872
- disconnectedDevice.connect(connectOptions)
873
- );
892
+ device = await device.connect(connectOptions);
874
893
  } catch (e) {
875
894
  Log?.debug('not connected, try to connect to device has error: ', e);
876
- if (isConnectTimeoutError(e)) {
877
- throw e;
878
- }
879
895
  if (
880
896
  e.errorCode === BleErrorCode.DeviceMTUChangeFailed ||
881
897
  e.errorCode === BleErrorCode.OperationCancelled
882
898
  ) {
883
899
  Log?.debug('second try to reconnect without params');
884
900
  try {
885
- device = await this.connectWithTimeout(uuid, () =>
886
- disconnectedDevice.connect(fallbackConnectOptions)
887
- );
901
+ device = await device.connect();
888
902
  } catch (e) {
889
903
  Log?.debug('last try to reconnect error: ', e);
890
904
  // last try to reconnect device if this issue exists
891
905
  // https://github.com/dotintent/react-native-ble-plx/issues/426
892
906
  if (e.errorCode === BleErrorCode.OperationCancelled) {
893
907
  Log?.debug('last try to reconnect');
894
- await disconnectedDevice.cancelConnection();
895
- device = await this.connectWithTimeout(uuid, () =>
896
- disconnectedDevice.connect(fallbackConnectOptions)
897
- );
908
+ await device.cancelConnection();
909
+ device = await device.connect();
898
910
  }
899
911
  }
900
912
  } else {
@@ -903,10 +915,11 @@ export default class ReactNativeBleTransport {
903
915
  }
904
916
  }
905
917
 
906
- device = await requestAndroidMtu(device);
918
+ device = await resolveNegotiatedMtu(device);
907
919
  const acquiredDevice = device;
908
- const { writeCharacteristic, notifyCharacteristic } =
909
- await this.resolveCharacteristicsWithTimeout(uuid, acquiredDevice);
920
+ const { writeCharacteristic, notifyCharacteristic } = await this.resolveCharacteristics(
921
+ acquiredDevice
922
+ );
910
923
 
911
924
  const protocolHint = expectedProtocol
912
925
  ? undefined
@@ -938,7 +951,7 @@ export default class ReactNativeBleTransport {
938
951
  if (!currentTransport) {
939
952
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
940
953
  }
941
- this.attachDisconnectSubscription(currentTransport, acquiredDevice, uuid);
954
+ this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
942
955
  return { uuid, protocolType };
943
956
  } catch (error) {
944
957
  await this.release(uuid, true);
@@ -970,7 +983,7 @@ export default class ReactNativeBleTransport {
970
983
  Log?.debug('monitor error ignored for stale transport: ', uuid, notifyTransactionId);
971
984
  return;
972
985
  }
973
- if (this.getActiveProtocol(uuid) === 'V2') {
986
+ if (this.deviceProtocol.get(uuid) === 'V2') {
974
987
  let errorCode:
975
988
  | typeof HardwareErrorCode.BleDeviceBondError
976
989
  | typeof HardwareErrorCode.BleCharacteristicNotifyError
@@ -1040,7 +1053,7 @@ export default class ReactNativeBleTransport {
1040
1053
 
1041
1054
  try {
1042
1055
  const data = Buffer.from(c.value as string, 'base64');
1043
- const protocol = this.getActiveProtocol(uuid);
1056
+ const protocol = this.deviceProtocol.get(uuid);
1044
1057
  if (!protocol) {
1045
1058
  Log?.debug('monitor data ignored before protocol detection: ', uuid);
1046
1059
  return;
@@ -1074,7 +1087,7 @@ export default class ReactNativeBleTransport {
1074
1087
  } catch (error) {
1075
1088
  Log?.debug('monitor data error: ', error);
1076
1089
  const notifyError = ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1077
- if (this.getActiveProtocol(uuid) === 'V2') {
1090
+ if (this.deviceProtocol.get(uuid) === 'V2') {
1078
1091
  this.rejectProtocolV2Frames(uuid, notifyError);
1079
1092
  } else if (this.runPromiseDeviceId === uuid) {
1080
1093
  this.runPromise?.reject(notifyError);
@@ -1108,6 +1121,8 @@ export default class ReactNativeBleTransport {
1108
1121
  return Promise.resolve(true);
1109
1122
  }
1110
1123
 
1124
+ await this.restoreAndroidConnectionPriority(uuid, transport);
1125
+
1111
1126
  if (transport) {
1112
1127
  if (this.monitorTokens.get(uuid) === transport.monitorToken) {
1113
1128
  this.monitorTokens.delete(uuid);
@@ -1137,8 +1152,9 @@ export default class ReactNativeBleTransport {
1137
1152
  delete transportCache[uuid];
1138
1153
  }
1139
1154
 
1155
+ this.protocolV2HighVolumeLogSignatures.delete(uuid);
1156
+
1140
1157
  this.deviceProtocol.delete(uuid);
1141
- this.probingProtocols.delete(uuid);
1142
1158
  // Preserve a name-derived hint across disconnects so reconnect can probe V2 first.
1143
1159
  this.protocolV2Assemblers.get(uuid)?.reset();
1144
1160
  this.protocolV2Assemblers.delete(uuid);
@@ -1205,25 +1221,8 @@ export default class ReactNativeBleTransport {
1205
1221
  const transport = this.getCachedTransport(uuid);
1206
1222
  const runPromise = createDeferred<string>();
1207
1223
  runPromise.promise.catch(() => undefined);
1208
- const supersededRunPromise = this.runPromise;
1209
- if (supersededRunPromise) {
1210
- // Only forceRun calls (Initialize/Cancel) reach here with a pending call. Settle
1211
- // the superseded deferred now so its response race resolves and its finally block
1212
- // clears its timeout timer; an orphaned timer would otherwise fire much later and
1213
- // tear down the shared connection while another call is using it.
1214
- supersededRunPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise));
1215
- }
1216
1224
  this.runPromise = runPromise;
1217
1225
  this.runPromiseDeviceId = uuid;
1218
- // A superseded call's late write failure must not clear the successor's ownership;
1219
- // only the call that still owns the slot may release it.
1220
- const releaseOwnershipIfCurrent = () => {
1221
- if (this.runPromise === runPromise) {
1222
- this.runPromise = null;
1223
- this.runPromiseDeviceId = null;
1224
- }
1225
- };
1226
- const isCurrentOwner = () => this.runPromise === runPromise;
1227
1226
  const messages = this._messages;
1228
1227
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
1229
1228
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -1249,9 +1248,6 @@ export default class ReactNativeBleTransport {
1249
1248
  chunk = ByteBuffer.allocate(packetCapacity);
1250
1249
  } catch (e) {
1251
1250
  onError(e);
1252
- if (isWedgedWriteError(e)) {
1253
- throw e;
1254
- }
1255
1251
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1256
1252
  }
1257
1253
  }
@@ -1283,9 +1279,6 @@ export default class ReactNativeBleTransport {
1283
1279
  }
1284
1280
  } catch (e) {
1285
1281
  onError(e);
1286
- if (isWedgedWriteError(e)) {
1287
- throw e;
1288
- }
1289
1282
  throw ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError);
1290
1283
  }
1291
1284
  }
@@ -1299,15 +1292,9 @@ export default class ReactNativeBleTransport {
1299
1292
  if (name === 'EmmcFileWrite') {
1300
1293
  await writeChunkedData(
1301
1294
  buffers,
1302
- data =>
1303
- this.writeBlePacket(
1304
- uuid,
1305
- data,
1306
- payload => transport.writeWithRetry(payload),
1307
- isCurrentOwner
1308
- ),
1295
+ data => transport.writeWithRetry(data),
1309
1296
  e => {
1310
- releaseOwnershipIfCurrent();
1297
+ this.runPromise = null;
1311
1298
  Log?.error('writeCharacteristic write error: ', e);
1312
1299
  }
1313
1300
  );
@@ -1328,12 +1315,7 @@ export default class ReactNativeBleTransport {
1328
1315
  // eslint-disable-next-line no-constant-condition
1329
1316
  while (true) {
1330
1317
  try {
1331
- await this.writeBlePacket(
1332
- uuid,
1333
- data,
1334
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1335
- isCurrentOwner
1336
- );
1318
+ await transport.writeWithRetry(data);
1337
1319
  return;
1338
1320
  } catch (error) {
1339
1321
  const retryType = getFirmwareUploadWriteRetryType(error);
@@ -1352,7 +1334,7 @@ export default class ReactNativeBleTransport {
1352
1334
  }
1353
1335
  },
1354
1336
  e => {
1355
- releaseOwnershipIfCurrent();
1337
+ this.runPromise = null;
1356
1338
  Log?.error('writeCharacteristic write error: ', e);
1357
1339
  }
1358
1340
  );
@@ -1361,18 +1343,16 @@ export default class ReactNativeBleTransport {
1361
1343
  const outData = o.toString('base64');
1362
1344
  // Upload resources on low-end phones may OOM
1363
1345
  try {
1364
- await this.writeBlePacket(
1365
- uuid,
1366
- outData,
1367
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
1368
- isCurrentOwner
1369
- );
1346
+ const shouldUseWriteWithResponse =
1347
+ Platform.OS === 'ios' && transport.writeCharacteristic.isWritableWithResponse;
1348
+ if (shouldUseWriteWithResponse) {
1349
+ await transport.writeCharacteristic.writeWithResponse(outData);
1350
+ } else {
1351
+ await transport.writeCharacteristic.writeWithoutResponse(outData);
1352
+ }
1370
1353
  } catch (e) {
1371
1354
  Log?.debug('writeCharacteristic write error: ', e);
1372
- releaseOwnershipIfCurrent();
1373
- if (isWedgedWriteError(e)) {
1374
- throw e;
1375
- }
1355
+ this.runPromise = null;
1376
1356
  if (e.errorCode === BleErrorCode.DeviceDisconnected) {
1377
1357
  throw ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded);
1378
1358
  } else if (e.errorCode === BleErrorCode.OperationStartFailed) {
@@ -1415,13 +1395,8 @@ export default class ReactNativeBleTransport {
1415
1395
  }
1416
1396
  const isProbeTimeout =
1417
1397
  name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
1418
- // A call that has been superseded (forceRun) or cleaned up no longer owns the
1419
- // transport; its late timeout must not tear down the connection the current
1420
- // call is actively using.
1421
- const isStaleCall = this.runPromise !== runPromise;
1422
1398
  if (
1423
1399
  !isProbeTimeout &&
1424
- !isStaleCall &&
1425
1400
  (e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError
1426
1401
  ) {
1427
1402
  await this.disconnect(uuid);
@@ -1501,10 +1476,7 @@ export default class ReactNativeBleTransport {
1501
1476
  delete transportCache[session];
1502
1477
  }
1503
1478
  this.deviceProtocol.delete(session);
1504
- this.probingProtocols.delete(session);
1505
1479
  this.deviceProtocolHints.delete(session);
1506
- this.sessionProtocols.delete(session);
1507
- this.protocolReprobeFailures.delete(session);
1508
1480
  this.protocolV2Assemblers.delete(session);
1509
1481
  this.resetProtocolV2Frames(session);
1510
1482
 
@@ -1530,113 +1502,6 @@ export default class ReactNativeBleTransport {
1530
1502
  this.runPromiseDeviceId = null;
1531
1503
  }
1532
1504
 
1533
- /** Run a native connect under the JS backstop budget. */
1534
- private async connectWithTimeout<T>(uuid: string, connect: () => Promise<T>): Promise<T> {
1535
- let timer: ReturnType<typeof setTimeout> | undefined;
1536
- let timedOut = false;
1537
- const pending = connect();
1538
- // The abandoned attempt keeps running; swallow its late outcome so it cannot
1539
- // surface as an unhandled rejection after we have already given up on it.
1540
- pending.catch(() => undefined);
1541
- try {
1542
- const result = await Promise.race([
1543
- pending,
1544
- new Promise<never>((_, reject) => {
1545
- timer = setTimeout(() => {
1546
- timedOut = true;
1547
- reject(
1548
- ERRORS.TypedError(
1549
- HardwareErrorCode.BleConnectedError,
1550
- `BLE connect timeout after ${BLE_CONNECT_TIMEOUT_MS}ms for ${uuid}`
1551
- )
1552
- );
1553
- }, BLE_CONNECT_TIMEOUT_MS);
1554
- }),
1555
- ]);
1556
- return result;
1557
- } catch (error) {
1558
- if (timedOut || isNativeOperationTimeoutError(error)) {
1559
- this.abandonStalledConnection(uuid, timedOut ? 'connect-backstop' : 'connect-native');
1560
- }
1561
- throw error;
1562
- } finally {
1563
- if (timer) clearTimeout(timer);
1564
- }
1565
- }
1566
-
1567
- /** Resolve the complete GATT shape under a budget so acquire() always settles. */
1568
- private async resolveCharacteristicsWithTimeout(
1569
- uuid: string,
1570
- device: Device
1571
- ): Promise<ResolvedBleCharacteristics> {
1572
- let timer: ReturnType<typeof setTimeout> | undefined;
1573
- let timedOut = false;
1574
- const pending = this.resolveCharacteristics(device);
1575
- pending.catch(() => undefined);
1576
- try {
1577
- const result = await Promise.race([
1578
- pending,
1579
- new Promise<never>((_, reject) => {
1580
- timer = setTimeout(() => {
1581
- timedOut = true;
1582
- reject(
1583
- ERRORS.TypedError(
1584
- HardwareErrorCode.BleConnectedError,
1585
- `BLE GATT setup timeout after ${BLE_GATT_SETUP_TIMEOUT_MS}ms for ${uuid}`
1586
- )
1587
- );
1588
- }, BLE_GATT_SETUP_TIMEOUT_MS);
1589
- }),
1590
- ]);
1591
- this.connectionSetupTimeoutCounts.delete(uuid);
1592
- return result;
1593
- } catch (error) {
1594
- if (timedOut || isNativeOperationTimeoutError(error)) {
1595
- this.abandonStalledConnection(uuid, timedOut ? 'gatt-backstop' : 'gatt-native');
1596
- }
1597
- throw error;
1598
- } finally {
1599
- if (timer) clearTimeout(timer);
1600
- }
1601
- }
1602
-
1603
- /**
1604
- * Give up on a BLE setup operation the native layer did not settle. The abandoned
1605
- * operation still owns native connection/GATT state that can poison the next attempt,
1606
- * so it is cleared here without awaiting the same queue that stopped responding.
1607
- */
1608
- private abandonStalledConnection(
1609
- uuid: string,
1610
- stage: 'connect-backstop' | 'connect-native' | 'gatt-backstop' | 'gatt-native'
1611
- ) {
1612
- const timeouts = (this.connectionSetupTimeoutCounts.get(uuid) ?? 0) + 1;
1613
- this.connectionSetupTimeoutCounts.set(uuid, timeouts);
1614
- Log?.error('[ReactNativeBleTransport] BLE setup timed out:', uuid, {
1615
- stage,
1616
- setupTimeoutsSinceSuccess: timeouts,
1617
- });
1618
-
1619
- this.blePlxManager?.cancelDeviceConnection(uuid).catch(() => {
1620
- // Rejects with "Operation was cancelled" while merely connecting — expected.
1621
- });
1622
- const stalled = transportCache[uuid];
1623
- if (stalled) {
1624
- delete transportCache[uuid];
1625
- }
1626
- this.deviceProtocol.delete(uuid);
1627
- this.probingProtocols.delete(uuid);
1628
- this.protocolV2Assemblers.delete(uuid);
1629
- this.resetProtocolV2Frames(uuid);
1630
-
1631
- if (timeouts >= BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1632
- // BleManager.destroy() force-rejects every promise the native queue abandoned —
1633
- // the only JS-reachable way to settle them — and drops all cached peripherals.
1634
- Log?.error('[ReactNativeBleTransport] BLE setup wedged repeatedly, resetting BLE manager');
1635
- this.resetPlxManager();
1636
- this.connectionSetupTimeoutCounts.delete(uuid);
1637
- }
1638
- }
1639
-
1640
1505
  private getCachedTransport(uuid: string) {
1641
1506
  const transport = transportCache[uuid];
1642
1507
  if (!transport) {
@@ -1645,107 +1510,6 @@ export default class ReactNativeBleTransport {
1645
1510
  return transport;
1646
1511
  }
1647
1512
 
1648
- /**
1649
- * Write one packet under a bounded budget. A write that never settles means the
1650
- * peripheral is wedged even though the GATT link still reports connected, so the
1651
- * link is torn down: releasing JS state alone would leave the poisoned peripheral
1652
- * cached and every later call would hang on it again.
1653
- */
1654
- private async writeBlePacket(
1655
- uuid: string,
1656
- data: string,
1657
- write: (payload: string) => Promise<unknown>,
1658
- isCurrentOwner?: () => boolean
1659
- ) {
1660
- let timer: ReturnType<typeof setTimeout> | undefined;
1661
- let timedOut = false;
1662
- try {
1663
- await Promise.race([
1664
- write(data),
1665
- new Promise<never>((_, reject) => {
1666
- timer = setTimeout(() => {
1667
- timedOut = true;
1668
- reject(
1669
- ERRORS.TypedError(
1670
- HardwareErrorCode.BleWriteCharacteristicError,
1671
- `BLE write timeout after ${BLE_WRITE_PACKET_TIMEOUT_MS}ms`
1672
- )
1673
- );
1674
- }, BLE_WRITE_PACKET_TIMEOUT_MS);
1675
- }),
1676
- ]);
1677
- this.writeTimeoutCounts.delete(uuid);
1678
- } catch (error) {
1679
- if (timedOut) {
1680
- // A superseded call's late write must not tear down the link the current
1681
- // call is using; only the owner of the transport may declare it dead.
1682
- if (isCurrentOwner && !isCurrentOwner()) {
1683
- Log?.debug('[ReactNativeBleTransport] stale BLE write timed out, link kept:', uuid);
1684
- } else {
1685
- this.tearDownWedgedLink(uuid);
1686
- }
1687
- }
1688
- throw error;
1689
- } finally {
1690
- if (timer) clearTimeout(timer);
1691
- }
1692
- }
1693
-
1694
- /**
1695
- * Drop a link whose writes stopped completing. The JS state is purged synchronously
1696
- * so the next acquire() cannot reuse the dead transport, while the native teardown is
1697
- * intentionally NOT awaited: it talks to the very layer that just stopped settling
1698
- * promises, so awaiting it could hang exactly like the write it is recovering from.
1699
- */
1700
- private tearDownWedgedLink(uuid: string) {
1701
- const timeouts = (this.writeTimeoutCounts.get(uuid) ?? 0) + 1;
1702
- this.writeTimeoutCounts.set(uuid, timeouts);
1703
- Log?.error('[ReactNativeBleTransport] BLE write timed out, tearing down link:', uuid, {
1704
- consecutiveWriteTimeouts: timeouts,
1705
- });
1706
-
1707
- const wedged = transportCache[uuid];
1708
- this.disconnect(uuid).catch(error => {
1709
- Log?.debug('[ReactNativeBleTransport] wedged link teardown failed (ignored):', error);
1710
- });
1711
- if (wedged && transportCache[uuid] === wedged) {
1712
- delete transportCache[uuid];
1713
- }
1714
- this.deviceProtocol.delete(uuid);
1715
- this.probingProtocols.delete(uuid);
1716
- this.protocolV2Assemblers.delete(uuid);
1717
- this.resetProtocolV2Frames(uuid);
1718
-
1719
- if (timeouts >= BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD) {
1720
- // Reconnecting reuses the same native peripheral object. When it stays wedged
1721
- // across attempts the poison lives in the BLE manager itself, and only a fresh
1722
- // manager drops every cached peripheral — the JS equivalent of restarting the app.
1723
- Log?.error('[ReactNativeBleTransport] BLE writes wedged repeatedly, resetting BLE manager');
1724
- this.resetPlxManager();
1725
- this.writeTimeoutCounts.delete(uuid);
1726
- }
1727
- }
1728
-
1729
- private resetPlxManager() {
1730
- const manager = this.blePlxManager;
1731
- this.blePlxManager = undefined;
1732
- // Every cached transport belongs to the destroyed manager's peripherals.
1733
- Object.keys(transportCache).forEach(key => {
1734
- delete transportCache[key];
1735
- });
1736
- this.deviceProtocol.clear();
1737
- this.probingProtocols.clear();
1738
- this.sessionProtocols.clear();
1739
- this.protocolReprobeFailures.clear();
1740
- this.monitorTokens.clear();
1741
- this.protocolV2Assemblers.clear();
1742
- try {
1743
- manager?.destroy();
1744
- } catch (error) {
1745
- Log?.debug('[ReactNativeBleTransport] BLE manager destroy failed (ignored):', error);
1746
- }
1747
- }
1748
-
1749
1513
  private createProtocolMismatchError(expected: ProtocolType) {
1750
1514
  return ERRORS.TypedError(
1751
1515
  HardwareErrorCode.RuntimeError,
@@ -1761,29 +1525,30 @@ export default class ReactNativeBleTransport {
1761
1525
  }
1762
1526
 
1763
1527
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
1764
- if (this.probingProtocols.get(uuid) === protocol) {
1765
- this.probingProtocols.delete(uuid);
1766
- }
1767
1528
  if (this.deviceProtocol.get(uuid) === protocol) {
1768
1529
  this.deviceProtocol.delete(uuid);
1769
1530
  }
1770
1531
  }
1771
1532
 
1772
- /** Protocol to route a call with: confirmed if known, otherwise the one being probed. */
1773
- private getActiveProtocol(uuid: string): ProtocolType | undefined {
1774
- return this.deviceProtocol.get(uuid) ?? this.probingProtocols.get(uuid);
1775
- }
1776
-
1777
1533
  private async detectProtocol(
1778
1534
  uuid: string,
1779
1535
  expectedProtocol?: ProtocolType,
1780
1536
  protocolHint?: ProtocolType,
1781
1537
  rebuildTransport?: () => Promise<void>
1782
1538
  ): Promise<ProtocolType> {
1539
+ if (Platform.OS === 'ios' && expectedProtocol) {
1540
+ this.deviceProtocol.set(uuid, expectedProtocol);
1541
+ Log?.debug('[ReactNativeBleTransport] protocol selected', {
1542
+ deviceId: uuid,
1543
+ protocol: expectedProtocol,
1544
+ source: 'expected',
1545
+ });
1546
+ return expectedProtocol;
1547
+ }
1548
+
1783
1549
  if (expectedProtocol === 'V1') {
1784
1550
  if (await this.probeProtocolV1(uuid)) {
1785
1551
  this.deviceProtocol.set(uuid, 'V1');
1786
- this.sessionProtocols.set(uuid, 'V1');
1787
1552
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1788
1553
  deviceId: uuid,
1789
1554
  protocol: 'V1',
@@ -1797,7 +1562,6 @@ export default class ReactNativeBleTransport {
1797
1562
  if (expectedProtocol === 'V2') {
1798
1563
  if (await this.probeProtocolV2(uuid)) {
1799
1564
  this.deviceProtocol.set(uuid, 'V2');
1800
- this.sessionProtocols.set(uuid, 'V2');
1801
1565
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1802
1566
  deviceId: uuid,
1803
1567
  protocol: 'V2',
@@ -1810,18 +1574,8 @@ export default class ReactNativeBleTransport {
1810
1574
 
1811
1575
  // Protocol must be actively probed after connection. Name, PID, and descriptors only
1812
1576
  // influence probe order; a V2 hint probes V2 first and falls back to V1.
1813
- const sessionProtocol = this.sessionProtocols.get(uuid);
1814
- const reprobeFailures = this.protocolReprobeFailures.get(uuid) ?? 0;
1815
- const fullProbeOrder: ProtocolType[] =
1577
+ const probeOrder: ProtocolType[] =
1816
1578
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
1817
- // A device that already answered on a protocol in this session keeps answering on
1818
- // it; while it is rebooting nothing answers at all, so probing the other protocol
1819
- // only adds its timeout to every poll.
1820
- const trustSessionProtocol =
1821
- sessionProtocol !== undefined &&
1822
- !protocolHint &&
1823
- reprobeFailures < PROTOCOL_REPROBE_FALLBACK_ATTEMPTS;
1824
- const probeOrder: ProtocolType[] = trustSessionProtocol ? [sessionProtocol] : fullProbeOrder;
1825
1579
 
1826
1580
  for (let i = 0; i < probeOrder.length; i += 1) {
1827
1581
  const protocol = probeOrder[i];
@@ -1839,8 +1593,6 @@ export default class ReactNativeBleTransport {
1839
1593
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
1840
1594
  if (detected) {
1841
1595
  this.deviceProtocol.set(uuid, protocol);
1842
- this.sessionProtocols.set(uuid, protocol);
1843
- this.protocolReprobeFailures.delete(uuid);
1844
1596
  Log?.debug('[ReactNativeBleTransport] protocol detected', {
1845
1597
  deviceId: uuid,
1846
1598
  protocol,
@@ -1850,16 +1602,7 @@ export default class ReactNativeBleTransport {
1850
1602
  }
1851
1603
  }
1852
1604
 
1853
- if (trustSessionProtocol) {
1854
- // Still silent on its own protocol: count it, and let the streak expire the
1855
- // shortcut so a device that genuinely switched protocols is found again.
1856
- this.protocolReprobeFailures.set(uuid, reprobeFailures + 1);
1857
- } else {
1858
- this.protocolReprobeFailures.delete(uuid);
1859
- }
1860
-
1861
1605
  this.deviceProtocol.delete(uuid);
1862
- this.probingProtocols.delete(uuid);
1863
1606
  throw this.createProtocolDetectionError();
1864
1607
  }
1865
1608
 
@@ -1921,20 +1664,14 @@ export default class ReactNativeBleTransport {
1921
1664
  }
1922
1665
 
1923
1666
  try {
1924
- this.probingProtocols.set(uuid, 'V1');
1667
+ this.deviceProtocol.set(uuid, 'V1');
1925
1668
  // GetFeatures identifies Protocol V1 without resetting an existing wallet
1926
1669
  // session before Core has a chance to restore a hidden wallet.
1927
1670
  await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
1928
- this.probingProtocols.delete(uuid);
1929
1671
  return true;
1930
1672
  } catch (error) {
1931
1673
  this.clearProbeProtocol(uuid, 'V1');
1932
1674
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
1933
- // A wedged write already dropped the link, so probing another protocol on it
1934
- // would only fail against a torn-down transport: surface the real cause.
1935
- if (isWedgedWriteError(error)) {
1936
- throw error;
1937
- }
1938
1675
  return false;
1939
1676
  }
1940
1677
  }
@@ -1944,7 +1681,7 @@ export default class ReactNativeBleTransport {
1944
1681
  return false;
1945
1682
  }
1946
1683
 
1947
- this.probingProtocols.set(uuid, 'V2');
1684
+ this.deviceProtocol.set(uuid, 'V2');
1948
1685
  this.protocolV2Assemblers.get(uuid)?.reset();
1949
1686
  const detected = await probeProtocolV2Helper({
1950
1687
  call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
@@ -1959,8 +1696,6 @@ export default class ReactNativeBleTransport {
1959
1696
  });
1960
1697
  if (!detected) {
1961
1698
  this.clearProbeProtocol(uuid, 'V2');
1962
- } else {
1963
- this.probingProtocols.delete(uuid);
1964
1699
  }
1965
1700
  return detected;
1966
1701
  }
@@ -2042,12 +1777,17 @@ export default class ReactNativeBleTransport {
2042
1777
  }
2043
1778
 
2044
1779
  private async writeProtocolV2Packet(
2045
- uuid: string,
2046
1780
  transport: BleTransport,
2047
1781
  base64: string,
2048
1782
  context: ProtocolV2CallContext,
2049
1783
  assertCurrentGeneration: () => void
2050
1784
  ) {
1785
+ const shouldUseWriteWithResponse = shouldWriteProtocolV2WithResponse({
1786
+ platform: Platform.OS,
1787
+ highVolume: context.highVolume,
1788
+ requestedWithResponse: context.writeWithResponse,
1789
+ characteristic: transport.writeCharacteristic,
1790
+ });
2051
1791
  let attempt = 0;
2052
1792
  for (;;) {
2053
1793
  assertCurrentGeneration();
@@ -2055,21 +1795,11 @@ export default class ReactNativeBleTransport {
2055
1795
  throw new Error(`Protocol V2 BLE write aborted for ${context.messageName}`);
2056
1796
  }
2057
1797
  try {
2058
- await this.writeBlePacket(
2059
- uuid,
2060
- base64,
2061
- payload => transport.writeCharacteristic.writeWithoutResponse(payload),
2062
- // Same rule as Protocol V1: a write from a superseded generation must not
2063
- // tear down the link that the current generation is using.
2064
- () => {
2065
- try {
2066
- assertCurrentGeneration();
2067
- return !context.signal.aborted;
2068
- } catch {
2069
- return false;
2070
- }
2071
- }
2072
- );
1798
+ if (shouldUseWriteWithResponse) {
1799
+ await transport.writeCharacteristic.writeWithResponse(base64);
1800
+ } else {
1801
+ await transport.writeCharacteristic.writeWithoutResponse(base64);
1802
+ }
2073
1803
  assertCurrentGeneration();
2074
1804
  return;
2075
1805
  } catch (error) {
@@ -2092,7 +1822,6 @@ export default class ReactNativeBleTransport {
2092
1822
  }
2093
1823
 
2094
1824
  private async writeProtocolV2Frame(
2095
- uuid: string,
2096
1825
  transport: BleTransport,
2097
1826
  frame: Uint8Array,
2098
1827
  context: ProtocolV2CallContext,
@@ -2103,7 +1832,7 @@ export default class ReactNativeBleTransport {
2103
1832
  platform: Platform.OS,
2104
1833
  iosPacketLength: tuning.iosPacketLength,
2105
1834
  androidPacketLength: tuning.androidPacketLength,
2106
- mtu: Platform.OS === 'android' ? transport.mtuSize : undefined,
1835
+ mtu: transport.mtuSize,
2107
1836
  });
2108
1837
  await writeProtocolV2BleFrame({
2109
1838
  frame,
@@ -2111,13 +1840,9 @@ export default class ReactNativeBleTransport {
2111
1840
  assertActive: assertCurrentGeneration,
2112
1841
  signal: context.signal,
2113
1842
  abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
2114
- burstSize: FIRMWARE_UPLOAD_WRITE_BURST_SIZE,
2115
- burstPauseMs: FIRMWARE_UPLOAD_WRITE_PAUSE_MS,
2116
- flushDelayMs: FIRMWARE_UPLOAD_WRITE_FLUSH_DELAY_MS,
2117
1843
  wait: delay,
2118
1844
  writePacket: packet =>
2119
1845
  this.writeProtocolV2Packet(
2120
- uuid,
2121
1846
  transport,
2122
1847
  Buffer.from(packet).toString('base64'),
2123
1848
  context,
@@ -2141,11 +1866,39 @@ export default class ReactNativeBleTransport {
2141
1866
 
2142
1867
  if (highVolumeWrite) {
2143
1868
  const tuning = getProtocolV2BleTuning();
2144
- Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
2145
- name,
2146
- writeMode: 'withoutResponse',
2147
- packetCapacity: Platform.OS === 'ios' ? tuning.iosPacketLength : tuning.androidPacketLength,
1869
+ const currentTransport = this.getCachedTransport(uuid);
1870
+ const writeWithResponse = shouldWriteProtocolV2WithResponse({
1871
+ platform: Platform.OS,
1872
+ highVolume: true,
1873
+ requestedWithResponse: options?.writeWithResponse,
1874
+ characteristic: currentTransport.writeCharacteristic,
1875
+ });
1876
+ const packetCapacity = resolveProtocolV2PacketCapacity({
1877
+ platform: Platform.OS,
1878
+ iosPacketLength: tuning.iosPacketLength,
1879
+ androidPacketLength: tuning.androidPacketLength,
1880
+ mtu: currentTransport.mtuSize,
2148
1881
  });
1882
+ const writeMode = writeWithResponse ? 'withResponse' : 'withoutResponse';
1883
+ const logSignature = `${name}:${writeMode}:${String(
1884
+ currentTransport.mtuSize
1885
+ )}:${packetCapacity}`;
1886
+ const loggedSignatures =
1887
+ this.protocolV2HighVolumeLogSignatures.get(uuid) ?? new Set<string>();
1888
+ if (!loggedSignatures.has(logSignature)) {
1889
+ loggedSignatures.add(logSignature);
1890
+ this.protocolV2HighVolumeLogSignatures.set(uuid, loggedSignatures);
1891
+ Log?.debug('[ReactNativeBleTransport] Protocol V2 high-volume write configured', {
1892
+ name,
1893
+ writeMode,
1894
+ reportedMtu: currentTransport.mtuSize,
1895
+ packetCapacity,
1896
+ });
1897
+ }
1898
+ }
1899
+
1900
+ if (highVolumeWrite) {
1901
+ await this.enableAndroidHighConnectionPriority(uuid);
2149
1902
  }
2150
1903
 
2151
1904
  try {
@@ -2159,6 +1912,73 @@ export default class ReactNativeBleTransport {
2159
1912
  } catch (e) {
2160
1913
  Log?.error('[ReactNativeBleTransport] Protocol V2 call error:', e);
2161
1914
  throw e;
1915
+ } finally {
1916
+ if (highVolumeWrite) {
1917
+ this.scheduleAndroidBalancedConnectionPriority(uuid);
1918
+ }
1919
+ }
1920
+ }
1921
+
1922
+ private clearAndroidPriorityResetTimer(uuid: string) {
1923
+ const timerId = this.androidPriorityResetTimers.get(uuid);
1924
+ if (timerId !== undefined) {
1925
+ clearTimeout(timerId);
1926
+ this.androidPriorityResetTimers.delete(uuid);
1927
+ }
1928
+ }
1929
+
1930
+ private async enableAndroidHighConnectionPriority(uuid: string) {
1931
+ if (Platform.OS !== 'android') return;
1932
+
1933
+ this.clearAndroidPriorityResetTimer(uuid);
1934
+ if (this.androidHighPriorityDevices.has(uuid)) return;
1935
+
1936
+ const transport = transportCache[uuid];
1937
+ if (!transport) return;
1938
+
1939
+ try {
1940
+ transport.device = await transport.device.requestConnectionPriority(ConnectionPriority.High);
1941
+ this.androidHighPriorityDevices.add(uuid);
1942
+ Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
1943
+ priority: 'high',
1944
+ });
1945
+ } catch (error) {
1946
+ Log?.debug('[ReactNativeBleTransport] Android BLE high priority request failed', {
1947
+ error: error instanceof Error ? error.message : String(error),
1948
+ });
1949
+ }
1950
+ }
1951
+
1952
+ private scheduleAndroidBalancedConnectionPriority(uuid: string) {
1953
+ if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.has(uuid)) return;
1954
+
1955
+ this.clearAndroidPriorityResetTimer(uuid);
1956
+ const timerId = setTimeout(() => {
1957
+ this.androidPriorityResetTimers.delete(uuid);
1958
+ this.restoreAndroidConnectionPriority(uuid, transportCache[uuid]).catch(error =>
1959
+ Log?.debug('[ReactNativeBleTransport] Android BLE priority restore failed', error)
1960
+ );
1961
+ }, ANDROID_HIGH_PRIORITY_IDLE_MS);
1962
+ this.androidPriorityResetTimers.set(uuid, timerId);
1963
+ }
1964
+
1965
+ private async restoreAndroidConnectionPriority(uuid: string, transport?: BleTransport) {
1966
+ this.clearAndroidPriorityResetTimer(uuid);
1967
+ if (Platform.OS !== 'android' || !this.androidHighPriorityDevices.delete(uuid) || !transport) {
1968
+ return;
1969
+ }
1970
+
1971
+ try {
1972
+ transport.device = await transport.device.requestConnectionPriority(
1973
+ ConnectionPriority.Balanced
1974
+ );
1975
+ Log?.debug('[ReactNativeBleTransport] Android BLE connection priority changed', {
1976
+ priority: 'balanced',
1977
+ });
1978
+ } catch (error) {
1979
+ Log?.debug('[ReactNativeBleTransport] Android BLE balanced priority request failed', {
1980
+ error: error instanceof Error ? error.message : String(error),
1981
+ });
2162
1982
  }
2163
1983
  }
2164
1984
 
@@ -2182,13 +2002,7 @@ export default class ReactNativeBleTransport {
2182
2002
  writeFrame: async (frame: Uint8Array, context: ProtocolV2CallContext) => {
2183
2003
  assertCurrentGeneration();
2184
2004
  const currentTransport = this.getCachedTransport(uuid);
2185
- await this.writeProtocolV2Frame(
2186
- uuid,
2187
- currentTransport,
2188
- frame,
2189
- context,
2190
- assertCurrentGeneration
2191
- );
2005
+ await this.writeProtocolV2Frame(currentTransport, frame, context, assertCurrentGeneration);
2192
2006
  },
2193
2007
  readFrame: async () => {
2194
2008
  assertCurrentGeneration();
@@ -2213,6 +2027,6 @@ export default class ReactNativeBleTransport {
2213
2027
  }
2214
2028
 
2215
2029
  getProtocolType(path: string): ProtocolType | undefined {
2216
- return this.getActiveProtocol(path);
2030
+ return this.deviceProtocol.get(path);
2217
2031
  }
2218
2032
  }