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