@onekeyfe/hd-transport-react-native 1.2.2-alpha.116 → 1.2.2-alpha.117

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.
@@ -111,18 +111,10 @@ const createHarness = ({
111
111
  deviceName = 'OneKey Pro 2',
112
112
  isWritableWithResponse = true,
113
113
  monitorError,
114
- pro2BleReplies = false,
115
114
  }: {
116
115
  deviceName?: string;
117
116
  isWritableWithResponse?: boolean;
118
117
  monitorError?: (Error & { reason?: string; attErrorCode?: number; iosErrorCode?: number }) | null;
119
- /**
120
- * Answer like a real Pro 2 over BLE: reassemble a frame split across ATT writes, echo the
121
- * 17-character probe message so the reply is 29 bytes, and send it as one notification of
122
- * at most ATT_MTU-3 bytes. A reply that does not fit is cut, never continued (btsnoop:
123
- * 20 bytes declaring 29 at the default MTU).
124
- */
125
- pro2BleReplies?: boolean;
126
118
  } = {}) => {
127
119
  const uuid = 'rn-pro2-id';
128
120
  const sentSeqs: number[] = [];
@@ -147,31 +139,18 @@ const createHarness = ({
147
139
  return { remove: jest.fn() };
148
140
  }),
149
141
  };
150
- let pendingPro2Frame: Buffer | undefined;
151
- let negotiatedMtu = () => 23;
152
142
  const handleWrite = (base64: string) => {
153
- let frame = Buffer.from(base64, 'base64');
154
- if (pro2BleReplies) {
155
- if (frame[0] !== 0x5a && !pendingPro2Frame) return Promise.resolve();
156
- pendingPro2Frame =
157
- pendingPro2Frame && frame[0] !== 0x5a ? Buffer.concat([pendingPro2Frame, frame]) : frame;
158
- const declaredLength =
159
- pendingPro2Frame.length >= 3 ? pendingPro2Frame[1] + pendingPro2Frame[2] * 256 : Infinity;
160
- if (pendingPro2Frame.length < declaredLength) return Promise.resolve();
161
- frame = pendingPro2Frame;
162
- pendingPro2Frame = undefined;
163
- }
143
+ const frame = Buffer.from(base64, 'base64');
164
144
  sentSeqs.push(frame[6]);
165
145
  if (shouldRespond) {
166
146
  responseSeq += 1;
167
147
  const response = ProtocolV2.encodeFrame(
168
148
  schemas,
169
149
  'Success',
170
- { message: pro2BleReplies ? 'protocol-v2-probe' : 'ok' },
150
+ { message: 'ok' },
171
151
  { router: PROTOCOL_V2_CHANNEL_BLE_UART, seq: responseSeq }
172
152
  );
173
- const notification = pro2BleReplies ? response.subarray(0, negotiatedMtu() - 3) : response;
174
- notifyCallback?.(null, { value: Buffer.from(notification).toString('base64') });
153
+ notifyCallback?.(null, { value: Buffer.from(response).toString('base64') });
175
154
  }
176
155
  return Promise.resolve();
177
156
  };
@@ -191,11 +170,13 @@ const createHarness = ({
191
170
  serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
192
171
  isConnected: jest.fn(() => Promise.resolve(true)),
193
172
  cancelConnection: jest.fn(() => Promise.resolve()),
173
+ connect: jest.fn(),
194
174
  onDisconnected: jest.fn(callback => {
195
175
  disconnectCallback = callback;
196
176
  return { remove: jest.fn() };
197
177
  }),
198
178
  } as any;
179
+ device.connect.mockResolvedValue(device);
199
180
  device.requestMTU = jest.fn(() => Promise.resolve(device));
200
181
  device.requestConnectionPriority = jest.fn(() => Promise.resolve(device));
201
182
  const bleManager = {
@@ -215,14 +196,6 @@ const createHarness = ({
215
196
  transport.init(logger, emitter);
216
197
  transport.configure(protocolV1Schema);
217
198
  transport.configureProtocolV2(protocolV2Schema);
218
- // The Pro 2 sizes replies to the MTU of the link, i.e. the one the transport adopted.
219
- negotiatedMtu = () => {
220
- try {
221
- return (transport as any).getCachedTransport(uuid).mtuSize ?? device.mtu;
222
- } catch {
223
- return device.mtu;
224
- }
225
- };
226
199
 
227
200
  return {
228
201
  transport,
@@ -233,7 +206,6 @@ const createHarness = ({
233
206
  bleManager,
234
207
  sentSeqs,
235
208
  writeCharacteristic,
236
- notifyCharacteristic,
237
209
  setShouldRespond(value: boolean) {
238
210
  shouldRespond = value;
239
211
  },
@@ -1157,42 +1129,106 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1157
1129
  await transport.release(uuid, true);
1158
1130
  });
1159
1131
 
1160
- test('drops the link when a stalled MTU negotiation is abandoned', async () => {
1132
+ test('bounds a stalled MTU negotiation and continues protocol probing', async () => {
1161
1133
  const { transport, uuid, device, bleManager } = createHarness();
1162
1134
  device.mtu = 23;
1163
1135
  device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1164
- const { resolveCharacteristics } = transport as any;
1165
1136
 
1166
- // cancelTransaction only disposes the JS subscription: RxAndroidBle releases the
1167
- // connection's serial queue from onComplete/onError alone, so the abandoned exchange
1168
- // keeps it until its own native timeout. Service discovery must not queue behind it.
1169
- await expect(transport.acquire({ uuid })).rejects.toMatchObject({
1170
- errorCode: HardwareErrorCode.BleConnectedError,
1137
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
1138
+ uuid,
1139
+ protocolType: 'V2',
1171
1140
  });
1172
1141
  const transactionId = device.requestMTU.mock.calls[0]?.[1];
1173
1142
  expect(transactionId).toEqual(expect.stringContaining(`${uuid}:mtu:connected:0:`));
1174
1143
  expect(bleManager.cancelTransaction).toHaveBeenCalledWith(transactionId);
1175
- expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
1176
- expect(resolveCharacteristics).not.toHaveBeenCalled();
1144
+ expect(device.cancelConnection).toHaveBeenCalled();
1145
+ expect(device.connect).toHaveBeenCalledWith(
1146
+ expect.objectContaining({ timeout: expect.any(Number) })
1147
+ );
1148
+ expect(device.connect.mock.calls.at(-1)?.[0]).not.toHaveProperty('requestMTU');
1149
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
1150
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
1151
+ await transport.release(uuid, true);
1177
1152
  }, 10_000);
1178
1153
 
1179
- describe('Android MTU before service discovery', () => {
1180
- const connectedPeripherals = () => jest.requireMock('../BleManager').getConnectedDeviceIds;
1154
+ test('does not request MTU again after connect falls back without requestMTU', async () => {
1155
+ const { BleError: BleErrorMock, BleErrorCode } = jest.requireMock('react-native-ble-plx');
1156
+ const { transport, uuid, device } = createHarness();
1157
+ device.mtu = 23;
1158
+ device.isConnected.mockResolvedValueOnce(false).mockResolvedValue(true);
1159
+ device.connect
1160
+ .mockRejectedValueOnce(
1161
+ Object.assign(new BleErrorMock('Operation was cancelled'), {
1162
+ errorCode: BleErrorCode.OperationCancelled,
1163
+ })
1164
+ )
1165
+ .mockResolvedValue(device);
1166
+
1167
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
1168
+ uuid,
1169
+ protocolType: 'V2',
1170
+ });
1171
+ expect(device.connect).toHaveBeenCalledTimes(2);
1172
+ expect(device.connect.mock.calls[0][0]).toEqual(expect.objectContaining({ requestMTU: 247 }));
1173
+ expect(device.connect.mock.calls[1][0]).not.toHaveProperty('requestMTU');
1174
+ expect(device.requestMTU).not.toHaveBeenCalled();
1175
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
1176
+ await transport.release(uuid, true);
1177
+ });
1178
+
1179
+ test('keeps the iOS connect chain unchanged', async () => {
1180
+ const { transport, uuid, device } = createHarness();
1181
+ device.isConnected.mockResolvedValueOnce(false);
1181
1182
 
1183
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1184
+ uuid,
1185
+ protocolType: 'V2',
1186
+ });
1187
+ expect(device.connect).toHaveBeenCalledWith({
1188
+ requestMTU: 247,
1189
+ timeout: expect.any(Number),
1190
+ refreshGatt: 'OnConnected',
1191
+ });
1192
+ expect(device.requestMTU).not.toHaveBeenCalled();
1193
+ await transport.release(uuid, true);
1194
+ });
1195
+
1196
+ describe('Android MTU before service discovery', () => {
1182
1197
  beforeEach(() => {
1183
1198
  setPlatformOS('android');
1184
- connectedPeripherals().mockClear();
1199
+ jest.useFakeTimers({
1200
+ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick', 'performance'],
1201
+ });
1185
1202
  });
1186
1203
 
1187
1204
  afterEach(() => {
1188
- connectedPeripherals().mockImplementation(() => Promise.resolve([]));
1205
+ jest.clearAllTimers();
1206
+ jest.useRealTimers();
1189
1207
  });
1190
1208
 
1191
- /** Production bounds are seconds long; behaviour tests shorten the real-time waits. */
1192
- const fastAndroidWaits = (transport: ReactNativeBleTransport) => {
1193
- transport.androidMtuExchangeTimeoutMs = 200;
1194
- transport.androidLinkDropQuietMs = 120;
1195
- transport.androidLinkDropTimeoutMs = 600;
1209
+ const advanceUntil = async (condition: () => boolean, budgetMs = 60_000) => {
1210
+ for (let elapsed = 0; !condition(); elapsed += 50) {
1211
+ if (elapsed >= budgetMs) throw new Error(`fake time exhausted after ${budgetMs}ms`);
1212
+ jest.advanceTimersByTime(50);
1213
+ // eslint-disable-next-line no-await-in-loop
1214
+ await new Promise(resolve => {
1215
+ setImmediate(resolve);
1216
+ });
1217
+ }
1218
+ };
1219
+
1220
+ const settle = async <T>(promise: Promise<T>, budgetMs?: number): Promise<T> => {
1221
+ let done = false;
1222
+ promise.then(
1223
+ () => {
1224
+ done = true;
1225
+ },
1226
+ () => {
1227
+ done = true;
1228
+ }
1229
+ );
1230
+ await advanceUntil(() => done, budgetMs);
1231
+ return promise;
1196
1232
  };
1197
1233
 
1198
1234
  /**
@@ -1220,25 +1256,17 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1220
1256
  return negotiated;
1221
1257
  };
1222
1258
 
1223
- const defaultMtuError = {
1224
- errorCode: HardwareErrorCode.BleConnectedError,
1225
- message: expect.stringContaining('BLE link stayed at the default MTU'),
1226
- };
1227
-
1228
1259
  test('negotiates the MTU after a bare connect and before service discovery, adopting the returned device', async () => {
1229
1260
  const { transport, uuid, device } = createHarness();
1230
1261
  reconnectingDevice(device);
1231
1262
  device.mtu = 23;
1232
1263
  const negotiated = negotiatedSnapshot(device, 247);
1233
1264
 
1234
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1265
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1235
1266
  uuid,
1236
1267
  protocolType: 'V2',
1237
1268
  });
1238
1269
 
1239
- // No MTU request or GATT refresh inside the native connect budget: refreshGatt makes
1240
- // the stack rediscover first, and a budget that expires mid-rediscovery closes the
1241
- // client with the MTU request still unsent.
1242
1270
  expect(device.connect).toHaveBeenCalledTimes(1);
1243
1271
  expect(device.connect).toHaveBeenCalledWith({ timeout: expect.any(Number) });
1244
1272
  const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
@@ -1250,25 +1278,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1250
1278
  const cached = (transport as any).getCachedTransport(uuid);
1251
1279
  expect(cached.device).toBe(negotiated);
1252
1280
  expect(cached.mtuSize).toBe(247);
1253
- await transport.release(uuid, true);
1254
- });
1255
-
1256
- test('keeps the iOS connect chain unchanged', async () => {
1257
- setPlatformOS('ios');
1258
- const { transport, uuid, device } = createHarness();
1259
- reconnectingDevice(device);
1260
-
1261
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1262
- uuid,
1263
- protocolType: 'V2',
1264
- });
1265
- expect(device.connect).toHaveBeenCalledWith({
1266
- requestMTU: 247,
1267
- timeout: expect.any(Number),
1268
- refreshGatt: 'OnConnected',
1269
- });
1270
- expect(device.requestMTU).not.toHaveBeenCalled();
1271
- await transport.release(uuid, true);
1281
+ await settle(transport.release(uuid, true));
1272
1282
  });
1273
1283
 
1274
1284
  test.each([
@@ -1276,164 +1286,53 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1276
1286
  { expectedProtocol: 'V1' as const, label: 'an expected Protocol V1 device' },
1277
1287
  { expectedProtocol: undefined, label: 'a device of unknown protocol' },
1278
1288
  ])('drops a link that stays at the default MTU for $label', async ({ expectedProtocol }) => {
1279
- const { transport, uuid, device, bleManager, writeCharacteristic, notifyCharacteristic } =
1280
- createHarness({ pro2BleReplies: true });
1281
- fastAndroidWaits(transport);
1282
- device.mtu = 23;
1283
- // The exchange completes without raising the MTU.
1284
- device.requestMTU.mockImplementation(() => Promise.resolve(device));
1285
-
1286
- // Protocol V1 writes 192-byte packets whatever the MTU, and the fake Pro 2 would
1287
- // answer the 29-byte probe with 20 bytes and stop, so the link is useless to either.
1288
- await expect(transport.acquire({ uuid, expectedProtocol })).rejects.toMatchObject(
1289
- defaultMtuError
1290
- );
1291
- // Refused before a transport exists, i.e. by acquire itself: no discovery, no
1292
- // notification subscription, no write of any protocol.
1293
- expect((transport as any).resolveCharacteristics).not.toHaveBeenCalled();
1294
- expect(notifyCharacteristic.monitor).not.toHaveBeenCalled();
1295
- expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled();
1296
- expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled();
1297
- expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
1298
- expect(device.cancelConnection).toHaveBeenCalled();
1299
- });
1300
-
1301
- test('treats a fast native MTU rejection as a default-MTU link, not as a timeout', async () => {
1302
- const { transport, uuid, device, bleManager } = createHarness();
1303
- fastAndroidWaits(transport);
1304
- device.mtu = 23;
1305
- device.requestMTU.mockImplementation(() => Promise.reject(new Error('MTU request failed')));
1306
-
1307
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject(
1308
- defaultMtuError
1309
- );
1310
- // No bounded wait ran, so nothing was abandoned mid-flight.
1311
- expect(bleManager.cancelTransaction).not.toHaveBeenCalled();
1312
- expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
1313
- });
1314
-
1315
- test('drops a link whose MTU exchange never completes and holds it past the quiet period', async () => {
1316
- const { transport, uuid, device, bleManager } = createHarness();
1317
- fastAndroidWaits(transport);
1289
+ const { transport, uuid, device } = createHarness();
1318
1290
  device.mtu = 23;
1319
- device.requestMTU.mockImplementation(() => new Promise(() => {}));
1320
- // The GATT registry empties as soon as this client closes; that alone must not
1321
- // release the wait, because the LE link outlives the client by the idle timer.
1322
- connectedPeripherals().mockImplementation(() => Promise.resolve([]));
1323
1291
 
1324
- const startedAt = Date.now();
1325
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
1292
+ await expect(settle(transport.acquire({ uuid, expectedProtocol }))).rejects.toMatchObject({
1326
1293
  errorCode: HardwareErrorCode.BleConnectedError,
1327
- message: expect.stringContaining('BLE MTU exchange did not complete'),
1328
1294
  });
1329
- expect(Date.now() - startedAt).toBeGreaterThanOrEqual(
1330
- transport.androidMtuExchangeTimeoutMs + transport.androidLinkDropQuietMs
1331
- );
1332
- expect(bleManager.cancelTransaction).toHaveBeenCalledWith(
1333
- expect.stringContaining(`${uuid}:mtu:connected:0:`)
1334
- );
1335
- expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
1336
- expect(device.cancelConnection).toHaveBeenCalled();
1337
1295
  expect((transport as any).resolveCharacteristics).not.toHaveBeenCalled();
1296
+ expect(device.cancelConnection).toHaveBeenCalled();
1338
1297
  });
1339
1298
 
1340
- test('a second consecutive MTU timeout trips the wedged-link guard', async () => {
1341
- const { transport, uuid, device, bleManager } = createHarness();
1342
- fastAndroidWaits(transport);
1343
- device.mtu = 23;
1344
- device.requestMTU.mockImplementation(() => new Promise(() => {}));
1299
+ test.each([
1300
+ ['never completes', () => new Promise(() => {})],
1301
+ ['completes at the default MTU', undefined],
1302
+ ['is rejected', () => Promise.reject(new Error('MTU request failed'))],
1303
+ ])(
1304
+ 'a second consecutive MTU failure that %s trips the wedged-link guard',
1305
+ async (_label, requestMTU) => {
1306
+ const { transport, uuid, device, bleManager } = createHarness();
1307
+ device.mtu = 23;
1308
+ if (requestMTU) device.requestMTU.mockImplementation(requestMTU);
1345
1309
 
1346
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
1347
- errorCode: HardwareErrorCode.BleConnectedError,
1348
- });
1349
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
1350
- errorCode: HardwareErrorCode.PollingTimeout,
1351
- message: expect.stringContaining(BLE_SETUP_WEDGED_MESSAGE),
1352
- });
1353
- expect(bleManager.destroy).toHaveBeenCalled();
1354
- });
1310
+ await expect(
1311
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1312
+ ).rejects.toMatchObject({
1313
+ errorCode: HardwareErrorCode.BleConnectedError,
1314
+ });
1315
+ await expect(
1316
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1317
+ ).rejects.toMatchObject({
1318
+ errorCode: HardwareErrorCode.PollingTimeout,
1319
+ message: expect.stringContaining(BLE_SETUP_WEDGED_MESSAGE),
1320
+ });
1321
+ expect(bleManager.destroy).toHaveBeenCalled();
1322
+ }
1323
+ );
1355
1324
 
1356
1325
  test('stop() during the link-drop wait releases it promptly', async () => {
1357
1326
  const { transport, uuid, device } = createHarness();
1358
- fastAndroidWaits(transport);
1359
- transport.androidLinkDropQuietMs = 3000;
1360
- transport.androidLinkDropTimeoutMs = 6000;
1361
1327
  device.mtu = 23;
1362
1328
  device.requestMTU.mockImplementation(() => new Promise(() => {}));
1363
1329
 
1364
1330
  const acquiring = transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error);
1365
- await new Promise(resolve => {
1366
- setTimeout(resolve, transport.androidMtuExchangeTimeoutMs + 100);
1367
- });
1368
- const stoppedAt = Date.now();
1369
- await transport.stop();
1370
- await expect(acquiring).resolves.toBeInstanceOf(Error);
1371
- expect(Date.now() - stoppedAt).toBeLessThan(1500);
1372
- }, 10_000);
1373
-
1374
- test('does not reuse a cached transport that reports the default MTU', async () => {
1375
- const { transport, uuid, device } = createHarness();
1376
- fastAndroidWaits(transport);
1377
- device.mtu = 23;
1378
- negotiatedSnapshot(device, 247);
1379
-
1380
- await transport.acquire({ uuid, expectedProtocol: 'V2' });
1381
- expect(device.requestMTU).toHaveBeenCalledTimes(1);
1382
- (transport as any).getCachedTransport(uuid).mtuSize = 23;
1383
-
1384
- await expect(transport.call(uuid, 'Ping', { message: 'x' })).rejects.toMatchObject({
1385
- errorCode: HardwareErrorCode.BleConnectedError,
1386
- message: expect.stringContaining('Protocol V2 needs a negotiated BLE MTU'),
1387
- });
1388
- // The next acquire goes through the full path and negotiates again instead of
1389
- // handing the same unusable link back to every retry.
1390
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1391
- uuid,
1392
- protocolType: 'V2',
1393
- });
1394
- expect(device.requestMTU).toHaveBeenCalledTimes(2);
1395
- expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(247);
1396
- await transport.release(uuid, true);
1397
- });
1398
-
1399
- test('reassembles a Protocol V2 frame split across ATT writes at a negotiated MTU', async () => {
1400
- const { transport, uuid, device, writeCharacteristic } = createHarness({
1401
- pro2BleReplies: true,
1402
- });
1403
- device.mtu = 23;
1404
- negotiatedSnapshot(device, 247);
1405
-
1406
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1407
- uuid,
1408
- protocolType: 'V2',
1409
- });
1410
- const writesBefore = writeCharacteristic.writeWithoutResponse.mock.calls.length;
1411
- // Longer than one 244-byte packet, so the fake Pro 2 only answers once it has
1412
- // reassembled both writes by the declared frame length.
1413
- await expect(
1414
- transport.call(uuid, 'Ping', { message: 'x'.repeat(300) })
1415
- ).resolves.toMatchObject({ type: 'Success' });
1416
- expect(writeCharacteristic.writeWithoutResponse.mock.calls.length - writesBefore).toBe(2);
1417
- await transport.release(uuid, true);
1418
- });
1419
-
1420
- test('a reply larger than ATT_MTU-3 is cut by the fake Pro 2 and the call never completes', async () => {
1421
- const { transport, uuid, device } = createHarness({ pro2BleReplies: true });
1422
- fastAndroidWaits(transport);
1423
- (transport as any).sessionProtocols.set(uuid, 'V2');
1424
- reconnectingDevice(device);
1425
- device.mtu = 23;
1426
- // 30 passes the default-MTU check but only carries 27-byte notifications, short of
1427
- // the 29-byte reply — the truncation seen in the capture at MTU 23.
1428
- negotiatedSnapshot(device, 30);
1429
-
1430
- await expect(
1431
- transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true })
1432
- ).resolves.toEqual({ uuid, protocolType: 'V2' });
1433
- await expect(
1434
- transport.call(uuid, 'Ping', { message: 'protocol-v2-probe' }, { timeoutMs: 300 })
1435
- ).rejects.toBeDefined();
1436
- await transport.release(uuid, true);
1331
+ // The link-drop teardown cancels the device connection before the wait starts.
1332
+ await advanceUntil(() => device.cancelConnection.mock.calls.length > 0);
1333
+ await advanceUntil(() => false, 1000).catch(() => undefined);
1334
+ await settle(transport.stop(), 1000);
1335
+ await expect(settle(acquiring, 1000)).resolves.toBeInstanceOf(Error);
1437
1336
  });
1438
1337
 
1439
1338
  test.each([
@@ -1451,23 +1350,21 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1451
1350
  ERRORS.TypedError('BLECharacteristicNotWritable: write characteristic not writable'),
1452
1351
  },
1453
1352
  ])(
1454
- 'drops the link on $label and rediscovers the GATT table on the next connect, before the MTU exchange',
1353
+ 'marks the endpoint on $label and refreshes the GATT table on the next connect, before the MTU exchange',
1455
1354
  async ({ error }) => {
1456
1355
  const { transport, uuid, device } = createHarness();
1457
- fastAndroidWaits(transport);
1458
1356
  const link = reconnectingDevice(device);
1459
1357
  device.mtu = 23;
1460
1358
  negotiatedSnapshot(device, 247);
1461
1359
  const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
1462
1360
  resolveCharacteristics.mockImplementationOnce(() => Promise.reject(error()));
1463
1361
 
1464
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toBeDefined();
1465
- expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number) });
1466
- // The stale table is only refreshed through a connect, so the link must be down.
1467
- expect(device.cancelConnection).toHaveBeenCalled();
1468
- expect(link.connected).toBe(false);
1362
+ await expect(
1363
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1364
+ ).rejects.toBeDefined();
1365
+ expect(link.connected).toBe(true);
1469
1366
 
1470
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1367
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1471
1368
  uuid,
1472
1369
  protocolType: 'V2',
1473
1370
  });
@@ -1475,6 +1372,11 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1475
1372
  timeout: expect.any(Number),
1476
1373
  refreshGatt: 'OnConnected',
1477
1374
  });
1375
+ // The stale table is only refreshed through a connect, so the live link is dropped first.
1376
+ const connectOrders: number[] = device.connect.mock.invocationCallOrder;
1377
+ expect(device.cancelConnection.mock.invocationCallOrder[0]).toBeLessThan(
1378
+ connectOrders[connectOrders.length - 1]
1379
+ );
1478
1380
  const discoveryOrders = resolveCharacteristics.mock.invocationCallOrder;
1479
1381
  const mtuOrders: number[] = device.requestMTU.mock.invocationCallOrder;
1480
1382
  expect(discoveryOrders[discoveryOrders.length - 1]).toBeLessThan(
@@ -1482,17 +1384,16 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1482
1384
  );
1483
1385
 
1484
1386
  // The refresh is spent once the table resolved through a refreshed connect.
1485
- await transport.release(uuid, true);
1387
+ await settle(transport.release(uuid, true));
1486
1388
  link.connected = false;
1487
- await transport.acquire({ uuid, expectedProtocol: 'V2' });
1389
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }));
1488
1390
  expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number) });
1489
- await transport.release(uuid, true);
1391
+ await settle(transport.release(uuid, true));
1490
1392
  }
1491
1393
  );
1492
1394
 
1493
1395
  test('keeps the refresh marker when the refresh connect fell back without refreshGatt', async () => {
1494
1396
  const { transport, uuid, device } = createHarness();
1495
- fastAndroidWaits(transport);
1496
1397
  const link = reconnectingDevice(device);
1497
1398
  device.mtu = 23;
1498
1399
  negotiatedSnapshot(device, 247);
@@ -1500,115 +1401,142 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1500
1401
  resolveCharacteristics.mockImplementationOnce(() =>
1501
1402
  Promise.reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound))
1502
1403
  );
1503
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toBeDefined();
1404
+ await expect(
1405
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1406
+ ).rejects.toBeDefined();
1504
1407
 
1505
- // The refresh connect is cancelled by the native budget; the fallback connect that
1506
- // succeeds carries no refreshGatt, so the cache was never cleared.
1408
+ // The fallback connect carries no refreshGatt.
1507
1409
  const cancelled = Object.assign(new Error('Operation was cancelled'), { errorCode: 2 });
1508
1410
  device.connect.mockImplementationOnce(() => Promise.reject(cancelled));
1509
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1411
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1510
1412
  uuid,
1511
1413
  protocolType: 'V2',
1512
1414
  });
1513
- expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number) });
1514
1415
 
1515
- await transport.release(uuid, true);
1416
+ await settle(transport.release(uuid, true));
1516
1417
  link.connected = false;
1517
- await transport.acquire({ uuid, expectedProtocol: 'V2' });
1418
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }));
1518
1419
  expect(device.connect).toHaveBeenLastCalledWith({
1519
1420
  timeout: expect.any(Number),
1520
1421
  refreshGatt: 'OnConnected',
1521
1422
  });
1522
- await transport.release(uuid, true);
1423
+ await settle(transport.release(uuid, true));
1523
1424
  });
1524
1425
 
1525
- test('arms a GATT refresh from a stale-table notify failure', async () => {
1526
- const harness = createHarness();
1527
- const { transport, uuid, device } = harness;
1528
- fastAndroidWaits(transport);
1529
- const link = reconnectingDevice(device);
1530
- device.mtu = 23;
1531
- negotiatedSnapshot(device, 247);
1426
+ test('does not drop a link it has just connected with refreshGatt', async () => {
1427
+ const { transport, uuid, device, bleManager } = createHarness();
1428
+ bleManager.devices.mockResolvedValue([]);
1429
+ const connectToDevice = jest.fn(() => Promise.resolve(device));
1430
+ Object.assign(bleManager, { connectToDevice });
1431
+ (transport as any).androidGattCacheRefreshes.add(uuid);
1532
1432
 
1533
- await transport.acquire({ uuid, expectedProtocol: 'V2' });
1534
- expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number) });
1535
- // The symptom recorded after Pro firmware upgrades: the UUIDs still resolve from the
1536
- // cached table, the stale CCCD handle fails when notifications are enabled.
1537
- harness.emitMonitorError(
1538
- Object.assign(new Error('notify failed'), {
1539
- reason: 'Cannot find client characteristic config descriptor',
1540
- })
1541
- );
1433
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1434
+ uuid,
1435
+ protocolType: 'V2',
1436
+ });
1437
+ expect(connectToDevice).toHaveBeenCalledTimes(1);
1438
+ expect(connectToDevice).toHaveBeenCalledWith(uuid, {
1439
+ timeout: expect.any(Number),
1440
+ refreshGatt: 'OnConnected',
1441
+ });
1442
+ expect(device.cancelConnection).not.toHaveBeenCalled();
1443
+ expect(device.connect).not.toHaveBeenCalled();
1444
+ await settle(transport.release(uuid, true));
1445
+ });
1446
+
1447
+ test('still refreshes after a connect-by-id refresh fell back without refreshGatt', async () => {
1448
+ const { transport, uuid, device, bleManager } = createHarness();
1449
+ const link = reconnectingDevice(device);
1450
+ bleManager.devices.mockResolvedValue([]);
1451
+ const cancelled = Object.assign(new Error('Operation was cancelled'), { errorCode: 2 });
1452
+ const connectToDevice = jest
1453
+ .fn()
1454
+ .mockImplementationOnce(() => Promise.reject(cancelled))
1455
+ .mockImplementation(() => {
1456
+ link.connected = true;
1457
+ return Promise.resolve(device);
1458
+ });
1459
+ Object.assign(bleManager, { connectToDevice });
1460
+ (transport as any).androidGattCacheRefreshes.add(uuid);
1542
1461
 
1543
- await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1462
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1544
1463
  uuid,
1545
1464
  protocolType: 'V2',
1546
1465
  });
1547
- expect(link.connected).toBe(true);
1466
+ expect(connectToDevice).toHaveBeenLastCalledWith(uuid, { timeout: expect.any(Number) });
1548
1467
  expect(device.connect).toHaveBeenLastCalledWith({
1549
1468
  timeout: expect.any(Number),
1550
1469
  refreshGatt: 'OnConnected',
1551
1470
  });
1552
- await transport.release(uuid, true);
1471
+ expect((transport as any).androidGattCacheRefreshes.has(uuid)).toBe(false);
1472
+ await settle(transport.release(uuid, true));
1553
1473
  });
1554
1474
 
1555
- test('keeps the GATT refresh for a firmware-install reconnect', async () => {
1556
- const { transport, uuid, device } = createHarness();
1557
- (transport as any).sessionProtocols.set(uuid, 'V2');
1475
+ test.each([
1476
+ 'Cannot write client characteristic config descriptor',
1477
+ 'Cannot find client characteristic config descriptor',
1478
+ 'The handle is invalid',
1479
+ 'Writing is not permitted',
1480
+ ])('arms a GATT refresh from the notify failure "%s"', async reason => {
1481
+ const harness = createHarness();
1482
+ const { transport, uuid, device } = harness;
1558
1483
  reconnectingDevice(device);
1484
+ device.mtu = 23;
1485
+ negotiatedSnapshot(device, 247);
1559
1486
 
1560
- await expect(
1561
- transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true })
1562
- ).resolves.toEqual({ uuid, protocolType: 'V2' });
1563
- expect(device.connect).toHaveBeenCalledWith({
1487
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }));
1488
+ harness.emitMonitorError(Object.assign(new Error('notify failed'), { reason }));
1489
+
1490
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1491
+ uuid,
1492
+ protocolType: 'V2',
1493
+ });
1494
+ expect(device.connect).toHaveBeenLastCalledWith({
1564
1495
  timeout: expect.any(Number),
1565
1496
  refreshGatt: 'OnConnected',
1566
1497
  });
1567
- await transport.release(uuid, true);
1498
+ await settle(transport.release(uuid, true));
1568
1499
  });
1569
1500
 
1570
- test('a firmware upload reconnect refreshes the table, discovers, then negotiates the MTU', async () => {
1571
- const { transport, uuid, device } = createHarness();
1572
- fastAndroidWaits(transport);
1501
+ test('arms a GATT refresh from a notify failure while notifications are being enabled', async () => {
1502
+ const { transport, uuid, device } = createHarness({
1503
+ monitorError: Object.assign(new Error('notify failed'), {
1504
+ reason: 'Cannot write client characteristic config descriptor',
1505
+ }),
1506
+ });
1573
1507
  const link = reconnectingDevice(device);
1574
1508
  device.mtu = 23;
1575
- const negotiated = negotiatedSnapshot(device, 247);
1576
- await transport.acquire({ uuid, expectedProtocol: 'V2' });
1577
- const cached = (transport as any).getCachedTransport(uuid);
1578
- const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
1579
- device.connect.mockClear();
1580
- device.requestMTU.mockClear();
1581
- resolveCharacteristics.mockClear();
1509
+ negotiatedSnapshot(device, 247);
1582
1510
 
1511
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error));
1512
+ await settle(transport.release(uuid, true));
1583
1513
  link.connected = false;
1584
- cached.mtuSize = 23;
1585
- await transport.reconnectFirmwareUploadTransport(uuid, cached);
1586
-
1587
- expect(device.connect).toHaveBeenCalledWith({
1514
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error));
1515
+ expect(device.connect).toHaveBeenLastCalledWith({
1588
1516
  timeout: expect.any(Number),
1589
1517
  refreshGatt: 'OnConnected',
1590
1518
  });
1591
- const [connectOrder] = device.connect.mock.invocationCallOrder;
1592
- const [discoveryOrder] = resolveCharacteristics.mock.invocationCallOrder;
1593
- const [mtuOrder] = device.requestMTU.mock.invocationCallOrder;
1594
- expect(connectOrder).toBeLessThan(discoveryOrder);
1595
- expect(discoveryOrder).toBeLessThan(mtuOrder);
1596
- expect(cached.device).toBe(negotiated);
1597
- expect(cached.mtuSize).toBe(247);
1598
- await transport.release(uuid, true);
1519
+ await settle(transport.release(uuid, true));
1599
1520
  });
1600
- });
1601
1521
 
1602
- test('cleans up a cancelled native GATT setup, not only a timed-out one', async () => {
1603
- const { transport, uuid, bleManager } = createHarness();
1604
- const cancelled = Object.assign(new Error('Operation was cancelled'), { errorCode: 2 });
1605
- (transport as any).resolveCharacteristics = jest.fn(() => Promise.reject(cancelled));
1522
+ test('keeps the GATT refresh for a firmware-install reconnect', async () => {
1523
+ const { transport, uuid, device } = createHarness();
1524
+ (transport as any).sessionProtocols.set(uuid, 'V2');
1525
+ reconnectingDevice(device);
1606
1526
 
1607
- await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1608
- expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid);
1527
+ await expect(
1528
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true }))
1529
+ ).resolves.toEqual({ uuid, protocolType: 'V2' });
1530
+ expect(device.connect).toHaveBeenCalledWith({
1531
+ timeout: expect.any(Number),
1532
+ refreshGatt: 'OnConnected',
1533
+ });
1534
+ await settle(transport.release(uuid, true));
1535
+ });
1609
1536
  });
1610
1537
 
1611
1538
  test('spends one Initialize wake on the detection after a fully silent one', async () => {
1539
+ setPlatformOS('android');
1612
1540
  const { transport, uuid } = createHarness();
1613
1541
  const probes = transport as any;
1614
1542
  jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false);
@@ -1632,6 +1560,35 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1632
1560
  expect(callProtocolV1).not.toHaveBeenCalled();
1633
1561
  });
1634
1562
 
1563
+ test('does not send the Initialize wake on iOS', async () => {
1564
+ const { transport, uuid } = createHarness();
1565
+ const probes = transport as any;
1566
+ jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false);
1567
+ jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false);
1568
+ const callProtocolV1 = jest.spyOn(probes, 'callProtocolV1').mockResolvedValue({});
1569
+
1570
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1571
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1572
+ expect(callProtocolV1).not.toHaveBeenCalled();
1573
+ });
1574
+
1575
+ test('an unanswered Initialize wake settles the acquire and frees the lifecycle lock', async () => {
1576
+ setPlatformOS('android');
1577
+ const harness = createHarness();
1578
+ const { transport, uuid } = harness;
1579
+ const probes = transport as any;
1580
+ harness.setShouldRespond(false);
1581
+ jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false);
1582
+ jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false);
1583
+
1584
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1585
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
1586
+ errorCode: HardwareErrorCode.BleTimeoutError,
1587
+ });
1588
+ await transport.disconnect(uuid);
1589
+ await transport.stop();
1590
+ }, 10_000);
1591
+
1635
1592
  test('accepts a stable low MTU without the delayed refresh loop', async () => {
1636
1593
  const { transport, uuid, device } = createHarness();
1637
1594
  device.mtu = 185;