import { EventEmitter } from 'events'; import transportPackage, { PROTOCOL_V2_CHANNEL_BLE_UART, ProtocolV2, TRANSPORT_EVENT, } from '@onekeyfe/hd-transport'; import { ERRORS, HardwareErrorCode, createDeferred } from '@onekeyfe/hd-shared'; import ReactNativeBleTransport, { BLE_NATIVE_TEARDOWN_TIMEOUT_MS, BLE_SETUP_WEDGED_MESSAGE, BLE_WRITE_PACKET_TIMEOUT_MS, configureProtocolV2BleTuning, getFirmwareUploadWriteRetryType, resetProtocolV2BleTuning, } from '../index'; jest.mock( 'react-native', () => ({ PermissionsAndroid: {}, Platform: { OS: 'ios' }, }), { virtual: true } ); jest.mock('react-native-ble-plx', () => ({ BleATTErrorCode: { UnlikelyError: 14 }, BleError: class BleError extends Error {}, BleErrorCode: { DeviceAlreadyConnected: 203, DeviceDisconnected: 201, DeviceMTUChangeFailed: 206, OperationCancelled: 2, CharacteristicNotFound: 404, }, BleManager: jest.fn(), ConnectionPriority: { Balanced: 0, High: 1, LowPower: 2 }, ScanMode: { LowLatency: 2 }, })); jest.mock('../BleManager', () => ({ getConnectedDeviceIds: jest.fn(() => Promise.resolve([])), onDeviceBondState: jest.fn(() => Promise.resolve()), pairDevice: jest.fn(() => Promise.resolve({ bonded: true, bonding: false })), })); // The native facade is replaced wholesale above; key missing and link encryption are covered // by their own suites. jest.mock('../bleKeyMissing', () => ({ isBleKeyMissingSupported: jest.fn(() => false), startBleKeyMissingTracking: jest.fn(() => false), stopBleKeyMissingTracking: jest.fn(), waitForBleKeyMissing: jest.fn(() => Promise.resolve(false)), })); jest.mock('../bleEncryption', () => ({ markBleLinkEncrypted: jest.fn(), startBleEncryptionTracking: jest.fn(() => false), stopBleEncryptionTracking: jest.fn(), waitForAndroidLinkEncryption: jest.fn(() => Promise.resolve('unresolved')), })); jest.mock('../subscribeBleOn', () => ({ subscribeBleOn: jest.fn(() => Promise.resolve()), })); const setPlatformOS = (os: 'ios' | 'android') => { const reactNative: { Platform: { OS: string } } = jest.requireMock('react-native'); reactNative.Platform.OS = os; }; const { parseConfigure } = transportPackage; const protocolV1Schema = { nested: { Initialize: { fields: {} }, GetFeatures: { fields: {} }, Success: { fields: { message: { type: 'string', id: 1 }, }, }, MessageType: { values: { MessageType_Initialize: 1, MessageType_Success: 2, MessageType_GetFeatures: 55, }, }, }, }; const protocolV2Schema = { nested: { ProtocolInfoRequest: { fields: {} }, Ping: { fields: { message: { type: 'string', id: 1 }, }, }, DeviceInfoGet: { fields: {} }, FileWrite: { fields: {} }, Success: { fields: { message: { type: 'string', id: 1 }, }, }, MessageType: { values: { MessageType_ProtocolInfoRequest: 60200, MessageType_Ping: 60206, MessageType_Success: 60207, MessageType_DeviceInfoGet: 60600, MessageType_FileWrite: 60805, }, }, }, }; const schemas = { protocolV1: parseConfigure(protocolV1Schema), protocolV2: parseConfigure(protocolV2Schema), }; const createHarness = ({ deviceName = 'OneKey Pro 2', isWritableWithResponse = true, monitorError, }: { deviceName?: string; isWritableWithResponse?: boolean; monitorError?: (Error & { reason?: string; attErrorCode?: number; iosErrorCode?: number }) | null; } = {}) => { const uuid = 'rn-pro2-id'; const sentSeqs: number[] = []; let responseSeq = 0; let shouldRespond = true; let notifyCallback: | (( error: (Error & { reason?: string }) | null, characteristic: { value: string } | null ) => void) | undefined; let disconnectCallback: (() => void) | undefined; const notifyCharacteristic = { uuid: '0003', deviceID: uuid, isNotifiable: true, monitor: jest.fn(callback => { notifyCallback = callback; if (monitorError) { queueMicrotask(() => callback(monitorError, null)); } return { remove: jest.fn() }; }), }; const handleWrite = (base64: string) => { const frame = Buffer.from(base64, 'base64'); sentSeqs.push(frame[6]); if (shouldRespond) { responseSeq += 1; const response = ProtocolV2.encodeFrame( schemas, 'Success', { message: 'ok' }, { router: PROTOCOL_V2_CHANNEL_BLE_UART, seq: responseSeq } ); notifyCallback?.(null, { value: Buffer.from(response).toString('base64') }); } return Promise.resolve(); }; const writeCharacteristic = { uuid: '0002', deviceID: uuid, isWritableWithResponse, isWritableWithoutResponse: true, writeWithResponse: jest.fn(handleWrite), writeWithoutResponse: jest.fn(handleWrite), }; const device = { id: uuid, name: deviceName, localName: deviceName, mtu: 247, serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'], isConnected: jest.fn(() => Promise.resolve(true)), cancelConnection: jest.fn(() => Promise.resolve()), connect: jest.fn(), onDisconnected: jest.fn(callback => { disconnectCallback = callback; return { remove: jest.fn() }; }), } as any; device.connect.mockResolvedValue(device); device.requestMTU = jest.fn(() => Promise.resolve(device)); device.requestConnectionPriority = jest.fn(() => Promise.resolve(device)); const bleManager = { devices: jest.fn(() => Promise.resolve([device])), connectedDevices: jest.fn(() => Promise.resolve([])), cancelTransaction: jest.fn(() => Promise.resolve()), cancelDeviceConnection: jest.fn(() => Promise.resolve()), destroy: jest.fn(), }; const transport = new ReactNativeBleTransport({ scanTimeout: 1 }); const emitter = new EventEmitter(); const logger = { debug: jest.fn(), error: jest.fn() }; transport.blePlxManager = bleManager; transport.resolveCharacteristics = jest.fn(() => Promise.resolve({ writeCharacteristic, notifyCharacteristic }) ); transport.init(logger, emitter); transport.configure(protocolV1Schema); transport.configureProtocolV2(protocolV2Schema); return { transport, emitter, logger, uuid, device, bleManager, sentSeqs, writeCharacteristic, setShouldRespond(value: boolean) { shouldRespond = value; }, emitMonitorError(error: Error & { reason?: string }) { notifyCallback?.(error, null); }, emitDisconnect() { disconnectCallback?.(); }, }; }; const createV1Harness = ({ respondOnWriteCount = 1, isWritableWithResponse = true, }: { respondOnWriteCount?: number | number[]; isWritableWithResponse?: boolean; } = {}) => { const uuid = 'rn-classic-id'; const notifySubscriptionRemovers: jest.Mock[] = []; const disconnectSubscriptionRemovers: jest.Mock[] = []; let notifyCallback: | ((error: Error | null, characteristic: { value: string } | null) => void) | undefined; const notifyCharacteristic = { uuid: '0003', deviceID: uuid, isNotifiable: true, monitor: jest.fn(callback => { notifyCallback = callback; const remove = jest.fn(); notifySubscriptionRemovers.push(remove); return { remove }; }), }; let writeCount = 0; const responseWriteCounts = new Set( Array.isArray(respondOnWriteCount) ? respondOnWriteCount : [respondOnWriteCount] ); const handleWrite = () => { writeCount += 1; if (responseWriteCounts.has(writeCount)) { notifyCallback?.(null, { value: Buffer.from('3f23230002000000040a026f6b', 'hex').toString('base64'), }); } return Promise.resolve(); }; const writeCharacteristic = { uuid: '0002', deviceID: uuid, isWritableWithResponse, isWritableWithoutResponse: true, writeWithResponse: jest.fn(handleWrite), writeWithoutResponse: jest.fn(handleWrite), }; const device = { id: uuid, name: 'OneKey Classic', localName: 'OneKey Classic', mtu: 247, serviceUUIDs: ['00000001-0000-1000-8000-00805f9b34fb'], isConnected: jest.fn(() => Promise.resolve(true)), cancelConnection: jest.fn(() => Promise.resolve()), onDisconnected: jest.fn(() => { const remove = jest.fn(); disconnectSubscriptionRemovers.push(remove); return { remove }; }), } as any; device.requestMTU = jest.fn(() => Promise.resolve(device)); const transport = new ReactNativeBleTransport({ scanTimeout: 1 }); const bleManager = { devices: jest.fn(() => Promise.resolve([device])), connectedDevices: jest.fn(() => Promise.resolve([])), cancelTransaction: jest.fn(() => Promise.resolve()), }; transport.blePlxManager = bleManager as any; transport.resolveCharacteristics = jest.fn(() => Promise.resolve({ writeCharacteristic, notifyCharacteristic }) ); transport.init({ debug: jest.fn(), error: jest.fn() }, new EventEmitter()); transport.configure(protocolV1Schema); transport.configureProtocolV2(protocolV2Schema); return { transport, uuid, device, bleManager, writeCharacteristic, notifySubscriptionRemovers, disconnectSubscriptionRemovers, }; }; describe('ReactNativeBleTransport Protocol V2 link lifecycle', () => { test('does not classify disconnects as retryable firmware writes', () => { expect( getFirmwareUploadWriteRetryType({ errorCode: 205, message: 'Device disconnected after write', }) ).toBeNull(); }); test.each(['status 143', 'status:143', 'status = 143', 'GATT_CONGESTED'])( 'classifies %s as transient GATT congestion', message => { expect(getFirmwareUploadWriteRetryType({ message })).toBe('congested'); } ); test('handles long uncontrolled status messages without a backtracking regular expression', () => { const message = `status${' '.repeat(100_000)}142`; expect(getFirmwareUploadWriteRetryType({ message })).toBeNull(); }); test('keeps another device reader when releasing a device with an active V1 call', async () => { const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any; const activeV1Call = createDeferred(); const otherDeviceReader = createDeferred(); activeV1Call.promise.catch(() => undefined); otherDeviceReader.promise.catch(() => undefined); transport.runPromise = activeV1Call; transport.runPromiseDeviceId = 'device-a'; transport.protocolV2FramePromises.set('device-b', otherDeviceReader); await transport.releaseNative('device-a', true); expect(transport.protocolV2FramePromises.get('device-b')).toBe(otherDeviceReader); }); test('rejects a pending reader when its device frame state resets', async () => { const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any; const reader = createDeferred(); transport.protocolV2FramePromises.set('device-a', reader); const result = Promise.race([ reader.promise.then( () => 'resolved', () => 'rejected' ), new Promise(resolve => { setTimeout(() => resolve('pending'), 20); }), ]); transport.resetProtocolV2Frames('device-a'); await expect(result).resolves.toBe('rejected'); }); test('keeps the legacy default BLE scan timeout', () => { expect(new ReactNativeBleTransport({}).scanTimeout).toBe(3000); }); test.each(['V1', 'V2'] as const)( 'waits for Android bonding before connecting the %s GATT link', async protocol => { setPlatformOS('android'); const { transport, uuid, device } = protocol === 'V1' ? createV1Harness() : createHarness(); const operationOrder: string[] = []; const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock; const bondStateMock = jest.requireMock('../BleManager').onDeviceBondState as jest.Mock; const bonding = createDeferred(); device.isConnected.mockResolvedValueOnce(false); device.connect = jest.fn(() => { operationOrder.push('connect'); return Promise.resolve(device); }); pairDeviceMock.mockImplementationOnce(() => { operationOrder.push('bond'); return Promise.resolve({ bonded: false, bonding: true }); }); bondStateMock.mockImplementationOnce(() => bonding.promise); const acquiring = transport.acquire({ uuid, expectedProtocol: protocol }); await new Promise(resolve => { setImmediate(resolve); }); expect(operationOrder).toEqual(['bond']); expect(device.connect).not.toHaveBeenCalled(); bonding.resolve(); await expect(acquiring).resolves.toEqual({ uuid, protocolType: protocol, }); expect(operationOrder).toEqual(['bond', 'connect']); await transport.release(uuid, true); } ); test('preserves Android connect errors after bonding when both reconnect attempts fail', async () => { setPlatformOS('android'); const { transport, uuid, device } = createHarness(); const BleErrorMock = jest.requireMock('react-native-ble-plx').BleError as new ( message: string ) => Error; const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock; const firstError = Object.assign(new BleErrorMock('MTU change failed'), { errorCode: 206, reason: 'MTU change failed', }); const fallbackError = Object.assign(new BleErrorMock('GATT connect failed'), { errorCode: 205, reason: 'GATT connect failed', }); device.isConnected.mockResolvedValueOnce(false); device.connect = jest .fn() .mockRejectedValueOnce(firstError) .mockRejectedValueOnce(fallbackError); pairDeviceMock.mockClear(); await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleConnectedError, }); expect(device.connect).toHaveBeenCalledTimes(2); expect(pairDeviceMock).toHaveBeenCalledTimes(1); }); test('does not connect Android GATT when the system cannot start bonding', async () => { setPlatformOS('android'); const { transport, uuid, device, bleManager } = createHarness(); const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock; device.connect = jest.fn().mockResolvedValue(device); pairDeviceMock.mockClear(); pairDeviceMock.mockResolvedValueOnce({ bonded: false, bonding: false }); await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceNotBonded, }); expect(device.connect).not.toHaveBeenCalled(); expect(bleManager.devices).not.toHaveBeenCalled(); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid); }); test('checks that Android GATT is connected after bonding and reconnecting', async () => { setPlatformOS('android'); const { transport, uuid, device, bleManager } = createHarness(); device.isConnected.mockResolvedValueOnce(false).mockResolvedValueOnce(false); device.connect = jest.fn().mockResolvedValue(device); await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleConnectedError, }); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid); expect(device.cancelConnection).toHaveBeenCalledTimes(1); }); test.each([ [ 'ios', 'OneKey Neo', { iosErrorCode: 14, reason: 'Peer removed pairing information', }, HardwareErrorCode.BlePeerRemovedPairingInformation, ], [ 'android', 'OneKey Pro 2', { androidErrorCode: 5, reason: 'Connection state changed with status 5', }, HardwareErrorCode.BleDeviceBondError, ], ] as const)( 'maps a %s %s stale bond during connect before protocol detection', async (platform, deviceName, nativeError, errorCode) => { setPlatformOS(platform); const { transport, uuid, device } = createHarness({ deviceName }); const BleErrorMock = jest.requireMock('react-native-ble-plx').BleError as new ( message: string ) => Error; const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock; pairDeviceMock.mockClear(); device.isConnected.mockResolvedValueOnce(false); device.connect = jest .fn() .mockRejectedValue(Object.assign(new BleErrorMock(nativeError.reason), nativeError)); await expect(transport.acquire({ uuid })).rejects.toMatchObject({ errorCode }); expect(pairDeviceMock).toHaveBeenCalledTimes(platform === 'android' ? 1 : 0); expect(transport.getProtocolType(uuid)).toBeUndefined(); } ); test('cleans up Android bonding failures without starting GATT', async () => { setPlatformOS('android'); const { transport, uuid, device, bleManager } = createHarness(); const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock; pairDeviceMock.mockRejectedValueOnce(new Error('bonding canceled')); await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toThrow( 'bonding canceled' ); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid); expect(bleManager.devices).not.toHaveBeenCalled(); expect(device.isConnected).not.toHaveBeenCalled(); }); test('does not connect after stop while Android bonding is pending', async () => { setPlatformOS('android'); const { transport, uuid, bleManager } = createHarness(); const pairDeviceMock = jest.requireMock('../BleManager').pairDevice as jest.Mock; const bondStateMock = jest.requireMock('../BleManager').onDeviceBondState as jest.Mock; const bonding = createDeferred(); pairDeviceMock.mockResolvedValueOnce({ bonded: false, bonding: true }); bondStateMock.mockImplementationOnce(() => bonding.promise); const acquiring = transport.acquire({ uuid, expectedProtocol: 'V2' }); const rejection = expect(acquiring).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceDisconnected, }); await new Promise(resolve => { setImmediate(resolve); }); const stopping = transport.stop(); bonding.resolve(); await rejection; await stopping; expect(bleManager.devices).not.toHaveBeenCalled(); }); test('uses withResponse for consecutive iOS Protocol V1 control commands without releasing', async () => { const { transport, uuid, writeCharacteristic } = createV1Harness({ respondOnWriteCount: [1, 2], }); await expect(transport.acquire({ uuid, expectedProtocol: 'V1' })).resolves.toEqual({ uuid, protocolType: 'V1', }); const releaseNative = jest.spyOn(transport as any, 'releaseNative'); expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled(); await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).resolves.toBeDefined(); await expect(transport.call(uuid, 'GetFeatures', {}, { timeoutMs: 50 })).resolves.toBeDefined(); expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(2); expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled(); expect(releaseNative).not.toHaveBeenCalled(); await transport.release(uuid, true); }); test('falls back to withoutResponse for an iOS Protocol V1 control command when required', async () => { const { transport, uuid, writeCharacteristic } = createV1Harness({ isWritableWithResponse: false, }); await transport.acquire({ uuid, expectedProtocol: 'V1' }); await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).resolves.toBeDefined(); expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1); await transport.release(uuid, true); }); test('does not resend a failed iOS Protocol V1 control write without response', async () => { const { transport, uuid, writeCharacteristic } = createV1Harness(); const writeError = new Error('write with response failed'); await transport.acquire({ uuid, expectedProtocol: 'V1' }); writeCharacteristic.writeWithResponse.mockRejectedValueOnce(writeError); await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleWriteCharacteristicError, }); expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1); expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled(); await transport.release(uuid, true); }); test('keeps native stale-bond write mapping out of Protocol V1 calls', async () => { const { transport, uuid, writeCharacteristic } = createV1Harness(); const nativeError = Object.assign(new Error('Encryption is insufficient'), { attErrorCode: 15, reason: 'Encryption is insufficient', }); await transport.acquire({ uuid, expectedProtocol: 'V1' }); writeCharacteristic.writeWithResponse.mockRejectedValueOnce(nativeError); await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 50 })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleWriteCharacteristicError, }); await transport.release(uuid, true); }); test('detects Protocol V2 on iOS without using the BLE name as a protocol hint', async () => { const { transport, uuid, device, sentSeqs, writeCharacteristic } = createHarness({ deviceName: 'Pro2 6E9E', }); await expect(transport.acquire({ uuid })).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.requestMTU).not.toHaveBeenCalled(); expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalled(); await expect( transport.call(uuid, 'Ping', { message: 'first-core-command' }) ).resolves.toBeDefined(); expect(sentSeqs.filter(seq => seq > 0)).toEqual([1, 2]); await transport.release(uuid, true); }); test('physically refreshes an uncached Protocol V2 firmware install link without Ping', async () => { const { transport, uuid, device, bleManager, writeCharacteristic } = createHarness(); const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2'); (transport as any).sessionProtocols.set(uuid, 'V2'); device.connect = jest.fn().mockResolvedValue(device); device.isConnected.mockResolvedValueOnce(false); await expect( transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true }) ).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid); expect(device.cancelConnection).not.toHaveBeenCalled(); expect(device.connect).toHaveBeenCalled(); expect(probeProtocolV2).not.toHaveBeenCalled(); expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled(); expect(transport.getProtocolType(uuid)).toBe('V2'); await transport.release(uuid, true); }); test('refreshes a cached firmware install connection instead of trusting GATT state', async () => { const { transport, uuid, device, bleManager, writeCharacteristic } = createHarness(); const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2'); await transport.acquire({ uuid, expectedProtocol: 'V2' }); device.connect = jest.fn().mockResolvedValue(device); device.isConnected.mockResolvedValueOnce(false); writeCharacteristic.writeWithResponse.mockClear(); writeCharacteristic.writeWithoutResponse.mockClear(); await expect( transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true }) ).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid); expect(device.cancelConnection).toHaveBeenCalled(); expect(device.connect).toHaveBeenCalled(); expect(probeProtocolV2).toHaveBeenCalledTimes(1); expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled(); await transport.release(uuid, true); }); test('rejects no-probe acquire before the BLE endpoint has confirmed a protocol', async () => { const { transport, uuid } = createHarness(); await expect( transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true }) ).rejects.toThrow('previously confirmed protocol'); expect(transport.getProtocolType(uuid)).toBeUndefined(); }); test('keeps confirmed V2 authorization across a BLE manager reset', async () => { const { transport, uuid, device, bleManager, writeCharacteristic } = createHarness(); const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2'); await transport.acquire({ uuid, expectedProtocol: 'V2' }); expect(probeProtocolV2).toHaveBeenCalledTimes(1); (transport as any).resetPlxManager(); transport.blePlxManager = bleManager as any; device.connect = jest.fn().mockResolvedValue(device); device.isConnected.mockResolvedValueOnce(false); writeCharacteristic.writeWithResponse.mockClear(); writeCharacteristic.writeWithoutResponse.mockClear(); await expect( transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true }) ).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(probeProtocolV2).toHaveBeenCalledTimes(1); expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); expect(writeCharacteristic.writeWithoutResponse).not.toHaveBeenCalled(); await transport.release(uuid, true); }); test.each(['ios', 'android'] as const)( 'waits for native disconnect after failed protocol detection before reconnecting on %s', async platform => { setPlatformOS(platform); const { transport, uuid, device, bleManager } = createHarness(); const probes = transport as unknown as { probeProtocolV1(uuid: string): Promise; probeProtocolV2(uuid: string): Promise; releaseNative(uuid: string, onclose: boolean): Promise; }; jest.spyOn(probes, 'probeProtocolV1').mockResolvedValueOnce(false); const probesFinished = createDeferred(); const probeProtocolV2 = jest .spyOn(probes, 'probeProtocolV2') .mockImplementationOnce(async () => { // A link-fatal V2 timeout removes the cached transport before acquire fails. await probes.releaseNative(uuid, true); probesFinished.resolve(); return false; }); let connected = true; device.isConnected.mockImplementation(() => Promise.resolve(connected)); device.connect = jest.fn(() => { connected = true; return Promise.resolve(device); }); const disconnectGate = createDeferred(); bleManager.cancelDeviceConnection.mockImplementation(async () => { await disconnectGate.promise; connected = false; }); const acquiring = transport.acquire({ uuid }); const failure = expect(acquiring).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleTimeoutError, }); const reconnecting = transport.acquire({ uuid, expectedProtocol: 'V2' }); try { await probesFinished.promise; await new Promise(resolve => { setImmediate(resolve); }); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid); expect(device.connect).not.toHaveBeenCalled(); expect(probeProtocolV2).toHaveBeenCalledTimes(1); disconnectGate.resolve(); await failure; await expect(reconnecting).resolves.toEqual({ uuid, protocolType: 'V2' }); expect(device.connect).toHaveBeenCalledTimes(1); await expect( transport.call(uuid, 'Ping', { message: 'after-reconnect' }) ).resolves.toMatchObject({ type: 'Success', message: { message: 'ok' }, }); } finally { disconnectGate.resolve(); await Promise.allSettled([failure, reconnecting]); await transport.release(uuid, true); } } ); test.each(['ios', 'android'] as const)( 'disconnects after a first expected Protocol V2 probe miss while keeping it retryable on %s', async platform => { setPlatformOS(platform); const { transport, uuid, device } = createHarness(); jest.spyOn(transport as any, 'probeProtocolV2').mockResolvedValue(false); await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({ errorCode: HardwareErrorCode.RuntimeError, }); expect(device.cancelConnection).toHaveBeenCalledTimes(1); expect(transport.getProtocolType(uuid)).toBeUndefined(); } ); test.each(['ios', 'android'] as const)( 'disconnects after each expected Protocol V2 probe miss while keeping it retryable on %s', async platform => { setPlatformOS(platform); const { transport, uuid, device } = createHarness(); jest.spyOn(transport as any, 'probeProtocolV2').mockResolvedValue(false); await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({ errorCode: HardwareErrorCode.RuntimeError, }); await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({ errorCode: HardwareErrorCode.RuntimeError, }); expect(device.cancelConnection).toHaveBeenCalledTimes(2); expect(transport.getProtocolType(uuid)).toBeUndefined(); } ); test.each([ [ 'Encryption is insufficient', { reason: 'Encryption is insufficient', attErrorCode: 15 }, HardwareErrorCode.BleDeviceBondError, ], [ 'Peer removed pairing information', { reason: 'Peer removed pairing information', iosErrorCode: 14 }, HardwareErrorCode.BlePeerRemovedPairingInformation, ], ] as const)( 'fails Protocol V2 acquire immediately on %s instead of waiting for Ping', async (_label, nativeError, errorCode) => { const { transport, uuid, device } = createHarness({ monitorError: Object.assign(new Error(nativeError.reason), nativeError), }); const probe = jest.spyOn(transport as any, 'probeProtocolV2'); await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({ errorCode, }); expect(probe).not.toHaveBeenCalled(); expect(device.cancelConnection).toHaveBeenCalled(); expect(transport.getProtocolType(uuid)).toBeUndefined(); } ); test.each(['ios', 'android'] as const)( 'disconnects after a confirmed Protocol V2 probe miss on %s without reporting a bond error', async platform => { setPlatformOS(platform); const { transport, uuid, device } = createHarness(); await transport.acquire({ uuid, expectedProtocol: 'V2' }); await transport.release(uuid, true); jest.spyOn(transport as any, 'probeProtocolV2').mockResolvedValue(false); await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({ errorCode: HardwareErrorCode.RuntimeError, }); expect(device.cancelConnection).toHaveBeenCalledTimes(1); expect(transport.getProtocolType(uuid)).toBeUndefined(); } ); test('waits for an in-flight release before reacquiring the same device', async () => { const { transport, uuid } = createHarness(); await transport.acquire({ uuid, expectedProtocol: 'V2' }); const releaseGate = createDeferred(); const releaseStarted = createDeferred(); const { protocolV2Links } = transport as any; const invalidateLink = protocolV2Links.invalidateLink.bind(protocolV2Links); jest .spyOn(protocolV2Links, 'invalidateLink') .mockImplementationOnce(async (...args: unknown[]) => { releaseStarted.resolve(); await releaseGate.promise; return invalidateLink(...args); }); const release = transport.release(uuid, true); await releaseStarted.promise; let reacquired = false; const acquire = transport.acquire({ uuid, expectedProtocol: 'V2' }).then(result => { reacquired = true; return result; }); await Promise.resolve(); expect(reacquired).toBe(false); releaseGate.resolve(); await release; await expect(acquire).resolves.toEqual({ uuid, protocolType: 'V2' }); await expect(transport.call(uuid, 'Ping', { message: 'after-release' })).resolves.toMatchObject( { type: 'Success', message: { message: 'ok' }, } ); }); test( 'releases the lifecycle queue when native teardown never settles', async () => { const { transport, uuid, device, bleManager } = createHarness(); await transport.acquire({ uuid, expectedProtocol: 'V2' }); const otherUuid = 'rn-pro2-other-id'; const otherDevice = { ...device, id: otherUuid, name: 'OneKey Pro 2 Other', localName: 'OneKey Pro 2 Other', }; await (transport as any).installTransportForAcquire(otherUuid, otherDevice); (transport as any).deviceProtocol.set(otherUuid, 'V2'); const disconnectEvents: Array<{ connectId: string }> = []; transport.emitter?.on(TRANSPORT_EVENT.DEVICE_DISCONNECT, event => { disconnectEvents.push(event); }); const stalledNativeCleanup = createDeferred(); bleManager.cancelTransaction .mockImplementationOnce(() => stalledNativeCleanup.promise) .mockResolvedValue(undefined); const resetPlxManager = jest.spyOn(transport as any, 'resetPlxManager'); const nextLifecycleOperation = jest.fn().mockResolvedValue('next-operation'); const release = transport.release(uuid, true); const nextOperation = (transport as any).runLifecycleOperation(uuid, nextLifecycleOperation); await expect(release).resolves.toBe(true); await expect(nextOperation).resolves.toBe('next-operation'); expect(resetPlxManager).toHaveBeenCalledTimes(1); expect(nextLifecycleOperation).toHaveBeenCalledTimes(1); expect(disconnectEvents).toContainEqual(expect.objectContaining({ connectId: otherUuid })); expect(() => (transport as any).getCachedTransport(otherUuid)).toThrow(); await (transport as any).installTransportForAcquire(uuid, device); (transport as any).deviceProtocol.set(uuid, 'V2'); stalledNativeCleanup.resolve(); await Promise.resolve(); await expect( transport.call(uuid, 'Ping', { message: 'after-stale-cleanup' }) ).resolves.toMatchObject({ type: 'Success', message: { message: 'ok' }, }); }, BLE_NATIVE_TEARDOWN_TIMEOUT_MS + 5_000 ); test('preserves the unpaired error when iOS disconnects during the first V1 probe', async () => { const { transport, uuid, writeCharacteristic } = createHarness({ deviceName: 'Neo Test' }); writeCharacteristic.writeWithResponse.mockRejectedValueOnce({ errorCode: 201, iosErrorCode: 7, reason: 'The specified device has disconnected from us.', }); const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2'); await expect(transport.acquire({ uuid })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceNotBonded, }); expect(probeProtocolV2).not.toHaveBeenCalled(); expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1); expect(transport.getProtocolType(uuid)).toBeUndefined(); }); test('preserves a native iOS disconnect during an expected Protocol V2 probe', async () => { const { transport, uuid, writeCharacteristic, bleManager } = createHarness({ deviceName: 'Neo Test', }); const nativeDisconnect = { errorCode: 201, iosErrorCode: 7, reason: 'The specified device has disconnected from us.', }; writeCharacteristic.writeWithoutResponse.mockRejectedValueOnce(nativeDisconnect); const probeProtocolV1 = jest.spyOn(transport as any, 'probeProtocolV1'); await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceDisconnected, }); expect(probeProtocolV1).not.toHaveBeenCalled(); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid); expect(transport.getProtocolType(uuid)).toBeUndefined(); }); test('preserves a native iOS disconnect during a V2-first probe without falling back to V1', async () => { const { transport, uuid, writeCharacteristic } = createHarness({ deviceName: 'Neo Test' }); writeCharacteristic.writeWithoutResponse.mockRejectedValueOnce({ errorCode: 201, iosErrorCode: 7, reason: 'The specified device has disconnected from us.', }); const probeProtocolV1 = jest.spyOn(transport as any, 'probeProtocolV1'); await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleDeviceDisconnected, }); expect(probeProtocolV1).not.toHaveBeenCalled(); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1); expect(transport.getProtocolType(uuid)).toBeUndefined(); }); test.each(['V1', 'V2'] as const)( 'preserves terminal BLE failures from the %s probe without trying another protocol', async protocol => { for (const errorCode of [ HardwareErrorCode.BleDeviceNotBonded, HardwareErrorCode.BleDeviceBondedCanceled, HardwareErrorCode.BlePeerRemovedPairingInformation, HardwareErrorCode.BleDeviceDisconnected, HardwareErrorCode.BleCharacteristicNotifyError, HardwareErrorCode.BleCharacteristicNotifyChangeFailure, HardwareErrorCode.BleWriteCharacteristicError, ]) { const { transport, uuid } = createHarness(); const error = ERRORS.TypedError(errorCode); const call = jest .spyOn(transport as any, protocol === 'V1' ? 'callProtocolV1' : 'callProtocolV2') .mockRejectedValue(error); const otherProbe = jest.spyOn( transport as any, protocol === 'V1' ? 'probeProtocolV2' : 'probeProtocolV1' ); await expect(transport.acquire({ uuid, protocolHint: protocol })).rejects.toBe(error); expect(call).toHaveBeenCalledTimes(1); expect(otherProbe).not.toHaveBeenCalled(); expect(transport.getProtocolType(uuid)).toBeUndefined(); } } ); test.each(['V1', 'V2'] as const)( 'still falls back after a silent %s probe timeout', async protocol => { const { transport, uuid, device, bleManager } = createHarness(); jest .spyOn(transport as any, protocol === 'V1' ? 'callProtocolV1' : 'callProtocolV2') .mockRejectedValue(ERRORS.TypedError(HardwareErrorCode.BleTimeoutError)); const otherProbe = jest .spyOn(transport as any, protocol === 'V1' ? 'probeProtocolV2' : 'probeProtocolV1') .mockResolvedValue(true); await expect(transport.acquire({ uuid, protocolHint: protocol })).resolves.toEqual({ uuid, protocolType: protocol === 'V1' ? 'V2' : 'V1', }); expect(otherProbe).toHaveBeenCalledTimes(1); expect(bleManager.cancelDeviceConnection).not.toHaveBeenCalled(); expect(device.cancelConnection).not.toHaveBeenCalled(); await transport.release(uuid, true); } ); test('falls back to the other active probe on iOS when protocol metadata is absent', async () => { const { transport, uuid } = createHarness({ deviceName: 'OneKey' }); const probeProtocolV1 = jest .spyOn(transport as any, 'probeProtocolV1') .mockResolvedValue(false); const probeProtocolV2 = jest.spyOn(transport as any, 'probeProtocolV2').mockResolvedValue(true); await expect(transport.acquire({ uuid })).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(probeProtocolV1).toHaveBeenCalledTimes(1); expect(probeProtocolV2).toHaveBeenCalledTimes(1); expect(probeProtocolV1.mock.invocationCallOrder[0]).toBeLessThan( probeProtocolV2.mock.invocationCallOrder[0] ); await transport.release(uuid, true); }); test('does not renegotiate an already usable MTU during acquire', async () => { const { transport, uuid, device } = createHarness(); await expect(transport.acquire({ uuid })).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.requestMTU).not.toHaveBeenCalled(); expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(247); await transport.release(uuid, true); }); test('requests a low MTU once before protocol probing', async () => { const { transport, uuid, device } = createHarness(); device.mtu = 23; device.requestMTU.mockImplementationOnce(() => { device.mtu = 247; return Promise.resolve(device); }); await expect(transport.acquire({ uuid })).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.requestMTU).toHaveBeenCalledTimes(1); expect(device.requestMTU).toHaveBeenCalledWith( 247, expect.stringContaining(`${uuid}:mtu:connected:0:`) ); expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(247); await transport.release(uuid, true); }); test('continues with a low MTU when the single negotiation fails', async () => { const { transport, uuid, device } = createHarness(); device.mtu = 23; device.requestMTU.mockRejectedValueOnce(new Error('MTU negotiation failed')); await expect(transport.acquire({ uuid })).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.requestMTU).toHaveBeenCalledTimes(1); expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23); await transport.release(uuid, true); }); test('bounds a stalled MTU negotiation and continues protocol probing', async () => { const { transport, uuid, device, bleManager } = createHarness(); device.mtu = 23; device.requestMTU.mockImplementationOnce(() => new Promise(() => {})); await expect(transport.acquire({ uuid })).resolves.toEqual({ uuid, protocolType: 'V2', }); const transactionId = device.requestMTU.mock.calls[0]?.[1]; expect(transactionId).toEqual(expect.stringContaining(`${uuid}:mtu:connected:0:`)); expect(bleManager.cancelTransaction).toHaveBeenCalledWith(transactionId); expect(device.cancelConnection).toHaveBeenCalled(); expect(device.connect).toHaveBeenCalledWith( expect.objectContaining({ timeout: expect.any(Number) }) ); expect(device.connect.mock.calls.at(-1)?.[0]).not.toHaveProperty('requestMTU'); expect(device.requestMTU).toHaveBeenCalledTimes(1); expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23); await transport.release(uuid, true); }, 10_000); test('keeps the iOS link when the MTU-timeout reconnect reports it is still connected', async () => { const { BleError: BleErrorMock, BleErrorCode } = jest.requireMock('react-native-ble-plx'); const { transport, uuid, device, bleManager } = createHarness(); device.mtu = 23; device.requestMTU.mockImplementationOnce(() => new Promise(() => {})); device.connect.mockRejectedValueOnce( Object.assign(new BleErrorMock('Device already connected'), { errorCode: BleErrorCode.DeviceAlreadyConnected, }) ); await expect(transport.acquire({ uuid })).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.cancelConnection).toHaveBeenCalledTimes(1); expect(device.connect).toHaveBeenCalledTimes(1); expect(device.requestMTU).toHaveBeenCalledTimes(1); expect(bleManager.destroy).not.toHaveBeenCalled(); expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23); await transport.release(uuid, true); }, 10_000); test('reconnects after an iOS MTU timeout when cancelling the timed-out link fails', async () => { const { BleError: BleErrorMock, BleErrorCode } = jest.requireMock('react-native-ble-plx'); const { transport, uuid, device, bleManager } = createHarness(); device.mtu = 23; device.requestMTU.mockImplementationOnce(() => new Promise(() => {})); device.cancelConnection.mockRejectedValueOnce( Object.assign(new BleErrorMock('Operation was cancelled'), { errorCode: BleErrorCode.OperationCancelled, }) ); await expect(transport.acquire({ uuid })).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.connect).toHaveBeenCalledTimes(1); expect(device.connect.mock.calls[0]?.[0]).not.toHaveProperty('requestMTU'); expect(bleManager.destroy).not.toHaveBeenCalled(); await transport.release(uuid, true); }, 10_000); test('maps a stale bond from the iOS MTU-timeout reconnect', async () => { const { BleError: BleErrorMock } = jest.requireMock('react-native-ble-plx'); const { transport, uuid, device } = createHarness(); device.mtu = 23; device.requestMTU.mockImplementationOnce(() => new Promise(() => {})); device.connect.mockRejectedValueOnce( Object.assign(new BleErrorMock('Peer removed pairing information'), { errorCode: 200, iosErrorCode: 14, }) ); await expect(transport.acquire({ uuid })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BlePeerRemovedPairingInformation, }); await transport.release(uuid, true).catch(() => undefined); }, 10_000); test('bounds a hung iOS MTU-timeout teardown and resets the BLE manager without reconnecting', async () => { const { transport, uuid, device, bleManager } = createHarness(); device.mtu = 23; device.requestMTU.mockImplementationOnce(() => new Promise(() => {})); device.cancelConnection.mockImplementationOnce(() => new Promise(() => {})); await expect(transport.acquire({ uuid })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleTimeoutError, message: expect.stringContaining('BLE MTU cleanup timed out'), }); expect(bleManager.destroy).toHaveBeenCalledTimes(1); expect(transport.blePlxManager).toBeUndefined(); expect(device.connect).not.toHaveBeenCalled(); expect((transport as any).lifecycleOperations.has(uuid)).toBe(false); }, 15_000); test('does not reconnect after a hung iOS MTU-timeout teardown when the manager reset is already pending', async () => { const { transport, uuid, device, bleManager } = createHarness(); device.mtu = 23; device.requestMTU.mockImplementationOnce(() => new Promise(() => {})); device.cancelConnection.mockImplementationOnce(() => new Promise(() => {})); // Another device's reset is still draining, so this teardown timeout cannot swap the manager. jest.spyOn(transport as any, 'resetPlxManager').mockImplementation(() => undefined); await expect(transport.acquire({ uuid })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleTimeoutError, }); expect(transport.blePlxManager).toBe(bleManager); expect(device.connect).not.toHaveBeenCalled(); }, 15_000); test('does not request MTU again after connect falls back without requestMTU', async () => { const { BleError: BleErrorMock, BleErrorCode } = jest.requireMock('react-native-ble-plx'); const { transport, uuid, device } = createHarness(); device.mtu = 23; device.isConnected.mockResolvedValueOnce(false).mockResolvedValue(true); device.connect .mockRejectedValueOnce( Object.assign(new BleErrorMock('Operation was cancelled'), { errorCode: BleErrorCode.OperationCancelled, }) ) .mockResolvedValue(device); await expect(transport.acquire({ uuid })).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.connect).toHaveBeenCalledTimes(2); expect(device.connect.mock.calls[0][0]).toEqual(expect.objectContaining({ requestMTU: 247 })); expect(device.connect.mock.calls[1][0]).not.toHaveProperty('requestMTU'); expect(device.requestMTU).not.toHaveBeenCalled(); expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(23); await transport.release(uuid, true); }); test('keeps the iOS connect chain unchanged', async () => { const { transport, uuid, device } = createHarness(); device.isConnected.mockResolvedValueOnce(false); await expect(transport.acquire({ uuid, expectedProtocol: 'V2' })).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.connect).toHaveBeenCalledWith({ requestMTU: 247, timeout: expect.any(Number), refreshGatt: 'OnConnected', }); expect(device.requestMTU).not.toHaveBeenCalled(); await transport.release(uuid, true); }); describe('Android MTU before service discovery', () => { beforeEach(() => { setPlatformOS('android'); jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick', 'performance'], }); }); afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); }); const advanceUntil = async (condition: () => boolean, budgetMs = 60_000) => { for (let elapsed = 0; !condition(); elapsed += 50) { if (elapsed >= budgetMs) throw new Error(`fake time exhausted after ${budgetMs}ms`); jest.advanceTimersByTime(50); // eslint-disable-next-line no-await-in-loop await new Promise(resolve => { setImmediate(resolve); }); } }; const settle = async (promise: Promise, budgetMs?: number): Promise => { let done = false; promise.then( () => { done = true; }, () => { done = true; } ); await advanceUntil(() => done, budgetMs); return promise; }; /** * A device whose LE link is only up between connect() and cancelConnection(), so the * transport has to reconnect after every drop instead of the test flipping the link. */ const reconnectingDevice = (device: any) => { const link = { connected: false }; device.isConnected.mockImplementation(() => Promise.resolve(link.connected)); device.connect = jest.fn(() => { link.connected = true; return Promise.resolve(device); }); device.cancelConnection.mockImplementation(() => { link.connected = false; return Promise.resolve(); }); return link; }; /** requestMTU resolves a fresh Device snapshot, as react-native-ble-plx does. */ const negotiatedSnapshot = (device: any, mtu: number) => { const negotiated = { ...device, mtu }; device.requestMTU.mockImplementation(() => Promise.resolve(negotiated)); return negotiated; }; test('negotiates the MTU after a bare connect and before service discovery, adopting the returned device', async () => { const { transport, uuid, device } = createHarness(); reconnectingDevice(device); device.mtu = 23; const negotiated = negotiatedSnapshot(device, 247); await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.connect).toHaveBeenCalledTimes(1); expect(device.connect).toHaveBeenCalledWith({ timeout: expect.any(Number) }); const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock; const [connectOrder] = device.connect.mock.invocationCallOrder; const [mtuOrder] = device.requestMTU.mock.invocationCallOrder; const [discoveryOrder] = resolveCharacteristics.mock.invocationCallOrder; expect(connectOrder).toBeLessThan(mtuOrder); expect(mtuOrder).toBeLessThan(discoveryOrder); const cached = (transport as any).getCachedTransport(uuid); expect(cached.device).toBe(negotiated); expect(cached.mtuSize).toBe(247); await settle(transport.release(uuid, true)); }); test.each([ { expectedProtocol: 'V2' as const, label: 'an expected Protocol V2 device' }, { expectedProtocol: 'V1' as const, label: 'an expected Protocol V1 device' }, { expectedProtocol: undefined, label: 'a device of unknown protocol' }, ])('drops a link that stays at the default MTU for $label', async ({ expectedProtocol }) => { const { transport, uuid, device } = createHarness(); device.mtu = 23; await expect(settle(transport.acquire({ uuid, expectedProtocol }))).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleConnectedError, }); expect((transport as any).resolveCharacteristics).not.toHaveBeenCalled(); expect(device.cancelConnection).toHaveBeenCalled(); }); test.each([ ['never completes', () => new Promise(() => {})], ['completes at the default MTU', undefined], ['is rejected', () => Promise.reject(new Error('MTU request failed'))], ])( 'a second consecutive MTU failure that %s trips the wedged-link guard', async (_label, requestMTU) => { const { transport, uuid, device, bleManager } = createHarness(); device.mtu = 23; if (requestMTU) device.requestMTU.mockImplementation(requestMTU); await expect( settle(transport.acquire({ uuid, expectedProtocol: 'V2' })) ).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleConnectedError, }); await expect( settle(transport.acquire({ uuid, expectedProtocol: 'V2' })) ).rejects.toMatchObject({ errorCode: HardwareErrorCode.PollingTimeout, message: expect.stringContaining(BLE_SETUP_WEDGED_MESSAGE), }); expect(bleManager.destroy).toHaveBeenCalled(); } ); test('stop() during the link-drop wait releases it promptly', async () => { const { transport, uuid, device } = createHarness(); device.mtu = 23; device.requestMTU.mockImplementation(() => new Promise(() => {})); const acquiring = transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error); // The link-drop teardown cancels the device connection before the wait starts. await advanceUntil(() => device.cancelConnection.mock.calls.length > 0); await advanceUntil(() => false, 1000).catch(() => undefined); await settle(transport.stop(), 1000); await expect(settle(acquiring, 1000)).resolves.toBeInstanceOf(Error); }); test.each([ { label: 'a missing OneKey service', error: () => ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound), }, { label: 'a missing characteristic', error: () => ERRORS.TypedError(HardwareErrorCode.BleCharacteristicNotFound), }, { label: 'a mis-typed characteristic', error: () => ERRORS.TypedError('BLECharacteristicNotWritable: write characteristic not writable'), }, ])( 'marks the endpoint on $label and refreshes the GATT table on the next connect, before the MTU exchange', async ({ error }) => { const { transport, uuid, device } = createHarness(); const link = reconnectingDevice(device); device.mtu = 23; negotiatedSnapshot(device, 247); const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock; resolveCharacteristics.mockImplementationOnce(() => Promise.reject(error())); await expect( settle(transport.acquire({ uuid, expectedProtocol: 'V2' })) ).rejects.toBeDefined(); expect(link.connected).toBe(true); await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number), refreshGatt: 'OnConnected', }); // The stale table is only refreshed through a connect, so the live link is dropped first. const connectOrders: number[] = device.connect.mock.invocationCallOrder; expect(device.cancelConnection.mock.invocationCallOrder[0]).toBeLessThan( connectOrders[connectOrders.length - 1] ); const discoveryOrders = resolveCharacteristics.mock.invocationCallOrder; const mtuOrders: number[] = device.requestMTU.mock.invocationCallOrder; expect(discoveryOrders[discoveryOrders.length - 1]).toBeLessThan( mtuOrders[mtuOrders.length - 1] ); // The refresh is spent once the table resolved through a refreshed connect. await settle(transport.release(uuid, true)); link.connected = false; await settle(transport.acquire({ uuid, expectedProtocol: 'V2' })); expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number) }); await settle(transport.release(uuid, true)); } ); test('keeps the refresh marker when the refresh connect fell back without refreshGatt', async () => { const { transport, uuid, device } = createHarness(); const link = reconnectingDevice(device); device.mtu = 23; negotiatedSnapshot(device, 247); const resolveCharacteristics = (transport as any).resolveCharacteristics as jest.Mock; resolveCharacteristics.mockImplementationOnce(() => Promise.reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound)) ); await expect( settle(transport.acquire({ uuid, expectedProtocol: 'V2' })) ).rejects.toBeDefined(); // The fallback connect carries no refreshGatt. const cancelled = Object.assign(new Error('Operation was cancelled'), { errorCode: 2 }); device.connect.mockImplementationOnce(() => Promise.reject(cancelled)); await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({ uuid, protocolType: 'V2', }); await settle(transport.release(uuid, true)); link.connected = false; await settle(transport.acquire({ uuid, expectedProtocol: 'V2' })); expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number), refreshGatt: 'OnConnected', }); await settle(transport.release(uuid, true)); }); test('does not drop a link it has just connected with refreshGatt', async () => { const { transport, uuid, device, bleManager } = createHarness(); bleManager.devices.mockResolvedValue([]); const connectToDevice = jest.fn(() => Promise.resolve(device)); Object.assign(bleManager, { connectToDevice }); (transport as any).androidGattCacheRefreshes.add(uuid); await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(connectToDevice).toHaveBeenCalledTimes(1); expect(connectToDevice).toHaveBeenCalledWith(uuid, { timeout: expect.any(Number), refreshGatt: 'OnConnected', }); expect(device.cancelConnection).not.toHaveBeenCalled(); expect(device.connect).not.toHaveBeenCalled(); await settle(transport.release(uuid, true)); }); test('still refreshes after a connect-by-id refresh fell back without refreshGatt', async () => { const { transport, uuid, device, bleManager } = createHarness(); const link = reconnectingDevice(device); bleManager.devices.mockResolvedValue([]); const cancelled = Object.assign(new Error('Operation was cancelled'), { errorCode: 2 }); const connectToDevice = jest .fn() .mockImplementationOnce(() => Promise.reject(cancelled)) .mockImplementation(() => { link.connected = true; return Promise.resolve(device); }); Object.assign(bleManager, { connectToDevice }); (transport as any).androidGattCacheRefreshes.add(uuid); await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(connectToDevice).toHaveBeenLastCalledWith(uuid, { timeout: expect.any(Number) }); expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number), refreshGatt: 'OnConnected', }); expect((transport as any).androidGattCacheRefreshes.has(uuid)).toBe(false); await settle(transport.release(uuid, true)); }); test.each([ 'Cannot write client characteristic config descriptor', 'Cannot find client characteristic config descriptor', 'The handle is invalid', 'Writing is not permitted', ])('arms a GATT refresh from the notify failure "%s"', async reason => { const harness = createHarness(); const { transport, uuid, device } = harness; reconnectingDevice(device); device.mtu = 23; negotiatedSnapshot(device, 247); await settle(transport.acquire({ uuid, expectedProtocol: 'V2' })); harness.emitMonitorError(Object.assign(new Error('notify failed'), { reason })); await expect(settle(transport.acquire({ uuid, expectedProtocol: 'V2' }))).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number), refreshGatt: 'OnConnected', }); await settle(transport.release(uuid, true)); }); test('arms a GATT refresh from a notify failure while notifications are being enabled', async () => { const { transport, uuid, device } = createHarness({ monitorError: Object.assign(new Error('notify failed'), { reason: 'Cannot write client characteristic config descriptor', }), }); const link = reconnectingDevice(device); device.mtu = 23; negotiatedSnapshot(device, 247); await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error)); await settle(transport.release(uuid, true)); link.connected = false; await settle(transport.acquire({ uuid, expectedProtocol: 'V2' }).catch(error => error)); expect(device.connect).toHaveBeenLastCalledWith({ timeout: expect.any(Number), refreshGatt: 'OnConnected', }); await settle(transport.release(uuid, true)); }); test('keeps the GATT refresh for a firmware-install reconnect', async () => { const { transport, uuid, device } = createHarness(); (transport as any).sessionProtocols.set(uuid, 'V2'); reconnectingDevice(device); await expect( settle(transport.acquire({ uuid, expectedProtocol: 'V2', skipProtocolProbe: true })) ).resolves.toEqual({ uuid, protocolType: 'V2' }); expect(device.connect).toHaveBeenCalledWith({ timeout: expect.any(Number), refreshGatt: 'OnConnected', }); await settle(transport.release(uuid, true)); }); }); test('spends one Initialize wake on the detection after a fully silent one', async () => { setPlatformOS('android'); const { transport, uuid } = createHarness(); const probes = transport as any; jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false); jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false); const callProtocolV1 = jest.spyOn(probes, 'callProtocolV1').mockResolvedValue({}); // Nothing has gone silent yet, so the device keeps whatever session it has. await expect(transport.acquire({ uuid })).rejects.toBeDefined(); expect(callProtocolV1).not.toHaveBeenCalled(); // The previous detection answered on no protocol, which is what a sleeping Classic // looks like, so this one wakes on the fresh link before probing. await expect(transport.acquire({ uuid })).rejects.toBeDefined(); expect(callProtocolV1).toHaveBeenCalledTimes(1); expect(callProtocolV1.mock.calls[0]?.[1]).toBe('Initialize'); // Still silent: the wake is not repeated, so a device that is simply away is not // pushed into a fresh wallet session on every poll. callProtocolV1.mockClear(); await expect(transport.acquire({ uuid })).rejects.toBeDefined(); expect(callProtocolV1).not.toHaveBeenCalled(); }); test('does not send the Initialize wake before a V2-first probe', async () => { setPlatformOS('android'); const { transport, uuid } = createHarness(); const probes = transport as any; jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false); jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false); const callProtocolV1 = jest.spyOn(probes, 'callProtocolV1').mockResolvedValue({}); await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toBeDefined(); // Silent now, but the next detection probes V2 first: a late V1 reply would land on // the V2 probe, so no Initialize is sent. await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toBeDefined(); expect(callProtocolV1).not.toHaveBeenCalled(); }); test('does not send the Initialize wake on iOS', async () => { const { transport, uuid } = createHarness(); const probes = transport as any; jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false); jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false); const callProtocolV1 = jest.spyOn(probes, 'callProtocolV1').mockResolvedValue({}); await expect(transport.acquire({ uuid })).rejects.toBeDefined(); await expect(transport.acquire({ uuid })).rejects.toBeDefined(); expect(callProtocolV1).not.toHaveBeenCalled(); }); test('an unanswered Initialize wake settles the acquire and frees the lifecycle lock', async () => { setPlatformOS('android'); const harness = createHarness(); const { transport, uuid } = harness; const probes = transport as any; harness.setShouldRespond(false); jest.spyOn(probes, 'probeProtocolV1').mockResolvedValue(false); jest.spyOn(probes, 'probeProtocolV2').mockResolvedValue(false); await expect(transport.acquire({ uuid })).rejects.toBeDefined(); await expect(transport.acquire({ uuid })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleTimeoutError, }); await transport.disconnect(uuid); await transport.stop(); }, 10_000); test('accepts a stable low MTU without the delayed refresh loop', async () => { const { transport, uuid, device } = createHarness(); device.mtu = 185; await expect(transport.acquire({ uuid })).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.requestMTU).not.toHaveBeenCalled(); expect((transport as any).getCachedTransport(uuid).mtuSize).toBe(185); await transport.release(uuid, true); }); test('continues Protocol V2 probing with a conservative packet size when MTU is unavailable', async () => { const { transport, uuid, device, writeCharacteristic } = createHarness(); device.mtu = undefined; await expect(transport.acquire({ uuid })).resolves.toEqual({ uuid, protocolType: 'V2', }); expect(device.requestMTU).toHaveBeenCalledTimes(1); expect((transport as any).getCachedTransport(uuid).mtuSize).toBeUndefined(); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalled(); await transport.release(uuid, true); }); test('refreshes an unavailable MTU before a Protocol V2 high-volume write', async () => { const { transport, uuid, device, writeCharacteristic } = createHarness(); device.mtu = undefined; await transport.acquire({ uuid, expectedProtocol: 'V2' }); const writesBeforeFileWrite = writeCharacteristic.writeWithoutResponse.mock.calls.length; device.requestMTU.mockImplementationOnce(() => { device.mtu = 247; return Promise.resolve(device); }); await expect(transport.call(uuid, 'FileWrite', {})).resolves.toBeDefined(); expect(device.requestMTU).toHaveBeenCalledTimes(2); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes( writesBeforeFileWrite + 1 ); await transport.release(uuid, true); }); test('rejects a Protocol V2 high-volume write when MTU remains unavailable', async () => { const { transport, uuid, device, writeCharacteristic } = createHarness(); device.mtu = undefined; await transport.acquire({ uuid, expectedProtocol: 'V2' }); const writesBeforeFileWrite = writeCharacteristic.writeWithoutResponse.mock.calls.length; await expect(transport.call(uuid, 'FileWrite', {})).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleConnectedError, }); expect(device.requestMTU).toHaveBeenCalledTimes(2); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(writesBeforeFileWrite); await transport.release(uuid, true); }); test('reconnects before falling back to Protocol V1 after a fatal V2 probe failure', async () => { setPlatformOS('android'); const { transport, uuid, device, notifySubscriptionRemovers, disconnectSubscriptionRemovers } = createV1Harness(); const probeProtocolV2 = jest .spyOn(transport as any, 'probeProtocolV2') .mockImplementation(async () => { await (transport as any).releaseNative(uuid, true); return false; }); const resolveCharacteristics = jest.spyOn(transport as any, 'resolveCharacteristics'); await expect(transport.acquire({ uuid, protocolHint: 'V2' })).resolves.toEqual({ uuid, protocolType: 'V1', }); expect(probeProtocolV2).toHaveBeenCalledTimes(1); expect(resolveCharacteristics).toHaveBeenCalledTimes(2); expect(transport.getProtocolType(uuid)).toBe('V1'); expect(device.onDisconnected).toHaveBeenCalledTimes(1); expect(notifySubscriptionRemovers).toHaveLength(2); expect(notifySubscriptionRemovers[0]).toHaveBeenCalledTimes(1); await transport.release(uuid, true); expect(notifySubscriptionRemovers[1]).toHaveBeenCalledTimes(1); expect(disconnectSubscriptionRemovers).toHaveLength(1); expect(disconnectSubscriptionRemovers[0]).toHaveBeenCalledTimes(1); }); test('cleans the rebuilt transport when Protocol V1 fallback also fails', async () => { setPlatformOS('android'); const { transport, uuid, device, bleManager, notifySubscriptionRemovers } = createV1Harness(); jest.spyOn(transport as any, 'probeProtocolV2').mockImplementation(async () => { await (transport as any).releaseNative(uuid, true); return false; }); jest.spyOn(transport as any, 'probeProtocolV1').mockResolvedValue(false); await expect(transport.acquire({ uuid, protocolHint: 'V2' })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleTimeoutError, }); expect(device.onDisconnected).not.toHaveBeenCalled(); expect(notifySubscriptionRemovers).toHaveLength(2); expect(notifySubscriptionRemovers[0]).toHaveBeenCalledTimes(1); expect(notifySubscriptionRemovers[1]).toHaveBeenCalledTimes(1); expect(bleManager.cancelTransaction).toHaveBeenCalled(); expect(device.cancelConnection).toHaveBeenCalledTimes(1); expect(transport.getProtocolType(uuid)).toBeUndefined(); }); test('disconnects and invalidates a Protocol V1 link after a response timeout', async () => { const { transport, uuid, device } = createV1Harness({ respondOnWriteCount: Number.POSITIVE_INFINITY, }); await transport.acquire({ uuid, expectedProtocol: 'V1' }); await expect(transport.call(uuid, 'Initialize', {}, { timeoutMs: 5 })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleTimeoutError, }); expect(device.cancelConnection).toHaveBeenCalled(); expect(transport.getProtocolType(uuid)).toBeUndefined(); }); afterEach(() => { setPlatformOS('ios'); resetProtocolV2BleTuning(); }); test('uses the first Protocol V2 sequence for the first Core call when protocol is known', async () => { const { transport, uuid, sentSeqs } = createHarness(); await transport.acquire({ uuid, expectedProtocol: 'V2' }); await transport.call(uuid, 'Ping', { message: 'first-core-command' }); expect(sentSeqs).toEqual([1, 2]); await transport.release(uuid, true); }); test('rejects the active Protocol V2 reader when the current monitor errors', async () => { const harness = createHarness(); const { transport, uuid, sentSeqs } = harness; await transport.acquire({ uuid, expectedProtocol: 'V2' }); harness.setShouldRespond(false); const call = transport.call(uuid, 'Ping', { message: 'wait-for-monitor' }, { timeoutMs: 50 }); while (sentSeqs.length < 1) { await new Promise(resolve => { setTimeout(resolve, 0); }); } await new Promise(resolve => { setTimeout(resolve, 0); }); harness.emitMonitorError(Object.assign(new Error('monitor failed'), { reason: 'link lost' })); await expect(call).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleCharacteristicNotifyError, }); }); test.each(['ios', 'android'] as const)( 'disconnects after an active V2 timeout and reconnects even if a listener fails on %s', async platform => { setPlatformOS(platform); const harness = createHarness(); const { transport, uuid, device, bleManager, sentSeqs, emitter } = harness; const disconnected = jest.fn(() => { throw new Error('Disconnect listener failed'); }); emitter.on(TRANSPORT_EVENT.DEVICE_DISCONNECT, disconnected); let connected = true; device.isConnected.mockImplementation(() => Promise.resolve(connected)); device.connect = jest.fn(() => { connected = true; return Promise.resolve(device); }); bleManager.cancelDeviceConnection.mockImplementation(() => { connected = false; return Promise.resolve(); }); await transport.acquire({ uuid, expectedProtocol: 'V2' }); harness.setShouldRespond(false); await expect(transport.call(uuid, 'Ping', {}, { timeoutMs: 10 })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleTimeoutError, }); expect(bleManager.cancelDeviceConnection).toHaveBeenCalledWith(uuid); expect(connected).toBe(false); expect(transport.getProtocolType(uuid)).toBeUndefined(); expect(disconnected).toHaveBeenCalledTimes(1); expect(disconnected).toHaveBeenCalledWith({ id: uuid, connectId: uuid, name: device.name, }); harness.setShouldRespond(true); await transport.acquire({ uuid, expectedProtocol: 'V2' }); await expect(transport.call(uuid, 'Ping', {})).resolves.toMatchObject({ type: 'Success' }); expect(device.connect).toHaveBeenCalledTimes(1); expect(sentSeqs).toEqual([1, 2, 3, 4]); await transport.release(uuid, true); } ); test('does not disconnect an active Protocol V2 call when a queued call times out', async () => { const harness = createHarness(); const { transport, uuid, bleManager } = harness; await transport.acquire({ uuid, expectedProtocol: 'V2' }); harness.setShouldRespond(false); const active = transport.call(uuid, 'Ping', {}, { timeoutMs: 1000 }).catch(error => error); await new Promise(resolve => { setImmediate(resolve); }); try { await expect(transport.call(uuid, 'Ping', {}, { timeoutMs: 10 })).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleTimeoutError, }); expect(bleManager.cancelDeviceConnection).not.toHaveBeenCalled(); expect(transport.getProtocolType(uuid)).toBe('V2'); } finally { await transport.disconnect(uuid); await active; } }); test('does not disconnect a newer acquire when V2 timeout cleanup waits for the lifecycle lock', async () => { const harness = createHarness(); const { transport, uuid, bleManager } = harness; await transport.acquire({ uuid, expectedProtocol: 'V2' }); const lifecycle = transport as unknown as { runLifecycleOperation(id: string, operation: () => Promise): Promise; releaseNative(id: string, onclose: boolean): Promise; }; const gate = createDeferred(); const holding = lifecycle.runLifecycleOperation(uuid, () => gate.promise); const reacquiring = transport.acquire({ uuid, expectedProtocol: 'V2' }); const invalidated = createDeferred(); const releaseNative = lifecycle.releaseNative.bind(lifecycle); jest.spyOn(lifecycle, 'releaseNative').mockImplementationOnce(async (id, onclose) => { const result = await releaseNative(id, onclose); invalidated.resolve(); return result; }); harness.setShouldRespond(false); const failure = expect( transport.call(uuid, 'Ping', {}, { timeoutMs: 10 }) ).rejects.toMatchObject({ errorCode: HardwareErrorCode.BleTimeoutError, }); try { await invalidated.promise; harness.setShouldRespond(true); gate.resolve(); await holding; await reacquiring; await failure; expect(bleManager.cancelDeviceConnection).not.toHaveBeenCalled(); await expect(transport.call(uuid, 'Ping', {})).resolves.toMatchObject({ type: 'Success' }); } finally { gate.resolve(); await Promise.allSettled([holding, reacquiring, failure]); await transport.release(uuid, true); } }); test('retains the sequence cursor when a new monitor generation is acquired', async () => { const { transport, uuid, sentSeqs } = createHarness(); await transport.acquire({ uuid, expectedProtocol: 'V2' }); await transport.call(uuid, 'Ping', { message: 'first-generation' }); await transport.release(uuid, true); await transport.acquire({ uuid, expectedProtocol: 'V2' }); await transport.call(uuid, 'Ping', { message: 'second-generation' }); expect(sentSeqs).toEqual([1, 2, 3, 4]); await transport.release(uuid, true); }); test('uses withoutResponse for consecutive iOS Protocol V2 control calls without releasing', async () => { const { transport, uuid, writeCharacteristic } = createHarness(); await transport.acquire({ uuid, expectedProtocol: 'V2' }); const releaseNative = jest.spyOn(transport as any, 'releaseNative'); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1); expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); await transport.call(uuid, 'DeviceInfoGet', {}); await transport.call(uuid, 'ProtocolInfoRequest', {}); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(3); expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); expect(releaseNative).not.toHaveBeenCalled(); await transport.release(uuid, true); }); test('keeps iOS Protocol V2 high-volume calls on withoutResponse', async () => { const { transport, uuid, logger, writeCharacteristic } = createHarness(); await transport.acquire({ uuid, expectedProtocol: 'V2' }); await transport.call(uuid, 'FileWrite', {}); await transport.call(uuid, 'FileWrite', {}); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(3); expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); expect( logger.debug.mock.calls.filter( ([message]) => message === '[ReactNativeBleTransport] Protocol V2 high-volume write configured' ) ).toHaveLength(1); await transport.release(uuid, true); }); test('uses Android 517 MTU and high connection priority during Protocol V2 high-volume calls', async () => { setPlatformOS('android'); const { transport, uuid, device } = createHarness(); device.mtu = 23; device.requestMTU.mockImplementationOnce(() => { device.mtu = 517; return Promise.resolve(device); }); await transport.acquire({ uuid, expectedProtocol: 'V2' }); expect(device.requestMTU).toHaveBeenCalledWith( 517, expect.stringContaining(`${uuid}:mtu:connected:0:`) ); await transport.call(uuid, 'FileWrite', {}); await transport.call(uuid, 'FileWrite', {}); expect(device.requestConnectionPriority).toHaveBeenCalledTimes(1); expect(device.requestConnectionPriority).toHaveBeenCalledWith(1); await transport.release(uuid, true); expect(device.requestConnectionPriority).toHaveBeenLastCalledWith(0); }); test('uses withResponse for an iOS Protocol V2 firmware file write when requested', async () => { const { transport, uuid, writeCharacteristic } = createHarness(); await transport.acquire({ uuid, expectedProtocol: 'V2' }); await transport.call(uuid, 'FileWrite', {}, { writeWithResponse: true }); expect(writeCharacteristic.writeWithResponse).toHaveBeenCalledTimes(1); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(1); await transport.release(uuid, true); }); test('falls back to withoutResponse for an iOS Protocol V2 control call when required', async () => { const { transport, uuid, writeCharacteristic } = createHarness({ isWritableWithResponse: false, }); await transport.acquire({ uuid, expectedProtocol: 'V2' }); await transport.call(uuid, 'ProtocolInfoRequest', {}); expect(writeCharacteristic.writeWithResponse).not.toHaveBeenCalled(); expect(writeCharacteristic.writeWithoutResponse).toHaveBeenCalledTimes(2); await transport.release(uuid, true); }); test('does not resend a failed iOS Protocol V2 control write without response', async () => { const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any; const writeError = new Error('write with response failed'); const writeWithResponse = jest.fn().mockRejectedValue(writeError); const writeWithoutResponse = jest.fn().mockResolvedValue(undefined); const context = { messageName: 'ProtocolInfoRequest', timeoutMs: 1000, highThroughput: false, generation: 1, signal: new AbortController().signal, }; await expect( transport.writeProtocolV2Packet( 'test-device', { writeCharacteristic: { isWritableWithResponse: true, writeWithResponse, writeWithoutResponse, }, }, Buffer.from('control').toString('base64'), context, jest.fn() ) ).rejects.toBe(writeError); expect(writeWithResponse).toHaveBeenCalledTimes(1); expect(writeWithoutResponse).not.toHaveBeenCalled(); }); test('does not pace a one-packet Protocol V2 control write on iOS', async () => { const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any; const writeWithoutResponse = jest.fn().mockResolvedValue(undefined); const bleTransport = { mtuSize: 23, writeCharacteristic: { writeWithoutResponse }, }; const context = { messageName: 'ProtocolInfoRequest', timeoutMs: 1000, highThroughput: false, generation: 1, signal: new AbortController().signal, }; configureProtocolV2BleTuning({ iosPacketLength: 20 }); const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); try { const call = transport.writeProtocolV2Frame( 'test-device', bleTransport, new Uint8Array(10), context, jest.fn() ); await call; // The only scheduled timer is the per-packet BLE write watchdog: no pacing delay. expect(setTimeoutSpy.mock.calls.map(([, timeout]) => timeout)).toEqual([ BLE_WRITE_PACKET_TIMEOUT_MS, ]); expect(writeWithoutResponse).toHaveBeenCalledTimes(1); } finally { setTimeoutSpy.mockRestore(); } }); test('rejects an active Protocol V2 reader when disconnect resets the link', async () => { const harness = createHarness(); const { transport, uuid, sentSeqs } = harness; await transport.acquire({ uuid, expectedProtocol: 'V2' }); harness.setShouldRespond(false); const call = transport.call(uuid, 'Ping', { message: 'disconnect' }, { timeoutMs: 50 }); while (sentSeqs.length < 1) { await new Promise(resolve => { setTimeout(resolve, 0); }); } const rejection = expect(call).rejects.toThrow('React Native BLE transport disconnected'); await transport.disconnect(uuid); await rejection; }); test('emits one disconnect event when the physical callback races manual cleanup', async () => { const harness = createHarness(); const disconnectListener = jest.fn(); harness.emitter.on(TRANSPORT_EVENT.DEVICE_DISCONNECT, disconnectListener); await harness.transport.acquire({ uuid: harness.uuid, expectedProtocol: 'V2' }); harness.emitDisconnect(); await harness.transport.disconnect(harness.uuid); expect(disconnectListener).toHaveBeenCalledTimes(1); expect(disconnectListener).toHaveBeenCalledWith({ name: 'OneKey Pro 2', id: harness.uuid, connectId: harness.uuid, }); }); test('chunks large frames and retries only transient GATT congestion', async () => { const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any; const congested = { status: 143, message: 'GATT_CONGESTED' }; const writeWithoutResponse = jest .fn() .mockRejectedValueOnce(congested) .mockResolvedValue(undefined); const bleTransport = { mtuSize: 23, writeCharacteristic: { writeWithoutResponse }, }; const context = { messageName: 'Ping', timeoutMs: 1000, highThroughput: false, generation: 1, signal: new AbortController().signal, }; const assertCurrentGeneration = jest.fn(); configureProtocolV2BleTuning({ iosPacketLength: 20 }); await transport.writeProtocolV2Frame( 'device-uuid', bleTransport, new Uint8Array(30), context, assertCurrentGeneration ); expect(writeWithoutResponse).toHaveBeenCalledTimes(3); expect(writeWithoutResponse.mock.calls[0][0]).toBe(writeWithoutResponse.mock.calls[1][0]); expect(Buffer.from(writeWithoutResponse.mock.calls[2][0], 'base64')).toHaveLength(10); expect(assertCurrentGeneration).toHaveBeenCalled(); }); test('does not retry a disconnected Protocol V2 write inside a partial frame', async () => { const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any; const writeWithoutResponse = jest .fn() .mockRejectedValue({ errorCode: 205, message: 'Device disconnected' }); const bleTransport = { mtuSize: 23, writeCharacteristic: { writeWithoutResponse }, }; const context = { messageName: 'FileWrite', timeoutMs: 1000, highThroughput: true, generation: 1, signal: new AbortController().signal, }; configureProtocolV2BleTuning({ iosPacketLength: 20 }); await expect( transport.writeProtocolV2Frame( 'device-uuid', bleTransport, new Uint8Array(30), context, jest.fn() ) ).rejects.toMatchObject({ errorCode: 205 }); expect(writeWithoutResponse).toHaveBeenCalledTimes(1); }); test('does not apply fixed burst or flush pauses to high-volume Protocol V2 writes', async () => { const transport = new ReactNativeBleTransport({ scanTimeout: 1 }) as any; const writeWithoutResponse = jest.fn().mockResolvedValue(undefined); const bleTransport = { mtuSize: 247, writeCharacteristic: { writeWithoutResponse }, }; const context = { messageName: 'FileWrite', timeoutMs: 1000, highThroughput: true, generation: 1, signal: new AbortController().signal, }; const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); try { await transport.writeProtocolV2Frame( 'test-device', bleTransport, new Uint8Array(600), context, jest.fn() ); expect(writeWithoutResponse).toHaveBeenCalledTimes(3); // One BLE write watchdog per packet and nothing else: no burst or flush pauses. expect(setTimeoutSpy.mock.calls.map(([, timeout]) => timeout)).toEqual([ BLE_WRITE_PACKET_TIMEOUT_MS, BLE_WRITE_PACKET_TIMEOUT_MS, BLE_WRITE_PACKET_TIMEOUT_MS, ]); } finally { setTimeoutSpy.mockRestore(); } }); });