Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .nx/version-plans/version-plan-1789372800000.md
Original file line number Diff line number Diff line change
@@ -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`.
84 changes: 46 additions & 38 deletions packages/platform-android/src/adb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -201,12 +202,6 @@ const ensureAvdConfigExists = async (name: string): Promise<string> => {
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,
Expand All @@ -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;
};
Expand Down Expand Up @@ -500,43 +501,50 @@ 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`
// boot flag) so it's part of the AVD's persisted hardware profile: boot
// 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<void> => {
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 (
Expand Down
18 changes: 14 additions & 4 deletions packages/platform-android/src/avd-config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 => {
Expand Down
85 changes: 47 additions & 38 deletions packages/platform-android/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand All @@ -56,9 +64,8 @@ const pathExists = async (filePath: string): Promise<boolean> => {
}
};

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<string> => {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -217,18 +224,16 @@ const ensureAndroidCommandLineTools = async (
}
};

const acceptAndroidLicenses = async (sdkRoot: string): Promise<void> => {
const sdkManagerBinaryPath = getSdkManagerBinaryPath(sdkRoot);

// Answers "y" to every prompt, like piping `yes` into sdkmanager.
const runSdkManagerWithConsent = async (
sdkRoot: string,
args: readonly string[],
): Promise<void> => {
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,
Expand All @@ -238,6 +243,10 @@ const acceptAndroidLicenses = async (sdkRoot: string): Promise<void> => {
);
};

const acceptAndroidLicenses = async (sdkRoot: string): Promise<void> => {
await runSdkManagerWithConsent(sdkRoot, ['--licenses']);
};

const getPackageVerificationPath = (
sdkRoot: string,
packageName: string,
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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'),
);
Loading