1import spawnAsync from '@expo/spawn-async';
2import { execFileSync } from 'child_process';
3
4import * as Log from '../../../log';
5import { AbortCommandError } from '../../../utils/errors';
6import { installExitHooks } from '../../../utils/exit';
7
8const BEGINNING_OF_ADB_ERROR_MESSAGE = 'error: ';
9
10// This is a tricky class since it controls a system state (side-effects).
11// A more ideal solution would be to implement ADB in JS.
12// The main reason this is a class is to control the flow of testing.
13
14export class ADBServer {
15  isRunning: boolean = false;
16  removeExitHook: () => void = () => {};
17
18  /** Returns the command line reference to ADB. */
19  getAdbExecutablePath(): string {
20    // https://developer.android.com/studio/command-line/variables
21    // TODO: Add ANDROID_SDK_ROOT support as well https://github.com/expo/expo/pull/16516#discussion_r820037917
22    if (process.env.ANDROID_HOME) {
23      return `${process.env.ANDROID_HOME}/platform-tools/adb`;
24    }
25    return 'adb';
26  }
27
28  /** Start the ADB server. */
29  async startAsync(): Promise<boolean> {
30    if (this.isRunning) {
31      return false;
32    }
33    // clean up
34    this.removeExitHook = installExitHooks(() => {
35      if (this.isRunning) {
36        this.stopAsync();
37      }
38    });
39    const adb = this.getAdbExecutablePath();
40    const result = await this.resolveAdbPromise(spawnAsync(adb, ['start-server']));
41    const lines = result.stderr.trim().split(/\r?\n/);
42    const isStarted = lines.includes('* daemon started successfully');
43    this.isRunning = isStarted;
44    return isStarted;
45  }
46
47  /** Kill the ADB server. */
48  async stopAsync(): Promise<boolean> {
49    Log.debug('Stopping ADB server');
50
51    if (!this.isRunning) {
52      Log.debug('ADB server is not running');
53      return false;
54    }
55    this.removeExitHook();
56    try {
57      await this.runAsync(['kill-server']);
58      return true;
59    } catch (error: any) {
60      Log.error('Failed to stop ADB server: ' + error.message);
61      return false;
62    } finally {
63      Log.debug('Stopped ADB server');
64      this.isRunning = false;
65    }
66  }
67
68  /** Execute an ADB command with given args. */
69  async runAsync(args: string[]): Promise<string> {
70    // TODO: Add a global package that installs adb to the path.
71    const adb = this.getAdbExecutablePath();
72
73    await this.startAsync();
74
75    Log.debug([adb, ...args].join(' '));
76    const result = await this.resolveAdbPromise(spawnAsync(adb, args));
77    return result.output.join('\n');
78  }
79
80  /** Get ADB file output. Useful for reading device state/settings. */
81  async getFileOutputAsync(args: string[]): Promise<string> {
82    // TODO: Add a global package that installs adb to the path.
83    const adb = this.getAdbExecutablePath();
84
85    await this.startAsync();
86
87    const results = await this.resolveAdbPromise(
88      execFileSync(adb, args, {
89        encoding: 'latin1',
90        stdio: 'pipe',
91      })
92    );
93    Log.debug('[ADB] File output:\n', results);
94    return results;
95  }
96
97  /** Formats error info. */
98  async resolveAdbPromise<T>(promise: T | Promise<T>): Promise<T> {
99    try {
100      return await promise;
101    } catch (error: any) {
102      // User pressed ctrl+c to cancel the process...
103      if (error.signal === 'SIGINT') {
104        throw new AbortCommandError();
105      }
106      // TODO: Support heap corruption for adb 29 (process exits with code -1073740940) (windows and linux)
107      let errorMessage = (error.stderr || error.stdout || error.message).trim();
108      if (errorMessage.startsWith(BEGINNING_OF_ADB_ERROR_MESSAGE)) {
109        errorMessage = errorMessage.substring(BEGINNING_OF_ADB_ERROR_MESSAGE.length);
110      }
111      error.message = errorMessage;
112      throw error;
113    }
114  }
115}
116