@onekeyfe/hd-transport-react-native 1.2.3-alpha.9 → 1.2.4-alpha.0

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.
@@ -0,0 +1,267 @@
1
+ import { Platform } from 'react-native';
2
+ import BleUtils from '@onekeyfe/react-native-ble-utils';
3
+ import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
4
+
5
+ import {
6
+ hasBleKeyMissingSince,
7
+ onBleKeyMissing,
8
+ startBleKeyMissingTracking,
9
+ } from './bleKeyMissing';
10
+
11
+ /**
12
+ * Android 16+ starts encrypting a bonded LE link on its own right after connecting, with the
13
+ * stored key. A GATT request that needs encryption and is sent before that attempt finishes is
14
+ * rejected by the peer, and the framework retries it by encrypting again with the same key. When
15
+ * the peer has lost the bond, that second failure makes firmware that tolerates a single one per
16
+ * link drop the connection, before the system's own re-pairing can finish.
17
+ *
18
+ * Tracking the link's encryption result lets the transport hold GATT until then, so a lost bond
19
+ * is re-paired in place (the system shows a pairing request) instead of failing.
20
+ */
21
+
22
+ /** HCI "PIN or Key Missing": the peer no longer has the keys of the stored bond. */
23
+ export const HCI_PIN_OR_KEY_MISSING = 0x06;
24
+
25
+ export type AndroidLinkEncryption =
26
+ /** This link was encrypted with the stored bond. */
27
+ | 'encrypted'
28
+ /** The link was reused and had already been encrypted. */
29
+ | 'already-encrypted'
30
+ /** The stored bond was lost and the system re-paired on this link. */
31
+ | 're-paired'
32
+ /** No result arrived in time; callers proceed as if nothing was known. */
33
+ | 'unresolved';
34
+
35
+ type EncryptionRecord = { at: number; status: number; enabled: boolean };
36
+
37
+ // An app can still resolve an older react-native-ble-utils, and the JS bundle can run on a native
38
+ // build that predates the events, so none of these members is assumed to exist.
39
+ type EncryptionCapableBleUtils = Partial<
40
+ Pick<
41
+ typeof BleUtils,
42
+ 'supportsDeviceEncryptionChange' | 'onDeviceEncryptionChange' | 'onDeviceAclDisconnected'
43
+ >
44
+ >;
45
+
46
+ type LinkListener = (deviceId: string) => void;
47
+
48
+ const lastEncryption = new Map<string, EncryptionRecord>();
49
+ const lastAclDisconnectAt = new Map<string, number>();
50
+ const listeners = new Set<LinkListener>();
51
+ let unsubscribeNative: Array<() => void> | undefined;
52
+
53
+ const normalizeDeviceId = (deviceId: string) => deviceId.toLowerCase();
54
+
55
+ const isEncrypted = (record: EncryptionRecord) => record.status === 0 && record.enabled;
56
+
57
+ const getCapableBleUtils = (): EncryptionCapableBleUtils | undefined => {
58
+ if (Platform.OS !== 'android') return undefined;
59
+ const bleUtils: EncryptionCapableBleUtils = BleUtils;
60
+ if (
61
+ typeof bleUtils.supportsDeviceEncryptionChange !== 'function' ||
62
+ typeof bleUtils.onDeviceEncryptionChange !== 'function' ||
63
+ typeof bleUtils.onDeviceAclDisconnected !== 'function'
64
+ ) {
65
+ return undefined;
66
+ }
67
+ return bleUtils.supportsDeviceEncryptionChange() ? bleUtils : undefined;
68
+ };
69
+
70
+ const notify = (deviceId: string) => {
71
+ listeners.forEach(listener => listener(deviceId));
72
+ };
73
+
74
+ /** False means the encryption result can never be observed here, so nothing should wait for it. */
75
+ export const isBleEncryptionTrackingSupported = () => getCapableBleUtils() !== undefined;
76
+
77
+ /** Idempotent. Returns whether encryption results are observable on this OS and native build. */
78
+ export const startBleEncryptionTracking = (): boolean => {
79
+ if (unsubscribeNative) return true;
80
+ const bleUtils = getCapableBleUtils();
81
+ if (!bleUtils?.onDeviceEncryptionChange || !bleUtils.onDeviceAclDisconnected) return false;
82
+
83
+ unsubscribeNative = [
84
+ bleUtils.onDeviceEncryptionChange(event => {
85
+ if (typeof event?.id !== 'string') return;
86
+ const deviceId = normalizeDeviceId(event.id);
87
+ lastEncryption.set(deviceId, {
88
+ at: Date.now(),
89
+ status: Number(event.status),
90
+ enabled: event.enabled === true,
91
+ });
92
+ notify(deviceId);
93
+ }),
94
+ bleUtils.onDeviceAclDisconnected(event => {
95
+ if (typeof event?.id !== 'string') return;
96
+ const deviceId = normalizeDeviceId(event.id);
97
+ lastAclDisconnectAt.set(deviceId, Date.now());
98
+ notify(deviceId);
99
+ }),
100
+ ];
101
+ return true;
102
+ };
103
+
104
+ export const stopBleEncryptionTracking = () => {
105
+ unsubscribeNative?.forEach(unsubscribe => unsubscribe());
106
+ unsubscribeNative = undefined;
107
+ lastEncryption.clear();
108
+ lastAclDisconnectAt.clear();
109
+ listeners.clear();
110
+ };
111
+
112
+ /**
113
+ * Records a link that carried requests needing encryption, so reusing it later does not wait for
114
+ * an encryption result that was reported before tracking started.
115
+ */
116
+ export const markBleLinkEncrypted = (deviceId: string) => {
117
+ if (!unsubscribeNative) return;
118
+ lastEncryption.set(normalizeDeviceId(deviceId), { at: Date.now(), status: 0, enabled: true });
119
+ };
120
+
121
+ export type WaitForAndroidLinkEncryptionOptions = {
122
+ deviceId: string;
123
+ /** When the current link attempt began; earlier results describe another link. */
124
+ linkStartedAt: number;
125
+ /** How long to wait for the first encryption result of this link. */
126
+ resultTimeoutMs: number;
127
+ /** How long a system re-pairing may take once the stored bond was reported lost. */
128
+ repairTimeoutMs: number;
129
+ /** After the link drops during re-pairing, how long to wait for the key-missing report. */
130
+ keyMissingGraceMs: number;
131
+ signal?: AbortSignal;
132
+ onRepairStarted?: () => void;
133
+ };
134
+
135
+ /**
136
+ * Resolves once the link can carry requests that need encryption. Rejects with `BleBondInvalid`
137
+ * when the system reports that re-pairing failed, with `BleDeviceDisconnected` when the link drops
138
+ * during re-pairing, and with `BleDeviceNotBonded` when re-pairing does not finish in time.
139
+ */
140
+ export const waitForAndroidLinkEncryption = ({
141
+ deviceId,
142
+ linkStartedAt,
143
+ resultTimeoutMs,
144
+ repairTimeoutMs,
145
+ keyMissingGraceMs,
146
+ signal,
147
+ onRepairStarted,
148
+ }: WaitForAndroidLinkEncryptionOptions): Promise<AndroidLinkEncryption> => {
149
+ if (!startBleEncryptionTracking()) return Promise.resolve('unresolved');
150
+ startBleKeyMissingTracking();
151
+ const target = normalizeDeviceId(deviceId);
152
+
153
+ const previous = lastEncryption.get(target);
154
+ const previousDropAt = lastAclDisconnectAt.get(target);
155
+ if (
156
+ previous &&
157
+ previous.at < linkStartedAt &&
158
+ isEncrypted(previous) &&
159
+ (previousDropAt === undefined || previousDropAt < previous.at)
160
+ ) {
161
+ return Promise.resolve('already-encrypted');
162
+ }
163
+
164
+ return new Promise((resolve, reject) => {
165
+ if (signal?.aborted) {
166
+ reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
167
+ return;
168
+ }
169
+
170
+ /** Set once this link's first encryption failed because the peer lost the bond. */
171
+ let repairStartedAt: number | undefined;
172
+ let droppedDuringRepair = false;
173
+ let timer: ReturnType<typeof setTimeout> | undefined;
174
+ let settled = false;
175
+
176
+ const cleanup = () => {
177
+ settled = true;
178
+ if (timer) clearTimeout(timer);
179
+ listeners.delete(onLinkEvent);
180
+ cleanupKeyMissing();
181
+ signal?.removeEventListener('abort', onAbort);
182
+ };
183
+ const finish = (result: AndroidLinkEncryption) => {
184
+ cleanup();
185
+ resolve(result);
186
+ };
187
+ const fail = (error: Error) => {
188
+ cleanup();
189
+ reject(error);
190
+ };
191
+ const arm = (ms: number, onExpire: () => void) => {
192
+ if (timer) clearTimeout(timer);
193
+ timer = setTimeout(onExpire, ms);
194
+ };
195
+ const bondInvalid = () =>
196
+ ERRORS.TypedError(HardwareErrorCode.BleBondInvalid, undefined, {
197
+ phase: 'connect',
198
+ reason: 'key_missing',
199
+ });
200
+
201
+ const evaluate = () => {
202
+ if (settled) return;
203
+ const record = lastEncryption.get(target);
204
+ const dropAt = lastAclDisconnectAt.get(target);
205
+
206
+ if (repairStartedAt === undefined) {
207
+ if (hasBleKeyMissingSince(target, linkStartedAt)) {
208
+ fail(bondInvalid());
209
+ return;
210
+ }
211
+ if (dropAt !== undefined && dropAt >= linkStartedAt) {
212
+ // The link is gone; the caller's next request reports it with its usual error.
213
+ finish('unresolved');
214
+ return;
215
+ }
216
+ if (!record || record.at < linkStartedAt) return;
217
+ if (isEncrypted(record)) {
218
+ finish('encrypted');
219
+ return;
220
+ }
221
+ if (record.status !== HCI_PIN_OR_KEY_MISSING) {
222
+ finish('unresolved');
223
+ return;
224
+ }
225
+ // The system re-pairs on its own after a lost bond. Nothing may touch GATT until then.
226
+ repairStartedAt = record.at;
227
+ onRepairStarted?.();
228
+ arm(repairTimeoutMs, () =>
229
+ fail(
230
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing timed out', {
231
+ phase: 'bond',
232
+ reason: 'timeout',
233
+ })
234
+ )
235
+ );
236
+ return;
237
+ }
238
+
239
+ if (hasBleKeyMissingSince(target, repairStartedAt)) {
240
+ fail(bondInvalid());
241
+ return;
242
+ }
243
+ if (record && record.at > repairStartedAt && isEncrypted(record)) {
244
+ finish('re-paired');
245
+ return;
246
+ }
247
+ if (!droppedDuringRepair && dropAt !== undefined && dropAt >= repairStartedAt) {
248
+ droppedDuringRepair = true;
249
+ // A failed re-pairing drops the link and reports key missing right after.
250
+ arm(keyMissingGraceMs, () =>
251
+ fail(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected))
252
+ );
253
+ }
254
+ };
255
+
256
+ const onLinkEvent: LinkListener = id => {
257
+ if (id === target) evaluate();
258
+ };
259
+ const onAbort = () => fail(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
260
+
261
+ listeners.add(onLinkEvent);
262
+ const cleanupKeyMissing = onBleKeyMissing(target, evaluate);
263
+ signal?.addEventListener('abort', onAbort, { once: true });
264
+ arm(resultTimeoutMs, () => finish('unresolved'));
265
+ evaluate();
266
+ });
267
+ };
package/src/index.ts CHANGED
@@ -53,6 +53,12 @@ import {
53
53
  stopBleKeyMissingTracking,
54
54
  waitForBleKeyMissing,
55
55
  } from './bleKeyMissing';
56
+ import {
57
+ markBleLinkEncrypted,
58
+ startBleEncryptionTracking,
59
+ stopBleEncryptionTracking,
60
+ waitForAndroidLinkEncryption,
61
+ } from './bleEncryption';
56
62
  import { isNativeBleDisconnectError, toBleDisconnectHardwareError } from './bleNativeDisconnect';
57
63
  import {
58
64
  isBleStaleBondHardwareError,
@@ -68,6 +74,7 @@ import type { Deferred } from '@onekeyfe/hd-shared';
68
74
  import type { Characteristic, Device, Subscription } from 'react-native-ble-plx';
69
75
  import type EventEmitter from 'events';
70
76
  import type { BleAcquireInput, TransportOptions } from './types';
77
+ import type { AndroidLinkEncryption } from './bleEncryption';
71
78
 
72
79
  type FirmwareInstallBleAcquireInput = BleAcquireInput & {
73
80
  /**
@@ -202,6 +209,16 @@ const shouldRethrowProtocolProbeError = (error: unknown): boolean => {
202
209
  export const ANDROID_KEY_MISSING_LINK_WINDOW_MS = 10_000;
203
210
  /** The broadcast and the GATT disconnect it explains travel separately; either can land first. */
204
211
  export const ANDROID_KEY_MISSING_GRACE_MS = 500;
212
+ /**
213
+ * Android reports a bonded link's first encryption result within about half a second of
214
+ * connecting. Past this, the result is treated as unknown and the link is used as before.
215
+ */
216
+ export const ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS = 1500;
217
+ /**
218
+ * A system re-pairing needs the user to accept a pairing request and confirm the code on the
219
+ * device. The Bluetooth stack gives up after 30s and then reports key missing.
220
+ */
221
+ export const ANDROID_SYSTEM_REPAIR_TIMEOUT_MS = 35_000;
205
222
  /**
206
223
  * How a link dropped by a device that refuses a stale bond reaches JS. A wedged write is
207
224
  * excluded: the system is still re-pairing then and has not reported key missing yet.
@@ -216,6 +233,55 @@ const isAndroidLinkLossError = (error: unknown): boolean => {
216
233
  code === HardwareErrorCode.BleConnectedError
217
234
  );
218
235
  };
236
+
237
+ const logIosBleBondDiagnostic = (
238
+ phase: string,
239
+ error: unknown,
240
+ outcome: 'fallback' | 'rethrow'
241
+ ) => {
242
+ if (Platform.OS !== 'ios') return;
243
+
244
+ const nativeError = (error ?? {}) as {
245
+ name?: unknown;
246
+ errorCode?: unknown;
247
+ attErrorCode?: unknown;
248
+ iosErrorCode?: unknown;
249
+ androidErrorCode?: unknown;
250
+ };
251
+ const scalar = (value: unknown) =>
252
+ typeof value === 'string' || typeof value === 'number' ? value : undefined;
253
+
254
+ Log?.debug('[ReactNativeBleTransport][BleBondDiagnostic] probe error', {
255
+ phase,
256
+ outcome,
257
+ errorName: scalar(nativeError.name),
258
+ errorCode: scalar(nativeError.errorCode),
259
+ attErrorCode: scalar(nativeError.attErrorCode),
260
+ iosErrorCode: scalar(nativeError.iosErrorCode),
261
+ androidErrorCode: scalar(nativeError.androidErrorCode),
262
+ nativeStaleBond: isNativeBleStaleBondError(error),
263
+ hardwareStaleBond: isBleStaleBondHardwareError(error),
264
+ nativeDisconnect: isNativeBleDisconnectError(error),
265
+ });
266
+ };
267
+
268
+ type IosProbeDisconnectEvidence = {
269
+ phase: 'protocol-v1-get-features' | 'protocol-v2-ping';
270
+ errorCode?: string | number;
271
+ recordedAt: number;
272
+ };
273
+
274
+ const IOS_PROBE_DISCONNECT_EVIDENCE_TTL_MS = 5 * 60_000;
275
+
276
+ const isIosProbeDisconnectError = (error: unknown): boolean => {
277
+ if (Platform.OS !== 'ios') return false;
278
+ const code = (error as { errorCode?: unknown })?.errorCode;
279
+ return (
280
+ isNativeBleDisconnectError(error) ||
281
+ code === HardwareErrorCode.BleDeviceNotBonded ||
282
+ code === HardwareErrorCode.BleDeviceDisconnected
283
+ );
284
+ };
219
285
  /** Consecutive wedged writes on one device before the BLE manager itself is recreated. */
220
286
  export const BLE_WRITE_TIMEOUT_MANAGER_RESET_THRESHOLD = 2;
221
287
  const DEVICE_SCAN_TIMEOUT_MS = 3000;
@@ -558,6 +624,14 @@ export default class ReactNativeBleTransport {
558
624
  /** Endpoints whose last detection got no answer; 'woken' once their Initialize wake is spent. */
559
625
  private silentDetections = new Map<string, 'silent' | 'woken'>();
560
626
 
627
+ /**
628
+ * iOS can report a reset Pro2 as a generic peripheral disconnect instead of
629
+ * CBErrorPeerRemovedPairingInformation. Keep that first probe failure across
630
+ * the automatic cold reconnect and require a second failed probe before
631
+ * classifying the OS bond as invalid.
632
+ */
633
+ private iosProbeDisconnectEvidence = new Map<string, IosProbeDisconnectEvidence>();
634
+
561
635
  /** Android endpoints whose cached GATT table is suspect; the next connect refreshes it. */
562
636
  private androidGattCacheRefreshes = new Set<string>();
563
637
 
@@ -635,6 +709,11 @@ export default class ReactNativeBleTransport {
635
709
  init(logger: any, emitter: EventEmitter) {
636
710
  setBleLogger(logger);
637
711
  this.emitter = emitter;
712
+ if (Platform.OS === 'android') {
713
+ // Link security events are only meaningful if they were observed since the link came up.
714
+ startBleKeyMissingTracking();
715
+ startBleEncryptionTracking();
716
+ }
638
717
  }
639
718
 
640
719
  configure(signedData: any) {
@@ -1172,6 +1251,8 @@ export default class ReactNativeBleTransport {
1172
1251
 
1173
1252
  let device: Device | null = null;
1174
1253
  const isAndroid = Platform.OS === 'android';
1254
+ // Only a bond that existed before this acquire can have been lost by the device.
1255
+ let androidBondedBeforeConnect = false;
1175
1256
  // A firmware-install reconnect always refreshes: the new firmware may expose a different table.
1176
1257
  const refreshAndroidGattCache =
1177
1258
  isAndroid && (!!skipProtocolProbe || this.androidGattCacheRefreshes.has(uuid));
@@ -1205,11 +1286,13 @@ export default class ReactNativeBleTransport {
1205
1286
  if (Platform.OS === 'android') {
1206
1287
  // Subscribe before the link exists: key missing is broadcast while Android encrypts it.
1207
1288
  startBleKeyMissingTracking();
1289
+ startBleEncryptionTracking();
1208
1290
  // Failures before the new link starts must not be read against the previous link.
1209
1291
  this.androidLinkStartedAt.delete(uuid);
1210
1292
  // Initiate bonding locally before GATT can trigger peripheral-initiated pairing.
1211
1293
  try {
1212
1294
  const bondState = await pairDevice(uuid);
1295
+ androidBondedBeforeConnect = !bondState.bonding && bondState.bonded;
1213
1296
  if (bondState.bonding) {
1214
1297
  await onDeviceBondState(uuid, this.bondAbortController.signal, {
1215
1298
  systemInitiated: bondState.initiated === false,
@@ -1413,7 +1496,13 @@ export default class ReactNativeBleTransport {
1413
1496
  this.acquiringProtocolV2.add(uuid);
1414
1497
  }
1415
1498
 
1499
+ let linkEncryption: AndroidLinkEncryption | undefined;
1416
1500
  try {
1501
+ // A firmware-install reconnect keeps its existing sequence.
1502
+ if (androidBondedBeforeConnect && !skipProtocolProbe) {
1503
+ linkEncryption = await this.waitForAndroidLinkSecurity(uuid);
1504
+ if (this.stopped) throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
1505
+ }
1417
1506
  await this.installTransportForAcquire(uuid, acquiredDevice, {
1418
1507
  writeCharacteristic,
1419
1508
  notifyCharacteristic,
@@ -1465,6 +1554,11 @@ export default class ReactNativeBleTransport {
1465
1554
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotFound);
1466
1555
  }
1467
1556
  this.attachDisconnectSubscription(currentTransport, currentTransport.device, uuid);
1557
+ if (linkEncryption === 'unresolved') {
1558
+ // Notifications and the probe only work on an encrypted link, so the next reuse of this
1559
+ // link needs no result that was reported before tracking started.
1560
+ markBleLinkEncrypted(uuid);
1561
+ }
1468
1562
  return { uuid, protocolType };
1469
1563
  } catch (error) {
1470
1564
  // A failed acquire must retire the physical link before Core retries. Logical
@@ -1773,6 +1867,39 @@ export default class ReactNativeBleTransport {
1773
1867
  });
1774
1868
  }
1775
1869
 
1870
+ /**
1871
+ * Android 16+ encrypts a bonded link on its own right after connecting. Subscribing to
1872
+ * notifications before that finishes gets the CCCD write rejected, and the framework's retry
1873
+ * encrypts again with the same stale key; firmware that allows one failure per link then drops
1874
+ * it before the system can re-pair. Holding GATT until the result lets a lost bond be re-paired
1875
+ * in place. Without the platform signal this resolves immediately.
1876
+ */
1877
+ private async waitForAndroidLinkSecurity(uuid: string): Promise<AndroidLinkEncryption> {
1878
+ const linkStartedAt = this.androidLinkStartedAt.get(uuid);
1879
+ if (linkStartedAt === undefined) return 'unresolved';
1880
+ const waitStartedAt = Date.now();
1881
+ const result = await waitForAndroidLinkEncryption({
1882
+ deviceId: uuid,
1883
+ linkStartedAt,
1884
+ resultTimeoutMs: ANDROID_ENCRYPTION_RESULT_TIMEOUT_MS,
1885
+ repairTimeoutMs: ANDROID_SYSTEM_REPAIR_TIMEOUT_MS,
1886
+ keyMissingGraceMs: ANDROID_KEY_MISSING_GRACE_MS,
1887
+ signal: this.bondAbortController.signal,
1888
+ onRepairStarted: () => {
1889
+ Log?.debug(
1890
+ '[ReactNativeBleTransport] Android bond lost, waiting for system re-pairing:',
1891
+ uuid
1892
+ );
1893
+ },
1894
+ });
1895
+ Log?.debug('[ReactNativeBleTransport] Android link encryption', {
1896
+ connectIdSuffix: uuid.slice(-8),
1897
+ result,
1898
+ waitedMs: Date.now() - waitStartedAt,
1899
+ });
1900
+ return result;
1901
+ }
1902
+
1776
1903
  private async callProtocolV1(
1777
1904
  uuid: string,
1778
1905
  name: string,
@@ -2040,6 +2167,8 @@ export default class ReactNativeBleTransport {
2040
2167
  this.androidHighPriorityDevices.clear();
2041
2168
  this.androidLinkStartedAt.clear();
2042
2169
  stopBleKeyMissingTracking();
2170
+ stopBleEncryptionTracking();
2171
+ this.iosProbeDisconnectEvidence.clear();
2043
2172
  const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
2044
2173
  this.runPromise?.reject(error);
2045
2174
  this.runPromise = null;
@@ -2659,6 +2788,64 @@ export default class ReactNativeBleTransport {
2659
2788
  );
2660
2789
  }
2661
2790
 
2791
+ private rememberIosProbeDisconnect(
2792
+ uuid: string,
2793
+ phase: IosProbeDisconnectEvidence['phase'],
2794
+ error: unknown
2795
+ ): Error | undefined {
2796
+ if (!isIosProbeDisconnectError(error)) return undefined;
2797
+
2798
+ const previous = this.getIosProbeDisconnectEvidence(uuid);
2799
+ const errorCode = (error as { errorCode?: unknown })?.errorCode;
2800
+ const evidence: IosProbeDisconnectEvidence = {
2801
+ phase,
2802
+ ...(typeof errorCode === 'string' || typeof errorCode === 'number' ? { errorCode } : {}),
2803
+ recordedAt: Date.now(),
2804
+ };
2805
+ this.iosProbeDisconnectEvidence.set(uuid, evidence);
2806
+ Log?.debug('[ReactNativeBleTransport][BleBondDiagnostic] probe disconnect recorded', {
2807
+ phase,
2808
+ errorCode: evidence.errorCode,
2809
+ repeated: Boolean(previous),
2810
+ });
2811
+
2812
+ if (!previous) return undefined;
2813
+ return this.confirmIosProbeDisconnect(uuid, 'repeated-probe-disconnect');
2814
+ }
2815
+
2816
+ private getIosProbeDisconnectEvidence(uuid: string): IosProbeDisconnectEvidence | undefined {
2817
+ const evidence = this.iosProbeDisconnectEvidence.get(uuid);
2818
+ if (!evidence) return undefined;
2819
+
2820
+ const ageMs = Date.now() - evidence.recordedAt;
2821
+ if (ageMs <= IOS_PROBE_DISCONNECT_EVIDENCE_TTL_MS) return evidence;
2822
+
2823
+ this.iosProbeDisconnectEvidence.delete(uuid);
2824
+ Log?.debug('[ReactNativeBleTransport][BleBondDiagnostic] probe disconnect expired', {
2825
+ triggerPhase: evidence.phase,
2826
+ triggerErrorCode: evidence.errorCode,
2827
+ ageMs,
2828
+ });
2829
+ return undefined;
2830
+ }
2831
+
2832
+ private confirmIosProbeDisconnect(
2833
+ uuid: string,
2834
+ confirmation: 'repeated-probe-disconnect' | 'follow-up-no-response'
2835
+ ): Error | undefined {
2836
+ const evidence = this.getIosProbeDisconnectEvidence(uuid);
2837
+ if (!evidence) return undefined;
2838
+
2839
+ Log?.debug('[ReactNativeBleTransport][BleBondDiagnostic] stale bond confirmed', {
2840
+ confirmation,
2841
+ triggerPhase: evidence.phase,
2842
+ triggerErrorCode: evidence.errorCode,
2843
+ evidenceAgeMs: Date.now() - evidence.recordedAt,
2844
+ finalErrorCode: HardwareErrorCode.BleBondInvalid,
2845
+ });
2846
+ return ERRORS.TypedError(HardwareErrorCode.BleBondInvalid);
2847
+ }
2848
+
2662
2849
  private clearProbeProtocol(uuid: string, protocol: ProtocolType) {
2663
2850
  if (this.probingProtocols.get(uuid) === protocol) {
2664
2851
  this.probingProtocols.delete(uuid);
@@ -2690,6 +2877,7 @@ export default class ReactNativeBleTransport {
2690
2877
  // received no response. skipProtocolProbe does not need it either — its only
2691
2878
  // caller is the V2 firmware-install reconnect.
2692
2879
  if (expectedProtocol === 'V1') {
2880
+ this.iosProbeDisconnectEvidence.delete(uuid);
2693
2881
  this.deviceProtocol.set(uuid, 'V1');
2694
2882
  Log?.debug('[ReactNativeBleTransport] protocol selected', {
2695
2883
  deviceId: uuid,
@@ -2705,6 +2893,7 @@ export default class ReactNativeBleTransport {
2705
2893
 
2706
2894
  if (expectedProtocol === 'V2') {
2707
2895
  if (await this.probeProtocolV2(uuid)) {
2896
+ this.iosProbeDisconnectEvidence.delete(uuid);
2708
2897
  this.deviceProtocol.set(uuid, 'V2');
2709
2898
  this.sessionProtocols.set(uuid, 'V2');
2710
2899
  this.confirmedProtocolV2.add(uuid);
@@ -2715,6 +2904,18 @@ export default class ReactNativeBleTransport {
2715
2904
  });
2716
2905
  return 'V2';
2717
2906
  }
2907
+ const staleBondError = this.confirmIosProbeDisconnect(uuid, 'follow-up-no-response');
2908
+ if (Platform.OS === 'ios') {
2909
+ Log?.debug('[ReactNativeBleTransport][BleBondDiagnostic] protocol detection ended', {
2910
+ outcome: 'expected-v2-no-response',
2911
+ expectedProtocol,
2912
+ protocolHint: protocolHint ?? 'none',
2913
+ finalErrorCode: staleBondError
2914
+ ? HardwareErrorCode.BleBondInvalid
2915
+ : HardwareErrorCode.RuntimeError,
2916
+ });
2917
+ }
2918
+ if (staleBondError) throw staleBondError;
2718
2919
  throw this.createProtocolMismatchError(expectedProtocol);
2719
2920
  }
2720
2921
 
@@ -2750,6 +2951,7 @@ export default class ReactNativeBleTransport {
2750
2951
  const detected =
2751
2952
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
2752
2953
  if (detected) {
2954
+ this.iosProbeDisconnectEvidence.delete(uuid);
2753
2955
  this.deviceProtocol.set(uuid, protocol);
2754
2956
  this.sessionProtocols.set(uuid, protocol);
2755
2957
  if (protocol === 'V2') {
@@ -2779,6 +2981,25 @@ export default class ReactNativeBleTransport {
2779
2981
 
2780
2982
  this.deviceProtocol.delete(uuid);
2781
2983
  this.probingProtocols.delete(uuid);
2984
+ const staleBondError = this.confirmIosProbeDisconnect(uuid, 'follow-up-no-response');
2985
+ if (Platform.OS === 'ios') {
2986
+ Log?.debug('[ReactNativeBleTransport][BleBondDiagnostic] protocol detection ended', {
2987
+ outcome: 'all-probes-no-response',
2988
+ expectedProtocol: expectedProtocol ?? 'none',
2989
+ protocolHint: protocolHint ?? 'none',
2990
+ sessionProtocol: sessionProtocol ?? 'none',
2991
+ probeOrder: probeOrder.join(','),
2992
+ trustSessionProtocol,
2993
+ reprobeFailures,
2994
+ transportCached: Boolean(transportCache[uuid]),
2995
+ notifyActive: Boolean(transportCache[uuid]?.notifySubscription),
2996
+ staleBondRecorded: this.iosProbeDisconnectEvidence.has(uuid),
2997
+ finalErrorCode: staleBondError
2998
+ ? HardwareErrorCode.BleBondInvalid
2999
+ : HardwareErrorCode.BleTimeoutError,
3000
+ });
3001
+ }
3002
+ if (staleBondError) throw staleBondError;
2782
3003
  throw this.createProtocolDetectionError();
2783
3004
  }
2784
3005
 
@@ -2877,7 +3098,19 @@ export default class ReactNativeBleTransport {
2877
3098
  } catch (error) {
2878
3099
  this.clearProbeProtocol(uuid, 'V1');
2879
3100
  Log?.debug('[ReactNativeBleTransport] Protocol V1 GetFeatures probe failed:', error);
2880
- if (shouldRethrowProtocolProbeError(error)) {
3101
+ const shouldRethrow = shouldRethrowProtocolProbeError(error);
3102
+ const staleBondError = this.rememberIosProbeDisconnect(
3103
+ uuid,
3104
+ 'protocol-v1-get-features',
3105
+ error
3106
+ );
3107
+ logIosBleBondDiagnostic(
3108
+ 'protocol-v1-get-features',
3109
+ error,
3110
+ shouldRethrow ? 'rethrow' : 'fallback'
3111
+ );
3112
+ if (staleBondError) throw staleBondError;
3113
+ if (shouldRethrow) {
2881
3114
  throw error;
2882
3115
  }
2883
3116
  return false;
@@ -2892,18 +3125,26 @@ export default class ReactNativeBleTransport {
2892
3125
  this.probingProtocols.set(uuid, 'V2');
2893
3126
  this.protocolV2Assemblers.get(uuid)?.reset();
2894
3127
  this.throwIfStaleBondError(uuid);
2895
- const detected = await probeProtocolV2Helper({
2896
- call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
2897
- this.callProtocolV2(uuid, name, data, options),
2898
- timeoutMs: PROTOCOL_V2_PROBE_TIMEOUT_MS,
2899
- logger: Log,
2900
- logPrefix: 'ProtocolV2 RN-BLE',
2901
- onProbeFailed: () => {
2902
- this.protocolV2Assemblers.get(uuid)?.reset();
2903
- this.resetProtocolV2Frames(uuid);
2904
- },
2905
- shouldRethrow: shouldRethrowProtocolProbeError,
2906
- });
3128
+ let detected: boolean;
3129
+ try {
3130
+ detected = await probeProtocolV2Helper({
3131
+ call: (name: string, data: Record<string, unknown>, options?: TransportCallOptions) =>
3132
+ this.callProtocolV2(uuid, name, data, options),
3133
+ timeoutMs: PROTOCOL_V2_PROBE_TIMEOUT_MS,
3134
+ logger: Log,
3135
+ logPrefix: 'ProtocolV2 RN-BLE',
3136
+ onProbeFailed: () => {
3137
+ this.protocolV2Assemblers.get(uuid)?.reset();
3138
+ this.resetProtocolV2Frames(uuid);
3139
+ },
3140
+ shouldRethrow: shouldRethrowProtocolProbeError,
3141
+ });
3142
+ } catch (error) {
3143
+ const staleBondError = this.rememberIosProbeDisconnect(uuid, 'protocol-v2-ping', error);
3144
+ logIosBleBondDiagnostic('protocol-v2-ping', error, 'rethrow');
3145
+ if (staleBondError) throw staleBondError;
3146
+ throw error;
3147
+ }
2907
3148
  if (!detected) {
2908
3149
  this.clearProbeProtocol(uuid, 'V2');
2909
3150
  } else {