1import assert from 'assert';
2import chalk from 'chalk';
3
4import * as Log from '../../../log';
5import { AbortCommandError, CommandError } from '../../../utils/errors';
6import { validateUrl } from '../../../utils/url';
7import { DeviceManager } from '../DeviceManager';
8import { ExpoGoInstaller } from '../ExpoGoInstaller';
9import { BaseResolveDeviceProps } from '../PlatformManager';
10import { activateWindowAsync } from './activateWindow';
11import * as AndroidDebugBridge from './adb';
12import { startDeviceAsync } from './emulator';
13import { getDevicesAsync } from './getDevices';
14import { promptForDeviceAsync } from './promptAndroidDevice';
15
16const EXPO_GO_APPLICATION_IDENTIFIER = 'host.exp.exponent';
17
18export class AndroidDeviceManager extends DeviceManager<AndroidDebugBridge.Device> {
19  static async resolveAsync({
20    device,
21    shouldPrompt,
22  }: BaseResolveDeviceProps<AndroidDebugBridge.Device> = {}): Promise<AndroidDeviceManager> {
23    if (device) {
24      const manager = new AndroidDeviceManager(device);
25      if (!(await manager.attemptToStartAsync())) {
26        throw new AbortCommandError();
27      }
28      return manager;
29    }
30
31    const devices = await getDevicesAsync();
32    const _device = shouldPrompt ? await promptForDeviceAsync(devices) : devices[0];
33    return AndroidDeviceManager.resolveAsync({ device: _device, shouldPrompt: false });
34  }
35
36  get name() {
37    // TODO: Maybe strip `_` from the device name?
38    return this.device.name;
39  }
40
41  get identifier(): string {
42    return this.device.pid ?? 'unknown';
43  }
44
45  async getAppVersionAsync(applicationId: string): Promise<string | null> {
46    const info = await AndroidDebugBridge.getPackageInfoAsync(this.device, {
47      appId: applicationId,
48    });
49
50    const regex = /versionName=([0-9.]+)/;
51    return regex.exec(info)?.[1] ?? null;
52  }
53
54  protected async attemptToStartAsync(): Promise<AndroidDebugBridge.Device | null> {
55    // TODO: Add a light-weight method for checking since a device could disconnect.
56    if (!(await AndroidDebugBridge.isDeviceBootedAsync(this.device))) {
57      this.device = await startDeviceAsync(this.device);
58    }
59
60    if (!this.device.isAuthorized) {
61      AndroidDebugBridge.logUnauthorized(this.device);
62      return null;
63    }
64
65    return this.device;
66  }
67
68  async startAsync(): Promise<AndroidDebugBridge.Device> {
69    const device = await this.attemptToStartAsync();
70    assert(device, `Failed to boot emulator.`);
71    return this.device;
72  }
73
74  async installAppAsync(binaryPath: string) {
75    await AndroidDebugBridge.installAsync(this.device, {
76      filePath: binaryPath,
77    });
78  }
79
80  async uninstallAppAsync(appId: string) {
81    // we need to check if the app is installed, else we might bump into "Failure [DELETE_FAILED_INTERNAL_ERROR]"
82    const isInstalled = await this.isAppInstalledAsync(appId);
83    if (!isInstalled) {
84      return;
85    }
86
87    try {
88      await AndroidDebugBridge.uninstallAsync(this.device, {
89        appId,
90      });
91    } catch (e) {
92      Log.error(
93        `Could not uninstall app "${appId}" from your device, please uninstall it manually and try again.`
94      );
95      throw e;
96    }
97  }
98
99  /**
100   * @param launchActivity Activity to launch `[application identifier]/.[main activity name]`, ex: `com.bacon.app/.MainActivity`
101   */
102  async launchActivityAsync(launchActivity: string): Promise<string> {
103    try {
104      return await AndroidDebugBridge.launchActivityAsync(this.device, {
105        launchActivity,
106      });
107    } catch (error: any) {
108      let errorMessage = `Couldn't open Android app with activity "${launchActivity}" on device "${this.name}".`;
109      if (error instanceof CommandError && error.code === 'APP_NOT_INSTALLED') {
110        errorMessage += `\nThe app might not be installed, try installing it with: ${chalk.bold(
111          `npx expo run:android -d ${this.name}`
112        )}`;
113      }
114      errorMessage += chalk.gray(`\n${error.message}`);
115      error.message = errorMessage;
116      throw error;
117    }
118  }
119
120  async isAppInstalledAsync(applicationId: string) {
121    return await AndroidDebugBridge.isPackageInstalledAsync(this.device, applicationId);
122  }
123
124  async openUrlAsync(url: string) {
125    // Non-compliant URLs will be treated as application identifiers.
126    if (!validateUrl(url, { requireProtocol: true })) {
127      await this.launchActivityAsync(url);
128      return;
129    }
130
131    const parsed = new URL(url);
132
133    if (parsed.protocol === 'exp:') {
134      // NOTE(brentvatne): temporary workaround! launch Expo Go first, then
135      // launch the project!
136      // https://github.com/expo/expo/issues/7772
137      // adb shell monkey -p host.exp.exponent -c android.intent.category.LAUNCHER 1
138      // Note: this is not needed in Expo Development Client, it only applies to Expo Go
139      await AndroidDebugBridge.openAppIdAsync(
140        { pid: this.device.pid },
141        { applicationId: EXPO_GO_APPLICATION_IDENTIFIER }
142      );
143    }
144
145    await AndroidDebugBridge.openUrlAsync({ pid: this.device.pid }, { url });
146  }
147
148  async activateWindowAsync() {
149    // Bring the emulator window to the front on macos devices.
150    await activateWindowAsync(this.device);
151  }
152
153  async ensureExpoGoAsync(sdkVersion?: string): Promise<boolean> {
154    const installer = new ExpoGoInstaller('android', EXPO_GO_APPLICATION_IDENTIFIER, sdkVersion);
155    return installer.ensureAsync(this);
156  }
157}
158