1import chalk from 'chalk';
2import os from 'os';
3
4import { ADBServer } from './ADBServer';
5import * as Log from '../../../log';
6import { CommandError } from '../../../utils/errors';
7import { learnMore } from '../../../utils/link';
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      // FLAG_ACTIVITY_SINGLE_TOP -- If set, the activity will not be launched if it is already running at the top of the history stack.
108      '-f',
109      '0x20000000',
110      // Activity to open first: com.bacon.app/.MainActivity
111      '-n',
112      launchActivity
113    )
114  );
115}
116
117/**
118 * @param device.pid Process ID of the Android device to launch.
119 * @param props.applicationId package name to launch.
120 */
121export async function openAppIdAsync(
122  device: DeviceContext,
123  {
124    applicationId,
125  }: {
126    applicationId: string;
127  }
128) {
129  return openAsync(
130    adbArgs(
131      device.pid,
132      'shell',
133      'monkey',
134      '-p',
135      applicationId,
136      '-c',
137      'android.intent.category.LAUNCHER',
138      '1'
139    )
140  );
141}
142
143/**
144 * @param device.pid Process ID of the Android device to launch.
145 * @param props.url URL to launch.
146 */
147export async function openUrlAsync(
148  device: DeviceContext,
149  {
150    url,
151  }: {
152    url: string;
153  }
154) {
155  return openAsync(
156    adbArgs(
157      device.pid,
158      'shell',
159      'am',
160      'start',
161      '-a',
162      'android.intent.action.VIEW',
163      '-d',
164      // ADB requires ampersands to be escaped.
165      url.replace(/&/g, String.raw`\&`)
166    )
167  );
168}
169
170/** Runs a generic command watches for common errors in order to throw with an expected code. */
171async function openAsync(args: string[]): Promise<string> {
172  const results = await getServer().runAsync(args);
173  if (
174    results.includes(CANT_START_ACTIVITY_ERROR) ||
175    results.match(/Error: Activity class .* does not exist\./g)
176  ) {
177    throw new CommandError('APP_NOT_INSTALLED', results.substring(results.indexOf('Error: ')));
178  }
179  return results;
180}
181
182/** Uninstall an app given its Android package name. */
183export async function uninstallAsync(
184  device: DeviceContext,
185  { appId }: { appId: string }
186): Promise<string> {
187  return await getServer().runAsync(adbArgs(device.pid, 'uninstall', appId));
188}
189
190/** Get package info from an app based on its Android package name. */
191export async function getPackageInfoAsync(
192  device: DeviceContext,
193  { appId }: { appId: string }
194): Promise<string> {
195  return await getServer().runAsync(adbArgs(device.pid, 'shell', 'dumpsys', 'package', appId));
196}
197
198/** Install an app on a connected device. */
199export async function installAsync(device: DeviceContext, { filePath }: { filePath: string }) {
200  // TODO: Handle the `INSTALL_FAILED_INSUFFICIENT_STORAGE` error.
201  return await getServer().runAsync(adbArgs(device.pid, 'install', '-r', '-d', filePath));
202}
203
204/** Format ADB args with process ID. */
205export function adbArgs(pid: Device['pid'], ...options: string[]): string[] {
206  const args = [];
207  if (pid) {
208    args.push('-s', pid);
209  }
210  return args.concat(options);
211}
212
213// TODO: This is very expensive for some operations.
214export async function getAttachedDevicesAsync(): Promise<Device[]> {
215  const output = await getServer().runAsync(['devices', '-l']);
216
217  const splitItems = output.trim().replace(/\n$/, '').split(os.EOL);
218  // First line is `"List of devices attached"`, remove it
219  // @ts-ignore: todo
220  const attachedDevices: {
221    props: string[];
222    type: Device['type'];
223    isAuthorized: Device['isAuthorized'];
224  }[] = splitItems
225    .slice(1, splitItems.length)
226    .map((line) => {
227      // unauthorized: ['FA8251A00719', 'unauthorized', 'usb:338690048X', 'transport_id:5']
228      // authorized: ['FA8251A00719', 'device', 'usb:336592896X', 'product:walleye', 'model:Pixel_2', 'device:walleye', 'transport_id:4']
229      // emulator: ['emulator-5554', 'offline', 'transport_id:1']
230      const props = line.split(' ').filter(Boolean);
231
232      const isAuthorized = props[1] !== 'unauthorized';
233      const type = line.includes('emulator') ? 'emulator' : 'device';
234      return { props, type, isAuthorized };
235    })
236    .filter(({ props: [pid] }) => !!pid);
237
238  const devicePromises = attachedDevices.map<Promise<Device>>(async (props) => {
239    const {
240      type,
241      props: [pid, ...deviceInfo],
242      isAuthorized,
243    } = props;
244
245    let name: string | null = null;
246
247    if (type === 'device') {
248      if (isAuthorized) {
249        // Possibly formatted like `model:Pixel_2`
250        // Transform to `Pixel_2`
251        const modelItem = deviceInfo.find((info) => info.includes('model:'));
252        if (modelItem) {
253          name = modelItem.replace('model:', '');
254        }
255      }
256      // unauthorized devices don't have a name available to read
257      if (!name) {
258        // Device FA8251A00719
259        name = `Device ${pid}`;
260      }
261    } else {
262      // Given an emulator pid, get the emulator name which can be used to start the emulator later.
263      name = (await getAdbNameForDeviceIdAsync({ pid })) ?? '';
264    }
265
266    return {
267      pid,
268      name,
269      type,
270      isAuthorized,
271      isBooted: true,
272    };
273  });
274
275  return Promise.all(devicePromises);
276}
277
278/**
279 * Return the Emulator name for an emulator ID, this can be used to determine if an emulator is booted.
280 *
281 * @param device.pid a value like `emulator-5554` from `abd devices`
282 */
283export async function getAdbNameForDeviceIdAsync(device: DeviceContext): Promise<string | null> {
284  const results = await getServer().runAsync(adbArgs(device.pid, 'emu', 'avd', 'name'));
285
286  if (results.match(/could not connect to TCP port .*: Connection refused/)) {
287    // Can also occur when the emulator does not exist.
288    throw new CommandError('EMULATOR_NOT_FOUND', results);
289  }
290
291  return sanitizeAdbDeviceName(results) ?? null;
292}
293
294export async function isDeviceBootedAsync({
295  name,
296}: { name?: string } = {}): Promise<Device | null> {
297  const devices = await getAttachedDevicesAsync();
298
299  if (!name) {
300    return devices[0] ?? null;
301  }
302
303  return devices.find((device) => device.name === name) ?? null;
304}
305
306/**
307 * Returns true when a device's splash screen animation has stopped.
308 * This can be used to detect when a device is fully booted and ready to use.
309 *
310 * @param pid
311 */
312export async function isBootAnimationCompleteAsync(pid?: string): Promise<boolean> {
313  try {
314    const props = await getPropertyDataForDeviceAsync({ pid }, PROP_BOOT_ANIMATION_STATE);
315    return !!props[PROP_BOOT_ANIMATION_STATE].match(/stopped/);
316  } catch {
317    return false;
318  }
319}
320
321/** Get a list of ABIs for the provided device. */
322export async function getDeviceABIsAsync(
323  device: Pick<Device, 'name' | 'pid'>
324): Promise<DeviceABI[]> {
325  const cpuAbiList = (await getPropertyDataForDeviceAsync(device, PROP_CPU_ABI_LIST_NAME))[
326    PROP_CPU_ABI_LIST_NAME
327  ];
328
329  if (cpuAbiList) {
330    return cpuAbiList.trim().split(',') as DeviceABI[];
331  }
332
333  const abi = (await getPropertyDataForDeviceAsync(device, PROP_CPU_NAME))[
334    PROP_CPU_NAME
335  ] as DeviceABI;
336  return [abi];
337}
338
339export async function getPropertyDataForDeviceAsync(
340  device: DeviceContext,
341  prop?: string
342): Promise<DeviceProperties> {
343  // @ts-ignore
344  const propCommand = adbArgs(...[device.pid, 'shell', 'getprop', prop].filter(Boolean));
345  try {
346    // Prevent reading as UTF8.
347    const results = await getServer().getFileOutputAsync(propCommand);
348    // Like:
349    // [wifi.direct.interface]: [p2p-dev-wlan0]
350    // [wifi.interface]: [wlan0]
351
352    if (prop) {
353      debug(`Property data: (device pid: ${device.pid}, prop: ${prop}, data: ${results})`);
354      return {
355        [prop]: results,
356      };
357    }
358    const props = parseAdbDeviceProperties(results);
359
360    debug(`Parsed data:`, props);
361
362    return props;
363  } catch (error: any) {
364    // TODO: Ensure error has message and not stderr
365    throw new CommandError(`Failed to get properties for device (${device.pid}): ${error.message}`);
366  }
367}
368
369function parseAdbDeviceProperties(devicePropertiesString: string) {
370  const properties: DeviceProperties = {};
371  const propertyExp = /\[(.*?)\]: \[(.*?)\]/gm;
372  for (const match of devicePropertiesString.matchAll(propertyExp)) {
373    properties[match[1]] = match[2];
374  }
375  return properties;
376}
377
378/**
379 * Sanitize the ADB device name to only get the actual device name.
380 * On Windows, we need to do \r, \n, and \r\n filtering to get the name.
381 */
382export function sanitizeAdbDeviceName(deviceName: string) {
383  return deviceName
384    .trim()
385    .split(/[\r\n]+/)
386    .shift();
387}
388