18d307f52SEvan Baconimport chalk from 'chalk';
28d307f52SEvan Baconimport os from 'os';
38d307f52SEvan Bacon
4*8a424bebSJames Ideimport { ADBServer } from './ADBServer';
58d307f52SEvan Baconimport * as Log from '../../../log';
68d307f52SEvan Baconimport { CommandError } from '../../../utils/errors';
78d307f52SEvan Baconimport { learnMore } from '../../../utils/link';
88d307f52SEvan Bacon
9474a7a4bSEvan Baconconst debug = require('debug')('expo:start:platforms:android:adb') as typeof console.log;
10474a7a4bSEvan Bacon
113d6e487dSEvan Baconexport enum DeviceABI {
123d6e487dSEvan Bacon  // The arch specific android target platforms are soft-deprecated.
133d6e487dSEvan Bacon  // Instead of using TargetPlatform as a combination arch + platform
143d6e487dSEvan Bacon  // the code will be updated to carry arch information in [DarwinArch]
153d6e487dSEvan Bacon  // and [AndroidArch].
163d6e487dSEvan Bacon  arm = 'arm',
173d6e487dSEvan Bacon  arm64 = 'arm64',
183d6e487dSEvan Bacon  x64 = 'x64',
193d6e487dSEvan Bacon  x86 = 'x86',
203d6e487dSEvan Bacon  armeabiV7a = 'armeabi-v7a',
213d6e487dSEvan Bacon  armeabi = 'armeabi',
223d6e487dSEvan Bacon  universal = 'universal',
233d6e487dSEvan Bacon}
243d6e487dSEvan Bacon
258d307f52SEvan Bacon/** Represents a connected Android device. */
268d307f52SEvan Baconexport type Device = {
278d307f52SEvan Bacon  /** Process ID. */
288d307f52SEvan Bacon  pid?: string;
298d307f52SEvan Bacon  /** Name of the device, also used as the ID for opening devices. */
308d307f52SEvan Bacon  name: string;
318d307f52SEvan Bacon  /** Is emulator or connected device. */
328d307f52SEvan Bacon  type: 'emulator' | 'device';
338d307f52SEvan Bacon  /** Is the device booted (emulator). */
348d307f52SEvan Bacon  isBooted: boolean;
358d307f52SEvan Bacon  /** Is device authorized for developing. https://expo.fyi/authorize-android-device */
368d307f52SEvan Bacon  isAuthorized: boolean;
378d307f52SEvan Bacon};
388d307f52SEvan Bacon
398d307f52SEvan Bacontype DeviceContext = Pick<Device, 'pid'>;
408d307f52SEvan Bacon
418d307f52SEvan Bacontype DeviceProperties = Record<string, string>;
428d307f52SEvan Bacon
438d307f52SEvan Baconconst CANT_START_ACTIVITY_ERROR = 'Activity not started, unable to resolve Intent';
443d6e487dSEvan Bacon// http://developer.android.com/ndk/guides/abis.html
453d6e487dSEvan Baconconst PROP_CPU_NAME = 'ro.product.cpu.abi';
463d6e487dSEvan Bacon
473d6e487dSEvan Baconconst PROP_CPU_ABI_LIST_NAME = 'ro.product.cpu.abilist';
483d6e487dSEvan Bacon
493d6e487dSEvan Bacon// Can sometimes be null
503d6e487dSEvan Bacon// http://developer.android.com/ndk/guides/abis.html
513d6e487dSEvan Baconconst PROP_BOOT_ANIMATION_STATE = 'init.svc.bootanim';
528d307f52SEvan Bacon
538d307f52SEvan Baconlet _server: ADBServer | null;
548d307f52SEvan Bacon
558d307f52SEvan Bacon/** Return the lazily loaded ADB server instance. */
568d307f52SEvan Baconexport function getServer() {
578d307f52SEvan Bacon  _server ??= new ADBServer();
588d307f52SEvan Bacon  return _server;
598d307f52SEvan Bacon}
608d307f52SEvan Bacon
618d307f52SEvan Bacon/** Logs an FYI message about authorizing your device. */
628d307f52SEvan Baconexport function logUnauthorized(device: Device) {
638d307f52SEvan Bacon  Log.warn(
648d307f52SEvan Bacon    `\nThis computer is not authorized for developing on ${chalk.bold(device.name)}. ${chalk.dim(
658d307f52SEvan Bacon      learnMore('https://expo.fyi/authorize-android-device')
668d307f52SEvan Bacon    )}`
678d307f52SEvan Bacon  );
688d307f52SEvan Bacon}
698d307f52SEvan Bacon
708d307f52SEvan Bacon/** Returns true if the provided package name is installed on the provided Android device. */
718d307f52SEvan Baconexport async function isPackageInstalledAsync(
728d307f52SEvan Bacon  device: DeviceContext,
738d307f52SEvan Bacon  androidPackage: string
748d307f52SEvan Bacon): Promise<boolean> {
758d307f52SEvan Bacon  const packages = await getServer().runAsync(
768d307f52SEvan Bacon    adbArgs(device.pid, 'shell', 'pm', 'list', 'packages', androidPackage)
778d307f52SEvan Bacon  );
788d307f52SEvan Bacon
798d307f52SEvan Bacon  const lines = packages.split(/\r?\n/);
808d307f52SEvan Bacon  for (let i = 0; i < lines.length; i++) {
818d307f52SEvan Bacon    const line = lines[i].trim();
828d307f52SEvan Bacon    if (line === `package:${androidPackage}`) {
838d307f52SEvan Bacon      return true;
848d307f52SEvan Bacon    }
858d307f52SEvan Bacon  }
868d307f52SEvan Bacon  return false;
878d307f52SEvan Bacon}
888d307f52SEvan Bacon
898d307f52SEvan Bacon/**
908d307f52SEvan Bacon * @param device.pid Process ID of the Android device to launch.
918d307f52SEvan Bacon * @param props.launchActivity Activity to launch `[application identifier]/.[main activity name]`, ex: `com.bacon.app/.MainActivity`
928d307f52SEvan Bacon */
938d307f52SEvan Baconexport async function launchActivityAsync(
948d307f52SEvan Bacon  device: DeviceContext,
958d307f52SEvan Bacon  {
968d307f52SEvan Bacon    launchActivity,
978d307f52SEvan Bacon  }: {
988d307f52SEvan Bacon    launchActivity: string;
998d307f52SEvan Bacon  }
1008d307f52SEvan Bacon) {
1018d307f52SEvan Bacon  return openAsync(
1028d307f52SEvan Bacon    adbArgs(
1038d307f52SEvan Bacon      device.pid,
1048d307f52SEvan Bacon      'shell',
1058d307f52SEvan Bacon      'am',
1068d307f52SEvan Bacon      'start',
1078d307f52SEvan Bacon      // FLAG_ACTIVITY_SINGLE_TOP -- If set, the activity will not be launched if it is already running at the top of the history stack.
1088d307f52SEvan Bacon      '-f',
1098d307f52SEvan Bacon      '0x20000000',
1108d307f52SEvan Bacon      // Activity to open first: com.bacon.app/.MainActivity
1118d307f52SEvan Bacon      '-n',
1128d307f52SEvan Bacon      launchActivity
1138d307f52SEvan Bacon    )
1148d307f52SEvan Bacon  );
1158d307f52SEvan Bacon}
1168d307f52SEvan Bacon
1178d307f52SEvan Bacon/**
1188d307f52SEvan Bacon * @param device.pid Process ID of the Android device to launch.
1198d307f52SEvan Bacon * @param props.applicationId package name to launch.
1208d307f52SEvan Bacon */
1218d307f52SEvan Baconexport async function openAppIdAsync(
1228d307f52SEvan Bacon  device: DeviceContext,
1238d307f52SEvan Bacon  {
1248d307f52SEvan Bacon    applicationId,
1258d307f52SEvan Bacon  }: {
1268d307f52SEvan Bacon    applicationId: string;
1278d307f52SEvan Bacon  }
1288d307f52SEvan Bacon) {
1298d307f52SEvan Bacon  return openAsync(
1308d307f52SEvan Bacon    adbArgs(
1318d307f52SEvan Bacon      device.pid,
1328d307f52SEvan Bacon      'shell',
1338d307f52SEvan Bacon      'monkey',
1348d307f52SEvan Bacon      '-p',
1358d307f52SEvan Bacon      applicationId,
1368d307f52SEvan Bacon      '-c',
1378d307f52SEvan Bacon      'android.intent.category.LAUNCHER',
1388d307f52SEvan Bacon      '1'
1398d307f52SEvan Bacon    )
1408d307f52SEvan Bacon  );
1418d307f52SEvan Bacon}
1428d307f52SEvan Bacon
1438d307f52SEvan Bacon/**
1448d307f52SEvan Bacon * @param device.pid Process ID of the Android device to launch.
1458d307f52SEvan Bacon * @param props.url URL to launch.
1468d307f52SEvan Bacon */
1478d307f52SEvan Baconexport async function openUrlAsync(
1488d307f52SEvan Bacon  device: DeviceContext,
1498d307f52SEvan Bacon  {
1508d307f52SEvan Bacon    url,
1518d307f52SEvan Bacon  }: {
1528d307f52SEvan Bacon    url: string;
1538d307f52SEvan Bacon  }
1548d307f52SEvan Bacon) {
1558d307f52SEvan Bacon  return openAsync(
1568ce30dc2SEvan Bacon    adbArgs(
1578ce30dc2SEvan Bacon      device.pid,
1588ce30dc2SEvan Bacon      'shell',
1598ce30dc2SEvan Bacon      'am',
1608ce30dc2SEvan Bacon      'start',
1618ce30dc2SEvan Bacon      '-a',
1628ce30dc2SEvan Bacon      'android.intent.action.VIEW',
1638ce30dc2SEvan Bacon      '-d',
1648ce30dc2SEvan Bacon      // ADB requires ampersands to be escaped.
1658ce30dc2SEvan Bacon      url.replace(/&/g, String.raw`\&`)
1668ce30dc2SEvan Bacon    )
1678d307f52SEvan Bacon  );
1688d307f52SEvan Bacon}
1698d307f52SEvan Bacon
1708d307f52SEvan Bacon/** Runs a generic command watches for common errors in order to throw with an expected code. */
1718d307f52SEvan Baconasync function openAsync(args: string[]): Promise<string> {
1728d307f52SEvan Bacon  const results = await getServer().runAsync(args);
1738d307f52SEvan Bacon  if (
1748d307f52SEvan Bacon    results.includes(CANT_START_ACTIVITY_ERROR) ||
1758d307f52SEvan Bacon    results.match(/Error: Activity class .* does not exist\./g)
1768d307f52SEvan Bacon  ) {
1778d307f52SEvan Bacon    throw new CommandError('APP_NOT_INSTALLED', results.substring(results.indexOf('Error: ')));
1788d307f52SEvan Bacon  }
1798d307f52SEvan Bacon  return results;
1808d307f52SEvan Bacon}
1818d307f52SEvan Bacon
1828d307f52SEvan Bacon/** Uninstall an app given its Android package name. */
1838d307f52SEvan Baconexport async function uninstallAsync(
1848d307f52SEvan Bacon  device: DeviceContext,
1858d307f52SEvan Bacon  { appId }: { appId: string }
1868d307f52SEvan Bacon): Promise<string> {
1878d307f52SEvan Bacon  return await getServer().runAsync(adbArgs(device.pid, 'uninstall', appId));
1888d307f52SEvan Bacon}
1898d307f52SEvan Bacon
1908d307f52SEvan Bacon/** Get package info from an app based on its Android package name. */
1918d307f52SEvan Baconexport async function getPackageInfoAsync(
1928d307f52SEvan Bacon  device: DeviceContext,
1938d307f52SEvan Bacon  { appId }: { appId: string }
1948d307f52SEvan Bacon): Promise<string> {
1958d307f52SEvan Bacon  return await getServer().runAsync(adbArgs(device.pid, 'shell', 'dumpsys', 'package', appId));
1968d307f52SEvan Bacon}
1978d307f52SEvan Bacon
1988d307f52SEvan Bacon/** Install an app on a connected device. */
1998d307f52SEvan Baconexport async function installAsync(device: DeviceContext, { filePath }: { filePath: string }) {
2008d307f52SEvan Bacon  // TODO: Handle the `INSTALL_FAILED_INSUFFICIENT_STORAGE` error.
2018d307f52SEvan Bacon  return await getServer().runAsync(adbArgs(device.pid, 'install', '-r', '-d', filePath));
2028d307f52SEvan Bacon}
2038d307f52SEvan Bacon
2048d307f52SEvan Bacon/** Format ADB args with process ID. */
2058d307f52SEvan Baconexport function adbArgs(pid: Device['pid'], ...options: string[]): string[] {
2068d307f52SEvan Bacon  const args = [];
2078d307f52SEvan Bacon  if (pid) {
2088d307f52SEvan Bacon    args.push('-s', pid);
2098d307f52SEvan Bacon  }
2108d307f52SEvan Bacon  return args.concat(options);
2118d307f52SEvan Bacon}
2128d307f52SEvan Bacon
2138d307f52SEvan Bacon// TODO: This is very expensive for some operations.
2148d307f52SEvan Baconexport async function getAttachedDevicesAsync(): Promise<Device[]> {
2158d307f52SEvan Bacon  const output = await getServer().runAsync(['devices', '-l']);
2168d307f52SEvan Bacon
2178d307f52SEvan Bacon  const splitItems = output.trim().replace(/\n$/, '').split(os.EOL);
2188d307f52SEvan Bacon  // First line is `"List of devices attached"`, remove it
2198d307f52SEvan Bacon  // @ts-ignore: todo
2208d307f52SEvan Bacon  const attachedDevices: {
2218d307f52SEvan Bacon    props: string[];
2228d307f52SEvan Bacon    type: Device['type'];
2238d307f52SEvan Bacon    isAuthorized: Device['isAuthorized'];
2248d307f52SEvan Bacon  }[] = splitItems
2258d307f52SEvan Bacon    .slice(1, splitItems.length)
2268d307f52SEvan Bacon    .map((line) => {
2278d307f52SEvan Bacon      // unauthorized: ['FA8251A00719', 'unauthorized', 'usb:338690048X', 'transport_id:5']
2288d307f52SEvan Bacon      // authorized: ['FA8251A00719', 'device', 'usb:336592896X', 'product:walleye', 'model:Pixel_2', 'device:walleye', 'transport_id:4']
2298d307f52SEvan Bacon      // emulator: ['emulator-5554', 'offline', 'transport_id:1']
2308d307f52SEvan Bacon      const props = line.split(' ').filter(Boolean);
2318d307f52SEvan Bacon
2328d307f52SEvan Bacon      const isAuthorized = props[1] !== 'unauthorized';
2338d307f52SEvan Bacon      const type = line.includes('emulator') ? 'emulator' : 'device';
2348d307f52SEvan Bacon      return { props, type, isAuthorized };
2358d307f52SEvan Bacon    })
2368d307f52SEvan Bacon    .filter(({ props: [pid] }) => !!pid);
2378d307f52SEvan Bacon
2388d307f52SEvan Bacon  const devicePromises = attachedDevices.map<Promise<Device>>(async (props) => {
2398d307f52SEvan Bacon    const {
2408d307f52SEvan Bacon      type,
2418d307f52SEvan Bacon      props: [pid, ...deviceInfo],
2428d307f52SEvan Bacon      isAuthorized,
2438d307f52SEvan Bacon    } = props;
2448d307f52SEvan Bacon
2458d307f52SEvan Bacon    let name: string | null = null;
2468d307f52SEvan Bacon
2478d307f52SEvan Bacon    if (type === 'device') {
2488d307f52SEvan Bacon      if (isAuthorized) {
2498d307f52SEvan Bacon        // Possibly formatted like `model:Pixel_2`
2508d307f52SEvan Bacon        // Transform to `Pixel_2`
2518d307f52SEvan Bacon        const modelItem = deviceInfo.find((info) => info.includes('model:'));
2528d307f52SEvan Bacon        if (modelItem) {
2538d307f52SEvan Bacon          name = modelItem.replace('model:', '');
2548d307f52SEvan Bacon        }
2558d307f52SEvan Bacon      }
2568d307f52SEvan Bacon      // unauthorized devices don't have a name available to read
2578d307f52SEvan Bacon      if (!name) {
2588d307f52SEvan Bacon        // Device FA8251A00719
2598d307f52SEvan Bacon        name = `Device ${pid}`;
2608d307f52SEvan Bacon      }
2618d307f52SEvan Bacon    } else {
2628d307f52SEvan Bacon      // Given an emulator pid, get the emulator name which can be used to start the emulator later.
2638d307f52SEvan Bacon      name = (await getAdbNameForDeviceIdAsync({ pid })) ?? '';
2648d307f52SEvan Bacon    }
2658d307f52SEvan Bacon
2668d307f52SEvan Bacon    return {
2678d307f52SEvan Bacon      pid,
2688d307f52SEvan Bacon      name,
2698d307f52SEvan Bacon      type,
2708d307f52SEvan Bacon      isAuthorized,
2718d307f52SEvan Bacon      isBooted: true,
2728d307f52SEvan Bacon    };
2738d307f52SEvan Bacon  });
2748d307f52SEvan Bacon
2758d307f52SEvan Bacon  return Promise.all(devicePromises);
2768d307f52SEvan Bacon}
2778d307f52SEvan Bacon
2788d307f52SEvan Bacon/**
2798d307f52SEvan Bacon * Return the Emulator name for an emulator ID, this can be used to determine if an emulator is booted.
2808d307f52SEvan Bacon *
2818d307f52SEvan Bacon * @param device.pid a value like `emulator-5554` from `abd devices`
2828d307f52SEvan Bacon */
2838d307f52SEvan Baconexport async function getAdbNameForDeviceIdAsync(device: DeviceContext): Promise<string | null> {
2848d307f52SEvan Bacon  const results = await getServer().runAsync(adbArgs(device.pid, 'emu', 'avd', 'name'));
2858d307f52SEvan Bacon
2868d307f52SEvan Bacon  if (results.match(/could not connect to TCP port .*: Connection refused/)) {
2878d307f52SEvan Bacon    // Can also occur when the emulator does not exist.
2888d307f52SEvan Bacon    throw new CommandError('EMULATOR_NOT_FOUND', results);
2898d307f52SEvan Bacon  }
2908d307f52SEvan Bacon
291cc6b646aSCedric van Putten  return sanitizeAdbDeviceName(results) ?? null;
2928d307f52SEvan Bacon}
2938d307f52SEvan Bacon
2948d307f52SEvan Baconexport async function isDeviceBootedAsync({
2958d307f52SEvan Bacon  name,
2968d307f52SEvan Bacon}: { name?: string } = {}): Promise<Device | null> {
2978d307f52SEvan Bacon  const devices = await getAttachedDevicesAsync();
2988d307f52SEvan Bacon
2998d307f52SEvan Bacon  if (!name) {
3008d307f52SEvan Bacon    return devices[0] ?? null;
3018d307f52SEvan Bacon  }
3028d307f52SEvan Bacon
3038d307f52SEvan Bacon  return devices.find((device) => device.name === name) ?? null;
3048d307f52SEvan Bacon}
3058d307f52SEvan Bacon
3068d307f52SEvan Bacon/**
3078d307f52SEvan Bacon * Returns true when a device's splash screen animation has stopped.
3088d307f52SEvan Bacon * This can be used to detect when a device is fully booted and ready to use.
3098d307f52SEvan Bacon *
3108d307f52SEvan Bacon * @param pid
3118d307f52SEvan Bacon */
3128d307f52SEvan Baconexport async function isBootAnimationCompleteAsync(pid?: string): Promise<boolean> {
3138d307f52SEvan Bacon  try {
3148d307f52SEvan Bacon    const props = await getPropertyDataForDeviceAsync({ pid }, PROP_BOOT_ANIMATION_STATE);
3158d307f52SEvan Bacon    return !!props[PROP_BOOT_ANIMATION_STATE].match(/stopped/);
3168d307f52SEvan Bacon  } catch {
3178d307f52SEvan Bacon    return false;
3188d307f52SEvan Bacon  }
3198d307f52SEvan Bacon}
3208d307f52SEvan Bacon
3213d6e487dSEvan Bacon/** Get a list of ABIs for the provided device. */
3223d6e487dSEvan Baconexport async function getDeviceABIsAsync(
3233d6e487dSEvan Bacon  device: Pick<Device, 'name' | 'pid'>
3243d6e487dSEvan Bacon): Promise<DeviceABI[]> {
3253d6e487dSEvan Bacon  const cpuAbiList = (await getPropertyDataForDeviceAsync(device, PROP_CPU_ABI_LIST_NAME))[
3263d6e487dSEvan Bacon    PROP_CPU_ABI_LIST_NAME
3273d6e487dSEvan Bacon  ];
3283d6e487dSEvan Bacon
3293d6e487dSEvan Bacon  if (cpuAbiList) {
3303d6e487dSEvan Bacon    return cpuAbiList.trim().split(',') as DeviceABI[];
3313d6e487dSEvan Bacon  }
3323d6e487dSEvan Bacon
3333d6e487dSEvan Bacon  const abi = (await getPropertyDataForDeviceAsync(device, PROP_CPU_NAME))[
3343d6e487dSEvan Bacon    PROP_CPU_NAME
3353d6e487dSEvan Bacon  ] as DeviceABI;
3363d6e487dSEvan Bacon  return [abi];
3373d6e487dSEvan Bacon}
3383d6e487dSEvan Bacon
3398d307f52SEvan Baconexport async function getPropertyDataForDeviceAsync(
3408d307f52SEvan Bacon  device: DeviceContext,
3418d307f52SEvan Bacon  prop?: string
3428d307f52SEvan Bacon): Promise<DeviceProperties> {
3438d307f52SEvan Bacon  // @ts-ignore
3448d307f52SEvan Bacon  const propCommand = adbArgs(...[device.pid, 'shell', 'getprop', prop].filter(Boolean));
3458d307f52SEvan Bacon  try {
3468d307f52SEvan Bacon    // Prevent reading as UTF8.
3478d307f52SEvan Bacon    const results = await getServer().getFileOutputAsync(propCommand);
3488d307f52SEvan Bacon    // Like:
3498d307f52SEvan Bacon    // [wifi.direct.interface]: [p2p-dev-wlan0]
3508d307f52SEvan Bacon    // [wifi.interface]: [wlan0]
3518d307f52SEvan Bacon
3528d307f52SEvan Bacon    if (prop) {
353474a7a4bSEvan Bacon      debug(`Property data: (device pid: ${device.pid}, prop: ${prop}, data: ${results})`);
3548d307f52SEvan Bacon      return {
3558d307f52SEvan Bacon        [prop]: results,
3568d307f52SEvan Bacon      };
3578d307f52SEvan Bacon    }
3583d6e487dSEvan Bacon    const props = parseAdbDeviceProperties(results);
3593d6e487dSEvan Bacon
360474a7a4bSEvan Bacon    debug(`Parsed data:`, props);
3613d6e487dSEvan Bacon
3623d6e487dSEvan Bacon    return props;
36329975bfdSEvan Bacon  } catch (error: any) {
3648d307f52SEvan Bacon    // TODO: Ensure error has message and not stderr
36529975bfdSEvan Bacon    throw new CommandError(`Failed to get properties for device (${device.pid}): ${error.message}`);
3668d307f52SEvan Bacon  }
3678d307f52SEvan Bacon}
3688d307f52SEvan Bacon
3698d307f52SEvan Baconfunction parseAdbDeviceProperties(devicePropertiesString: string) {
3708d307f52SEvan Bacon  const properties: DeviceProperties = {};
3718d307f52SEvan Bacon  const propertyExp = /\[(.*?)\]: \[(.*?)\]/gm;
3728d307f52SEvan Bacon  for (const match of devicePropertiesString.matchAll(propertyExp)) {
3738d307f52SEvan Bacon    properties[match[1]] = match[2];
3748d307f52SEvan Bacon  }
3758d307f52SEvan Bacon  return properties;
3768d307f52SEvan Bacon}
377cc6b646aSCedric van Putten
378cc6b646aSCedric van Putten/**
379cc6b646aSCedric van Putten * Sanitize the ADB device name to only get the actual device name.
380cc6b646aSCedric van Putten * On Windows, we need to do \r, \n, and \r\n filtering to get the name.
381cc6b646aSCedric van Putten */
382cc6b646aSCedric van Puttenexport function sanitizeAdbDeviceName(deviceName: string) {
383cc6b646aSCedric van Putten  return deviceName
384cc6b646aSCedric van Putten    .trim()
385cc6b646aSCedric van Putten    .split(/[\r\n]+/)
386cc6b646aSCedric van Putten    .shift();
387cc6b646aSCedric van Putten}
388