1import chalk from 'chalk'; 2import os from 'os'; 3 4import * as Log from '../../../log'; 5import { CommandError } from '../../../utils/errors'; 6import { learnMore } from '../../../utils/link'; 7import { ADBServer } from './ADBServer'; 8 9/** Represents a connected Android device. */ 10export type Device = { 11 /** Process ID. */ 12 pid?: string; 13 /** Name of the device, also used as the ID for opening devices. */ 14 name: string; 15 /** Is emulator or connected device. */ 16 type: 'emulator' | 'device'; 17 /** Is the device booted (emulator). */ 18 isBooted: boolean; 19 /** Is device authorized for developing. https://expo.fyi/authorize-android-device */ 20 isAuthorized: boolean; 21}; 22 23type DeviceContext = Pick<Device, 'pid'>; 24 25type DeviceProperties = Record<string, string>; 26 27const CANT_START_ACTIVITY_ERROR = 'Activity not started, unable to resolve Intent'; 28 29let _server: ADBServer | null; 30 31/** Return the lazily loaded ADB server instance. */ 32export function getServer() { 33 _server ??= new ADBServer(); 34 return _server; 35} 36 37/** Logs an FYI message about authorizing your device. */ 38export function logUnauthorized(device: Device) { 39 Log.warn( 40 `\nThis computer is not authorized for developing on ${chalk.bold(device.name)}. ${chalk.dim( 41 learnMore('https://expo.fyi/authorize-android-device') 42 )}` 43 ); 44} 45 46/** Returns true if the provided package name is installed on the provided Android device. */ 47export async function isPackageInstalledAsync( 48 device: DeviceContext, 49 androidPackage: string 50): Promise<boolean> { 51 const packages = await getServer().runAsync( 52 adbArgs(device.pid, 'shell', 'pm', 'list', 'packages', androidPackage) 53 ); 54 55 const lines = packages.split(/\r?\n/); 56 for (let i = 0; i < lines.length; i++) { 57 const line = lines[i].trim(); 58 if (line === `package:${androidPackage}`) { 59 return true; 60 } 61 } 62 return false; 63} 64 65/** 66 * @param device.pid Process ID of the Android device to launch. 67 * @param props.launchActivity Activity to launch `[application identifier]/.[main activity name]`, ex: `com.bacon.app/.MainActivity` 68 */ 69export async function launchActivityAsync( 70 device: DeviceContext, 71 { 72 launchActivity, 73 }: { 74 launchActivity: string; 75 } 76) { 77 return openAsync( 78 adbArgs( 79 device.pid, 80 'shell', 81 'am', 82 'start', 83 '-a', 84 'android.intent.action.RUN', 85 // FLAG_ACTIVITY_SINGLE_TOP -- If set, the activity will not be launched if it is already running at the top of the history stack. 86 '-f', 87 '0x20000000', 88 // Activity to open first: com.bacon.app/.MainActivity 89 '-n', 90 launchActivity 91 ) 92 ); 93} 94 95/** 96 * @param device.pid Process ID of the Android device to launch. 97 * @param props.applicationId package name to launch. 98 */ 99export async function openAppIdAsync( 100 device: DeviceContext, 101 { 102 applicationId, 103 }: { 104 applicationId: string; 105 } 106) { 107 return openAsync( 108 adbArgs( 109 device.pid, 110 'shell', 111 'monkey', 112 '-p', 113 applicationId, 114 '-c', 115 'android.intent.category.LAUNCHER', 116 '1' 117 ) 118 ); 119} 120 121/** 122 * @param device.pid Process ID of the Android device to launch. 123 * @param props.url URL to launch. 124 */ 125export async function openUrlAsync( 126 device: DeviceContext, 127 { 128 url, 129 }: { 130 url: string; 131 } 132) { 133 return openAsync( 134 adbArgs(device.pid, 'shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url) 135 ); 136} 137 138/** Runs a generic command watches for common errors in order to throw with an expected code. */ 139async function openAsync(args: string[]): Promise<string> { 140 const results = await getServer().runAsync(args); 141 if ( 142 results.includes(CANT_START_ACTIVITY_ERROR) || 143 results.match(/Error: Activity class .* does not exist\./g) 144 ) { 145 throw new CommandError('APP_NOT_INSTALLED', results.substring(results.indexOf('Error: '))); 146 } 147 return results; 148} 149 150/** Uninstall an app given its Android package name. */ 151export async function uninstallAsync( 152 device: DeviceContext, 153 { appId }: { appId: string } 154): Promise<string> { 155 return await getServer().runAsync(adbArgs(device.pid, 'uninstall', appId)); 156} 157 158/** Get package info from an app based on its Android package name. */ 159export async function getPackageInfoAsync( 160 device: DeviceContext, 161 { appId }: { appId: string } 162): Promise<string> { 163 return await getServer().runAsync(adbArgs(device.pid, 'shell', 'dumpsys', 'package', appId)); 164} 165 166/** Install an app on a connected device. */ 167export async function installAsync(device: DeviceContext, { filePath }: { filePath: string }) { 168 // TODO: Handle the `INSTALL_FAILED_INSUFFICIENT_STORAGE` error. 169 return await getServer().runAsync(adbArgs(device.pid, 'install', '-r', '-d', filePath)); 170} 171 172/** Format ADB args with process ID. */ 173export function adbArgs(pid: Device['pid'], ...options: string[]): string[] { 174 const args = []; 175 if (pid) { 176 args.push('-s', pid); 177 } 178 return args.concat(options); 179} 180 181// TODO: This is very expensive for some operations. 182export async function getAttachedDevicesAsync(): Promise<Device[]> { 183 const output = await getServer().runAsync(['devices', '-l']); 184 185 const splitItems = output.trim().replace(/\n$/, '').split(os.EOL); 186 // First line is `"List of devices attached"`, remove it 187 // @ts-ignore: todo 188 const attachedDevices: { 189 props: string[]; 190 type: Device['type']; 191 isAuthorized: Device['isAuthorized']; 192 }[] = splitItems 193 .slice(1, splitItems.length) 194 .map((line) => { 195 // unauthorized: ['FA8251A00719', 'unauthorized', 'usb:338690048X', 'transport_id:5'] 196 // authorized: ['FA8251A00719', 'device', 'usb:336592896X', 'product:walleye', 'model:Pixel_2', 'device:walleye', 'transport_id:4'] 197 // emulator: ['emulator-5554', 'offline', 'transport_id:1'] 198 const props = line.split(' ').filter(Boolean); 199 200 const isAuthorized = props[1] !== 'unauthorized'; 201 const type = line.includes('emulator') ? 'emulator' : 'device'; 202 return { props, type, isAuthorized }; 203 }) 204 .filter(({ props: [pid] }) => !!pid); 205 206 const devicePromises = attachedDevices.map<Promise<Device>>(async (props) => { 207 const { 208 type, 209 props: [pid, ...deviceInfo], 210 isAuthorized, 211 } = props; 212 213 let name: string | null = null; 214 215 if (type === 'device') { 216 if (isAuthorized) { 217 // Possibly formatted like `model:Pixel_2` 218 // Transform to `Pixel_2` 219 const modelItem = deviceInfo.find((info) => info.includes('model:')); 220 if (modelItem) { 221 name = modelItem.replace('model:', ''); 222 } 223 } 224 // unauthorized devices don't have a name available to read 225 if (!name) { 226 // Device FA8251A00719 227 name = `Device ${pid}`; 228 } 229 } else { 230 // Given an emulator pid, get the emulator name which can be used to start the emulator later. 231 name = (await getAdbNameForDeviceIdAsync({ pid })) ?? ''; 232 } 233 234 return { 235 pid, 236 name, 237 type, 238 isAuthorized, 239 isBooted: true, 240 }; 241 }); 242 243 return Promise.all(devicePromises); 244} 245 246/** 247 * Return the Emulator name for an emulator ID, this can be used to determine if an emulator is booted. 248 * 249 * @param device.pid a value like `emulator-5554` from `abd devices` 250 */ 251export async function getAdbNameForDeviceIdAsync(device: DeviceContext): Promise<string | null> { 252 const results = await getServer().runAsync(adbArgs(device.pid, 'emu', 'avd', 'name')); 253 254 if (results.match(/could not connect to TCP port .*: Connection refused/)) { 255 // Can also occur when the emulator does not exist. 256 throw new CommandError('EMULATOR_NOT_FOUND', results); 257 } 258 259 return results.trim().split(/\r?\n/).shift() ?? null; 260} 261 262export async function isDeviceBootedAsync({ 263 name, 264}: { name?: string } = {}): Promise<Device | null> { 265 const devices = await getAttachedDevicesAsync(); 266 267 if (!name) { 268 return devices[0] ?? null; 269 } 270 271 return devices.find((device) => device.name === name) ?? null; 272} 273 274// Can sometimes be null 275// http://developer.android.com/ndk/guides/abis.html 276const PROP_BOOT_ANIMATION_STATE = 'init.svc.bootanim'; 277 278/** 279 * Returns true when a device's splash screen animation has stopped. 280 * This can be used to detect when a device is fully booted and ready to use. 281 * 282 * @param pid 283 */ 284export async function isBootAnimationCompleteAsync(pid?: string): Promise<boolean> { 285 try { 286 const props = await getPropertyDataForDeviceAsync({ pid }, PROP_BOOT_ANIMATION_STATE); 287 return !!props[PROP_BOOT_ANIMATION_STATE].match(/stopped/); 288 } catch { 289 return false; 290 } 291} 292 293export async function getPropertyDataForDeviceAsync( 294 device: DeviceContext, 295 prop?: string 296): Promise<DeviceProperties> { 297 // @ts-ignore 298 const propCommand = adbArgs(...[device.pid, 'shell', 'getprop', prop].filter(Boolean)); 299 try { 300 // Prevent reading as UTF8. 301 const results = await getServer().getFileOutputAsync(propCommand); 302 // Like: 303 // [wifi.direct.interface]: [p2p-dev-wlan0] 304 // [wifi.interface]: [wlan0] 305 306 if (prop) { 307 return { 308 [prop]: results, 309 }; 310 } 311 return parseAdbDeviceProperties(results); 312 } catch (error: any) { 313 // TODO: Ensure error has message and not stderr 314 throw new CommandError(`Failed to get properties for device (${device.pid}): ${error.message}`); 315 } 316} 317 318function parseAdbDeviceProperties(devicePropertiesString: string) { 319 const properties: DeviceProperties = {}; 320 const propertyExp = /\[(.*?)\]: \[(.*?)\]/gm; 321 for (const match of devicePropertiesString.matchAll(propertyExp)) { 322 properties[match[1]] = match[2]; 323 } 324 return properties; 325} 326