@onekeyfe/hd-transport-react-native 1.2.2-alpha.120 → 1.2.2-alpha.122

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,
@@ -169,11 +170,13 @@ const createHarness = ({
169
170
  serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'],
170
171
  isConnected: jest.fn(() => Promise.resolve(true)),
171
172
  cancelConnection: jest.fn(() => Promise.resolve()),
173
+ connect: jest.fn(),
172
174
  onDisconnected: jest.fn(callback => {
173
175
  disconnectCallback = callback;
174
176
  return { remove: jest.fn() };
175
177
  }),
176
178
  } as any;
179
+ device.connect.mockResolvedValue(device);
177
180
  device.requestMTU = jest.fn(() => Promise.resolve(device));
178
181
  device.requestConnectionPriority = jest.fn(() => Promise.resolve(device));
179
182
  const bleManager = {
@@ -615,7 +618,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
615
618
  uuid,
616
619
  protocolType: 'V2',
617
620
  });
618
- expect(device.requestMTU).toHaveBeenCalledWith(247);
621
+ expect(device.requestMTU).not.toHaveBeenCalled();
619
622
  expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1);
620
623
  expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalled();
621
624
 
@@ -1079,57 +1082,621 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1079
1082
  await transport.release(uuid, true);
1080
1083
  });
1081
1084
 
1082
- test('continues with the current MTU when the connected snapshot refresh fails', async () => {
1085
+ test('does not renegotiate an already usable MTU during acquire', async () => {
1083
1086
  const { transport, uuid, device } = createHarness();
1084
- const mtuError = new Error('MTU refresh failed');
1085
- device.requestMTU.mockRejectedValueOnce(mtuError);
1086
1087
 
1087
1088
  await expect(transport.acquire({ uuid })).resolves.toEqual({
1088
1089
  uuid,
1089
1090
  protocolType: 'V2',
1090
1091
  });
1091
- expect(device.requestMTU).toHaveBeenCalledTimes(1);
1092
+ expect(device.requestMTU).not.toHaveBeenCalled();
1092
1093
  expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(247);
1093
1094
  await transport.release(uuid, true);
1094
1095
  });
1095
1096
 
1096
- test('refreshes a transient bootloader MTU after notifications are ready', async () => {
1097
+ test('requests a low MTU once before protocol probing', async () => {
1097
1098
  const { transport, uuid, device } = createHarness();
1098
1099
  device.mtu = 23;
1099
- device.requestMTU
1100
- .mockResolvedValueOnce(device)
1101
- .mockResolvedValueOnce(device)
1102
- .mockImplementationOnce(() => {
1103
- device.mtu = 247;
1104
- return Promise.resolve(device);
1105
- });
1100
+ device.requestMTU.mockImplementationOnce(() => {
1101
+ device.mtu = 247;
1102
+ return Promise.resolve(device);
1103
+ });
1106
1104
 
1107
1105
  await expect(transport.acquire({ uuid })).resolves.toEqual({
1108
1106
  uuid,
1109
1107
  protocolType: 'V2',
1110
1108
  });
1111
- expect(device.requestMTU).toHaveBeenCalledTimes(3);
1109
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
1110
+ expect(device.requestMTU).toHaveBeenCalledWith(
1111
+ 247,
1112
+ expect.stringContaining(`${uuid}:mtu:connected:0:`)
1113
+ );
1112
1114
  expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(247);
1113
1115
  await transport.release(uuid, true);
1114
1116
  });
1115
1117
 
1116
- test('continues with a low bootloader MTU when the bounded retry fails', async () => {
1118
+ test('continues with a low MTU when the single negotiation fails', async () => {
1117
1119
  const { transport, uuid, device } = createHarness();
1118
1120
  device.mtu = 23;
1119
- device.requestMTU
1120
- .mockResolvedValueOnce(device)
1121
- .mockResolvedValueOnce(device)
1122
- .mockRejectedValueOnce(new Error('bootloader MTU retry failed'));
1121
+ device.requestMTU.mockRejectedValueOnce(new Error('MTU negotiation failed'));
1122
+
1123
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
1124
+ uuid,
1125
+ protocolType: 'V2',
1126
+ });
1127
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
1128
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
1129
+ await transport.release(uuid, true);
1130
+ });
1131
+
1132
+ test('bounds a stalled MTU negotiation and continues protocol probing', async () => {
1133
+ const { transport, uuid, device, bleManager } = createHarness();
1134
+ device.mtu = 23;
1135
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1136
+
1137
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
1138
+ uuid,
1139
+ protocolType: 'V2',
1140
+ });
1141
+ const transactionId = device.requestMTU.mock.calls[0]?.[1];
1142
+ expect(transactionId).toEqual(expect.stringContaining(`${uuid}:mtu:connected:0:`));
1143
+ expect(bleManager.cancelTransaction).toHaveBeenCalledWith(transactionId);
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);
1152
+ }, 10_000);
1153
+
1154
+ test('keeps the iOS link when the MTU-timeout reconnect reports it is still connected', async () => {
1155
+ const { BleError: BleErrorMock, BleErrorCode } = jest.requireMock('react-native-ble-plx');
1156
+ const { transport, uuid, device, bleManager } = createHarness();
1157
+ device.mtu = 23;
1158
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1159
+ device.connect.mockRejectedValueOnce(
1160
+ Object.assign(new BleErrorMock('Device already connected'), {
1161
+ errorCode: BleErrorCode.DeviceAlreadyConnected,
1162
+ })
1163
+ );
1123
1164
 
1124
1165
  await expect(transport.acquire({ uuid })).resolves.toEqual({
1125
1166
  uuid,
1126
1167
  protocolType: 'V2',
1127
1168
  });
1128
- expect(device.requestMTU).toHaveBeenCalledTimes(3);
1169
+ expect(device.cancelConnection).toHaveBeenCalledTimes(1);
1170
+ expect(device.connect).toHaveBeenCalledTimes(1);
1171
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
1172
+ expect(bleManager.destroy).not.toHaveBeenCalled();
1129
1173
  expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
1130
1174
  await transport.release(uuid, true);
1175
+ }, 10_000);
1176
+
1177
+ test('reconnects after an iOS MTU timeout when cancelling the timed-out link fails', async () => {
1178
+ const { BleError: BleErrorMock, BleErrorCode } = jest.requireMock('react-native-ble-plx');
1179
+ const { transport, uuid, device, bleManager } = createHarness();
1180
+ device.mtu = 23;
1181
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1182
+ device.cancelConnection.mockRejectedValueOnce(
1183
+ Object.assign(new BleErrorMock('Operation was cancelled'), {
1184
+ errorCode: BleErrorCode.OperationCancelled,
1185
+ })
1186
+ );
1187
+
1188
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
1189
+ uuid,
1190
+ protocolType: 'V2',
1191
+ });
1192
+ expect(device.connect).toHaveBeenCalledTimes(1);
1193
+ expect(device.connect.mock.calls[0]?.[0]).not.toHaveProperty('requestMTU');
1194
+ expect(bleManager.destroy).not.toHaveBeenCalled();
1195
+ await transport.release(uuid, true);
1196
+ }, 10_000);
1197
+
1198
+ test('maps a stale bond from the iOS MTU-timeout reconnect', async () => {
1199
+ const { BleError: BleErrorMock } = jest.requireMock('react-native-ble-plx');
1200
+ const { transport, uuid, device } = createHarness();
1201
+ device.mtu = 23;
1202
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1203
+ device.connect.mockRejectedValueOnce(
1204
+ Object.assign(new BleErrorMock('Peer removed pairing information'), {
1205
+ errorCode: 200,
1206
+ iosErrorCode: 14,
1207
+ })
1208
+ );
1209
+
1210
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
1211
+ errorCode: HardwareErrorCode.BlePeerRemovedPairingInformation,
1212
+ });
1213
+ await transport.release(uuid, true).catch(() => undefined);
1214
+ }, 10_000);
1215
+
1216
+ test('bounds a hung iOS MTU-timeout teardown and resets the BLE manager without reconnecting', async () => {
1217
+ const { transport, uuid, device, bleManager } = createHarness();
1218
+ device.mtu = 23;
1219
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1220
+ device.cancelConnection.mockImplementationOnce(() => new Promise(() => {}));
1221
+
1222
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
1223
+ errorCode: HardwareErrorCode.BleTimeoutError,
1224
+ message: expect.stringContaining('BLE MTU cleanup timed out'),
1225
+ });
1226
+ expect(bleManager.destroy).toHaveBeenCalledTimes(1);
1227
+ expect(transport.blePlxManager).toBeUndefined();
1228
+ expect(device.connect).not.toHaveBeenCalled();
1229
+ expect((transport as any).lifecycleOperations.has(uuid)).toBe(false);
1230
+ }, 15_000);
1231
+
1232
+ test('does not reconnect after a hung iOS MTU-timeout teardown when the manager reset is already pending', async () => {
1233
+ const { transport, uuid, device, bleManager } = createHarness();
1234
+ device.mtu = 23;
1235
+ device.requestMTU.mockImplementationOnce(() => new Promise(() => {}));
1236
+ device.cancelConnection.mockImplementationOnce(() => new Promise(() => {}));
1237
+ // Another device's reset is still draining, so this teardown timeout cannot swap the manager.
1238
+ jest.spyOn(transport as any, 'resetPlxManager').mockImplementation(() => undefined);
1239
+
1240
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
1241
+ errorCode: HardwareErrorCode.BleTimeoutError,
1242
+ });
1243
+ expect(transport.blePlxManager).toBe(bleManager);
1244
+ expect(device.connect).not.toHaveBeenCalled();
1245
+ }, 15_000);
1246
+
1247
+ test('does not request MTU again after connect falls back without requestMTU', async () => {
1248
+ const { BleError: BleErrorMock, BleErrorCode } = jest.requireMock('react-native-ble-plx');
1249
+ const { transport, uuid, device } = createHarness();
1250
+ device.mtu = 23;
1251
+ device.isConnected.mockResolvedValueOnce(false).mockResolvedValue(true);
1252
+ device.connect
1253
+ .mockRejectedValueOnce(
1254
+ Object.assign(new BleErrorMock('Operation was cancelled'), {
1255
+ errorCode: BleErrorCode.OperationCancelled,
1256
+ })
1257
+ )
1258
+ .mockResolvedValue(device);
1259
+
1260
+ await expect(transport.acquire({ uuid })).resolves.toEqual({
1261
+ uuid,
1262
+ protocolType: 'V2',
1263
+ });
1264
+ expect(device.connect).toHaveBeenCalledTimes(2);
1265
+ expect(device.connect.mock.calls[0][0]).toEqual(expect.objectContaining({ requestMTU: 247 }));
1266
+ expect(device.connect.mock.calls[1][0]).not.toHaveProperty('requestMTU');
1267
+ expect(device.requestMTU).not.toHaveBeenCalled();
1268
+ expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23);
1269
+ await transport.release(uuid, true);
1270
+ });
1271
+
1272
+ test('keeps the iOS connect chain unchanged', async () => {
1273
+ const { transport, uuid, device } = createHarness();
1274
+ device.isConnected.mockResolvedValueOnce(false);
1275
+
1276
+ await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({
1277
+ uuid,
1278
+ protocolType: 'V2',
1279
+ });
1280
+ expect(device.connect).toHaveBeenCalledWith({
1281
+ requestMTU: 247,
1282
+ timeout: expect.any(Number),
1283
+ refreshGatt: 'OnConnected',
1284
+ });
1285
+ expect(device.requestMTU).not.toHaveBeenCalled();
1286
+ await transport.release(uuid, true);
1287
+ });
1288
+
1289
+ describe('Android MTU before service discovery', () => {
1290
+ beforeEach(() => {
1291
+ setPlatformOS('android');
1292
+ jest.useFakeTimers({
1293
+ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick', 'performance'],
1294
+ });
1295
+ });
1296
+
1297
+ afterEach(() => {
1298
+ jest.clearAllTimers();
1299
+ jest.useRealTimers();
1300
+ });
1301
+
1302
+ const advanceUntil = async (condition: () => boolean, budgetMs = 60_000) => {
1303
+ for (let elapsed = 0; !condition(); elapsed += 50) {
1304
+ if (elapsed >= budgetMs) throw new Error(`fake time exhausted after ${budgetMs}ms`);
1305
+ jest.advanceTimersByTime(50);
1306
+ // eslint-disable-next-line no-await-in-loop
1307
+ await new Promise(resolve => {
1308
+ setImmediate(resolve);
1309
+ });
1310
+ }
1311
+ };
1312
+
1313
+ const settle = async <T>(promise: Promise<T>, budgetMs?: number): Promise<T> => {
1314
+ let done = false;
1315
+ promise.then(
1316
+ () => {
1317
+ done = true;
1318
+ },
1319
+ () => {
1320
+ done = true;
1321
+ }
1322
+ );
1323
+ await advanceUntil(() => done, budgetMs);
1324
+ return promise;
1325
+ };
1326
+
1327
+ /**
1328
+ * A device whose LE link is only up between connect() and cancelConnection(), so the
1329
+ * transport has to reconnect after every drop instead of the test flipping the link.
1330
+ */
1331
+ const reconnectingDevice = (device: any) => {
1332
+ const link = { connected: false };
1333
+ device.isConnected.mockImplementation(() => Promise.resolve(link.connected));
1334
+ device.connect = jest.fn(() => {
1335
+ link.connected = true;
1336
+ return Promise.resolve(device);
1337
+ });
1338
+ device.cancelConnection.mockImplementation(() => {
1339
+ link.connected = false;
1340
+ return Promise.resolve();
1341
+ });
1342
+ return link;
1343
+ };
1344
+
1345
+ /** requestMTU resolves a fresh Device snapshot, as react-native-ble-plx does. */
1346
+ const negotiatedSnapshot = (device: any, mtu: number) => {
1347
+ const negotiated = { ...device, mtu };
1348
+ device.requestMTU.mockImplementation(() => Promise.resolve(negotiated));
1349
+ return negotiated;
1350
+ };
1351
+
1352
+ test('negotiates the MTU after a bare connect and before service discovery, adopting the returned device', async () => {
1353
+ const { transport, uuid, device } = createHarness();
1354
+ reconnectingDevice(device);
1355
+ device.mtu = 23;
1356
+ const negotiated = negotiatedSnapshot(device, 247);
1357
+
1358
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1359
+ uuid,
1360
+ protocolType: 'V2',
1361
+ });
1362
+
1363
+ expect(device.connect).toHaveBeenCalledTimes(1);
1364
+ expect(device.connect).toHaveBeenCalledWith({ timeout: expect.any(Number) });
1365
+ const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
1366
+ const [connectOrder] = device.connect.mock.invocationCallOrder;
1367
+ const [mtuOrder] = device.requestMTU.mock.invocationCallOrder;
1368
+ const [discoveryOrder] = resolveCharacteristics.mock.invocationCallOrder;
1369
+ expect(connectOrder).toBeLessThan(mtuOrder);
1370
+ expect(mtuOrder).toBeLessThan(discoveryOrder);
1371
+ const cached = (transport as any).getCachedTransport(uuid);
1372
+ expect(cached.device).toBe(negotiated);
1373
+ expect(cached.mtuSize).toBe(247);
1374
+ await settle(transport.release(uuid, true));
1375
+ });
1376
+
1377
+ test.each([
1378
+ { expectedProtocol: 'V2' as const, label: 'an expected Protocol V2 device' },
1379
+ { expectedProtocol: 'V1' as const, label: 'an expected Protocol V1 device' },
1380
+ { expectedProtocol: undefined, label: 'a device of unknown protocol' },
1381
+ ])('drops a link that stays at the default MTU for $label', async ({ expectedProtocol }) => {
1382
+ const { transport, uuid, device } = createHarness();
1383
+ device.mtu = 23;
1384
+
1385
+ await expect(settle(transport.acquire({ uuid, expectedProtocol }))).rejects.toMatchObject({
1386
+ errorCode: HardwareErrorCode.BleConnectedError,
1387
+ });
1388
+ expect((transport as any).resolveCharacteristics).not.toHaveBeenCalled();
1389
+ expect(device.cancelConnection).toHaveBeenCalled();
1390
+ });
1391
+
1392
+ test.each([
1393
+ ['never completes', () => new Promise(() => {})],
1394
+ ['completes at the default MTU', undefined],
1395
+ ['is rejected', () => Promise.reject(new Error('MTU request failed'))],
1396
+ ])(
1397
+ 'a second consecutive MTU failure that %s trips the wedged-link guard',
1398
+ async (_label, requestMTU) => {
1399
+ const { transport, uuid, device, bleManager } = createHarness();
1400
+ device.mtu = 23;
1401
+ if (requestMTU) device.requestMTU.mockImplementation(requestMTU);
1402
+
1403
+ await expect(
1404
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1405
+ ).rejects.toMatchObject({
1406
+ errorCode: HardwareErrorCode.BleConnectedError,
1407
+ });
1408
+ await expect(
1409
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1410
+ ).rejects.toMatchObject({
1411
+ errorCode: HardwareErrorCode.PollingTimeout,
1412
+ message: expect.stringContaining(BLE_SETUP_WEDGED_MESSAGE),
1413
+ });
1414
+ expect(bleManager.destroy).toHaveBeenCalled();
1415
+ }
1416
+ );
1417
+
1418
+ test('stop() during the link-drop wait releases it promptly', async () => {
1419
+ const { transport, uuid, device } = createHarness();
1420
+ device.mtu = 23;
1421
+ device.requestMTU.mockImplementation(() => new Promise(() => {}));
1422
+
1423
+ const acquiring = transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error);
1424
+ // The link-drop teardown cancels the device connection before the wait starts.
1425
+ await advanceUntil(() => device.cancelConnection.mock.calls.length > 0);
1426
+ await advanceUntil(() => false, 1000).catch(() => undefined);
1427
+ await settle(transport.stop(), 1000);
1428
+ await expect(settle(acquiring, 1000)).resolves.toBeInstanceOf(Error);
1429
+ });
1430
+
1431
+ test.each([
1432
+ {
1433
+ label: 'a missing OneKey service',
1434
+ error: () => ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound),
1435
+ },
1436
+ {
1437
+ label: 'a missing characteristic',
1438
+ error: () => ERRORS.TypedError(HardwareErrorCode.BleCharacteristicNotFound),
1439
+ },
1440
+ {
1441
+ label: 'a mis-typed characteristic',
1442
+ error: () =>
1443
+ ERRORS.TypedError('BLECharacteristicNotWritable: write characteristic not writable'),
1444
+ },
1445
+ ])(
1446
+ 'marks the endpoint on $label and refreshes the GATT table on the next connect, before the MTU exchange',
1447
+ async ({ error }) => {
1448
+ const { transport, uuid, device } = createHarness();
1449
+ const link = reconnectingDevice(device);
1450
+ device.mtu = 23;
1451
+ negotiatedSnapshot(device, 247);
1452
+ const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
1453
+ resolveCharacteristics.mockImplementationOnce(() => Promise.reject(error()));
1454
+
1455
+ await expect(
1456
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1457
+ ).rejects.toBeDefined();
1458
+ expect(link.connected).toBe(true);
1459
+
1460
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1461
+ uuid,
1462
+ protocolType: 'V2',
1463
+ });
1464
+ expect(device.connect).toHaveBeenLastCalledWith({
1465
+ timeout: expect.any(Number),
1466
+ refreshGatt: 'OnConnected',
1467
+ });
1468
+ // The stale table is only refreshed through a connect, so the live link is dropped first.
1469
+ const connectOrders: number[] = device.connect.mock.invocationCallOrder;
1470
+ expect(device.cancelConnection.mock.invocationCallOrder[0]).toBeLessThan(
1471
+ connectOrders[connectOrders.length - 1]
1472
+ );
1473
+ const discoveryOrders = resolveCharacteristics.mock.invocationCallOrder;
1474
+ const mtuOrders: number[] = device.requestMTU.mock.invocationCallOrder;
1475
+ expect(discoveryOrders[discoveryOrders.length - 1]).toBeLessThan(
1476
+ mtuOrders[mtuOrders.length - 1]
1477
+ );
1478
+
1479
+ // The refresh is spent once the table resolved through a refreshed connect.
1480
+ await settle(transport.release(uuid, true));
1481
+ link.connected = false;
1482
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }));
1483
+ expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number) });
1484
+ await settle(transport.release(uuid, true));
1485
+ }
1486
+ );
1487
+
1488
+ test('keeps the refresh marker when the refresh connect fell back without refreshGatt', async () => {
1489
+ const { transport, uuid, device } = createHarness();
1490
+ const link = reconnectingDevice(device);
1491
+ device.mtu = 23;
1492
+ negotiatedSnapshot(device, 247);
1493
+ const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock;
1494
+ resolveCharacteristics.mockImplementationOnce(() =>
1495
+ Promise.reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound))
1496
+ );
1497
+ await expect(
1498
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))
1499
+ ).rejects.toBeDefined();
1500
+
1501
+ // The fallback connect carries no refreshGatt.
1502
+ const cancelled = Object.assign(new Error('Operation was cancelled'), { errorCode: 2 });
1503
+ device.connect.mockImplementationOnce(() => Promise.reject(cancelled));
1504
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1505
+ uuid,
1506
+ protocolType: 'V2',
1507
+ });
1508
+
1509
+ await settle(transport.release(uuid, true));
1510
+ link.connected = false;
1511
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }));
1512
+ expect(device.connect).toHaveBeenLastCalledWith({
1513
+ timeout: expect.any(Number),
1514
+ refreshGatt: 'OnConnected',
1515
+ });
1516
+ await settle(transport.release(uuid, true));
1517
+ });
1518
+
1519
+ test('does not drop a link it has just connected with refreshGatt', async () => {
1520
+ const { transport, uuid, device, bleManager } = createHarness();
1521
+ bleManager.devices.mockResolvedValue([]);
1522
+ const connectToDevice = jest.fn(() => Promise.resolve(device));
1523
+ Object.assign(bleManager, { connectToDevice });
1524
+ (transport as any).androidGattCacheRefreshes.add(uuid);
1525
+
1526
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1527
+ uuid,
1528
+ protocolType: 'V2',
1529
+ });
1530
+ expect(connectToDevice).toHaveBeenCalledTimes(1);
1531
+ expect(connectToDevice).toHaveBeenCalledWith(uuid, {
1532
+ timeout: expect.any(Number),
1533
+ refreshGatt: 'OnConnected',
1534
+ });
1535
+ expect(device.cancelConnection).not.toHaveBeenCalled();
1536
+ expect(device.connect).not.toHaveBeenCalled();
1537
+ await settle(transport.release(uuid, true));
1538
+ });
1539
+
1540
+ test('still refreshes after a connect-by-id refresh fell back without refreshGatt', async () => {
1541
+ const { transport, uuid, device, bleManager } = createHarness();
1542
+ const link = reconnectingDevice(device);
1543
+ bleManager.devices.mockResolvedValue([]);
1544
+ const cancelled = Object.assign(new Error('Operation was cancelled'), { errorCode: 2 });
1545
+ const connectToDevice = jest
1546
+ .fn()
1547
+ .mockImplementationOnce(() => Promise.reject(cancelled))
1548
+ .mockImplementation(() => {
1549
+ link.connected = true;
1550
+ return Promise.resolve(device);
1551
+ });
1552
+ Object.assign(bleManager, { connectToDevice });
1553
+ (transport as any).androidGattCacheRefreshes.add(uuid);
1554
+
1555
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1556
+ uuid,
1557
+ protocolType: 'V2',
1558
+ });
1559
+ expect(connectToDevice).toHaveBeenLastCalledWith(uuid, { timeout: expect.any(Number) });
1560
+ expect(device.connect).toHaveBeenLastCalledWith({
1561
+ timeout: expect.any(Number),
1562
+ refreshGatt: 'OnConnected',
1563
+ });
1564
+ expect((transport as any).androidGattCacheRefreshes.has(uuid)).toBe(false);
1565
+ await settle(transport.release(uuid, true));
1566
+ });
1567
+
1568
+ test.each([
1569
+ 'Cannot write client characteristic config descriptor',
1570
+ 'Cannot find client characteristic config descriptor',
1571
+ 'The handle is invalid',
1572
+ 'Writing is not permitted',
1573
+ ])('arms a GATT refresh from the notify failure "%s"', async reason => {
1574
+ const harness = createHarness();
1575
+ const { transport, uuid, device } = harness;
1576
+ reconnectingDevice(device);
1577
+ device.mtu = 23;
1578
+ negotiatedSnapshot(device, 247);
1579
+
1580
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }));
1581
+ harness.emitMonitorError(Object.assign(new Error('notify failed'), { reason }));
1582
+
1583
+ await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({
1584
+ uuid,
1585
+ protocolType: 'V2',
1586
+ });
1587
+ expect(device.connect).toHaveBeenLastCalledWith({
1588
+ timeout: expect.any(Number),
1589
+ refreshGatt: 'OnConnected',
1590
+ });
1591
+ await settle(transport.release(uuid, true));
1592
+ });
1593
+
1594
+ test('arms a GATT refresh from a notify failure while notifications are being enabled', async () => {
1595
+ const { transport, uuid, device } = createHarness({
1596
+ monitorError: Object.assign(new Error('notify failed'), {
1597
+ reason: 'Cannot write client characteristic config descriptor',
1598
+ }),
1599
+ });
1600
+ const link = reconnectingDevice(device);
1601
+ device.mtu = 23;
1602
+ negotiatedSnapshot(device, 247);
1603
+
1604
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error));
1605
+ await settle(transport.release(uuid, true));
1606
+ link.connected = false;
1607
+ await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error));
1608
+ expect(device.connect).toHaveBeenLastCalledWith({
1609
+ timeout: expect.any(Number),
1610
+ refreshGatt: 'OnConnected',
1611
+ });
1612
+ await settle(transport.release(uuid, true));
1613
+ });
1614
+
1615
+ test('keeps the GATT refresh for a firmware-install reconnect', async () => {
1616
+ const { transport, uuid, device } = createHarness();
1617
+ (transport as any).sessionProtocols.set(uuid, 'V2');
1618
+ reconnectingDevice(device);
1619
+
1620
+ await expect(
1621
+ settle(transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true }))
1622
+ ).resolves.toEqual({ uuid, protocolType: 'V2' });
1623
+ expect(device.connect).toHaveBeenCalledWith({
1624
+ timeout: expect.any(Number),
1625
+ refreshGatt: 'OnConnected',
1626
+ });
1627
+ await settle(transport.release(uuid, true));
1628
+ });
1629
+ });
1630
+
1631
+ test('spends one Initialize wake on the detection after a fully silent one', async () => {
1632
+ setPlatformOS('android');
1633
+ const { transport, uuid } = createHarness();
1634
+ const probes = transport as any;
1635
+ jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false);
1636
+ jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false);
1637
+ const callProtocolV1 = jest.spyOn(probes, 'callProtocolV1').mockResolvedValue({});
1638
+
1639
+ // Nothing has gone silent yet, so the device keeps whatever session it has.
1640
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1641
+ expect(callProtocolV1).not.toHaveBeenCalled();
1642
+
1643
+ // The previous detection answered on no protocol, which is what a sleeping Classic
1644
+ // looks like, so this one wakes on the fresh link before probing.
1645
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1646
+ expect(callProtocolV1).toHaveBeenCalledTimes(1);
1647
+ expect(callProtocolV1.mock.calls[0]?.[1]).toBe('Initialize');
1648
+
1649
+ // Still silent: the wake is not repeated, so a device that is simply away is not
1650
+ // pushed into a fresh wallet session on every poll.
1651
+ callProtocolV1.mockClear();
1652
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1653
+ expect(callProtocolV1).not.toHaveBeenCalled();
1131
1654
  });
1132
1655
 
1656
+ test('does not send the Initialize wake before a V2-first probe', async () => {
1657
+ setPlatformOS('android');
1658
+ const { transport, uuid } = createHarness();
1659
+ const probes = transport as any;
1660
+ jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false);
1661
+ jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false);
1662
+ const callProtocolV1 = jest.spyOn(probes, 'callProtocolV1').mockResolvedValue({});
1663
+
1664
+ await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toBeDefined();
1665
+ // Silent now, but the next detection probes V2 first: a late V1 reply would land on
1666
+ // the V2 probe, so no Initialize is sent.
1667
+ await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toBeDefined();
1668
+ expect(callProtocolV1).not.toHaveBeenCalled();
1669
+ });
1670
+
1671
+ test('does not send the Initialize wake on iOS', async () => {
1672
+ const { transport, uuid } = createHarness();
1673
+ const probes = transport as any;
1674
+ jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false);
1675
+ jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false);
1676
+ const callProtocolV1 = jest.spyOn(probes, 'callProtocolV1').mockResolvedValue({});
1677
+
1678
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1679
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1680
+ expect(callProtocolV1).not.toHaveBeenCalled();
1681
+ });
1682
+
1683
+ test('an unanswered Initialize wake settles the acquire and frees the lifecycle lock', async () => {
1684
+ setPlatformOS('android');
1685
+ const harness = createHarness();
1686
+ const { transport, uuid } = harness;
1687
+ const probes = transport as any;
1688
+ harness.setShouldRespond(false);
1689
+ jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false);
1690
+ jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false);
1691
+
1692
+ await expect(transport.acquire({ uuid })).rejects.toBeDefined();
1693
+ await expect(transport.acquire({ uuid })).rejects.toMatchObject({
1694
+ errorCode: HardwareErrorCode.BleTimeoutError,
1695
+ });
1696
+ await transport.disconnect(uuid);
1697
+ await transport.stop();
1698
+ }, 10_000);
1699
+
1133
1700
  test('accepts a stable low MTU without the delayed refresh loop', async () => {
1134
1701
  const { transport, uuid, device } = createHarness();
1135
1702
  device.mtu = 185;
@@ -1138,7 +1705,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1138
1705
  uuid,
1139
1706
  protocolType: 'V2',
1140
1707
  });
1141
- expect(device.requestMTU).toHaveBeenCalledTimes(1);
1708
+ expect(device.requestMTU).not.toHaveBeenCalled();
1142
1709
  expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(185);
1143
1710
  await transport.release(uuid, true);
1144
1711
  });
@@ -1151,7 +1718,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1151
1718
  uuid,
1152
1719
  protocolType: 'V2',
1153
1720
  });
1154
- expect(device.requestMTU).toHaveBeenCalledTimes(3);
1721
+ expect(device.requestMTU).toHaveBeenCalledTimes(1);
1155
1722
  expect((transport as any).getCachedTransport(uuid).mtuSize).toBeUndefined();
1156
1723
  expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalled();
1157
1724
  await transport.release(uuid, true);
@@ -1169,7 +1736,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1169
1736
  });
1170
1737
 
1171
1738
  await expect(transport.call(uuid, 'FileWrite', {})).resolves.toBeDefined();
1172
- expect(device.requestMTU).toHaveBeenCalledTimes(4);
1739
+ expect(device.requestMTU).toHaveBeenCalledTimes(2);
1173
1740
  expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(
1174
1741
  writesBeforeFileWrite + 1
1175
1742
  );
@@ -1186,7 +1753,7 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1186
1753
  await expect(transport.call(uuid, 'FileWrite', {})).rejects.toMatchObject({
1187
1754
  errorCode: HardwareErrorCode.BleConnectedError,
1188
1755
  });
1189
- expect(device.requestMTU).toHaveBeenCalledTimes(4);
1756
+ expect(device.requestMTU).toHaveBeenCalledTimes(2);
1190
1757
  expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(writesBeforeFileWrite);
1191
1758
  await transport.release(uuid, true);
1192
1759
  });
@@ -1456,9 +2023,17 @@ describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => {
1456
2023
  test('uses Android 517 MTU and high connection priority during Protocol V2 high-volume calls', async () => {
1457
2024
  setPlatformOS('android');
1458
2025
  const { transport, uuid, device } = createHarness();
2026
+ device.mtu = 23;
2027
+ device.requestMTU.mockImplementationOnce(() => {
2028
+ device.mtu = 517;
2029
+ return Promise.resolve(device);
2030
+ });
1459
2031
 
1460
2032
  await transport.acquire({ uuid, expectedProtocol: 'V2' });
1461
- expect(device.requestMTU).toHaveBeenCalledWith(517);
2033
+ expect(device.requestMTU).toHaveBeenCalledWith(
2034
+ 517,
2035
+ expect.stringContaining(`${uuid}:mtu:connected:0:`)
2036
+ );
1462
2037
 
1463
2038
  await transport.call(uuid, 'FileWrite', {});
1464
2039
  await transport.call(uuid, 'FileWrite', {});