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

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.
@@ -8,6 +8,7 @@ import { ERRORS, HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared';
8
8
 
9
9
  import ReactNativeBleTransport, {
10
10
  BLE_NATIVE_TEARDOWN_TIMEOUT_MS,
11
+ BLE_SETUP_WEDGED_MESSAGE,
11
12
  BLE_WRITE_PACKET_TIMEOUT_MS,
12
13
  configureProtocolV2BleTuning,
13
14
  getFirmwareUploadWriteRetryType,
@@ -110,10 +111,18 @@ const createHarness = ({
110
111
  deviceName = 'OneKey Pro 2',
111
112
  isWritableWithResponse = true,
112
113
  monitorError,
114
+ pro2BleReplies = false,
113
115
  }: {
114
116
  deviceName?: string;
115
117
  isWritableWithResponse?: boolean;
116
118
  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;
117
126
  } = {}) => {
118
127
  const uuid = 'rn-pro2-id';
119
128
  const sentSeqs: number[] = [];
@@ -138,18 +147,31 @@ const createHarness = ({
138
147
  return { remove: jest.fn() };
139
148
  }),
140
149
  };
150
+ let pendingPro2Frame: Buffer | undefined;
151
+ let negotiatedMtu = () => 23;
141
152
  const handleWrite = (base64: string) => {
142
- const frame = Buffer.from(base64, 'base64');
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
164
  sentSeqs.push(frame[6]);
144
165
  if (shouldRespond) {
145
166
  responseSeq += 1;
146
167
  const response = ProtocolV2.encodeFrame(
147
168
  schemas,
148
169
  'Success',
149
- { message: 'ok' },
170
+ { message: pro2BleReplies ? 'protocol-v2-probe' : 'ok' },
150
171
  { router: PROTOCOL_V2_CHANNEL_BLE_UART, seq: responseSeq }
151
172
  );
152
- notifyCallback?.(null, { value: Buffer.from(response).toString('base64') });
173
+ const notification = pro2BleReplies ? response.subarray(0, negotiatedMtu() - 3) : response;
174
+ notifyCallback?.(null, { value: Buffer.from(notification).toString('base64') });
153
175
  }
154
176
  return Promise.resolve();
155
177
  };
@@ -193,6 +215,14 @@ const createHarness = ({
193
215
  transport.init(logger, emitter);
194
216
  transport.configure(protocolV1Schema);
195
217
  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
+ };
196
226
 
197
227
  return {
198
228
  transport,
@@ -203,6 +233,7 @@ const createHarness = ({
203
233
  bleManager,
204
234
  sentSeqs,
205
235
  writeCharacteristic,
236
+ notifyCharacteristic,
206
237
  setShouldRespond(value: boolean) {
207
238
  shouldRespond = value;
208
239
  },
@@ -1145,32 +1176,427 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1145
1176
  expect(resolveCharacteristics).not.toHaveBeenCalled();
1146
1177
  }, 10_000);
1147
1178
 
1148
- test('skips the MTU refresh after an MTU-bearing connect was cancelled', async () => {
1149
- const { transport, uuid, device } = createHarness();
1150
- device.mtu = 23;
1151
- let connected = false;
1152
- device.isConnected.mockImplementation(() => Promise.resolve(connected));
1153
- const cancelled = Object.assign(new Error('Operation was cancelled'), { errorCode: 2 });
1154
- device.connect = jest
1155
- .fn()
1156
- .mockRejectedValueOnce(cancelled)
1157
- .mockImplementation(() => {
1158
- connected = true;
1179
+ describe('Android MTU before service discovery', () => {
1180
+ const connectedPeripherals = () => jest.requireMock('../BleManager').getConnectedDeviceIds;
1181
+
1182
+ beforeEach(() => {
1183
+ setPlatformOS('android');
1184
+ connectedPeripherals().mockClear();
1185
+ });
1186
+
1187
+ afterEach(() => {
1188
+ connectedPeripherals().mockImplementation(() => Promise.resolve([]));
1189
+ });
1190
+
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;
1196
+ };
1197
+
1198
+ /**
1199
+ * A device whose LE link is only up between connect() and cancelConnection(), so the
1200
+ * transport has to reconnect after every drop instead of the test flipping the link.
1201
+ */
1202
+ const reconnectingDevice = (device: any) => {
1203
+ const link = { connected: false };
1204
+ device.isConnected.mockImplementation(() => Promise.resolve(link.connected));
1205
+ device.connect = jest.fn(() => {
1206
+ link.connected = true;
1159
1207
  return Promise.resolve(device);
1160
1208
  });
1209
+ device.cancelConnection.mockImplementation(() => {
1210
+ link.connected = false;
1211
+ return Promise.resolve();
1212
+ });
1213
+ return link;
1214
+ };
1161
1215
 
1162
- await expect(transport.acquire({ uuid })).resolves.toEqual({
1163
- uuid,
1164
- protocolType: 'V2',
1216
+ /** requestMTU resolves a fresh Device snapshot, as react-native-ble-plx does. */
1217
+ const negotiatedSnapshot = (device: any, mtu: number) => {
1218
+ const negotiated = { ...device, mtu };
1219
+ device.requestMTU.mockImplementation(() => Promise.resolve(negotiated));
1220
+ return negotiated;
1221
+ };
1222
+
1223
+ const defaultMtuError = {
1224
+ errorCode: HardwareErrorCode.BleConnectedError,
1225
+ message: expect.stringContaining('BLE link stayed at the default MTU'),
1226
+ };
1227
+
1228
+ test('negotiates the MTU after a bare connect and before service discovery, adopting the returned device', async () => {
1229
+ const { transport, uuid, device } = createHarness();
1230
+ reconnectingDevice(device);
1231
+ device.mtu = 23;
1232
+ const negotiated = negotiatedSnapshot(device, 247);
1233
+
1234
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1235
+ uuid,
1236
+ protocolType: 'V2',
1237
+ });
1238
+
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
+ expect(device.connect).toHaveBeenCalledTimes(1);
1243
+ expect(device.connect).toHaveBeenCalledWith({ timeout: expect.any(Number) });
1244
+ const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
1245
+ const [connectOrder] = device.connect.mock.invocationCallOrder;
1246
+ const [mtuOrder] = device.requestMTU.mock.invocationCallOrder;
1247
+ const [discoveryOrder] = resolveCharacteristics.mock.invocationCallOrder;
1248
+ expect(connectOrder).toBeLessThan(mtuOrder);
1249
+ expect(mtuOrder).toBeLessThan(discoveryOrder);
1250
+ const cached = (transport as any).getCachedTransport(uuid);
1251
+ expect(cached.device).toBe(negotiated);
1252
+ expect(cached.mtuSize).toBe(247);
1253
+ await transport.release(uuid, true);
1165
1254
  });
1166
1255
 
1167
- // ble-plx applies the connect timeout to establishConnection -> refreshGatt ->
1168
- // requestMtu as one chain, so the fallback means this peripheral did not finish the
1169
- // MTU exchange. Asking again would only park a second abandoned exchange.
1170
- expect(device.connect).toHaveBeenCalledTimes(2);
1171
- expect(device.requestMTU).not.toHaveBeenCalled();
1172
- expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
1173
- await transport.release(uuid, true);
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);
1272
+ });
1273
+
1274
+ test.each([
1275
+ { expectedProtocol: 'V2' as const, label: 'an expected Protocol V2 device' },
1276
+ { expectedProtocol: 'V1' as const, label: 'an expected Protocol V1 device' },
1277
+ { expectedProtocol: undefined, label: 'a device of unknown protocol' },
1278
+ ])('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);
1318
+ 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
+
1324
+ const startedAt = Date.now();
1325
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({
1326
+ errorCode: HardwareErrorCode.BleConnectedError,
1327
+ message: expect.stringContaining('BLE MTU exchange did not complete'),
1328
+ });
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
+ expect((transport as any).resolveCharacteristics).not.toHaveBeenCalled();
1338
+ });
1339
+
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(() => {}));
1345
+
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
+ });
1355
+
1356
+ test('stop() during the link-drop wait releases it promptly', async () => {
1357
+ const { transport, uuid, device } = createHarness();
1358
+ fastAndroidWaits(transport);
1359
+ transport.androidLinkDropQuietMs = 3000;
1360
+ transport.androidLinkDropTimeoutMs = 6000;
1361
+ device.mtu = 23;
1362
+ device.requestMTU.mockImplementation(() => new Promise(() => {}));
1363
+
1364
+ 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);
1437
+ });
1438
+
1439
+ test.each([
1440
+ {
1441
+ label: 'a missing OneKey service',
1442
+ error: () => ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound),
1443
+ },
1444
+ {
1445
+ label: 'a missing characteristic',
1446
+ error: () => ERRORS.TypedError(HardwareErrorCode.BleCharacteristicNotFound),
1447
+ },
1448
+ {
1449
+ label: 'a mis-typed characteristic',
1450
+ error: () =>
1451
+ ERRORS.TypedError('BLECharacteristicNotWritable: write characteristic not writable'),
1452
+ },
1453
+ ])(
1454
+ 'drops the link on $label and rediscovers the GATT table on the next connect, before the MTU exchange',
1455
+ async ({ error }) => {
1456
+ const { transport, uuid, device } = createHarness();
1457
+ fastAndroidWaits(transport);
1458
+ const link = reconnectingDevice(device);
1459
+ device.mtu = 23;
1460
+ negotiatedSnapshot(device, 247);
1461
+ const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
1462
+ resolveCharacteristics.mockImplementationOnce(() => Promise.reject(error()));
1463
+
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);
1469
+
1470
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1471
+ uuid,
1472
+ protocolType: 'V2',
1473
+ });
1474
+ expect(device.connect).toHaveBeenLastCalledWith({
1475
+ timeout: expect.any(Number),
1476
+ refreshGatt: 'OnConnected',
1477
+ });
1478
+ const discoveryOrders = resolveCharacteristics.mock.invocationCallOrder;
1479
+ const mtuOrders: number[] = device.requestMTU.mock.invocationCallOrder;
1480
+ expect(discoveryOrders[discoveryOrders.length - 1]).toBeLessThan(
1481
+ mtuOrders[mtuOrders.length - 1]
1482
+ );
1483
+
1484
+ // The refresh is spent once the table resolved through a refreshed connect.
1485
+ await transport.release(uuid, true);
1486
+ link.connected = false;
1487
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
1488
+ expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number) });
1489
+ await transport.release(uuid, true);
1490
+ }
1491
+ );
1492
+
1493
+ test('keeps the refresh marker when the refresh connect fell back without refreshGatt', async () => {
1494
+ const { transport, uuid, device } = createHarness();
1495
+ fastAndroidWaits(transport);
1496
+ const link = reconnectingDevice(device);
1497
+ device.mtu = 23;
1498
+ negotiatedSnapshot(device, 247);
1499
+ const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
1500
+ resolveCharacteristics.mockImplementationOnce(() =>
1501
+ Promise.reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound))
1502
+ );
1503
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toBeDefined();
1504
+
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.
1507
+ const cancelled = Object.assign(new Error('Operation was cancelled'), { errorCode: 2 });
1508
+ device.connect.mockImplementationOnce(() => Promise.reject(cancelled));
1509
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1510
+ uuid,
1511
+ protocolType: 'V2',
1512
+ });
1513
+ expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number) });
1514
+
1515
+ await transport.release(uuid, true);
1516
+ link.connected = false;
1517
+ await transport.acquire({ uuid, expectedProtocol: 'V2' });
1518
+ expect(device.connect).toHaveBeenLastCalledWith({
1519
+ timeout: expect.any(Number),
1520
+ refreshGatt: 'OnConnected',
1521
+ });
1522
+ await transport.release(uuid, true);
1523
+ });
1524
+
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);
1532
+
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
+ );
1542
+
1543
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1544
+ uuid,
1545
+ protocolType: 'V2',
1546
+ });
1547
+ expect(link.connected).toBe(true);
1548
+ expect(device.connect).toHaveBeenLastCalledWith({
1549
+ timeout: expect.any(Number),
1550
+ refreshGatt: 'OnConnected',
1551
+ });
1552
+ await transport.release(uuid, true);
1553
+ });
1554
+
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');
1558
+ reconnectingDevice(device);
1559
+
1560
+ await expect(
1561
+ transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true })
1562
+ ).resolves.toEqual({ uuid, protocolType: 'V2' });
1563
+ expect(device.connect).toHaveBeenCalledWith({
1564
+ timeout: expect.any(Number),
1565
+ refreshGatt: 'OnConnected',
1566
+ });
1567
+ await transport.release(uuid, true);
1568
+ });
1569
+
1570
+ test('a firmware upload reconnect refreshes the table, discovers, then negotiates the MTU', async () => {
1571
+ const { transport, uuid, device } = createHarness();
1572
+ fastAndroidWaits(transport);
1573
+ const link = reconnectingDevice(device);
1574
+ 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();
1582
+
1583
+ link.connected = false;
1584
+ cached.mtuSize = 23;
1585
+ await transport.reconnectFirmwareUploadTransport(uuid, cached);
1586
+
1587
+ expect(device.connect).toHaveBeenCalledWith({
1588
+ timeout: expect.any(Number),
1589
+ refreshGatt: 'OnConnected',
1590
+ });
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);
1599
+ });
1174
1600
  });
1175
1601
 
1176
1602
  test('cleans up a cancelled native GATT setup, not only a timed-out one', async () => {