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