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