diff --git a/docs/commands.md b/docs/commands.md index a66d018d3..d6901cfc1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -380,6 +380,11 @@ Example: `yarn react-native run-android --tasks clean,installDebug`. Build native libraries only for the current device architecture for debug builds. +#### `--list-devices` + +> default: false + +List all available Android devices and simulators and let you choose one to run the app. ### `build-android` Usage: diff --git a/packages/cli-platform-android/src/commands/runAndroid/index.ts b/packages/cli-platform-android/src/commands/runAndroid/index.ts index 40d1a5c00..a582a7b4b 100644 --- a/packages/cli-platform-android/src/commands/runAndroid/index.ts +++ b/packages/cli-platform-android/src/commands/runAndroid/index.ts @@ -15,6 +15,9 @@ import tryLaunchAppOnDevice from './tryLaunchAppOnDevice'; import getAdbPath from './getAdbPath'; import {logger, CLIError} from '@react-native-community/cli-tools'; import {getAndroidProject} from '../../config/getAndroidProject'; +import listAndroidDevices from './listAndroidDevices'; +import tryLaunchEmulator from './tryLaunchEmulator'; +import chalk from 'chalk'; import {build, runPackager, BuildFlags, options} from '../buildAndroid'; export interface Flags extends BuildFlags { @@ -22,6 +25,7 @@ export interface Flags extends BuildFlags { appIdSuffix: string; mainActivity: string; deviceId?: string; + listDevices?: boolean; } type AndroidProject = NonNullable; @@ -36,12 +40,68 @@ async function runAndroid(_argv: Array, config: Config, args: Flags) { return buildAndRun(args, androidProject); } +const defaultPort = 5552; +async function getAvailableDevicePort( + port: number = defaultPort, +): Promise { + /** + * The default value is 5554 for the first virtual device instance running on your machine. A virtual device normally occupies a pair of adjacent ports: a console port and an adb port. The console of the first virtual device running on a particular machine uses console port 5554 and adb port 5555. Subsequent instances use port numbers increasing by two. For example, 5556/5557, 5558/5559, and so on. The range is 5554 to 5682, allowing for 64 concurrent virtual devices. + */ + const adbPath = getAdbPath(); + const devices = adb.getDevices(adbPath); + if (port > 5682) { + throw new CLIError('Failed to launch emulator...'); + } + if (devices.some((d) => d.includes(port.toString()))) { + return await getAvailableDevicePort(port + 2); + } + return port; +} + // Builds the app and runs it on a connected emulator / device. -function buildAndRun(args: Flags, androidProject: AndroidProject) { +async function buildAndRun(args: Flags, androidProject: AndroidProject) { process.chdir(androidProject.sourceDir); const cmd = process.platform.startsWith('win') ? 'gradlew.bat' : './gradlew'; const adbPath = getAdbPath(); + if (args.listDevices) { + if (args.deviceId) { + logger.warn( + 'Both "deviceId" and "list-devices" parameters were passed to "run" command. We will list available devices and let you choose from one', + ); + } + + const device = await listAndroidDevices(); + if (!device) { + throw new CLIError( + 'Failed to select device, please try to run app without "list-devices" command.', + ); + } + + if (device.connected) { + return runOnSpecificDevice( + {...args, deviceId: device.deviceId}, + adbPath, + androidProject, + ); + } + + const port = await getAvailableDevicePort(); + const emulator = `emulator-${port}`; + logger.info('Launching emulator...'); + const result = await tryLaunchEmulator(adbPath, device.readableName, port); + if (result.success) { + logger.info('Successfully launched emulator.'); + return runOnSpecificDevice( + {...args, deviceId: emulator}, + adbPath, + androidProject, + ); + } + throw new CLIError( + `Failed to launch emulator. Reason: ${chalk.dim(result.error || '')}`, + ); + } if (args.deviceId) { return runOnSpecificDevice(args, adbPath, androidProject); } else { @@ -178,5 +238,11 @@ export default { 'builds your app and starts it on a specific device/simulator with the ' + 'given device id (listed by running "adb devices" on the command line).', }, + { + name: '--list-devices', + description: + 'Lists all available Android devices and simulators and let you choose one to run the app', + default: false, + }, ], }; diff --git a/packages/cli-platform-android/src/commands/runAndroid/listAndroidDevices.ts b/packages/cli-platform-android/src/commands/runAndroid/listAndroidDevices.ts new file mode 100644 index 000000000..74a2c0c6e --- /dev/null +++ b/packages/cli-platform-android/src/commands/runAndroid/listAndroidDevices.ts @@ -0,0 +1,125 @@ +import {execSync} from 'child_process'; +import adb from './adb'; +import getAdbPath from './getAdbPath'; +import {getEmulators} from './tryLaunchEmulator'; +import os from 'os'; +import prompts from 'prompts'; +import chalk from 'chalk'; +import {CLIError} from '@react-native-community/cli-tools'; + +type DeviceData = { + deviceId: string | undefined; + readableName: string; + connected: boolean; + type: 'emulator' | 'phone'; +}; + +function toPascalCase(value: string) { + return value !== '' ? value[0].toUpperCase() + value.slice(1) : value; +} + +/** + * + * @param deviceId string + * @returns name of Android emulator + */ +function getEmulatorName(deviceId: string) { + const adbPath = getAdbPath(); + const buffer = execSync(`${adbPath} -s ${deviceId} emu avd name`); + + // 1st line should get us emu name + return buffer + .toString() + .split(os.EOL)[0] + .replace(/(\r\n|\n|\r)/gm, '') + .trim(); +} + +/** + * + * @param deviceId string + * @returns Android device name in readable format + */ +function getPhoneName(deviceId: string) { + const adbPath = getAdbPath(); + const buffer = execSync( + `${adbPath} -s ${deviceId} shell getprop | grep ro.product.model`, + ); + return buffer + .toString() + .replace(/\[ro\.product\.model\]:\s*\[(.*)\]/, '$1') + .trim(); +} + +async function promptForDeviceSelection( + allDevices: Array, +): Promise { + if (!allDevices.length) { + throw new CLIError( + 'No devices and/or emulators connected. Please create emulator with Android Studio or connect Android device.', + ); + } + const {device} = await prompts({ + type: 'select', + name: 'device', + message: 'Select the device / emulator you want to use', + choices: allDevices.map((d) => ({ + title: `${chalk.bold(`${toPascalCase(d.type)}`)} ${chalk.green( + `${d.readableName}`, + )} (${d.connected ? 'connected' : 'disconnected'})`, + value: d, + })), + min: 1, + }); + + return device; +} + +async function listAndroidDevices() { + const adbPath = getAdbPath(); + const devices = adb.getDevices(adbPath); + + let allDevices: Array = []; + + devices.forEach((deviceId) => { + if (deviceId.includes('emulator')) { + const emulatorData: DeviceData = { + deviceId, + readableName: getEmulatorName(deviceId), + connected: true, + type: 'emulator', + }; + allDevices = [...allDevices, emulatorData]; + } else { + const phoneData: DeviceData = { + deviceId, + readableName: getPhoneName(deviceId), + type: 'phone', + connected: true, + }; + allDevices = [...allDevices, phoneData]; + } + }); + + const emulators = getEmulators(); + + // Find not booted ones: + emulators.forEach((emulatorName) => { + // skip those already booted + if (allDevices.some((device) => device.readableName === emulatorName)) { + return; + } + const emulatorData: DeviceData = { + deviceId: undefined, + readableName: emulatorName, + type: 'emulator', + connected: false, + }; + allDevices = [...allDevices, emulatorData]; + }); + + const selectedDevice = await promptForDeviceSelection(allDevices); + return selectedDevice; +} + +export default listAndroidDevices; diff --git a/packages/cli-platform-android/src/commands/runAndroid/tryLaunchEmulator.ts b/packages/cli-platform-android/src/commands/runAndroid/tryLaunchEmulator.ts index 7f257f72a..ba8a41b8b 100644 --- a/packages/cli-platform-android/src/commands/runAndroid/tryLaunchEmulator.ts +++ b/packages/cli-platform-android/src/commands/runAndroid/tryLaunchEmulator.ts @@ -1,12 +1,12 @@ import os from 'os'; import execa from 'execa'; -import Adb from './adb'; +import adb from './adb'; const emulatorCommand = process.env.ANDROID_HOME ? `${process.env.ANDROID_HOME}/emulator/emulator` : 'emulator'; -const getEmulators = () => { +export const getEmulators = () => { try { const emulatorsOutput = execa.sync(emulatorCommand, ['-list-avds']).stdout; return emulatorsOutput.split(os.EOL).filter((name) => name !== ''); @@ -15,55 +15,75 @@ const getEmulators = () => { } }; -const launchEmulator = async (emulatorName: string, adbPath: string) => { - return new Promise((resolve, reject) => { - const cp = execa(emulatorCommand, [`@${emulatorName}`], { +const launchEmulator = async ( + emulatorName: string, + adbPath: string, + port?: number, +): Promise => { + const manualCommand = `${emulatorCommand} @${emulatorName}`; + + const cp = execa( + emulatorCommand, + [`@${emulatorName}`, port ? '-port' : '', port ? `${port}` : ''], + { detached: true, stdio: 'ignore', - }); - cp.unref(); - const timeout = 30; + }, + ); + cp.unref(); + const timeout = 30; - // Reject command after timeout - const rejectTimeout = setTimeout(() => { - cleanup(); - reject(`Could not start emulator within ${timeout} seconds.`); - }, timeout * 1000); - - const bootCheckInterval = setInterval(() => { - if (Adb.getDevices(adbPath).length > 0) { + return new Promise((resolve, reject) => { + const bootCheckInterval = setInterval(async () => { + const devices = adb.getDevices(adbPath); + const connected = port + ? devices.find((d) => d.includes(`${port}`)) + : devices.length > 0; + if (connected) { cleanup(); - resolve(); + resolve(true); } }, 1000); + // Reject command after timeout + const rejectTimeout = setTimeout(() => { + stopWaitingAndReject( + `It took too long to start and connect with Android emulator: ${emulatorName}. You can try starting the emulator manually from the terminal with: ${manualCommand}`, + ); + }, timeout * 1000); + const cleanup = () => { clearTimeout(rejectTimeout); clearInterval(bootCheckInterval); }; - cp.on('exit', () => { + const stopWaitingAndReject = (message: string) => { cleanup(); - reject('Emulator exited before boot.'); - }); + reject(new Error(message)); + }; - cp.on('error', (error) => { - cleanup(); - reject(error.message); + cp.on('error', ({message}) => stopWaitingAndReject(message)); + + cp.on('exit', () => { + stopWaitingAndReject( + `The emulator (${emulatorName}) quit before it finished opening. You can try starting the emulator manually from the terminal with: ${manualCommand}`, + ); }); }); }; export default async function tryLaunchEmulator( adbPath: string, + emulatorName?: string, + port?: number, ): Promise<{success: boolean; error?: string}> { const emulators = getEmulators(); if (emulators.length > 0) { try { - await launchEmulator(emulators[0], adbPath); + await launchEmulator(emulatorName ?? emulators[0], adbPath, port); return {success: true}; } catch (error) { - return {success: false, error}; + return {success: false, error: error?.message}; } } return {