1import chalk from 'chalk';
2import os from 'os';
3
4import * as Log from '../../../log';
5import { CommandError } from '../../../utils/errors';
6import { learnMore } from '../../../utils/link';
7import { ADBServer } from './ADBServer';
8
9const debug = require('debug')('expo:start:platforms:android:adb') as typeof console.log;
10
11export enum DeviceABI {
12  // The arch specific android target platforms are soft-deprecated.
13  // Instead of using TargetPlatform as a combination arch + platform
14  // the code will be updated to carry arch information in [DarwinArch]
15  // and [AndroidArch].
16  arm = 'arm',
17  arm64 = 'arm64',
18  x64 = 'x64',
19  x86 = 'x86',
20  armeabiV7a = 'armeabi-v7a',
21  armeabi = 'armeabi',
22  universal = 'universal',
23}
24
25/** Represents a connected Android device. */
26export type Device = {
27  /** Process ID. */
28  pid?: string;
29  /** Name of the device, also used as the ID for opening devices. */
30  name: string;
31  /** Is emulator or connected device. */
32  type: 'emulator' | 'device';
33  /** Is the device booted (emulator). */
34  isBooted: boolean;
35  /** Is device authorized for developing. https://expo.fyi/authorize-android-device */
36  isAuthorized: boolean;
37};
38
39type DeviceContext = Pick<Device, 'pid'>;
40
41type DeviceProperties = Record<string, string>;
42
43const CANT_START_ACTIVITY_ERROR = 'Activity not started, unable to resolve Intent';
44// http://developer.android.com/ndk/guides/abis.html
45const PROP_CPU_NAME = 'ro.product.cpu.abi';
46
47const PROP_CPU_ABI_LIST_NAME = 'ro.product.cpu.abilist';
48
49// Can sometimes be null
50// http://developer.android.com/ndk/guides/abis.html
51const PROP_BOOT_ANIMATION_STATE = 'init.svc.bootanim';
52
53let _server: ADBServer | null;
54
55/** Return the lazily loaded ADB server instance. */
56export function getServer() {
57  _server ??= new ADBServer();
58  return _server;
59}
60
61/** Logs an FYI message about authorizing your device. */
62export function logUnauthorized(device: Device) {
63  Log.warn(
64    `\nThis computer is not authorized for developing on ${chalk.bold(device.name)}. ${chalk.dim(
65      learnMore('https://expo.fyi/authorize-android-device')
66    )}`
67  );
68}
69
70/** Returns true if the provided package name is installed on the provided Android device. */
71export async function isPackageInstalledAsync(
72  device: DeviceContext,
73  androidPackage: string
74): Promise<boolean> {
75  const packages = await getServer().runAsync(
76    adbArgs(device.pid, 'shell', 'pm', 'list', 'packages', androidPackage)
77  );
78
79  const lines = packages.split(/\r?\n/);
80  for (let i = 0; i < lines.length; i++) {
81    const line = lines[i].trim();
82    if (line === `package:${androidPackage}`) {
83      return true;
84    }
85  }
86  return false;
87}
88
89/**
90 * @param device.pid Process ID of the Android device to launch.
91 * @param props.launchActivity Activity to launch `[application identifier]/.[main activity name]`, ex: `com.bacon.app/.MainActivity`
92 */
93export async function launchActivityAsync(
94  device: DeviceContext,
95  {
96    launchActivity,
97  }: {
98    launchActivity: string;
99  }
100) {
101  return openAsync(
102    adbArgs(
103      device.pid,
104      'shell',
105      'am',
106      'start',
107      '-a',
108      'android.intent.action.RUN',
109      // FLAG_ACTIVITY_SINGLE_TOP -- If set, the activity will not be launched if it is already running at the top of the history stack.
110      '-f',
111      '0x20000000',
112      // Activity to open first: com.bacon.app/.MainActivity
113      '-n',
114      launchActivity
115    )
116  );
117}
118
119/**
120 * @param device.pid Process ID of the Android device to launch.
121 * @param props.applicationId package name to launch.
122 */
123export async function openAppIdAsync(
124  device: DeviceContext,
125  {
126    applicationId,
127  }: {
128    applicationId: string;
129  }
130) {
131  return openAsync(
132    adbArgs(
133      device.pid,
134      'shell',
135      'monkey',
136      '-p',
137      applicationId,
138      '-c',
139      'android.intent.category.LAUNCHER',
140      '1'
141    )
142  );
143}
144
145/**
146 * @param device.pid Process ID of the Android device to launch.
147 * @param props.url URL to launch.
148 */
149export async function openUrlAsync(
150  device: DeviceContext,
151  {
152    url,
153  }: {
154    url: string;
155  }
156) {
157  return openAsync(
158    adbArgs(device.pid, 'shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url)
159  );
160}
161
162/** Runs a generic command watches for common errors in order to throw with an expected code. */
163async function openAsync(args: string[]): Promise<string> {
164  const results = await getServer().runAsync(args);
165  if (
166    results.includes(CANT_START_ACTIVITY_ERROR) ||
167    results.match(/Error: Activity class .* does not exist\./g)
168  ) {
169    throw new CommandError('APP_NOT_INSTALLED', results.substring(results.indexOf('Error: ')));
170  }
171  return results;
172}
173
174/** Uninstall an app given its Android package name. */
175export async function uninstallAsync(
176  device: DeviceContext,
177  { appId }: { appId: string }
178): Promise<string> {
179  return await getServer().runAsync(adbArgs(device.pid, 'uninstall', appId));
180}
181
182/** Get package info from an app based on its Android package name. */
183export async function getPackageInfoAsync(
184  device: DeviceContext,
185  { appId }: { appId: string }
186): Promise<string> {
187  return await getServer().runAsync(adbArgs(device.pid, 'shell', 'dumpsys', 'package', appId));
188}
189
190/** Install an app on a connected device. */
191export async function installAsync(device: DeviceContext, { filePath }: { filePath: string }) {
192  // TODO: Handle the `INSTALL_FAILED_INSUFFICIENT_STORAGE` error.
193  return await getServer().runAsync(adbArgs(device.pid, 'install', '-r', '-d', filePath));
194}
195
196/** Format ADB args with process ID. */
197export function adbArgs(pid: Device['pid'], ...options: string[]): string[] {
198  const args = [];
199  if (pid) {
200    args.push('-s', pid);
201  }
202  return args.concat(options);
203}
204
205// TODO: This is very expensive for some operations.
206export async function getAttachedDevicesAsync(): Promise<Device[]> {
207  const output = await getServer().runAsync(['devices', '-l']);
208
209  const splitItems = output.trim().replace(/\n$/, '').split(os.EOL);
210  // First line is `"List of devices attached"`, remove it
211  // @ts-ignore: todo
212  const attachedDevices: {
213    props: string[];
214    type: Device['type'];
215    isAuthorized: Device['isAuthorized'];
216  }[] = splitItems
217    .slice(1, splitItems.length)
218    .map((line) => {
219      // unauthorized: ['FA8251A00719', 'unauthorized', 'usb:338690048X', 'transport_id:5']
220      // authorized: ['FA8251A00719', 'device', 'usb:336592896X', 'product:walleye', 'model:Pixel_2', 'device:walleye', 'transport_id:4']
221      // emulator: ['emulator-5554', 'offline', 'transport_id:1']
222      const props = line.split(' ').filter(Boolean);
223
224      const isAuthorized = props[1] !== 'unauthorized';
225      const type = line.includes('emulator') ? 'emulator' : 'device';
226      return { props, type, isAuthorized };
227    })
228    .filter(({ props: [pid] }) => !!pid);
229
230  const devicePromises = attachedDevices.map<Promise<Device>>(async (props) => {
231    const {
232      type,
233      props: [pid, ...deviceInfo],
234      isAuthorized,
235    } = props;
236
237    let name: string | null = null;
238
239    if (type === 'device') {
240      if (isAuthorized) {
241        // Possibly formatted like `model:Pixel_2`
242        // Transform to `Pixel_2`
243        const modelItem = deviceInfo.find((info) => info.includes('model:'));
244        if (modelItem) {
245          name = modelItem.replace('model:', '');
246        }
247      }
248      // unauthorized devices don't have a name available to read
249      if (!name) {
250        // Device FA8251A00719
251        name = `Device ${pid}`;
252      }
253    } else {
254      // Given an emulator pid, get the emulator name which can be used to start the emulator later.
255      name = (await getAdbNameForDeviceIdAsync({ pid })) ?? '';
256    }
257
258    return {
259      pid,
260      name,
261      type,
262      isAuthorized,
263      isBooted: true,
264    };
265  });
266
267  return Promise.all(devicePromises);
268}
269
270/**
271 * Return the Emulator name for an emulator ID, this can be used to determine if an emulator is booted.
272 *
273 * @param device.pid a value like `emulator-5554` from `abd devices`
274 */
275export async function getAdbNameForDeviceIdAsync(device: DeviceContext): Promise<string | null> {
276  const results = await getServer().runAsync(adbArgs(device.pid, 'emu', 'avd', 'name'));
277
278  if (results.match(/could not connect to TCP port .*: Connection refused/)) {
279    // Can also occur when the emulator does not exist.
280    throw new CommandError('EMULATOR_NOT_FOUND', results);
281  }
282
283  return sanitizeAdbDeviceName(results) ?? null;
284}
285
286export async function isDeviceBootedAsync({
287  name,
288}: { name?: string } = {}): Promise<Device | null> {
289  const devices = await getAttachedDevicesAsync();
290
291  if (!name) {
292    return devices[0] ?? null;
293  }
294
295  return devices.find((device) => device.name === name) ?? null;
296}
297
298/**
299 * Returns true when a device's splash screen animation has stopped.
300 * This can be used to detect when a device is fully booted and ready to use.
301 *
302 * @param pid
303 */
304export async function isBootAnimationCompleteAsync(pid?: string): Promise<boolean> {
305  try {
306    const props = await getPropertyDataForDeviceAsync({ pid }, PROP_BOOT_ANIMATION_STATE);
307    return !!props[PROP_BOOT_ANIMATION_STATE].match(/stopped/);
308  } catch {
309    return false;
310  }
311}
312
313/** Get a list of ABIs for the provided device. */
314export async function getDeviceABIsAsync(
315  device: Pick<Device, 'name' | 'pid'>
316): Promise<DeviceABI[]> {
317  const cpuAbiList = (await getPropertyDataForDeviceAsync(device, PROP_CPU_ABI_LIST_NAME))[
318    PROP_CPU_ABI_LIST_NAME
319  ];
320
321  if (cpuAbiList) {
322    return cpuAbiList.trim().split(',') as DeviceABI[];
323  }
324
325  const abi = (await getPropertyDataForDeviceAsync(device, PROP_CPU_NAME))[
326    PROP_CPU_NAME
327  ] as DeviceABI;
328  return [abi];
329}
330
331export async function getPropertyDataForDeviceAsync(
332  device: DeviceContext,
333  prop?: string
334): Promise<DeviceProperties> {
335  // @ts-ignore
336  const propCommand = adbArgs(...[device.pid, 'shell', 'getprop', prop].filter(Boolean));
337  try {
338    // Prevent reading as UTF8.
339    const results = await getServer().getFileOutputAsync(propCommand);
340    // Like:
341    // [wifi.direct.interface]: [p2p-dev-wlan0]
342    // [wifi.interface]: [wlan0]
343
344    if (prop) {
345      debug(`Property data: (device pid: ${device.pid}, prop: ${prop}, data: ${results})`);
346      return {
347        [prop]: results,
348      };
349    }
350    const props = parseAdbDeviceProperties(results);
351
352    debug(`Parsed data:`, props);
353
354    return props;
355  } catch (error: any) {
356    // TODO: Ensure error has message and not stderr
357    throw new CommandError(`Failed to get properties for device (${device.pid}): ${error.message}`);
358  }
359}
360
361function parseAdbDeviceProperties(devicePropertiesString: string) {
362  const properties: DeviceProperties = {};
363  const propertyExp = /\[(.*?)\]: \[(.*?)\]/gm;
364  for (const match of devicePropertiesString.matchAll(propertyExp)) {
365    properties[match[1]] = match[2];
366  }
367  return properties;
368}
369
370/**
371 * Sanitize the ADB device name to only get the actual device name.
372 * On Windows, we need to do \r, \n, and \r\n filtering to get the name.
373 */
374export function sanitizeAdbDeviceName(deviceName: string) {
375  return deviceName
376    .trim()
377    .split(/[\r\n]+/)
378    .shift();
379}
380