diff --git a/.nx/version-plans/version-plan-1789372800000.md b/.nx/version-plans/version-plan-1789372800000.md new file mode 100644 index 00000000..a9d0b7d1 --- /dev/null +++ b/.nx/version-plans/version-plan-1789372800000.md @@ -0,0 +1,9 @@ +--- +__default__: patch +--- + +The Android platform now works when Harness runs on a Windows host. Harness +finds the SDK's `adb.exe`, `emulator.exe`, `sdkmanager.bat` and +`avdmanager.bat`, looks in `%LOCALAPPDATA%\Android\Sdk` when `ANDROID_HOME` is +not set, and installs SDK packages and creates emulators without needing +`bash`. diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 98929824..86a91df2 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -10,11 +10,12 @@ import { } from '@react-native-harness/tools'; import { spawn as nodeSpawn } from 'node:child_process'; import type { ChildProcessByStdio } from 'node:child_process'; -import { access, rm } from 'node:fs/promises'; +import { access, appendFile, rm, writeFile } from 'node:fs/promises'; import type { Readable } from 'node:stream'; import { getAvdConfigPath, getAvdDirectory, + getAvdIniPath, readAvdConfig, } from './avd-config.js'; import { @@ -201,12 +202,6 @@ const ensureAvdConfigExists = async (name: string): Promise => { return configPath; }; -const getAvdIniPath = (name: string): string => { - const avdHome = - process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd`; - return `${avdHome}/${name}.ini`; -}; - const ensureAvdIniExists = async ({ name, apiLevel, @@ -217,10 +212,16 @@ const ensureAvdIniExists = async ({ const iniPath = getAvdIniPath(name); const avdDirectory = getAvdDirectory(name); - await spawn('bash', [ - '-lc', - `printf '%s\n%s\n%s\n%s\n' 'avd.ini.encoding=UTF-8' 'path=${avdDirectory}' 'path.rel=avd/${name}.avd' 'target=android-${apiLevel}' > "${iniPath}"`, - ]); + await writeFile( + iniPath, + [ + 'avd.ini.encoding=UTF-8', + `path=${avdDirectory}`, + `path.rel=avd/${name}.avd`, + `target=android-${apiLevel}`, + '', + ].join('\n') + ); return iniPath; }; @@ -500,12 +501,24 @@ export const createAvd = async ({ await verifyAndroidEmulatorSdk(apiLevel); await ensureAvdProfileAvailable(profile); - await spawn('bash', [ - '-lc', - `printf 'no\n' | "${getAvdManagerBinaryPath()}" create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}" -p "${getAvdDirectory( - name - )}"`, - ]); + // avdmanager asks whether to create a custom hardware profile; answer "no". + await spawn( + getAvdManagerBinaryPath(), + [ + 'create', + 'avd', + '--force', + '--name', + name, + '--package', + systemImagePackage, + '--device', + profile, + '-p', + getAvdDirectory(name), + ], + { stdin: { string: 'no\n' } } + ); await ensureAvdIniExists({ name, apiLevel }); const configPath = await ensureAvdConfigExists(name); // hw.cpu.ncore is baked into config.ini (rather than passed as a `-cores` @@ -513,30 +526,25 @@ export const createAvd = async ({ // snapshots require an identical hardware config to load, so the vCPU // count must be fixed at AVD-creation time and stay consistent across // boots. - await spawn('bash', [ - '-lc', - `printf '%s\n%s\n%s\n' 'disk.dataPartition.size=${diskSize}' 'vm.heapSize=${heapSize}' 'hw.cpu.ncore=${getEmulatorCpuCores()}' >> "${configPath}"`, - ]); + await appendFile( + configPath, + [ + `disk.dataPartition.size=${diskSize}`, + `vm.heapSize=${heapSize}`, + `hw.cpu.ncore=${getEmulatorCpuCores()}`, + '', + ].join('\n') + ); }; export const deleteAvd = async (name: string): Promise => { - await rm( - `${ - process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` - }/${name}.avd`, - { - force: true, - recursive: true, - } - ); - await rm( - `${ - process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` - }/${name}.ini`, - { - force: true, - } - ); + await rm(getAvdDirectory(name), { + force: true, + recursive: true, + }); + await rm(getAvdIniPath(name), { + force: true, + }); }; export const startEmulator = async ( diff --git a/packages/platform-android/src/avd-config.ts b/packages/platform-android/src/avd-config.ts index ac54fa6e..d3be0d12 100644 --- a/packages/platform-android/src/avd-config.ts +++ b/packages/platform-android/src/avd-config.ts @@ -1,4 +1,6 @@ import { access, readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import type { AndroidSystemImageArch } from './environment.js'; import type { AndroidEmulator, AndroidEmulatorAVDConfig } from './config.js'; import { getEmulatorCpuCores } from './emulator-startup.js'; @@ -16,14 +18,22 @@ export type AvdCompatibilityResult = | { compatible: true } | { compatible: false; reason: string }; +export const getAvdHome = (): string => { + return ( + process.env.ANDROID_AVD_HOME ?? path.join(os.homedir(), '.android', 'avd') + ); +}; + export const getAvdDirectory = (name: string): string => { - return `${ - process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` - }/${name}.avd`; + return path.join(getAvdHome(), `${name}.avd`); +}; + +export const getAvdIniPath = (name: string): string => { + return path.join(getAvdHome(), `${name}.ini`); }; export const getAvdConfigPath = (name: string): string => { - return `${getAvdDirectory(name)}/config.ini`; + return path.join(getAvdDirectory(name), 'config.ini'); }; const normalizeAvdValue = (value: string | undefined): string | undefined => { diff --git a/packages/platform-android/src/environment.ts b/packages/platform-android/src/environment.ts index 51cb555e..54efbd66 100644 --- a/packages/platform-android/src/environment.ts +++ b/packages/platform-android/src/environment.ts @@ -38,6 +38,14 @@ export const getDefaultUnixAndroidSdkRoot = ({ return path.join(homeDirectory, 'Android', 'Sdk'); } + if (platform === 'win32') { + return path.join( + process.env.LOCALAPPDATA ?? path.join(homeDirectory, 'AppData', 'Local'), + 'Android', + 'Sdk', + ); + } + return null; }; @@ -56,9 +64,8 @@ const pathExists = async (filePath: string): Promise => { } }; -const quoteShell = (value: string): string => { - return `'${value.replace(/'/g, `'\\''`)}'`; -}; +// Enough "y" answers for every license prompt sdkmanager may show. +const SDK_MANAGER_CONSENT_ANSWERS = 100; const downloadText = async (url: string): Promise => { return new Promise((resolve, reject) => { @@ -217,18 +224,16 @@ const ensureAndroidCommandLineTools = async ( } }; -const acceptAndroidLicenses = async (sdkRoot: string): Promise => { - const sdkManagerBinaryPath = getSdkManagerBinaryPath(sdkRoot); - +// Answers "y" to every prompt, like piping `yes` into sdkmanager. +const runSdkManagerWithConsent = async ( + sdkRoot: string, + args: readonly string[], +): Promise => { await spawn( - 'bash', - [ - '-lc', - `yes | ${quoteShell(sdkManagerBinaryPath)} --sdk_root=${quoteShell( - sdkRoot, - )} --licenses >/dev/null`, - ], + getSdkManagerBinaryPath(sdkRoot), + [`--sdk_root=${sdkRoot}`, ...args], { + stdin: { string: 'y\n'.repeat(SDK_MANAGER_CONSENT_ANSWERS) }, env: getAndroidProcessEnv({ ...process.env, ANDROID_HOME: sdkRoot, @@ -238,6 +243,10 @@ const acceptAndroidLicenses = async (sdkRoot: string): Promise => { ); }; +const acceptAndroidLicenses = async (sdkRoot: string): Promise => { + await runSdkManagerWithConsent(sdkRoot, ['--licenses']); +}; + const getPackageVerificationPath = ( sdkRoot: string, packageName: string, @@ -290,33 +299,13 @@ const installAndroidSdkPackages = async ( return; } - const sdkManagerBinaryPath = getSdkManagerBinaryPath(sdkRoot); - const packageArgs = packages - .map((packageName) => quoteShell(packageName)) - .join(' '); - androidEnvironmentLogger.info( 'Installing missing Android SDK packages: %s', packages.join(', '), ); await acceptAndroidLicenses(sdkRoot); - await spawn( - 'bash', - [ - '-lc', - `yes | ${quoteShell(sdkManagerBinaryPath)} --sdk_root=${quoteShell( - sdkRoot, - )} ${packageArgs}`, - ], - { - env: getAndroidProcessEnv({ - ...process.env, - ANDROID_HOME: sdkRoot, - ANDROID_SDK_ROOT: sdkRoot, - }), - }, - ); + await runSdkManagerWithConsent(sdkRoot, packages); }; export const getAndroidSdkRoot = ( @@ -535,20 +524,40 @@ export const initializeAndroidProcessEnv = (): void => { Object.assign(process.env, getAndroidProcessEnv()); }; +// On Windows, SDK binaries ship as `.exe` and command-line tools as `.bat`. +// Spawning handles either form, but existence checks need the real file name. +const getSdkExecutableName = ( + name: string, + windowsExtension: '.exe' | '.bat', + platform: NodeJS.Platform = process.platform, +): string => (platform === 'win32' ? `${name}${windowsExtension}` : name); + export const getAdbBinaryPath = ( sdkRoot: string = getRequiredAndroidSdkRoot(), -): string => path.join(sdkRoot, 'platform-tools', 'adb'); +): string => + path.join(sdkRoot, 'platform-tools', getSdkExecutableName('adb', '.exe')); export const getEmulatorBinaryPath = ( sdkRoot: string = getRequiredAndroidSdkRoot(), -): string => path.join(sdkRoot, 'emulator', 'emulator'); +): string => + path.join(sdkRoot, 'emulator', getSdkExecutableName('emulator', '.exe')); export const getSdkManagerBinaryPath = ( sdkRoot: string = getRequiredAndroidSdkRoot(), ): string => - path.join(sdkRoot, ...CMDLINE_TOOLS_PATH_SEGMENTS, 'bin', 'sdkmanager'); + path.join( + sdkRoot, + ...CMDLINE_TOOLS_PATH_SEGMENTS, + 'bin', + getSdkExecutableName('sdkmanager', '.bat'), + ); export const getAvdManagerBinaryPath = ( sdkRoot: string = getRequiredAndroidSdkRoot(), ): string => - path.join(sdkRoot, ...CMDLINE_TOOLS_PATH_SEGMENTS, 'bin', 'avdmanager'); + path.join( + sdkRoot, + ...CMDLINE_TOOLS_PATH_SEGMENTS, + 'bin', + getSdkExecutableName('avdmanager', '.bat'), + );