1import spawnAsync from '@expo/spawn-async';
2import chalk from 'chalk';
3import { spawn } from 'child_process';
4import os from 'os';
5
6import * as Log from '../../../log';
7import { AbortCommandError } from '../../../utils/errors';
8import { installExitHooks } from '../../../utils/exit';
9import { Device, getAttachedDevicesAsync, isBootAnimationCompleteAsync } from './adb';
10
11export const EMULATOR_MAX_WAIT_TIMEOUT = 60 * 1000 * 3;
12
13export function whichEmulator(): string {
14  // https://developer.android.com/studio/command-line/variables
15  // TODO: Add ANDROID_SDK_ROOT support as well https://github.com/expo/expo/pull/16516#discussion_r820037917
16  if (process.env.ANDROID_HOME) {
17    return `${process.env.ANDROID_HOME}/emulator/emulator`;
18  }
19  return 'emulator';
20}
21
22/** Returns a list of emulator names. */
23export async function listAvdsAsync(): Promise<Device[]> {
24  try {
25    const { stdout } = await spawnAsync(whichEmulator(), ['-list-avds']);
26    return stdout
27      .split(os.EOL)
28      .filter(Boolean)
29      .map((name) => ({
30        name,
31        type: 'emulator',
32        // unsure from this
33        isBooted: false,
34        isAuthorized: true,
35      }));
36  } catch {
37    return [];
38  }
39}
40
41/** Start an Android device and wait until it is booted. */
42export async function startDeviceAsync(
43  device: Pick<Device, 'name'>,
44  {
45    timeout = EMULATOR_MAX_WAIT_TIMEOUT,
46    interval = 1000,
47  }: {
48    /** Time in milliseconds to wait before asserting a timeout error. */
49    timeout?: number;
50    interval?: number;
51  } = {}
52): Promise<Device> {
53  Log.log(`\u203A Opening emulator ${chalk.bold(device.name)}`);
54
55  // Start a process to open an emulator
56  const emulatorProcess = spawn(
57    whichEmulator(),
58    [
59      `@${device.name}`,
60      // disable animation for faster boot -- this might make it harder to detect if it mounted properly tho
61      //'-no-boot-anim'
62    ],
63    {
64      stdio: 'ignore',
65      detached: true,
66    }
67  );
68
69  emulatorProcess.unref();
70
71  return new Promise<Device>((resolve, reject) => {
72    const waitTimer = setInterval(async () => {
73      try {
74        const bootedDevices = await getAttachedDevicesAsync();
75        const connected = bootedDevices.find(({ name }) => name === device.name);
76        if (connected) {
77          const isBooted = await isBootAnimationCompleteAsync(connected.pid);
78          if (isBooted) {
79            stopWaiting();
80            resolve(connected);
81          }
82        }
83      } catch (error) {
84        stopWaiting();
85        reject(error);
86      }
87    }, interval);
88
89    // Reject command after timeout
90    const maxTimer = setTimeout(() => {
91      const manualCommand = `${whichEmulator()} @${device.name}`;
92      stopWaitingAndReject(
93        `It took too long to start the Android emulator: ${device.name}. You can try starting the emulator manually from the terminal with: ${manualCommand}`
94      );
95    }, timeout);
96
97    const stopWaiting = () => {
98      clearTimeout(maxTimer);
99      clearInterval(waitTimer);
100      removeExitHook();
101    };
102
103    const stopWaitingAndReject = (message: string) => {
104      stopWaiting();
105      reject(new Error(message));
106    };
107
108    const removeExitHook = installExitHooks((signal) => {
109      stopWaiting();
110      emulatorProcess.kill(signal);
111      reject(new AbortCommandError());
112    });
113
114    emulatorProcess.on('error', ({ message }) => stopWaitingAndReject(message));
115
116    emulatorProcess.on('exit', () => {
117      const manualCommand = `${whichEmulator()} @${device.name}`;
118      stopWaitingAndReject(
119        `The emulator (${device.name}) quit before it finished opening. You can try starting the emulator manually from the terminal with: ${manualCommand}`
120      );
121    });
122  });
123}
124