@onekeyfe/hd-transport-react-native 1.2.3-alpha.1 → 1.2.3-alpha.10

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.
@@ -56,6 +56,34 @@ function createHarness() {
56
56
  }
57
57
 
58
58
  describe('Protocol V1 stale call timeout', () => {
59
+ test('settles a cancelled read and waits for native teardown before completing cancel', async () => {
60
+ const { t, disconnectSpy } = createHarness();
61
+ let finishDisconnect!: () => void;
62
+ disconnectSpy.mockImplementationOnce(
63
+ () =>
64
+ new Promise<void>(resolve => {
65
+ finishDisconnect = resolve;
66
+ })
67
+ );
68
+ const call = t.call(UUID, 'Initialize', {}, { timeoutMs: 25000 });
69
+ const result = call.catch(error => error);
70
+ await flush();
71
+ let cancelled = false;
72
+ const cleanup = t.cancel().then(() => {
73
+ cancelled = true;
74
+ });
75
+ await flush();
76
+ expect(await result).toMatchObject({ errorCode: HardwareErrorCode.CallQueueActionCancelled });
77
+ expect(t.runPromise).toBeNull();
78
+ expect(disconnectSpy).toHaveBeenCalledWith(UUID);
79
+ expect(cancelled).toBe(false);
80
+ finishDisconnect();
81
+ await cleanup;
82
+ jest.advanceTimersByTime(25000);
83
+ await flush();
84
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
85
+ });
86
+
59
87
  beforeAll(() => {
60
88
  jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] });
61
89
  });
@@ -172,24 +200,25 @@ describe('Protocol V1 stale call timeout', () => {
172
200
  expect(secondErrors).toHaveLength(1);
173
201
  });
174
202
 
175
- test('orphan timer left behind by cancel() must not disconnect the transport', async () => {
203
+ test('a cancelled read cannot disconnect a later transport when its old deadline passes', async () => {
176
204
  const { t, disconnectSpy } = createHarness();
177
205
 
178
- // cancel() nulls the ownership slot without settling the deferred, so the
179
- // call's response timer stays armed (reachable via DeviceCommands.dispose).
180
206
  const errors: Array<{ errorCode?: unknown }> = [];
181
207
  const p = t.call(UUID, 'GetFeatures', {}, { timeoutMs: 5000 });
182
208
  p.catch(e => errors.push(e));
183
209
  await flush();
184
210
 
185
- t.cancel();
211
+ await t.cancel();
212
+ await flush();
213
+ expect(disconnectSpy).toHaveBeenCalledTimes(1);
214
+ disconnectSpy.mockClear();
186
215
 
187
216
  jest.advanceTimersByTime(5000);
188
217
  await flush();
189
218
 
190
219
  expect(disconnectSpy).not.toHaveBeenCalled();
191
220
  expect(errors).toHaveLength(1);
192
- expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleTimeoutError);
221
+ expect(errors[0]?.errorCode).toBe(HardwareErrorCode.CallQueueActionCancelled);
193
222
  });
194
223
 
195
224
  test('timeout on the active call still disconnects the transport', async () => {
@@ -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
+ };
@@ -0,0 +1,106 @@
1
+ import { Platform } from 'react-native';
2
+ import BleUtils from '@onekeyfe/react-native-ble-utils';
3
+
4
+ /**
5
+ * Android 16+ keeps its side of a bond the device no longer has keys for, and reports it
6
+ * with ACTION_KEY_MISSING instead of a GATT status. The device then drops the link, so
7
+ * without this signal a wiped device is indistinguishable from any other disconnect.
8
+ *
9
+ * The signal only describes the link that was being encrypted when it fired. Callers must
10
+ * bound it to their own connection attempt with `sinceMs`; reusing an older one would turn
11
+ * an ordinary disconnect (a firmware-update reboot, for example) into a terminal bond error.
12
+ */
13
+
14
+ // An app can still resolve a react-native-ble-utils older than 0.1.8, and the JS bundle can
15
+ // run on a native build that predates the event, so neither method is assumed to exist.
16
+ type KeyMissingCapableBleUtils = Partial<
17
+ Pick<typeof BleUtils, 'supportsDeviceKeyMissing' | 'onDeviceKeyMissing'>
18
+ >;
19
+
20
+ type KeyMissingListener = (deviceId: string) => void;
21
+
22
+ const lastKeyMissingAt = new Map<string, number>();
23
+ const listeners = new Set<KeyMissingListener>();
24
+ let unsubscribeNative: (() => void) | undefined;
25
+
26
+ const normalizeDeviceId = (deviceId: string) => deviceId.toLowerCase();
27
+
28
+ const getCapableBleUtils = (): KeyMissingCapableBleUtils | undefined => {
29
+ if (Platform.OS !== 'android') return undefined;
30
+ const bleUtils: KeyMissingCapableBleUtils = BleUtils;
31
+ if (
32
+ typeof bleUtils.supportsDeviceKeyMissing !== 'function' ||
33
+ typeof bleUtils.onDeviceKeyMissing !== 'function'
34
+ ) {
35
+ return undefined;
36
+ }
37
+ return bleUtils.supportsDeviceKeyMissing() ? bleUtils : undefined;
38
+ };
39
+
40
+ /** False means the event can never fire here, so nothing should wait for it. */
41
+ export const isBleKeyMissingSupported = () => getCapableBleUtils() !== undefined;
42
+
43
+ /** Idempotent. Returns whether the signal is available on this OS and native build. */
44
+ export const startBleKeyMissingTracking = (): boolean => {
45
+ if (unsubscribeNative) return true;
46
+ const bleUtils = getCapableBleUtils();
47
+ if (!bleUtils?.onDeviceKeyMissing) return false;
48
+
49
+ unsubscribeNative = bleUtils.onDeviceKeyMissing(event => {
50
+ if (typeof event?.id !== 'string') return;
51
+ const deviceId = normalizeDeviceId(event.id);
52
+ lastKeyMissingAt.set(deviceId, Date.now());
53
+ listeners.forEach(listener => listener(deviceId));
54
+ });
55
+ return true;
56
+ };
57
+
58
+ export const stopBleKeyMissingTracking = () => {
59
+ unsubscribeNative?.();
60
+ unsubscribeNative = undefined;
61
+ lastKeyMissingAt.clear();
62
+ listeners.clear();
63
+ };
64
+
65
+ export const hasBleKeyMissingSince = (deviceId: string, sinceMs: number): boolean => {
66
+ const at = lastKeyMissingAt.get(normalizeDeviceId(deviceId));
67
+ return at !== undefined && at >= sinceMs;
68
+ };
69
+
70
+ /** Calls back for every later signal on this device until the returned cleanup runs. */
71
+ export const onBleKeyMissing = (deviceId: string, callback: () => void): (() => void) => {
72
+ const target = normalizeDeviceId(deviceId);
73
+ const listener: KeyMissingListener = id => {
74
+ if (id === target) callback();
75
+ };
76
+ listeners.add(listener);
77
+ return () => {
78
+ listeners.delete(listener);
79
+ };
80
+ };
81
+
82
+ /**
83
+ * Resolves true once a signal at or after `sinceMs` exists for the device, false when
84
+ * none arrives within `timeoutMs`. The broadcast and the GATT disconnect it explains are
85
+ * delivered on separate paths, so the disconnect can reach JS first.
86
+ */
87
+ export const waitForBleKeyMissing = (
88
+ deviceId: string,
89
+ sinceMs: number,
90
+ timeoutMs: number
91
+ ): Promise<boolean> => {
92
+ if (!startBleKeyMissingTracking()) return Promise.resolve(false);
93
+ if (hasBleKeyMissingSince(deviceId, sinceMs)) return Promise.resolve(true);
94
+
95
+ return new Promise(resolve => {
96
+ const settle = (found: boolean) => {
97
+ clearTimeout(timer);
98
+ cleanup();
99
+ resolve(found);
100
+ };
101
+ const cleanup = onBleKeyMissing(deviceId, () => {
102
+ if (hasBleKeyMissingSince(deviceId, sinceMs)) settle(true);
103
+ });
104
+ const timer = setTimeout(() => settle(false), timeoutMs);
105
+ });
106
+ };
@@ -0,0 +1,40 @@
1
+ import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
2
+
3
+ const BLE_PLX_DEVICE_DISCONNECTED = 201;
4
+ const IOS_PERIPHERAL_DISCONNECTED = 7;
5
+
6
+ type NativeBleErrorFields = {
7
+ errorCode?: unknown;
8
+ iosErrorCode?: unknown;
9
+ reason?: unknown;
10
+ message?: unknown;
11
+ };
12
+
13
+ const nativeErrorText = (error: NativeBleErrorFields) =>
14
+ [error.reason, error.message]
15
+ .filter((value): value is string => typeof value === 'string')
16
+ .join(' ');
17
+
18
+ export const isNativeBleDisconnectError = (error: unknown): boolean => {
19
+ if (!error || typeof error !== 'object') {
20
+ return false;
21
+ }
22
+
23
+ const nativeError = error as NativeBleErrorFields;
24
+ return (
25
+ nativeError.errorCode === BLE_PLX_DEVICE_DISCONNECTED ||
26
+ nativeError.iosErrorCode === IOS_PERIPHERAL_DISCONNECTED
27
+ );
28
+ };
29
+
30
+ export const toBleDisconnectHardwareError = (error: unknown) => {
31
+ if ((error as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected) {
32
+ return error as Error;
33
+ }
34
+
35
+ const nativeError = (error ?? {}) as NativeBleErrorFields;
36
+ return ERRORS.TypedError(
37
+ HardwareErrorCode.BleDeviceDisconnected,
38
+ nativeErrorText(nativeError) || undefined
39
+ );
40
+ };