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

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,307 @@
1
+ import EventEmitter from 'events';
2
+
3
+ import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
4
+
5
+ import ReactNativeBleTransport, {
6
+ ANDROID_KEY_MISSING_GRACE_MS,
7
+ ANDROID_KEY_MISSING_LINK_WINDOW_MS,
8
+ } from '../index';
9
+ import { SYSTEM_BONDING_RESTART_WINDOW_MS, onDeviceBondState } from '../BleManager';
10
+ import { stopBleKeyMissingTracking } from '../bleKeyMissing';
11
+ import protocolV1Schema from './protocolV1SchemaFixture';
12
+
13
+ let mockKeyMissingSupported = true;
14
+ let mockEmitKeyMissing: ((event: { id?: string }) => void) | undefined;
15
+ let mockEmitBondState: ((peripheral: unknown) => void) | undefined;
16
+
17
+ jest.mock(
18
+ 'react-native',
19
+ () => ({
20
+ Platform: { OS: 'android', select: (spec: Record<string, unknown>) => spec.android },
21
+ PermissionsAndroid: {
22
+ PERMISSIONS: {},
23
+ RESULTS: {},
24
+ request: jest.fn(),
25
+ requestMultiple: jest.fn(),
26
+ },
27
+ }),
28
+ { virtual: true }
29
+ );
30
+
31
+ jest.mock('react-native-ble-plx', () => ({
32
+ BleATTErrorCode: { InvalidHandle: 1 },
33
+ BleError: Error,
34
+ BleErrorCode: { DeviceDisconnected: 201, OperationStartFailed: 601 },
35
+ BleManager: jest.fn(),
36
+ ScanMode: { LowLatency: 2 },
37
+ }));
38
+
39
+ jest.mock('@onekeyfe/react-native-ble-utils', () => ({
40
+ __esModule: true,
41
+ default: {
42
+ getConnectedPeripherals: jest.fn(() => Promise.resolve([])),
43
+ getBondedPeripherals: jest.fn(() => Promise.resolve([])),
44
+ pairDevice: jest.fn(() => Promise.resolve({ bonded: true, bonding: false })),
45
+ supportsDeviceKeyMissing: jest.fn(() => mockKeyMissingSupported),
46
+ onDeviceKeyMissing: jest.fn((callback: (event: { id?: string }) => void) => {
47
+ mockEmitKeyMissing = callback;
48
+ return () => {
49
+ mockEmitKeyMissing = undefined;
50
+ };
51
+ }),
52
+ onDeviceBondState: jest.fn((callback: (peripheral: unknown) => void) => {
53
+ mockEmitBondState = callback;
54
+ return () => {
55
+ mockEmitBondState = undefined;
56
+ };
57
+ }),
58
+ },
59
+ }));
60
+
61
+ const UUID = 'CC:06:1C:FD:48:E9';
62
+
63
+ const flush = () =>
64
+ new Promise(resolve => {
65
+ setImmediate(resolve);
66
+ });
67
+
68
+ const advance = async (ms: number) => {
69
+ jest.advanceTimersByTime(ms);
70
+ await flush();
71
+ };
72
+
73
+ // What ble-plx reports when the device drops the link while the first write is queued.
74
+ const peerTerminated = () =>
75
+ Promise.reject(
76
+ Object.assign(new Error(`Device ${UUID} was disconnected`), {
77
+ errorCode: 201,
78
+ attErrorCode: 19,
79
+ reason: 'Disconnected with status 19 (GATT_CONN_TERMINATE_PEER_USER)',
80
+ })
81
+ );
82
+
83
+ function createConnectedTransport(linkAgeMs = 0) {
84
+ const t = new ReactNativeBleTransport({});
85
+ t.configure(protocolV1Schema);
86
+ (t as any).deviceProtocol.set(UUID, 'V1');
87
+ (t as any).getCachedTransport = () => ({
88
+ writeCharacteristic: { writeWithoutResponse: jest.fn(peerTerminated) },
89
+ });
90
+ (t as any).androidLinkStartedAt.set(UUID, Date.now() - linkAgeMs);
91
+ return t;
92
+ }
93
+
94
+ const settle = (promise: Promise<unknown>) => {
95
+ const outcome: { error?: { errorCode?: unknown }; done: boolean } = { done: false };
96
+ promise.then(
97
+ () => {
98
+ outcome.done = true;
99
+ },
100
+ error => {
101
+ outcome.error = error;
102
+ outcome.done = true;
103
+ }
104
+ );
105
+ return outcome;
106
+ };
107
+
108
+ describe('Android key missing', () => {
109
+ beforeEach(() => {
110
+ jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] });
111
+ mockKeyMissingSupported = true;
112
+ });
113
+
114
+ afterEach(() => {
115
+ stopBleKeyMissingTracking();
116
+ jest.clearAllTimers();
117
+ jest.useRealTimers();
118
+ });
119
+
120
+ describe('link dropped right after connect', () => {
121
+ test('reports an invalid bond when the system says the keys are missing', async () => {
122
+ const outcome = settle(createConnectedTransport().call(UUID, 'GetFeatures', {}));
123
+ await flush();
124
+ // The broadcast is delivered separately and can trail the GATT disconnect.
125
+ mockEmitKeyMissing?.({ id: UUID.toLowerCase() });
126
+ await flush();
127
+
128
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleBondInvalid);
129
+ });
130
+
131
+ test('keeps the original error when no key missing follows', async () => {
132
+ const outcome = settle(createConnectedTransport().call(UUID, 'GetFeatures', {}));
133
+ await flush();
134
+ expect(outcome.done).toBe(false);
135
+
136
+ await advance(ANDROID_KEY_MISSING_GRACE_MS);
137
+
138
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleDeviceNotBonded);
139
+ });
140
+
141
+ test('ignores key missing reported for an earlier link', async () => {
142
+ const transport = createConnectedTransport();
143
+ const earlier = settle(transport.call(UUID, 'GetFeatures', {}));
144
+ await flush();
145
+ mockEmitKeyMissing?.({ id: UUID });
146
+ await flush();
147
+ expect(earlier.error?.errorCode).toBe(HardwareErrorCode.BleBondInvalid);
148
+
149
+ // The user re-paired; a later link that drops is an ordinary disconnect.
150
+ await advance(1000);
151
+ (transport as any).androidLinkStartedAt.set(UUID, Date.now());
152
+ const later = settle(transport.call(UUID, 'GetFeatures', {}));
153
+ await flush();
154
+ await advance(ANDROID_KEY_MISSING_GRACE_MS);
155
+
156
+ expect(later.error?.errorCode).toBe(HardwareErrorCode.BleDeviceNotBonded);
157
+ });
158
+
159
+ test('ignores key missing for another device', async () => {
160
+ const outcome = settle(createConnectedTransport().call(UUID, 'GetFeatures', {}));
161
+ await flush();
162
+ mockEmitKeyMissing?.({ id: 'E9:B9:F4:DD:DE:CD' });
163
+ await advance(ANDROID_KEY_MISSING_GRACE_MS);
164
+
165
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleDeviceNotBonded);
166
+ });
167
+
168
+ test('does not wait on a link that has been up for a while', async () => {
169
+ const transport = createConnectedTransport(ANDROID_KEY_MISSING_LINK_WINDOW_MS + 1);
170
+ const outcome = settle(transport.call(UUID, 'GetFeatures', {}));
171
+ await flush();
172
+
173
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleDeviceNotBonded);
174
+ });
175
+
176
+ test('does not wait when the OS or native build never reports key missing', async () => {
177
+ mockKeyMissingSupported = false;
178
+ const outcome = settle(createConnectedTransport().call(UUID, 'GetFeatures', {}));
179
+ await flush();
180
+
181
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleDeviceNotBonded);
182
+ });
183
+ });
184
+
185
+ describe('acquire over a bond the device no longer holds', () => {
186
+ // Android still reports BONDED, so connect, MTU and GATT setup all succeed and the
187
+ // protocol probe is the first operation to meet the dropped link.
188
+ function createStaleBondHarness() {
189
+ const writeCharacteristic = {
190
+ uuid: '00000002-0000-1000-8000-00805f9b34fb',
191
+ isWritableWithoutResponse: true,
192
+ writeWithoutResponse: jest.fn(peerTerminated),
193
+ };
194
+ const notifyCharacteristic = {
195
+ uuid: '00000003-0000-1000-8000-00805f9b34fb',
196
+ isNotifiable: true,
197
+ monitor: jest.fn(() => ({ remove: jest.fn() })),
198
+ };
199
+ const device = {
200
+ id: UUID,
201
+ name: 'Pro 2 48E9',
202
+ localName: 'Pro 2 48E9',
203
+ mtu: 247,
204
+ serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
205
+ isConnected: jest.fn(() => Promise.resolve(true)),
206
+ cancelConnection: jest.fn(() => Promise.resolve()),
207
+ discoverAllServicesAndCharacteristics: jest.fn(() => Promise.resolve()),
208
+ characteristicsForService: jest.fn(() =>
209
+ Promise.resolve([writeCharacteristic, notifyCharacteristic])
210
+ ),
211
+ services: jest.fn(() => Promise.resolve([])),
212
+ onDisconnected: jest.fn(() => ({ remove: jest.fn() })),
213
+ };
214
+ const transport = new ReactNativeBleTransport({});
215
+ (transport as any).blePlxManager = {
216
+ devices: jest.fn(() => Promise.resolve([device])),
217
+ connectedDevices: jest.fn(() => Promise.resolve([])),
218
+ cancelTransaction: jest.fn(() => Promise.resolve()),
219
+ cancelDeviceConnection: jest.fn(() => Promise.resolve()),
220
+ onStateChange: jest.fn((listener: (state: string) => void) => {
221
+ setImmediate(() => listener('PoweredOn'));
222
+ return { remove: jest.fn() };
223
+ }),
224
+ state: jest.fn(() => Promise.resolve('PoweredOn')),
225
+ };
226
+ transport.init(
227
+ { debug: jest.fn(), error: jest.fn(), warn: jest.fn() } as any,
228
+ new EventEmitter()
229
+ );
230
+ transport.configure(protocolV1Schema);
231
+ return { transport, write: writeCharacteristic.writeWithoutResponse };
232
+ }
233
+
234
+ async function acquireUntilSettled(keyMissing: boolean) {
235
+ const { transport, write } = createStaleBondHarness();
236
+ const outcome = settle(transport.acquire({ uuid: UUID }));
237
+ for (let i = 0; i < 100 && !outcome.done; i += 1) {
238
+ // eslint-disable-next-line no-await-in-loop
239
+ await advance(100);
240
+ // The broadcast follows the dropped link, which the probe write is first to meet.
241
+ if (keyMissing && write.mock.calls.length > 0) mockEmitKeyMissing?.({ id: UUID });
242
+ }
243
+ expect(write).toHaveBeenCalled();
244
+ return JSON.parse(JSON.stringify(ERRORS.serializeError({ error: outcome.error })));
245
+ }
246
+
247
+ test('ends as an invalid bond that survives the Core response path', async () => {
248
+ await expect(acquireUntilSettled(true)).resolves.toEqual({
249
+ code: HardwareErrorCode.BleBondInvalid,
250
+ error: expect.stringContaining('Forget the device'),
251
+ params: { phase: 'connect', reason: 'key_missing' },
252
+ });
253
+ });
254
+
255
+ test('keeps the legacy error when the system reports nothing', async () => {
256
+ await expect(acquireUntilSettled(false)).resolves.toMatchObject({
257
+ code: HardwareErrorCode.BleDeviceNotBonded,
258
+ });
259
+ });
260
+ });
261
+
262
+ describe('waiting for bonding', () => {
263
+ const bondState = (preState: string, state: string) =>
264
+ mockEmitBondState?.({ id: UUID, bondState: { preState, state } });
265
+
266
+ test('fails as an invalid bond when the system re-pair ends in key missing', async () => {
267
+ const outcome = settle(onDeviceBondState(UUID, undefined, { systemInitiated: true }));
268
+ mockEmitKeyMissing?.({ id: UUID });
269
+ await flush();
270
+
271
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleBondInvalid);
272
+ });
273
+
274
+ test('follows a system re-pair that replaces the bond', async () => {
275
+ const outcome = settle(onDeviceBondState(UUID, undefined, { systemInitiated: true }));
276
+ bondState('BOND_BONDING', 'BOND_NONE');
277
+ bondState('BOND_NONE', 'BOND_BONDING');
278
+ await advance(SYSTEM_BONDING_RESTART_WINDOW_MS);
279
+ expect(outcome.done).toBe(false);
280
+
281
+ bondState('BOND_BONDING', 'BOND_BONDED');
282
+ await flush();
283
+
284
+ expect(outcome.done).toBe(true);
285
+ expect(outcome.error).toBeUndefined();
286
+ });
287
+
288
+ test('fails a system re-pair that ends without a bond', async () => {
289
+ const outcome = settle(onDeviceBondState(UUID, undefined, { systemInitiated: true }));
290
+ bondState('BOND_BONDING', 'BOND_NONE');
291
+ await flush();
292
+ expect(outcome.done).toBe(false);
293
+
294
+ await advance(SYSTEM_BONDING_RESTART_WINDOW_MS);
295
+
296
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleDeviceNotBonded);
297
+ });
298
+
299
+ test('fails a bonding this transport started as soon as it ends without a bond', async () => {
300
+ const outcome = settle(onDeviceBondState(UUID));
301
+ bondState('BOND_BONDING', 'BOND_NONE');
302
+ await flush();
303
+
304
+ expect(outcome.error?.errorCode).toBe(HardwareErrorCode.BleDeviceNotBonded);
305
+ });
306
+ });
307
+ });
@@ -25,6 +25,21 @@ jest.mock('../BleManager', () => ({
25
25
  pairDevice: jest.fn(),
26
26
  }));
27
27
 
28
+ // The native facade is replaced wholesale above; key missing and link encryption are covered
29
+ // by their own suites.
30
+ jest.mock('../bleKeyMissing', () => ({
31
+ isBleKeyMissingSupported: jest.fn(() => false),
32
+ startBleKeyMissingTracking: jest.fn(() => false),
33
+ stopBleKeyMissingTracking: jest.fn(),
34
+ waitForBleKeyMissing: jest.fn(() => Promise.resolve(false)),
35
+ }));
36
+ jest.mock('../bleEncryption', () => ({
37
+ markBleLinkEncrypted: jest.fn(),
38
+ startBleEncryptionTracking: jest.fn(() => false),
39
+ stopBleEncryptionTracking: jest.fn(),
40
+ waitForAndroidLinkEncryption: jest.fn(() => Promise.resolve('unresolved')),
41
+ }));
42
+
28
43
  jest.mock('../subscribeBleOn', () => ({
29
44
  subscribeBleOn: jest.fn(() => Promise.resolve()),
30
45
  }));
@@ -45,6 +45,21 @@ jest.mock('../BleManager', () => ({
45
45
  pairDevice: jest.fn(() => Promise.resolve({ bonded: true, bonding: false })),
46
46
  }));
47
47
 
48
+ // The native facade is replaced wholesale above; key missing and link encryption are covered
49
+ // by their own suites.
50
+ jest.mock('../bleKeyMissing', () => ({
51
+ isBleKeyMissingSupported: jest.fn(() => false),
52
+ startBleKeyMissingTracking: jest.fn(() => false),
53
+ stopBleKeyMissingTracking: jest.fn(),
54
+ waitForBleKeyMissing: jest.fn(() => Promise.resolve(false)),
55
+ }));
56
+ jest.mock('../bleEncryption', () => ({
57
+ markBleLinkEncrypted: jest.fn(),
58
+ startBleEncryptionTracking: jest.fn(() => false),
59
+ stopBleEncryptionTracking: jest.fn(),
60
+ waitForAndroidLinkEncryption: jest.fn(() => Promise.resolve('unresolved')),
61
+ }));
62
+
48
63
  jest.mock('../subscribeBleOn', () => ({
49
64
  subscribeBleOn: jest.fn(() => Promise.resolve()),
50
65
  }));
@@ -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
+ };