@onekeyfe/hd-transport-react-native 1.2.4-alpha.0 → 1.2.4-alpha.1
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/bleIosStaleBond.d.ts +16 -0
- package/dist/bleIosStaleBond.d.ts.map +1 -0
- package/dist/index.d.ts +7 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +102 -139
- package/package.json +5 -5
- package/src/__tests__/iosPeerTermination.test.ts +303 -0
- package/src/__tests__/protocolReprobe.test.ts +1 -116
- package/src/bleIosStaleBond.ts +92 -0
- package/src/index.ts +54 -184
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import EventEmitter from 'events';
|
|
2
|
+
import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
|
|
3
|
+
|
|
4
|
+
import ReactNativeBleTransport from '../index';
|
|
5
|
+
import {
|
|
6
|
+
IOS_PEER_TERMINATION_LINK_WINDOW_MS,
|
|
7
|
+
IOS_PEER_TERMINATION_REPEAT_WINDOW_MS,
|
|
8
|
+
IosPeerTerminationTracker,
|
|
9
|
+
} from '../bleIosStaleBond';
|
|
10
|
+
import protocolV1Schema from './protocolV1SchemaFixture';
|
|
11
|
+
|
|
12
|
+
jest.mock(
|
|
13
|
+
'react-native',
|
|
14
|
+
() => ({
|
|
15
|
+
Platform: { OS: 'ios', select: (spec: Record<string, unknown>) => spec.ios },
|
|
16
|
+
PermissionsAndroid: {
|
|
17
|
+
PERMISSIONS: {},
|
|
18
|
+
RESULTS: {},
|
|
19
|
+
request: jest.fn(),
|
|
20
|
+
requestMultiple: jest.fn(),
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
{ virtual: true }
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
jest.mock('react-native-ble-plx', () => ({
|
|
27
|
+
BleATTErrorCode: { InvalidHandle: 1, UnlikelyError: 14 },
|
|
28
|
+
BleError: Error,
|
|
29
|
+
BleErrorCode: {
|
|
30
|
+
DeviceDisconnected: 201,
|
|
31
|
+
OperationStartFailed: 601,
|
|
32
|
+
DeviceMTUChangeFailed: 401,
|
|
33
|
+
OperationCancelled: 2,
|
|
34
|
+
OperationTimedOut: 3,
|
|
35
|
+
DeviceAlreadyConnected: 203,
|
|
36
|
+
},
|
|
37
|
+
BleManager: jest.fn(),
|
|
38
|
+
ScanMode: { LowLatency: 2 },
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.mock('@onekeyfe/react-native-ble-utils', () => ({
|
|
42
|
+
__esModule: true,
|
|
43
|
+
default: {
|
|
44
|
+
getConnectedPeripherals: jest.fn(() => Promise.resolve([])),
|
|
45
|
+
getBondedPeripherals: jest.fn(() => Promise.resolve([])),
|
|
46
|
+
pairDevice: jest.fn(() => Promise.resolve({ bonded: true, bonding: false })),
|
|
47
|
+
onDeviceBondState: jest.fn(),
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const UUID = '2146E74A-21CC-2EA6-2CE3-B4258520FD0F';
|
|
52
|
+
|
|
53
|
+
// What ble-plx rejects the in-flight operation with when the device ends the link. An iPhone 17
|
|
54
|
+
// reports a wiped device this way instead of CBErrorPeerRemovedPairingInformation.
|
|
55
|
+
const nativeDisconnect = (iosErrorCode: number, reason: string) =>
|
|
56
|
+
Object.assign(new Error(`Device ${UUID} was disconnected`), {
|
|
57
|
+
errorCode: 201,
|
|
58
|
+
iosErrorCode,
|
|
59
|
+
reason,
|
|
60
|
+
});
|
|
61
|
+
const peerEndedLink = () => nativeDisconnect(7, 'The specified device has disconnected from us.');
|
|
62
|
+
const linkTimedOut = () => nativeDisconnect(6, 'The connection has timed out unexpectedly.');
|
|
63
|
+
|
|
64
|
+
function createHarness() {
|
|
65
|
+
const writeCharacteristic = {
|
|
66
|
+
uuid: '00000002-0000-1000-8000-00805f9b34fb',
|
|
67
|
+
isWritableWithResponse: true,
|
|
68
|
+
writeWithResponse: jest.fn(() => Promise.resolve()),
|
|
69
|
+
};
|
|
70
|
+
const notifyCharacteristic = {
|
|
71
|
+
uuid: '00000003-0000-1000-8000-00805f9b34fb',
|
|
72
|
+
isNotifiable: true,
|
|
73
|
+
monitor: jest.fn(() => ({ remove: jest.fn() })),
|
|
74
|
+
};
|
|
75
|
+
const device = {
|
|
76
|
+
id: UUID,
|
|
77
|
+
name: 'Pro 2 48E9',
|
|
78
|
+
localName: 'Pro 2 48E9',
|
|
79
|
+
mtu: 247,
|
|
80
|
+
serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
|
|
81
|
+
isConnected: jest.fn(() => Promise.resolve(false)),
|
|
82
|
+
cancelConnection: jest.fn(() => Promise.resolve()),
|
|
83
|
+
connect: jest.fn(),
|
|
84
|
+
discoverAllServicesAndCharacteristics: jest.fn(() => Promise.resolve()),
|
|
85
|
+
characteristicsForService: jest.fn(() =>
|
|
86
|
+
Promise.resolve([writeCharacteristic, notifyCharacteristic])
|
|
87
|
+
),
|
|
88
|
+
services: jest.fn(() => Promise.resolve([])),
|
|
89
|
+
onDisconnected: jest.fn(() => ({ remove: jest.fn() })),
|
|
90
|
+
};
|
|
91
|
+
device.connect.mockImplementation(() => Promise.resolve(device));
|
|
92
|
+
const transport = new ReactNativeBleTransport({});
|
|
93
|
+
(transport as any).blePlxManager = {
|
|
94
|
+
devices: jest.fn(() => Promise.resolve([device])),
|
|
95
|
+
connectedDevices: jest.fn(() => Promise.resolve([])),
|
|
96
|
+
connectToDevice: jest.fn(() => Promise.resolve(device)),
|
|
97
|
+
cancelTransaction: jest.fn(() => Promise.resolve()),
|
|
98
|
+
cancelDeviceConnection: jest.fn(() => Promise.resolve()),
|
|
99
|
+
onStateChange: jest.fn((listener: (state: string) => void) => {
|
|
100
|
+
setImmediate(() => listener('PoweredOn'));
|
|
101
|
+
return { remove: jest.fn() };
|
|
102
|
+
}),
|
|
103
|
+
state: jest.fn(() => Promise.resolve('PoweredOn')),
|
|
104
|
+
};
|
|
105
|
+
transport.init(
|
|
106
|
+
{ debug: jest.fn(), error: jest.fn(), warn: jest.fn() } as any,
|
|
107
|
+
new EventEmitter()
|
|
108
|
+
);
|
|
109
|
+
transport.configure(protocolV1Schema);
|
|
110
|
+
return { transport, device, writeCharacteristic, notifyCharacteristic };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type Outcome = { code?: unknown; params?: unknown; native?: unknown };
|
|
114
|
+
|
|
115
|
+
const flush = () =>
|
|
116
|
+
new Promise(resolve => {
|
|
117
|
+
setImmediate(resolve);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** Runs one acquire to its end, moving the fake clock past the transport's own waits. */
|
|
121
|
+
async function acquire(
|
|
122
|
+
transport: ReactNativeBleTransport,
|
|
123
|
+
input: Record<string, unknown> = {}
|
|
124
|
+
): Promise<Outcome> {
|
|
125
|
+
let outcome: Outcome | undefined;
|
|
126
|
+
transport.acquire({ uuid: UUID, ...input } as never).then(
|
|
127
|
+
() => {
|
|
128
|
+
outcome = {};
|
|
129
|
+
},
|
|
130
|
+
error => {
|
|
131
|
+
const { code, params } = ERRORS.serializeError({ error });
|
|
132
|
+
outcome = { code, params, native: (error as { iosErrorCode?: unknown }).iosErrorCode };
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
for (let i = 0; i < 400 && !outcome; i += 1) {
|
|
136
|
+
// eslint-disable-next-line no-await-in-loop
|
|
137
|
+
await flush();
|
|
138
|
+
if (!outcome) jest.advanceTimersByTime(100);
|
|
139
|
+
}
|
|
140
|
+
if (!outcome) throw new Error('acquire did not settle');
|
|
141
|
+
return outcome;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe('iOS link ended by the device right after connecting', () => {
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance', 'nextTick'] });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
jest.clearAllTimers();
|
|
151
|
+
jest.useRealTimers();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('keeps the first error so Core retries, then reports an invalid bond', async () => {
|
|
155
|
+
const { transport, device } = createHarness();
|
|
156
|
+
device.discoverAllServicesAndCharacteristics.mockImplementation(() =>
|
|
157
|
+
Promise.reject(peerEndedLink())
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Core retries an error it cannot classify, which is what makes the second attempt.
|
|
161
|
+
await expect(acquire(transport)).resolves.toEqual({ native: 7 });
|
|
162
|
+
await expect(acquire(transport)).resolves.toEqual({
|
|
163
|
+
code: HardwareErrorCode.BleBondInvalid,
|
|
164
|
+
params: { phase: 'connect', reason: 'peer_disconnected' },
|
|
165
|
+
});
|
|
166
|
+
// The verdict consumes the evidence, so the next one needs two attempts again.
|
|
167
|
+
await expect(acquire(transport)).resolves.toEqual({ native: 7 });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('counts a drop that interrupts the first write the same way', async () => {
|
|
171
|
+
const { transport, writeCharacteristic } = createHarness();
|
|
172
|
+
writeCharacteristic.writeWithResponse.mockImplementation(() => Promise.reject(peerEndedLink()));
|
|
173
|
+
|
|
174
|
+
await expect(acquire(transport)).resolves.toMatchObject({
|
|
175
|
+
code: HardwareErrorCode.BleDeviceNotBonded,
|
|
176
|
+
});
|
|
177
|
+
await expect(acquire(transport)).resolves.toMatchObject({
|
|
178
|
+
code: HardwareErrorCode.BleBondInvalid,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('counts a drop that only the notification stream reports', async () => {
|
|
183
|
+
const { transport, notifyCharacteristic } = createHarness();
|
|
184
|
+
// The request went out before the link ended, so no write is there to fail.
|
|
185
|
+
notifyCharacteristic.monitor.mockImplementation(((
|
|
186
|
+
listener: (error: unknown, characteristic: unknown) => void
|
|
187
|
+
) => {
|
|
188
|
+
setTimeout(() => listener(peerEndedLink(), null), 50);
|
|
189
|
+
return { remove: jest.fn() };
|
|
190
|
+
}) as never);
|
|
191
|
+
|
|
192
|
+
await expect(acquire(transport)).resolves.not.toMatchObject({
|
|
193
|
+
code: HardwareErrorCode.BleBondInvalid,
|
|
194
|
+
});
|
|
195
|
+
await expect(acquire(transport)).resolves.toMatchObject({
|
|
196
|
+
code: HardwareErrorCode.BleBondInvalid,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('needs the second drop soon after the first', async () => {
|
|
201
|
+
const { transport, device } = createHarness();
|
|
202
|
+
device.discoverAllServicesAndCharacteristics.mockImplementation(() =>
|
|
203
|
+
Promise.reject(peerEndedLink())
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
await acquire(transport);
|
|
207
|
+
jest.setSystemTime(Date.now() + IOS_PEER_TERMINATION_REPEAT_WINDOW_MS + 1);
|
|
208
|
+
|
|
209
|
+
await expect(acquire(transport)).resolves.toEqual({ native: 7 });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('starts over after an attempt that failed for another reason', async () => {
|
|
213
|
+
const { transport, device } = createHarness();
|
|
214
|
+
device.discoverAllServicesAndCharacteristics
|
|
215
|
+
.mockImplementationOnce(() => Promise.reject(peerEndedLink()))
|
|
216
|
+
.mockImplementationOnce(() => Promise.reject(linkTimedOut()))
|
|
217
|
+
.mockImplementation(() => Promise.reject(peerEndedLink()));
|
|
218
|
+
|
|
219
|
+
await acquire(transport);
|
|
220
|
+
await expect(acquire(transport)).resolves.toEqual({ native: 6 });
|
|
221
|
+
await expect(acquire(transport)).resolves.toEqual({ native: 7 });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('starts over once the device has answered', async () => {
|
|
225
|
+
const { transport, device } = createHarness();
|
|
226
|
+
(transport as any).detectProtocol = jest.fn(() => Promise.resolve('V1'));
|
|
227
|
+
device.discoverAllServicesAndCharacteristics
|
|
228
|
+
.mockImplementationOnce(() => Promise.reject(peerEndedLink()))
|
|
229
|
+
.mockImplementationOnce(() => Promise.resolve())
|
|
230
|
+
.mockImplementation(() => Promise.reject(peerEndedLink()));
|
|
231
|
+
|
|
232
|
+
await acquire(transport);
|
|
233
|
+
await expect(acquire(transport)).resolves.toEqual({});
|
|
234
|
+
|
|
235
|
+
await expect(acquire(transport)).resolves.toEqual({ native: 7 });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('never classifies a firmware-install reconnect', async () => {
|
|
239
|
+
const { transport, device } = createHarness();
|
|
240
|
+
device.discoverAllServicesAndCharacteristics.mockImplementation(() =>
|
|
241
|
+
Promise.reject(peerEndedLink())
|
|
242
|
+
);
|
|
243
|
+
const reconnect = { expectedProtocol: 'V2', skipProtocolProbe: true };
|
|
244
|
+
|
|
245
|
+
await expect(acquire(transport, reconnect)).resolves.toEqual({ native: 7 });
|
|
246
|
+
await expect(acquire(transport, reconnect)).resolves.toEqual({ native: 7 });
|
|
247
|
+
// The device was rebooting, so those drops are not evidence for a later attempt either.
|
|
248
|
+
await expect(acquire(transport)).resolves.toEqual({ native: 7 });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('reports a lost bond that iOS names during GATT setup', async () => {
|
|
252
|
+
const { transport, device } = createHarness();
|
|
253
|
+
device.discoverAllServicesAndCharacteristics.mockImplementation(() =>
|
|
254
|
+
Promise.reject(nativeDisconnect(14, 'Peer removed pairing information'))
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
await expect(acquire(transport)).resolves.toMatchObject({
|
|
258
|
+
code: HardwareErrorCode.BlePeerRemovedPairingInformation,
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('IosPeerTerminationTracker', () => {
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
jest.useFakeTimers({ doNotFake: ['performance'] });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
afterEach(() => {
|
|
269
|
+
jest.useRealTimers();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const dropLink = (tracker: IosPeerTerminationTracker, linkAgeMs = 0) => {
|
|
273
|
+
tracker.attemptStarted(UUID);
|
|
274
|
+
tracker.linkStarted(UUID);
|
|
275
|
+
jest.setSystemTime(Date.now() + linkAgeMs);
|
|
276
|
+
tracker.note(UUID, peerEndedLink());
|
|
277
|
+
return tracker.attemptFailed(UUID);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
test('ignores a drop on a link that was reused or had been up for a while', () => {
|
|
281
|
+
const tracker = new IosPeerTerminationTracker();
|
|
282
|
+
expect(dropLink(tracker)).toBe(false);
|
|
283
|
+
|
|
284
|
+
tracker.attemptStarted(UUID);
|
|
285
|
+
tracker.note(UUID, peerEndedLink());
|
|
286
|
+
expect(tracker.attemptFailed(UUID)).toBe(false);
|
|
287
|
+
|
|
288
|
+
expect(dropLink(tracker)).toBe(false);
|
|
289
|
+
expect(dropLink(tracker, IOS_PEER_TERMINATION_LINK_WINDOW_MS + 1)).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('ignores errors that do not say the peer ended the link', () => {
|
|
293
|
+
const tracker = new IosPeerTerminationTracker();
|
|
294
|
+
expect(dropLink(tracker)).toBe(false);
|
|
295
|
+
|
|
296
|
+
tracker.attemptStarted(UUID);
|
|
297
|
+
tracker.linkStarted(UUID);
|
|
298
|
+
tracker.note(UUID, linkTimedOut());
|
|
299
|
+
tracker.note(UUID, { errorCode: 201 });
|
|
300
|
+
tracker.note(UUID, undefined);
|
|
301
|
+
expect(tracker.attemptFailed(UUID)).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { Platform } from 'react-native';
|
|
2
|
-
import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
|
|
3
|
-
|
|
4
1
|
import ReactNativeBleTransport, { PROTOCOL_REPROBE_FALLBACK_ATTEMPTS } from '../index';
|
|
5
2
|
import protocolV1Schema from './protocolV1SchemaFixture';
|
|
6
3
|
|
|
@@ -92,9 +89,7 @@ describe('protocol re-probe after a known device goes away', () => {
|
|
|
92
89
|
test('an unknown device still probes both protocols', async () => {
|
|
93
90
|
const { transport, probeV1, probeV2 } = createHarness({ v1: false, v2: false });
|
|
94
91
|
|
|
95
|
-
await expect(detect(transport)).rejects.
|
|
96
|
-
errorCode: HardwareErrorCode.BleTimeoutError,
|
|
97
|
-
});
|
|
92
|
+
await expect(detect(transport)).rejects.toBeDefined();
|
|
98
93
|
|
|
99
94
|
expect(probeV1).toHaveBeenCalledTimes(1);
|
|
100
95
|
expect(probeV2).toHaveBeenCalledTimes(1);
|
|
@@ -159,113 +154,3 @@ describe('protocol re-probe after a known device goes away', () => {
|
|
|
159
154
|
expect((known.transport as any).protocolReprobeFailures.get(UUID)).toBeUndefined();
|
|
160
155
|
});
|
|
161
156
|
});
|
|
162
|
-
|
|
163
|
-
describe('iOS reset-device stale bond confirmation', () => {
|
|
164
|
-
afterEach(() => {
|
|
165
|
-
Object.assign(Platform, { OS: 'ios' });
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test('promotes a probe disconnect followed by a silent reconnect to BleBondInvalid', async () => {
|
|
169
|
-
const transport = new ReactNativeBleTransport({});
|
|
170
|
-
transport.configure(protocolV1Schema);
|
|
171
|
-
(transport as any).callProtocolV1 = jest
|
|
172
|
-
.fn()
|
|
173
|
-
.mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded));
|
|
174
|
-
|
|
175
|
-
await expect(detect(transport)).rejects.toMatchObject({
|
|
176
|
-
errorCode: HardwareErrorCode.BleDeviceNotBonded,
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
(transport as any).probeProtocolV1 = jest.fn(() => Promise.resolve(false));
|
|
180
|
-
(transport as any).probeProtocolV2 = jest.fn(() => Promise.resolve(false));
|
|
181
|
-
(transport as any).resetProbeStateAfterProtocolProbe = jest.fn(() => Promise.resolve());
|
|
182
|
-
|
|
183
|
-
await expect(detect(transport)).rejects.toMatchObject({
|
|
184
|
-
errorCode: HardwareErrorCode.BleBondInvalid,
|
|
185
|
-
});
|
|
186
|
-
await expect(detect(transport)).rejects.toMatchObject({
|
|
187
|
-
errorCode: HardwareErrorCode.BleBondInvalid,
|
|
188
|
-
});
|
|
189
|
-
expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(true);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test('promotes a repeated iOS probe disconnect without waiting for probe timeouts', () => {
|
|
193
|
-
const { transport } = createHarness({ v1: false, v2: false });
|
|
194
|
-
const firstError = Object.assign(new Error('The specified device has disconnected from us'), {
|
|
195
|
-
errorCode: 201,
|
|
196
|
-
iosErrorCode: 7,
|
|
197
|
-
});
|
|
198
|
-
const secondError = ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected);
|
|
199
|
-
|
|
200
|
-
expect(
|
|
201
|
-
(transport as any).rememberIosProbeDisconnect(UUID, 'protocol-v1-get-features', firstError)
|
|
202
|
-
).toBeUndefined();
|
|
203
|
-
expect(
|
|
204
|
-
(transport as any).rememberIosProbeDisconnect(UUID, 'protocol-v2-ping', secondError)
|
|
205
|
-
).toMatchObject({ errorCode: HardwareErrorCode.BleBondInvalid });
|
|
206
|
-
expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(true);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test('clears the disconnect evidence after a valid protocol response', async () => {
|
|
210
|
-
const { transport } = createHarness({ v1: true, v2: false });
|
|
211
|
-
(transport as any).rememberIosProbeDisconnect(
|
|
212
|
-
UUID,
|
|
213
|
-
'protocol-v1-get-features',
|
|
214
|
-
ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded)
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
await expect(detect(transport)).resolves.toBe('V1');
|
|
218
|
-
expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(false);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
test('preserves evidence across disconnect and stalled setup cleanup', async () => {
|
|
222
|
-
const { transport } = createHarness({ v1: false, v2: false });
|
|
223
|
-
(transport as any).rememberIosProbeDisconnect(
|
|
224
|
-
UUID,
|
|
225
|
-
'protocol-v1-get-features',
|
|
226
|
-
ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded)
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
await (transport as any).disconnectUnlocked(UUID);
|
|
230
|
-
expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(true);
|
|
231
|
-
|
|
232
|
-
(transport as any).abandonStalledConnection(UUID, 'connect-native');
|
|
233
|
-
expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(true);
|
|
234
|
-
|
|
235
|
-
await expect(detect(transport)).rejects.toMatchObject({
|
|
236
|
-
errorCode: HardwareErrorCode.BleBondInvalid,
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
test('expires old disconnect evidence before classifying a later timeout', async () => {
|
|
241
|
-
const { transport } = createHarness({ v1: false, v2: false });
|
|
242
|
-
(transport as any).rememberIosProbeDisconnect(
|
|
243
|
-
UUID,
|
|
244
|
-
'protocol-v1-get-features',
|
|
245
|
-
ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded)
|
|
246
|
-
);
|
|
247
|
-
const evidence = (transport as any).iosProbeDisconnectEvidence.get(UUID);
|
|
248
|
-
evidence.recordedAt = Date.now() - 5 * 60_000 - 1;
|
|
249
|
-
|
|
250
|
-
await expect(detect(transport)).rejects.toMatchObject({
|
|
251
|
-
errorCode: HardwareErrorCode.BleTimeoutError,
|
|
252
|
-
});
|
|
253
|
-
expect((transport as any).iosProbeDisconnectEvidence.has(UUID)).toBe(false);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
test('does not classify the same sequence as a stale bond on Android', async () => {
|
|
257
|
-
Object.assign(Platform, { OS: 'android' });
|
|
258
|
-
const { transport } = createHarness({ v1: false, v2: false });
|
|
259
|
-
|
|
260
|
-
expect(
|
|
261
|
-
(transport as any).rememberIosProbeDisconnect(
|
|
262
|
-
UUID,
|
|
263
|
-
'protocol-v1-get-features',
|
|
264
|
-
ERRORS.TypedError(HardwareErrorCode.BleDeviceNotBonded)
|
|
265
|
-
)
|
|
266
|
-
).toBeUndefined();
|
|
267
|
-
await expect(detect(transport)).rejects.toMatchObject({
|
|
268
|
-
errorCode: HardwareErrorCode.BleTimeoutError,
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iOS reports a bond the device no longer holds as CBErrorPeerRemovedPairingInformation, but not
|
|
3
|
+
* on every iPhone. On the iPhone 17 family the device ends the link about a second after
|
|
4
|
+
* connecting instead (its firmware drops a link on the second key failure), and the app only sees
|
|
5
|
+
* CBErrorPeripheralDisconnected on whatever operation was in flight. The disconnect event carries
|
|
6
|
+
* no reason on iOS, so that error is the only place the code appears.
|
|
7
|
+
*
|
|
8
|
+
* A device that powers off or reboots right after connecting ends one link the same way, so a
|
|
9
|
+
* single drop proves nothing. iOS never re-pairs on its own, which makes a stale bond end every
|
|
10
|
+
* new link like that: two acquire attempts in a row, each dropped by the peer on a link it had
|
|
11
|
+
* just opened and before any response, is what this classifies as a stale bond.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** CBError.peripheralDisconnected: the peer ended the link. A link timeout is a different code. */
|
|
15
|
+
const IOS_PERIPHERAL_DISCONNECTED = 7;
|
|
16
|
+
|
|
17
|
+
/** The device drops a stale bond about a second after connecting; later drops mean something else. */
|
|
18
|
+
export const IOS_PEER_TERMINATION_LINK_WINDOW_MS = 10_000;
|
|
19
|
+
/** How long one dropped link stays usable as evidence for the next attempt. */
|
|
20
|
+
export const IOS_PEER_TERMINATION_REPEAT_WINDOW_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
export const isIosPeerTerminationError = (error: unknown): boolean =>
|
|
23
|
+
(error as { iosErrorCode?: unknown } | null | undefined)?.iosErrorCode ===
|
|
24
|
+
IOS_PERIPHERAL_DISCONNECTED;
|
|
25
|
+
|
|
26
|
+
export class IosPeerTerminationTracker {
|
|
27
|
+
/** When the current acquire attempt opened its link; unset when it reuses an older one. */
|
|
28
|
+
private linkStartedAt = new Map<string, number>();
|
|
29
|
+
|
|
30
|
+
/** When the peer ended the current attempt's link. */
|
|
31
|
+
private terminatedAt = new Map<string, number>();
|
|
32
|
+
|
|
33
|
+
/** When the previous attempt ended that way. */
|
|
34
|
+
private evidenceAt = new Map<string, number>();
|
|
35
|
+
|
|
36
|
+
attemptStarted(deviceId: string) {
|
|
37
|
+
this.linkStartedAt.delete(deviceId);
|
|
38
|
+
this.terminatedAt.delete(deviceId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
linkStarted(deviceId: string) {
|
|
42
|
+
this.linkStartedAt.set(deviceId, Date.now());
|
|
43
|
+
this.terminatedAt.delete(deviceId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reads the native error of a failed operation before it is mapped to a typed one. */
|
|
47
|
+
note(deviceId: string, error: unknown) {
|
|
48
|
+
if (!isIosPeerTerminationError(error)) return;
|
|
49
|
+
if (!this.terminatedAt.has(deviceId)) this.terminatedAt.set(deviceId, Date.now());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The device answered on this link, so its bond is valid. */
|
|
53
|
+
linkProven(deviceId: string) {
|
|
54
|
+
this.forget(deviceId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
forget(deviceId: string) {
|
|
58
|
+
this.linkStartedAt.delete(deviceId);
|
|
59
|
+
this.terminatedAt.delete(deviceId);
|
|
60
|
+
this.evidenceAt.delete(deviceId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns true when this failed attempt is the second in a row the peer ended on a new link. */
|
|
64
|
+
attemptFailed(deviceId: string): boolean {
|
|
65
|
+
const startedAt = this.linkStartedAt.get(deviceId);
|
|
66
|
+
const terminatedAt = this.terminatedAt.get(deviceId);
|
|
67
|
+
const previous = this.evidenceAt.get(deviceId);
|
|
68
|
+
this.forget(deviceId);
|
|
69
|
+
|
|
70
|
+
if (
|
|
71
|
+
startedAt === undefined ||
|
|
72
|
+
terminatedAt === undefined ||
|
|
73
|
+
terminatedAt - startedAt > IOS_PEER_TERMINATION_LINK_WINDOW_MS
|
|
74
|
+
) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (
|
|
78
|
+
previous !== undefined &&
|
|
79
|
+
terminatedAt - previous <= IOS_PEER_TERMINATION_REPEAT_WINDOW_MS
|
|
80
|
+
) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
this.evidenceAt.set(deviceId, terminatedAt);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
reset() {
|
|
88
|
+
this.linkStartedAt.clear();
|
|
89
|
+
this.terminatedAt.clear();
|
|
90
|
+
this.evidenceAt.clear();
|
|
91
|
+
}
|
|
92
|
+
}
|