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(device.pid, 'shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url) 159 ); 160} 161 162/** Runs a generic command watches for common errors in order to throw with an expected code. */ 163async function openAsync(args: string[]): Promise<string> { 164 const results = await getServer().runAsync(args); 165 if ( 166 results.includes(CANT_START_ACTIVITY_ERROR) || 167 results.match(/Error: Activity class .* does not exist\./g) 168 ) { 169 throw new CommandError('APP_NOT_INSTALLED', results.substring(results.indexOf('Error: '))); 170 } 171 return results; 172} 173 174/** Uninstall an app given its Android package name. */ 175export async function uninstallAsync( 176 device: DeviceContext, 177 { appId }: { appId: string } 178): Promise<string> { 179 return await getServer().runAsync(adbArgs(device.pid, 'uninstall', appId)); 180} 181 182/** Get package info from an app based on its Android package name. */ 183export async function getPackageInfoAsync( 184 device: DeviceContext, 185 { appId }: { appId: string } 186): Promise<string> { 187 return await getServer().runAsync(adbArgs(device.pid, 'shell', 'dumpsys', 'package', appId)); 188} 189 190/** Install an app on a connected device. */ 191export async function installAsync(device: DeviceContext, { filePath }: { filePath: string }) { 192 // TODO: Handle the `INSTALL_FAILED_INSUFFICIENT_STORAGE` error. 193 return await getServer().runAsync(adbArgs(device.pid, 'install', '-r', '-d', filePath)); 194} 195 196/** Format ADB args with process ID. */ 197export function adbArgs(pid: Device['pid'], ...options: string[]): string[] { 198 const args = []; 199 if (pid) { 200 args.push('-s', pid); 201 } 202 return args.concat(options); 203} 204 205// TODO: This is very expensive for some operations. 206export async function getAttachedDevicesAsync(): Promise<Device[]> { 207 const output = await getServer().runAsync(['devices', '-l']); 208 209 const splitItems = output.trim().replace(/\n$/, '').split(os.EOL); 210 // First line is `"List of devices attached"`, remove it 211 // @ts-ignore: todo 212 const attachedDevices: { 213 props: string[]; 214 type: Device['type']; 215 isAuthorized: Device['isAuthorized']; 216 }[] = splitItems 217 .slice(1, splitItems.length) 218 .map((line) => { 219 // unauthorized: ['FA8251A00719', 'unauthorized', 'usb:338690048X', 'transport_id:5'] 220 // authorized: ['FA8251A00719', 'device', 'usb:336592896X', 'product:walleye', 'model:Pixel_2', 'device:walleye', 'transport_id:4'] 221 // emulator: ['emulator-5554', 'offline', 'transport_id:1'] 222 const props = line.split(' ').filter(Boolean); 223 224 const isAuthorized = props[1] !== 'unauthorized'; 225 const type = line.includes('emulator') ? 'emulator' : 'device'; 226 return { props, type, isAuthorized }; 227 }) 228 .filter(({ props: [pid] }) => !!pid); 229 230 const devicePromises = attachedDevices.map<Promise<Device>>(async (props) => { 231 const { 232 type, 233 props: [pid, ...deviceInfo], 234 isAuthorized, 235 } = props; 236 237 let name: string | null = null; 238 239 if (type === 'device') { 240 if (isAuthorized) { 241 // Possibly formatted like `model:Pixel_2` 242 // Transform to `Pixel_2` 243 const modelItem = deviceInfo.find((info) => info.includes('model:')); 244 if (modelItem) { 245 name = modelItem.replace('model:', ''); 246 } 247 } 248 // unauthorized devices don't have a name available to read 249 if (!name) { 250 // Device FA8251A00719 251 name = `Device ${pid}`; 252 } 253 } else { 254 // Given an emulator pid, get the emulator name which can be used to start the emulator later. 255 name = (await getAdbNameForDeviceIdAsync({ pid })) ?? ''; 256 } 257 258 return { 259 pid, 260 name, 261 type, 262 isAuthorized, 263 isBooted: true, 264 }; 265 }); 266 267 return Promise.all(devicePromises); 268} 269 270/** 271 * Return the Emulator name for an emulator ID, this can be used to determine if an emulator is booted. 272 * 273 * @param device.pid a value like `emulator-5554` from `abd devices` 274 */ 275export async function getAdbNameForDeviceIdAsync(device: DeviceContext): Promise<string | null> { 276 const results = await getServer().runAsync(adbArgs(device.pid, 'emu', 'avd', 'name')); 277 278 if (results.match(/could not connect to TCP port .*: Connection refused/)) { 279 // Can also occur when the emulator does not exist. 280 throw new CommandError('EMULATOR_NOT_FOUND', results); 281 } 282 283 return sanitizeAdbDeviceName(results) ?? null; 284} 285 286export async function isDeviceBootedAsync({ 287 name, 288}: { name?: string } = {}): Promise<Device | null> { 289 const devices = await getAttachedDevicesAsync(); 290 291 if (!name) { 292 return devices[0] ?? null; 293 } 294 295 return devices.find((device) => device.name === name) ?? null; 296} 297 298/** 299 * Returns true when a device's splash screen animation has stopped. 300 * This can be used to detect when a device is fully booted and ready to use. 301 * 302 * @param pid 303 */ 304export async function isBootAnimationCompleteAsync(pid?: string): Promise<boolean> { 305 try { 306 const props = await getPropertyDataForDeviceAsync({ pid }, PROP_BOOT_ANIMATION_STATE); 307 return !!props[PROP_BOOT_ANIMATION_STATE].match(/stopped/); 308 } catch { 309 return false; 310 } 311} 312 313/** Get a list of ABIs for the provided device. */ 314export async function getDeviceABIsAsync( 315 device: Pick<Device, 'name' | 'pid'> 316): Promise<DeviceABI[]> { 317 const cpuAbiList = (await getPropertyDataForDeviceAsync(device, PROP_CPU_ABI_LIST_NAME))[ 318 PROP_CPU_ABI_LIST_NAME 319 ]; 320 321 if (cpuAbiList) { 322 return cpuAbiList.trim().split(',') as DeviceABI[]; 323 } 324 325 const abi = (await getPropertyDataForDeviceAsync(device, PROP_CPU_NAME))[ 326 PROP_CPU_NAME 327 ] as DeviceABI; 328 return [abi]; 329} 330 331export async function getPropertyDataForDeviceAsync( 332 device: DeviceContext, 333 prop?: string 334): Promise<DeviceProperties> { 335 // @ts-ignore 336 const propCommand = adbArgs(...[device.pid, 'shell', 'getprop', prop].filter(Boolean)); 337 try { 338 // Prevent reading as UTF8. 339 const results = await getServer().getFileOutputAsync(propCommand); 340 // Like: 341 // [wifi.direct.interface]: [p2p-dev-wlan0] 342 // [wifi.interface]: [wlan0] 343 344 if (prop) { 345 debug(`Property data: (device pid: ${device.pid}, prop: ${prop}, data: ${results})`); 346 return { 347 [prop]: results, 348 }; 349 } 350 const props = parseAdbDeviceProperties(results); 351 352 debug(`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