@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.
- package/dist/bleEncryption.d.ts +17 -0
- package/dist/bleEncryption.d.ts.map +1 -0
- package/dist/index.d.ts +29 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +367 -31
- package/package.json +6 -6
- package/src/__tests__/androidEncryption.test.ts +482 -0
- package/src/__tests__/enumerate.test.ts +8 -1
- package/src/__tests__/protocolReprobe.test.ts +116 -1
- package/src/__tests__/protocolV2Link.test.ts +8 -1
- package/src/bleEncryption.ts +267 -0
- package/src/index.ts +254 -13
|
@@ -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
|
-
|
|
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
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
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 {
|