diff --git a/src/android/utils/__tests__/run.spec.ts b/src/android/utils/__tests__/run.spec.ts new file mode 100644 index 0000000..c4ece7b --- /dev/null +++ b/src/android/utils/__tests__/run.spec.ts @@ -0,0 +1,113 @@ +import type { Device } from '../adb'; +import { isLikelyEmulator, findAvailableEmulatorPort } from '../run'; + +describe('android/utils/run', () => { + describe('isLikelyEmulator', () => { + it('detects emulator by serial', () => { + const d = { + serial: 'emulator-5554', + state: 'device', + type: 'hardware', + connection: null, + properties: {}, + manufacturer: '', + model: '', + product: '', + sdkVersion: '', + } as Device; + expect(isLikelyEmulator(d)).toBe(true); + }); + + it('detects emulator by type', () => { + const d = { + serial: 'device-1', + state: 'device', + type: 'emulator', + connection: null, + properties: {}, + manufacturer: '', + model: '', + product: '', + sdkVersion: '', + } as Device; + expect(isLikelyEmulator(d)).toBe(true); + }); + + it('detects emulator by properties.device', () => { + const d = { + serial: 'device-2', + state: 'device', + type: 'hardware', + connection: null, + properties: { device: 'emu64a' }, + manufacturer: '', + model: '', + product: '', + sdkVersion: '', + } as Device; + expect(isLikelyEmulator(d)).toBe(true); + }); + + it('detects emulator by properties.product', () => { + const d = { + serial: 'device-3', + state: 'device', + type: 'hardware', + connection: null, + properties: { product: 'sdk_gphone_arm64' }, + manufacturer: '', + model: '', + product: '', + sdkVersion: '', + } as Device; + expect(isLikelyEmulator(d)).toBe(true); + }); + + it('returns false for real hardware', () => { + const d = { + serial: '0123456789', + state: 'device', + type: 'hardware', + connection: null, + properties: { product: 'samsung' }, + manufacturer: '', + model: 'SM-G', + product: '', + sdkVersion: '', + } as Device; + expect(isLikelyEmulator(d)).toBe(false); + }); + }); + + describe('findAvailableEmulatorPort', () => { + it('picks first free even port', async () => { + const devices: Device[] = [ + { serial: 'emulator-5554', type: 'emulator', properties: {}, model: '' } as Device, + { serial: 'emulator-5556', type: 'emulator', properties: {}, model: '' } as Device, + ]; + + const port = await findAvailableEmulatorPort(devices, 5554, 5560); + expect(port).toBe(5558); + }); + + it('falls back to 5554 when none found in range', async () => { + // mark the only port in the range as already used + const devices: Device[] = [ + { + serial: 'emulator-6000', + state: 'device', + type: 'emulator', + connection: null, + properties: {}, + manufacturer: '', + model: '', + product: '', + sdkVersion: '', + } as Device, + ]; + + const port = await findAvailableEmulatorPort(devices, 6000, 6000); + expect(port).toBe(5554); + }); + }); +}); diff --git a/src/android/utils/run.ts b/src/android/utils/run.ts index 50c0dc7..72d4be0 100644 --- a/src/android/utils/run.ts +++ b/src/android/utils/run.ts @@ -17,6 +17,59 @@ import type { SDK } from './sdk'; const modulePrefix = 'native-run:android:utils:run'; +export async function findAvailableEmulatorPort(devices: readonly Device[], start = 5554, end = 5584): Promise { + const debug = Debug(`${modulePrefix}:${findAvailableEmulatorPort.name}`); + const usedPorts = new Set(); + + for (const d of devices) { + const m = d.serial.match(/^emulator-(\d+)$/); + if (m) { + usedPorts.add(Number(m[1])); + } + } + + for (let port = start; port <= end; port += 2) { + if (!usedPorts.has(port)) { + debug('Available emulator port found: %d', port); + return port; + } + } + + debug('No available emulator ports found in range %d-%d; defaulting to 5554', start, end); + return 5554; +} + +export function isLikelyEmulator(device: Device): boolean { + const serialEmu = /^emulator-(\d+)$/; + + if (serialEmu.test(device.serial)) { + return true; + } + + if (device.type === 'emulator') { + return true; + } + + const props = device.properties || {}; + const deviceProp = (props['device'] || '').toLowerCase(); + const productProp = (props['product'] || '').toLowerCase(); + const model = (device.model || '').toLowerCase(); + + if (deviceProp.startsWith('emu') || deviceProp.includes('generic')) { + return true; + } + + if (productProp.includes('sdk_gphone') || productProp.includes('google_sdk')) { + return true; + } + + if (model.includes('android_sdk') || model.includes('sdk_gphone')) { + return true; + } + + return false; +} + export async function selectDeviceByTarget( sdk: SDK, devices: readonly Device[], @@ -34,7 +87,7 @@ export async function selectDeviceByTarget( return device; } - const emulatorDevices = devices.filter((d) => d.type === 'emulator'); + const emulatorDevices = devices.filter(isLikelyEmulator); const pairAVD = async (emulator: Device): Promise<[Device, AVD | undefined]> => { let avd: AVD | undefined; @@ -65,7 +118,9 @@ export async function selectDeviceByTarget( if (avd) { debug('AVD found by ID: %s', avd.id); - const device = await runEmulator(sdk, avd, 5554); // TODO: 5554 will not always be available at this point + const port = await findAvailableEmulatorPort(devices); + debug('Using emulator port: %d', port); + const device = await runEmulator(sdk, avd, port); debug('Emulator ready, running avd: %s on %s', avd.id, device.serial); return device;