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