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