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