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