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 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 '-a', 108 'android.intent.action.RUN', 109 // FLAG_ACTIVITY_SINGLE_TOP -- If set, the activity will not be launched if it is already running at the top of the history stack. 110 '-f', 111 '0x20000000', 112 // Activity to open first: com.bacon.app/.MainActivity 113 '-n', 114 launchActivity 115 ) 116 ); 117} 118 119/** 120 * @param device.pid Process ID of the Android device to launch. 121 * @param props.applicationId package name to launch. 122 */ 123export async function openAppIdAsync( 124 device: DeviceContext, 125 { 126 applicationId, 127 }: { 128 applicationId: string; 129 } 130) { 131 return openAsync( 132 adbArgs( 133 device.pid, 134 'shell', 135 'monkey', 136 '-p', 137 applicationId, 138 '-c', 139 'android.intent.category.LAUNCHER', 140 '1' 141 ) 142 ); 143} 144 145/** 146 * @param device.pid Process ID of the Android device to launch. 147 * @param props.url URL to launch. 148 */ 149export async function openUrlAsync( 150 device: DeviceContext, 151 { 152 url, 153 }: { 154 url: string; 155 } 156) { 157 return openAsync( 158 adbArgs( 159 device.pid, 160 'shell', 161 'am', 162 'start', 163 '-a', 164 'android.intent.action.VIEW', 165 '-d', 166 // ADB requires ampersands to be escaped. 167 url.replace(/&/g, String.raw`\&`) 168 ) 169 ); 170} 171 172/** Runs a generic command watches for common errors in order to throw with an expected code. */ 173async function openAsync(args: string[]): Promise<string> { 174 const results = await getServer().runAsync(args); 175 if ( 176 results.includes(CANT_START_ACTIVITY_ERROR) || 177 results.match(/Error: Activity class .* does not exist\./g) 178 ) { 179 throw new CommandError('APP_NOT_INSTALLED', results.substring(results.indexOf('Error: '))); 180 } 181 return results; 182} 183 184/** Uninstall an app given its Android package name. */ 185export async function uninstallAsync( 186 device: DeviceContext, 187 { appId }: { appId: string } 188): Promise<string> { 189 return await getServer().runAsync(adbArgs(device.pid, 'uninstall', appId)); 190} 191 192/** Get package info from an app based on its Android package name. */ 193export async function getPackageInfoAsync( 194 device: DeviceContext, 195 { appId }: { appId: string } 196): Promise<string> { 197 return await getServer().runAsync(adbArgs(device.pid, 'shell', 'dumpsys', 'package', appId)); 198} 199 200/** Install an app on a connected device. */ 201export async function installAsync(device: DeviceContext, { filePath }: { filePath: string }) { 202 // TODO: Handle the `INSTALL_FAILED_INSUFFICIENT_STORAGE` error. 203 return await getServer().runAsync(adbArgs(device.pid, 'install', '-r', '-d', filePath)); 204} 205 206/** Format ADB args with process ID. */ 207export function adbArgs(pid: Device['pid'], ...options: string[]): string[] { 208 const args = []; 209 if (pid) { 210 args.push('-s', pid); 211 } 212 return args.concat(options); 213} 214 215// TODO: This is very expensive for some operations. 216export async function getAttachedDevicesAsync(): Promise<Device[]> { 217 const output = await getServer().runAsync(['devices', '-l']); 218 219 const splitItems = output.trim().replace(/\n$/, '').split(os.EOL); 220 // First line is `"List of devices attached"`, remove it 221 // @ts-ignore: todo 222 const attachedDevices: { 223 props: string[]; 224 type: Device['type']; 225 isAuthorized: Device['isAuthorized']; 226 }[] = splitItems 227 .slice(1, splitItems.length) 228 .map((line) => { 229 // unauthorized: ['FA8251A00719', 'unauthorized', 'usb:338690048X', 'transport_id:5'] 230 // authorized: ['FA8251A00719', 'device', 'usb:336592896X', 'product:walleye', 'model:Pixel_2', 'device:walleye', 'transport_id:4'] 231 // emulator: ['emulator-5554', 'offline', 'transport_id:1'] 232 const props = line.split(' ').filter(Boolean); 233 234 const isAuthorized = props[1] !== 'unauthorized'; 235 const type = line.includes('emulator') ? 'emulator' : 'device'; 236 return { props, type, isAuthorized }; 237 }) 238 .filter(({ props: [pid] }) => !!pid); 239 240 const devicePromises = attachedDevices.map<Promise<Device>>(async (props) => { 241 const { 242 type, 243 props: [pid, ...deviceInfo], 244 isAuthorized, 245 } = props; 246 247 let name: string | null = null; 248 249 if (type === 'device') { 250 if (isAuthorized) { 251 // Possibly formatted like `model:Pixel_2` 252 // Transform to `Pixel_2` 253 const modelItem = deviceInfo.find((info) => info.includes('model:')); 254 if (modelItem) { 255 name = modelItem.replace('model:', ''); 256 } 257 } 258 // unauthorized devices don't have a name available to read 259 if (!name) { 260 // Device FA8251A00719 261 name = `Device ${pid}`; 262 } 263 } else { 264 // Given an emulator pid, get the emulator name which can be used to start the emulator later. 265 name = (await getAdbNameForDeviceIdAsync({ pid })) ?? ''; 266 } 267 268 return { 269 pid, 270 name, 271 type, 272 isAuthorized, 273 isBooted: true, 274 }; 275 }); 276 277 return Promise.all(devicePromises); 278} 279 280/** 281 * Return the Emulator name for an emulator ID, this can be used to determine if an emulator is booted. 282 * 283 * @param device.pid a value like `emulator-5554` from `abd devices` 284 */ 285export async function getAdbNameForDeviceIdAsync(device: DeviceContext): Promise<string | null> { 286 const results = await getServer().runAsync(adbArgs(device.pid, 'emu', 'avd', 'name')); 287 288 if (results.match(/could not connect to TCP port .*: Connection refused/)) { 289 // Can also occur when the emulator does not exist. 290 throw new CommandError('EMULATOR_NOT_FOUND', results); 291 } 292 293 return sanitizeAdbDeviceName(results) ?? null; 294} 295 296export async function isDeviceBootedAsync({ 297 name, 298}: { name?: string } = {}): Promise<Device | null> { 299 const devices = await getAttachedDevicesAsync(); 300 301 if (!name) { 302 return devices[0] ?? null; 303 } 304 305 return devices.find((device) => device.name === name) ?? null; 306} 307 308/** 309 * Returns true when a device's splash screen animation has stopped. 310 * This can be used to detect when a device is fully booted and ready to use. 311 * 312 * @param pid 313 */ 314export async function isBootAnimationCompleteAsync(pid?: string): Promise<boolean> { 315 try { 316 const props = await getPropertyDataForDeviceAsync({ pid }, PROP_BOOT_ANIMATION_STATE); 317 return !!props[PROP_BOOT_ANIMATION_STATE].match(/stopped/); 318 } catch { 319 return false; 320 } 321} 322 323/** Get a list of ABIs for the provided device. */ 324export async function getDeviceABIsAsync( 325 device: Pick<Device, 'name' | 'pid'> 326): Promise<DeviceABI[]> { 327 const cpuAbiList = (await getPropertyDataForDeviceAsync(device, PROP_CPU_ABI_LIST_NAME))[ 328 PROP_CPU_ABI_LIST_NAME 329 ]; 330 331 if (cpuAbiList) { 332 return cpuAbiList.trim().split(',') as DeviceABI[]; 333 } 334 335 const abi = (await getPropertyDataForDeviceAsync(device, PROP_CPU_NAME))[ 336 PROP_CPU_NAME 337 ] as DeviceABI; 338 return [abi]; 339} 340 341export async function getPropertyDataForDeviceAsync( 342 device: DeviceContext, 343 prop?: string 344): Promise<DeviceProperties> { 345 // @ts-ignore 346 const propCommand = adbArgs(...[device.pid, 'shell', 'getprop', prop].filter(Boolean)); 347 try { 348 // Prevent reading as UTF8. 349 const results = await getServer().getFileOutputAsync(propCommand); 350 // Like: 351 // [wifi.direct.interface]: [p2p-dev-wlan0] 352 // [wifi.interface]: [wlan0] 353 354 if (prop) { 355 debug(`Property data: (device pid: ${device.pid}, prop: ${prop}, data: ${results})`); 356 return { 357 [prop]: results, 358 }; 359 } 360 const props = parseAdbDeviceProperties(results); 361 362 debug(`Parsed data:`, props); 363 364 return props; 365 } catch (error: any) { 366 // TODO: Ensure error has message and not stderr 367 throw new CommandError(`Failed to get properties for device (${device.pid}): ${error.message}`); 368 } 369} 370 371function parseAdbDeviceProperties(devicePropertiesString: string) { 372 const properties: DeviceProperties = {}; 373 const propertyExp = /\[(.*?)\]: \[(.*?)\]/gm; 374 for (const match of devicePropertiesString.matchAll(propertyExp)) { 375 properties[match[1]] = match[2]; 376 } 377 return properties; 378} 379 380/** 381 * Sanitize the ADB device name to only get the actual device name. 382 * On Windows, we need to do \r, \n, and \r\n filtering to get the name. 383 */ 384export function sanitizeAdbDeviceName(deviceName: string) { 385 return deviceName 386 .trim() 387 .split(/[\r\n]+/) 388 .shift(); 389} 390