import { EventEmitter } from 'events'; import { Platform } from 'react-native'; import { BleErrorCode, BleManager } from 'react-native-ble-plx'; import BleUtils from '@onekeyfe/react-native-ble-utils'; import { ERRORS, HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared'; import { onDeviceBondState } from '../BleManager'; import ReactNativeBleTransport, { ANDROID_LINK_DROP_QUIET_MS, ANDROID_MTU_EXCHANGE_TIMEOUT_MS, BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD, BLE_CONNECT_TIMEOUT_MS, BLE_GATT_SETUP_TIMEOUT_MS, BLE_SETUP_WEDGED_MESSAGE, } from '../index'; import protocolV1Schema from './protocolV1SchemaFixture'; import type { Peripheral } from '@onekeyfe/react-native-ble-utils'; jest.mock( 'react-native', () => ({ Platform: { OS: 'ios', select: (spec: Record) => spec.ios }, PermissionsAndroid: { PERMISSIONS: {}, RESULTS: {}, request: jest.fn(), requestMultiple: jest.fn(), }, }), { virtual: true } ); jest.mock('react-native-ble-plx', () => ({ BleATTErrorCode: { InvalidHandle: 1, UnlikelyError: 14 }, BleError: Error, BleErrorCode: { DeviceDisconnected: 201, OperationStartFailed: 601, DeviceMTUChangeFailed: 401, OperationCancelled: 2, OperationTimedOut: 3, DeviceAlreadyConnected: 203, }, BleManager: jest.fn(), ScanMode: { LowLatency: 2 }, })); jest.mock('@onekeyfe/react-native-ble-utils', () => ({ __esModule: true, default: { getConnectedPeripherals: jest.fn(() => Promise.resolve([])), getBondedPeripherals: jest.fn(() => Promise.resolve([])), pairDevice: jest.fn(() => Promise.resolve({ bonded: true, bonding: false })), onDeviceBondState: jest.fn(), }, })); const UUID = 'stalled-connect-device'; describe('Android bond failure reasons', () => { let emitBondState: (peripheral: Peripheral) => void; const cleanup = jest.fn(); beforeEach(() => { jest.useFakeTimers({ doNotFake: ['performance'] }); cleanup.mockClear(); jest.spyOn(BleUtils, 'onDeviceBondState').mockImplementation(listener => { emitBondState = listener; return cleanup; }); }); afterEach(() => { jest.restoreAllMocks(); jest.useRealTimers(); }); test.each([ [6, 'timeout', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing timed out'], [3, 'canceled', HardwareErrorCode.BleDeviceBondedCanceled, 'Bluetooth pairing canceled'], [1, 'authentication_failed', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'], [2, 'rejected', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'], [4, 'device_unreachable', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'], [5, 'discovery_in_progress', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'], [7, 'repeated_attempts', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'], [8, 'remote_canceled', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'], [9, 'removed', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'], [undefined, 'unknown', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'], [999, 'unknown', HardwareErrorCode.BleDeviceNotBonded, 'Bluetooth pairing failed'], ] as const)( 'preserves Android reason %s as %s across serialization', async (nativeReason, reason, code, message) => { const result = onDeviceBondState(UUID).catch(error => JSON.parse(JSON.stringify(ERRORS.serializeError({ error }))) ); emitBondState({ id: UUID, advertising: {}, bondState: { preState: 'BOND_BONDING', state: 'BOND_NONE', ...(nativeReason === undefined ? {} : { reason: nativeReason }), }, }); await expect(result).resolves.toEqual({ code, error: message, params: { phase: 'bond', reason, ...(nativeReason === undefined ? {} : { nativeReason }), }, }); expect(cleanup).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); } ); test('reports a timeout if no bond event arrives within the SDK deadline', async () => { const result = onDeviceBondState(UUID); const rejection = expect(result).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceNotBonded, params: { phase: 'bond', reason: 'timeout' }, }); jest.advanceTimersByTime(60_000); await rejection; expect(cleanup).toHaveBeenCalledTimes(1); }); test('aborts the bond wait and removes its listener and deadline', async () => { const controller = new AbortController(); const result = onDeviceBondState(UUID, controller.signal); const rejection = expect(result).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceDisconnected, }); controller.abort(); await rejection; expect(cleanup).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); }); test('does not subscribe when the transport stopped before the bond wait starts', async () => { const controller = new AbortController(); controller.abort(); const subscribe = jest.spyOn(BleUtils, 'onDeviceBondState').mockClear(); await expect(onDeviceBondState(UUID, controller.signal)).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceDisconnected, }); expect(subscribe).not.toHaveBeenCalled(); expect(jest.getTimerCount()).toBe(0); }); test('ignores other devices and clears the deadline after bonding succeeds', async () => { const result = onDeviceBondState(UUID); const otherPeripheral = { id: 'another-device', advertising: {}, bondState: { preState: 'BOND_BONDING', state: 'BOND_NONE', reason: 6 }, }; emitBondState(otherPeripheral); expect(cleanup).not.toHaveBeenCalled(); const peripheral: Peripheral = { id: UUID.toUpperCase(), advertising: {}, bondState: { preState: 'BOND_BONDING', state: 'BOND_BONDED' }, }; emitBondState(peripheral); await expect(result).resolves.toBe(peripheral); expect(cleanup).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); }); }); const flush = () => new Promise(resolve => { setImmediate(resolve); }); async function advanceUntil(settled: () => boolean, totalMs: number, stepMs = 250) { for (let elapsed = 0; elapsed < totalMs; elapsed += stepMs) { jest.advanceTimersByTime(stepMs); // eslint-disable-next-line no-await-in-loop await flush(); if (settled()) return; } throw new Error(`fake timers exhausted after ${totalMs}ms before the connect settled`); } /** A device whose native connect() never settles — the observed iOS failure mode. */ function createHarness(connectImpl: () => Promise) { const connect = jest.fn(connectImpl); const writeCharacteristic = { uuid: '00000002-0000-1000-8000-00805f9b34fb', isWritableWithResponse: true, }; const notifyCharacteristic = { uuid: '00000003-0000-1000-8000-00805f9b34fb', isNotifiable: true, }; const device = { id: UUID, name: 'OneKey Classic', localName: 'OneKey Classic', serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'], isConnected: jest.fn(() => Promise.resolve(false)), cancelConnection: jest.fn(() => Promise.resolve()), connect, discoverAllServicesAndCharacteristics: jest.fn(() => Promise.resolve()), characteristicsForService: jest.fn(() => Promise.resolve([writeCharacteristic, notifyCharacteristic]) ), services: jest.fn(() => Promise.resolve([])), onDisconnected: jest.fn(() => ({ remove: jest.fn() })), }; const transport = new ReactNativeBleTransport({ scanTimeout: 1 }); const bleManager = { devices: jest.fn(() => Promise.resolve([device])), connectedDevices: jest.fn(() => Promise.resolve([])), connectToDevice: jest.fn(connectImpl), cancelTransaction: jest.fn(() => Promise.resolve()), cancelDeviceConnection: jest.fn(() => Promise.resolve()), onStateChange: jest.fn((listener: (state: string) => void) => { // Dispatch asynchronously: subscribeBleOn wires its own cleanup after // registering, so a synchronous callback would run before it is ready. setImmediate(() => listener('PoweredOn')); return { remove: jest.fn() }; }), state: jest.fn(() => Promise.resolve('PoweredOn')), startDeviceScan: jest.fn(), stopDeviceScan: jest.fn(), }; (transport as any).blePlxManager = bleManager; transport.init( { debug: jest.fn(), error: jest.fn(), warn: jest.fn() } as any, new EventEmitter() ); transport.configure(protocolV1Schema); return { transport, device, bleManager, connect }; } describe('BLE connect timeout', () => { beforeAll(() => { jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] }); }); afterAll(() => { jest.useRealTimers(); }); afterEach(() => { jest.clearAllTimers(); jest.restoreAllMocks(); Object.assign(Platform, { OS: 'ios' }); }); test('waits for singleton destruction before creating the next manager', async () => { const { transport, bleManager } = createHarness(() => Promise.resolve()); const destruction = createDeferred(); Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) }); const createManager = jest.mocked(BleManager); createManager.mockClear(); (transport as unknown as { resetPlxManager(): void }).resetPlxManager(); const first = transport.getPlxManager(); const second = transport.getPlxManager(); await flush(); expect(createManager).not.toHaveBeenCalled(); destruction.resolve(); expect(await first).toBe(await second); expect(createManager).toHaveBeenCalledTimes(1); }); test('does not reuse a manager after asynchronous destruction fails', async () => { const { bleManager } = createHarness(() => Promise.resolve()); let transport!: ReactNativeBleTransport; jest.isolateModules(() => { const { default: Transport } = jest.requireActual('../index'); transport = new Transport({}); }); transport.blePlxManager = bleManager as never; const destruction = createDeferred(); Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) }); (transport as unknown as { resetPlxManager(): void }).resetPlxManager(); const result = transport.getPlxManager(); const failure = expect(result).rejects.toMatchObject({ errorCode: HardwareErrorCode.PollingTimeout, }); destruction.reject(new Error('Native destruction failed')); await failure; }); test('bounds reset waiting without letting a new transport bypass unfinished destruction', async () => { const { transport, bleManager } = createHarness(() => Promise.resolve()); const destruction = createDeferred(); Object.assign(bleManager, { destroy: jest.fn(() => destruction.promise) }); const createManager = jest.mocked(BleManager); createManager.mockClear(); (transport as unknown as { resetPlxManager(): void }).resetPlxManager(); const nextTransport = new ReactNativeBleTransport({}); const result = nextTransport.getPlxManager().catch(error => error); await flush(); jest.advanceTimersByTime(BLE_CONNECT_TIMEOUT_MS); await expect(result).resolves.toMatchObject({ errorCode: HardwareErrorCode.PollingTimeout }); expect(createManager).not.toHaveBeenCalled(); destruction.resolve(); await nextTransport.getPlxManager(); expect(createManager).toHaveBeenCalledTimes(1); }); test('a native connect that never settles is bounded instead of blocking forever', async () => { // iOS applies its own connect timeout on a serial queue; when that queue is busy // the timeout never fires and acquire() blocks until the app-level 60s timeout. const { transport } = createHarness( () => new Promise(() => { // never settles }) ); const errors: Array<{ errorCode?: unknown }> = []; let settled = false; transport.acquire({ uuid: UUID }).catch(e => { errors.push(e); settled = true; }); await flush(); await advanceUntil(() => settled, BLE_CONNECT_TIMEOUT_MS + 5000); expect(errors).toHaveLength(1); expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleConnectedError); }); test('stop drains its scan and removes the timer before another transport scans', async () => { const { transport, bleManager } = createHarness(() => Promise.resolve()); const nativeStop = createDeferred(); bleManager.stopDeviceScan.mockImplementation(() => nativeStop.promise); const scanned = transport.enumerate().catch(error => error); await flush(); await flush(); expect(bleManager.startDeviceScan).toHaveBeenCalledTimes(1); const stopping = transport.stop(); expect(transport.stop()).toBe(stopping); let stopped = false; stopping.then(() => { stopped = true; }); await flush(); expect(stopped).toBe(false); nativeStop.resolve(); await stopping; await expect(scanned).resolves.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceDisconnected, }); jest.advanceTimersByTime(transport.scanTimeout); await flush(); expect(bleManager.stopDeviceScan).toHaveBeenCalledTimes(1); await expect(transport.getPlxManager()).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceDisconnected, }); }); test.each(['V1', 'V2'] as const)( 'stop completes while Android %s bonding remains pending', async expectedProtocol => { Object.assign(Platform, { OS: 'android' }); const { transport, bleManager, connect } = createHarness(() => Promise.resolve()); jest.spyOn(BleUtils, 'pairDevice').mockResolvedValueOnce({ bonded: false, bonding: true }); const listening = createDeferred(); const cleanup = jest.fn(); jest.spyOn(BleUtils, 'onDeviceBondState').mockImplementationOnce(() => { listening.resolve(); return cleanup; }); const acquiring = transport.acquire({ uuid: UUID, expectedProtocol }); const rejection = expect(acquiring).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceDisconnected, }); await listening.promise; // Advance only the existing 100ms disconnect drain, not the bond deadline. let stopped = false; const stopping = transport.stop().then(() => { stopped = true; }); await advanceUntil(() => stopped, 1000); expect(stopped).toBe(true); await stopping; await rejection; expect(cleanup).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); expect(bleManager.devices).not.toHaveBeenCalled(); expect(connect).not.toHaveBeenCalled(); } ); test('stop rejects a pending read and waits for native disconnection without destroying the shared manager', async () => { const { transport, bleManager } = createHarness(() => Promise.resolve()); const nativeDisconnect = createDeferred(); bleManager.cancelDeviceConnection.mockImplementation(() => nativeDisconnect.promise); const destroy = jest.fn(); Object.assign(bleManager, { destroy }); const read = createDeferred(); transport.runPromise = read; Object.assign(transport, { runPromiseDeviceId: UUID }); const readResult = read.promise.catch(error => error); let stopped = false; const stopping = transport.stop().then(() => { stopped = true; }); await flush(); await expect(readResult).resolves.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceDisconnected, }); expect(stopped).toBe(false); nativeDisconnect.resolve(); await advanceUntil(() => stopped, 1000); await stopping; expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID); expect(destroy).not.toHaveBeenCalled(); }); test('the connect budget leaves generous headroom over a healthy connect', () => { // Healthy connects finish in ~2-3s (the native budget is 3s); this backstop only // fires when the native timeout itself fails to. expect(BLE_CONNECT_TIMEOUT_MS).toBeGreaterThanOrEqual(6000); expect(BLE_CONNECT_TIMEOUT_MS).toBeLessThanOrEqual(12000); }); test.each( (['ios', 'android'] as const).flatMap(platform => (['connect', 'mtu', 'gatt'] as const).flatMap(stage => (['reject', 'resolve'] as const).map(completion => ({ platform, stage, completion })) ) ) )( 'stop cancels $platform $stage before draining a late $completion', async ({ platform, stage, completion }) => { Object.assign(Platform, { OS: platform }); const { transport, device, bleManager, connect } = createHarness( () => nativeOperation.promise ); const nativeOperation = createDeferred(); const nativeDisconnect = createDeferred(); const requestMtu = jest.fn(() => Promise.resolve(device)); Object.assign(device, { mtu: stage === 'mtu' ? 23 : 247, requestMTU: requestMtu }); device.isConnected.mockResolvedValue(true); if (stage === 'connect') device.isConnected.mockResolvedValueOnce(false); if (stage === 'mtu') requestMtu.mockImplementationOnce(() => nativeOperation.promise); if (stage === 'gatt') { device.discoverAllServicesAndCharacteristics.mockImplementationOnce(() => nativeOperation.promise.then(() => undefined) ); } const destroy = jest.fn(); Object.assign(bleManager, { destroy }); const monitor = jest.spyOn(transport, '_monitorCharacteristic'); const acquire = transport.acquire({ uuid: UUID }).catch(error => error); await flush(); await flush(); const pending = { connect, mtu: requestMtu, gatt: device.discoverAllServicesAndCharacteristics, }[stage]; expect(pending).toHaveBeenCalledTimes(1); bleManager.cancelDeviceConnection.mockImplementation(() => nativeDisconnect.promise); let stopped = false; const stopping = transport.stop().then(() => { stopped = true; }); try { await flush(); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID); expect(stopped).toBe(false); } finally { // Native completion can race cancellation; neither outcome may restart setup. if (completion === 'resolve') nativeOperation.resolve(device); else nativeOperation.reject( Object.assign(new Error('Operation was cancelled'), { errorCode: BleErrorCode.OperationCancelled, }) ); nativeDisconnect.resolve(); await advanceUntil(() => stopped, BLE_CONNECT_TIMEOUT_MS + BLE_GATT_SETUP_TIMEOUT_MS); await stopping; } await expect(acquire).resolves.toBeInstanceOf(Error); expect(connect).toHaveBeenCalledTimes(stage === 'connect' ? 1 : 0); expect(monitor).not.toHaveBeenCalled(); if (stage === 'mtu') expect(device.discoverAllServicesAndCharacteristics).not.toHaveBeenCalled(); expect(bleManager.cancelDeviceConnection.mock.calls).toEqual( Array.from({ length: bleManager.cancelDeviceConnection.mock.calls.length }, () => [UUID]) ); expect(destroy).not.toHaveBeenCalled(); } ); test('a stalled connect is abandoned natively so the next attempt is not cancelled by it', async () => { const { transport, bleManager } = createHarness( () => new Promise(() => { // never settles }) ); let settled = false; transport.acquire({ uuid: UUID }).catch(() => { settled = true; }); await flush(); await advanceUntil(() => settled, BLE_CONNECT_TIMEOUT_MS + 5000); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID); }); test('repeated stalled connects recreate the BLE manager and stop with PollingTimeout', async () => { const { transport, bleManager } = createHarness( () => new Promise(() => { // never settles }) ); const destroy = jest.fn(); (bleManager as unknown as { destroy: jest.Mock }).destroy = destroy; const errors: Array<{ errorCode?: unknown; message?: unknown }> = []; for (let attempt = 0; attempt < BLE_CONNECT_TIMEOUT_MANAGER_RESET_THRESHOLD; attempt += 1) { let settled = false; transport.acquire({ uuid: UUID }).catch(error => { errors.push(error); settled = true; }); // eslint-disable-next-line no-await-in-loop await flush(); // eslint-disable-next-line no-await-in-loop await advanceUntil(() => settled, BLE_CONNECT_TIMEOUT_MS + 5000); } expect(destroy).toHaveBeenCalledTimes(1); expect((transport as unknown as { blePlxManager?: unknown }).blePlxManager).toBeUndefined(); expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleConnectedError); expect(errors[1]).toMatchObject({ errorCode: HardwareErrorCode.PollingTimeout, message: BLE_SETUP_WEDGED_MESSAGE, }); }); test('a recreated BLE manager starts with fresh timeout budgets for every device', () => { const { transport, bleManager } = createHarness(() => Promise.resolve()); (bleManager as unknown as { destroy: jest.Mock }).destroy = jest.fn(); (transport as any).connectionSetupTimeoutCounts.set('setup-device', 1); (transport as any).writeTimeoutCounts.set('write-device', 1); (transport as any).resetPlxManager(); expect((transport as any).connectionSetupTimeoutCounts.size).toBe(0); expect((transport as any).writeTimeoutCounts.size).toBe(0); }); test('native connect timeouts contribute to the same manager reset budget', async () => { const { transport, bleManager } = createHarness(() => Promise.resolve()); const destroy = jest.fn(); (bleManager as unknown as { destroy: jest.Mock }).destroy = destroy; const nativeTimeout = Object.assign(new Error('Operation timed out'), { errorCode: BleErrorCode.OperationTimedOut, }); await expect( (transport as any).connectWithTimeout(UUID, () => Promise.reject(nativeTimeout)) ).rejects.toBe(nativeTimeout); await expect( (transport as any).connectWithTimeout(UUID, () => Promise.reject(nativeTimeout)) ).rejects.toMatchObject({ errorCode: HardwareErrorCode.PollingTimeout, message: BLE_SETUP_WEDGED_MESSAGE, }); expect(destroy).toHaveBeenCalledTimes(1); }); test('GATT discovery is bounded and abandons the native connection', async () => { const { transport, device, bleManager } = createHarness(() => Promise.resolve()); device.discoverAllServicesAndCharacteristics.mockImplementation( () => new Promise(() => { // never settles }) ); const errors: Array<{ errorCode?: unknown }> = []; let settled = false; (transport as any).resolveCharacteristicsWithTimeout(UUID, device).catch((error: unknown) => { errors.push(error as { errorCode?: unknown }); settled = true; }); await flush(); await advanceUntil(() => settled, BLE_GATT_SETUP_TIMEOUT_MS + 5000); expect(errors).toHaveLength(1); expect(errors[0]?.errorCode).toBe(HardwareErrorCode.BleConnectedError); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID); }); test('a native GATT timeout abandons the native connection', async () => { const { transport, device, bleManager } = createHarness(() => Promise.resolve()); const nativeTimeout = Object.assign(new Error('Operation timed out'), { errorCode: BleErrorCode.OperationTimedOut, }); device.discoverAllServicesAndCharacteristics.mockRejectedValueOnce(nativeTimeout); await expect((transport as any).resolveCharacteristicsWithTimeout(UUID, device)).rejects.toBe( nativeTimeout ); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID); }); test('a successful GATT retry clears the timeout budget before an abandoned call settles', async () => { const { transport, device, bleManager } = createHarness(() => Promise.resolve()); let resolveAbandonedDiscovery: (() => void) | undefined; device.discoverAllServicesAndCharacteristics .mockImplementationOnce( () => new Promise(resolve => { resolveAbandonedDiscovery = resolve; }) ) .mockResolvedValueOnce(undefined); let firstSettled = false; (transport as any).resolveCharacteristicsWithTimeout(UUID, device).catch(() => { firstSettled = true; }); await flush(); await advanceUntil(() => firstSettled, BLE_GATT_SETUP_TIMEOUT_MS + 5000); await expect( (transport as any).resolveCharacteristicsWithTimeout(UUID, device) ).resolves.toMatchObject({ writeCharacteristic: expect.any(Object), notifyCharacteristic: expect.any(Object), }); resolveAbandonedDiscovery?.(); await flush(); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledTimes(1); expect((transport as any).connectionSetupTimeoutCounts.has(UUID)).toBe(false); }); test('holds an unusable link past the idle timer', async () => { Object.assign(Platform, { OS: 'android' }); const { transport, device, bleManager } = createHarness(() => Promise.resolve(device)); device.isConnected.mockResolvedValue(true); Object.assign(device, { mtu: 23, requestMTU: jest.fn(() => new Promise(() => {})) }); let settled: Error | undefined; const acquire = transport.acquire({ uuid: UUID, expectedProtocol: 'V1' }).catch(error => { settled = error; }); await flush(); await flush(); expect(device.requestMTU).toHaveBeenCalledTimes(1); jest.advanceTimersByTime(ANDROID_MTU_EXCHANGE_TIMEOUT_MS); await flush(); await flush(); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(UUID); jest.advanceTimersByTime(4000); await flush(); await flush(); expect(settled).toBeUndefined(); await advanceUntil(() => !!settled, ANDROID_LINK_DROP_QUIET_MS); await acquire; expect(settled).toMatchObject({ errorCode: HardwareErrorCode.BleConnectedError }); expect(device.discoverAllServicesAndCharacteristics).not.toHaveBeenCalled(); }); test('a slow but successful MTU exchange completes inside the bound', async () => { Object.assign(Platform, { OS: 'android' }); const { transport, device } = createHarness(() => Promise.resolve(device)); device.isConnected.mockResolvedValue(true); const negotiated = { ...device, mtu: 247 }; Object.assign(device, { mtu: 23, // A cold cache makes the stack discover first; the exchange then completes late. requestMTU: jest.fn( () => new Promise(resolve => { setTimeout(() => resolve(negotiated), 10_000); }) ), }); const [, notifyCharacteristic] = await device.characteristicsForService(); Object.assign(notifyCharacteristic, { monitor: jest.fn(() => ({ remove: jest.fn() })) }); let result: unknown; let failure: Error | undefined; const acquire = transport.acquire({ uuid: UUID, expectedProtocol: 'V1' }).then( value => { result = value; }, error => { failure = error; } ); await flush(); await flush(); expect(device.discoverAllServicesAndCharacteristics).not.toHaveBeenCalled(); jest.advanceTimersByTime(10_000); await advanceUntil(() => result !== undefined || failure !== undefined, 5000); await acquire; expect(failure).toBeUndefined(); expect(result).toEqual({ uuid: UUID, protocolType: 'V1' }); expect(device.discoverAllServicesAndCharacteristics).toHaveBeenCalledTimes(1); await transport.release(UUID, true); }); });