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