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