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