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    if (!this.isRunning) {
50      return false;
51    }
52    this.removeExitHook();
53    try {
54      await this.runAsync(['kill-server']);
55      return true;
56    } catch (error: any) {
57      Log.error('Failed to stop ADB server: ' + error.message);
58      return false;
59    } finally {
60      this.isRunning = false;
61    }
62  }
63
64  /** Execute an ADB command with given args. */
65  async runAsync(args: string[]): Promise<string> {
66    // TODO: Add a global package that installs adb to the path.
67    const adb = this.getAdbExecutablePath();
68
69    await this.startAsync();
70
71    Log.debug([adb, ...args].join(' '));
72    const result = await this.resolveAdbPromise(spawnAsync(adb, args));
73    return result.output.join('\n');
74  }
75
76  /** Get ADB file output. Useful for reading device state/settings. */
77  async getFileOutputAsync(args: string[]): Promise<string> {
78    // TODO: Add a global package that installs adb to the path.
79    const adb = this.getAdbExecutablePath();
80
81    await this.startAsync();
82
83    return await this.resolveAdbPromise(
84      execFileSync(adb, args, {
85        encoding: 'latin1',
86        stdio: 'pipe',
87      })
88    );
89  }
90
91  /** Formats error info. */
92  async resolveAdbPromise<T>(promise: T | Promise<T>): Promise<T> {
93    try {
94      return await promise;
95    } catch (error: any) {
96      // User pressed ctrl+c to cancel the process...
97      if (error.signal === 'SIGINT') {
98        throw new AbortCommandError();
99      }
100      // TODO: Support heap corruption for adb 29 (process exits with code -1073740940) (windows and linux)
101      let errorMessage = (error.stderr || error.stdout || error.message).trim();
102      if (errorMessage.startsWith(BEGINNING_OF_ADB_ERROR_MESSAGE)) {
103        errorMessage = errorMessage.substring(BEGINNING_OF_ADB_ERROR_MESSAGE.length);
104      }
105      error.message = errorMessage;
106      throw error;
107    }
108  }
109}
110