feat(tools-android): add primitives for build and run commands (#3322)

This commit is contained in:
Tommy Nguyen 2024-08-30 09:00:19 +02:00 коммит произвёл GitHub
Родитель 923c91f6db
Коммит c2023a7e05
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
21 изменённых файлов: 565 добавлений и 319 удалений

Просмотреть файл

@ -0,0 +1,7 @@
---
"@rnx-kit/cli": patch
---
Added Android support to the experimental commands for building and running
apps. Again, this still needs more testing and will not be publicly available
yet.

Просмотреть файл

@ -0,0 +1,5 @@
---
"@rnx-kit/tools-android": patch
---
Added primitives for building 'build' and 'run' commands

Просмотреть файл

@ -50,6 +50,7 @@
"@rnx-kit/metro-serializer-esbuild": "^0.1.38",
"@rnx-kit/metro-service": "^3.1.6",
"@rnx-kit/third-party-notices": "^1.3.4",
"@rnx-kit/tools-android": "^0.1.0",
"@rnx-kit/tools-apple": "^0.1.2",
"@rnx-kit/tools-language": "^2.0.0",
"@rnx-kit/tools-node": "^2.1.1",

Просмотреть файл

@ -1,12 +1,13 @@
import type { Config } from "@react-native-community/cli-types";
import { InvalidArgumentError } from "commander";
import { buildAndroid } from "./build/android";
import { buildIOS } from "./build/ios";
import { buildMacOS } from "./build/macos";
import type {
BuildConfiguration,
DeviceType,
InputParams,
} from "./build/apple";
import { buildIOS } from "./build/ios";
import { buildMacOS } from "./build/macos";
} from "./build/types";
function asConfiguration(configuration: string): BuildConfiguration {
switch (configuration) {
@ -35,13 +36,14 @@ function asDestination(destination: string): DeviceType {
function asSupportedPlatform(platform: string): InputParams["platform"] {
switch (platform) {
case "android":
case "ios":
case "macos":
case "visionos":
return platform;
default:
throw new InvalidArgumentError(
"Supported platforms: 'ios', 'macos', 'visionos'."
"Supported platforms: 'android', 'ios', 'macos', 'visionos'."
);
}
}
@ -52,9 +54,13 @@ export function rnxBuild(
buildParams: InputParams
) {
switch (buildParams.platform) {
case "android":
return buildAndroid(config, buildParams);
case "ios":
case "visionos":
return buildIOS(config, buildParams);
case "macos":
return buildMacOS(config, buildParams);
}
@ -78,7 +84,7 @@ export const rnxBuildCommand = {
},
{
name: "--scheme <string>",
description: "Name of scheme to build",
description: "Name of scheme to build (Apple platforms only)",
},
{
name: "--configuration <string>",

Просмотреть файл

@ -0,0 +1,39 @@
import type { Config } from "@react-native-community/cli-types";
import ora from "ora";
import type { AndroidBuildParams } from "./types";
export async function buildAndroid(
config: Config,
buildParams: AndroidBuildParams,
logger = ora()
): Promise<string | number | null> {
const { sourceDir } = config.project.android ?? {};
if (!sourceDir) {
logger.fail("No Android project was found");
process.exitCode = 1;
return null;
}
return import("@rnx-kit/tools-android").then(({ assemble }) => {
return new Promise((resolve) => {
logger.start("Building");
const errors: Buffer[] = [];
const gradle = assemble(sourceDir, buildParams);
gradle.stdout.on("data", () => (logger.text += "."));
gradle.stderr.on("data", (data) => errors.push(data));
gradle.on("close", (code) => {
if (code === 0) {
logger.succeed("Build succeeded");
resolve(sourceDir);
} else {
logger.fail(Buffer.concat(errors).toString());
process.exitCode = code ?? 1;
resolve(code);
}
});
});
});
}

Просмотреть файл

@ -1,32 +1,5 @@
import type { Ora } from "ora";
// Copy of types from `@rnx-kit/tools-apple`. If Jest wasn't such a pain to
// configure, we would have added an `import type` at the top instead:
//
// import type { BuildParams } from "@rnx-kit/tools-apple" with { "resolution-mode": "import" };
//
// But Jest doesn't like import attributes and it doesn't matter if we add
// `@babel/plugin-syntax-import-attributes` in the config.
//
// TOOD: Remove the `DeviceType`, `BuildConfiguration` and `BuildParams` when we
// can migrate away from Jest in this package.
export type DeviceType = "device" | "emulator" | "simulator";
export type BuildConfiguration = "Debug" | "Release";
type BuildParams =
| {
platform: "ios" | "visionos";
scheme?: string;
destination?: DeviceType;
configuration?: BuildConfiguration;
archs?: string;
isBuiltRemotely?: boolean;
}
| {
platform: "macos";
scheme?: string;
configuration?: BuildConfiguration;
isBuiltRemotely?: boolean;
};
import type { AppleBuildParams } from "./types";
export type BuildArgs = {
xcworkspace: string;
@ -35,19 +8,14 @@ export type BuildArgs = {
export type BuildResult = BuildArgs | number | null;
export type InputParams = BuildParams & {
device?: string;
workspace?: string;
};
export function runBuild(
xcworkspace: string,
buildParams: BuildParams,
buildParams: AppleBuildParams,
logger: Ora
): Promise<BuildResult> {
return import("@rnx-kit/tools-apple").then(({ xcodebuild }) => {
return new Promise<BuildResult>((resolve) => {
logger.start("Building...");
logger.start("Building");
const errors: Buffer[] = [];
const proc = xcodebuild(xcworkspace, buildParams, (text) => {

Просмотреть файл

@ -1,12 +1,13 @@
import type { Config } from "@react-native-community/cli-types";
import * as path from "node:path";
import ora from "ora";
import type { BuildResult, InputParams } from "./apple";
import type { BuildResult } from "./apple";
import { runBuild } from "./apple";
import type { AppleInputParams } from "./types";
export function buildIOS(
config: Config,
buildParams: InputParams,
buildParams: AppleInputParams,
logger = ora()
): Promise<BuildResult> {
const { platform } = buildParams;

Просмотреть файл

@ -2,8 +2,9 @@ import type { Config } from "@react-native-community/cli-types";
import * as fs from "node:fs";
import * as path from "node:path";
import ora from "ora";
import type { BuildResult, InputParams } from "./apple";
import type { BuildResult } from "./apple";
import { runBuild } from "./apple";
import type { AppleInputParams } from "./types";
function findXcodeWorkspaces(searchDir: string) {
return fs.existsSync(searchDir)
@ -13,7 +14,7 @@ function findXcodeWorkspaces(searchDir: string) {
export function buildMacOS(
_config: Config,
{ workspace, ...buildParams }: InputParams,
{ workspace, ...buildParams }: AppleInputParams,
logger = ora()
): Promise<BuildResult> {
if (workspace) {

Просмотреть файл

@ -0,0 +1,50 @@
/**
* Copy of types from `@rnx-kit/tools-apple`. If Jest wasn't such a pain to
* configure, we would have added an `import type` at the top instead:
*
* import type { BuildParams as AndroidBuildParams } from "@rnx-kit/tools-android" with { "resolution-mode": "import" };
* import type { BuildParams as AppleBuildParams } from "@rnx-kit/tools-apple" with { "resolution-mode": "import" };
*
* But Jest doesn't like import attributes and it doesn't matter if we add
* `@babel/plugin-syntax-import-attributes` in the config.
*
* TOOD: Remove this file when we can migrate away from Jest in this package.
*/
export type DeviceType = "device" | "emulator" | "simulator";
export type BuildConfiguration = "Debug" | "Release";
export type AndroidBuildParams = {
platform: "android";
destination?: DeviceType;
configuration?: BuildConfiguration;
archs?: string;
};
export type AppleBuildParams =
| {
platform: "ios" | "visionos";
scheme?: string;
destination?: DeviceType;
configuration?: BuildConfiguration;
archs?: string;
isBuiltRemotely?: boolean;
}
| {
platform: "macos";
scheme?: string;
configuration?: BuildConfiguration;
isBuiltRemotely?: boolean;
};
export type AndroidInputParams = AndroidBuildParams & {
device?: string;
};
export type AppleInputParams = AppleBuildParams & {
device?: string;
workspace?: string;
};
export type InputParams = AndroidInputParams | AppleInputParams;

Просмотреть файл

@ -1,6 +1,7 @@
import type { Config } from "@react-native-community/cli-types";
import { rnxBuildCommand } from "./build";
import type { InputParams } from "./build/apple";
import type { InputParams } from "./build/types";
import { runAndroid } from "./run/android";
import { runIOS } from "./run/ios";
import { runMacOS } from "./run/macos";
@ -10,9 +11,13 @@ export function rnxRun(
buildParams: InputParams
) {
switch (buildParams.platform) {
case "android":
return runAndroid(config, buildParams);
case "ios":
case "visionos":
return runIOS(config, buildParams);
case "macos":
return runMacOS(config, buildParams);
}

Просмотреть файл

@ -0,0 +1,68 @@
import type { Config } from "@react-native-community/cli-types";
import * as path from "node:path";
import ora from "ora";
import { buildAndroid } from "../build/android";
import type { AndroidInputParams } from "../build/types";
export async function runAndroid(
config: Config,
buildParams: AndroidInputParams
) {
const logger = ora();
const projectDir = await buildAndroid(config, buildParams, logger);
if (typeof projectDir !== "string") {
return;
}
logger.start("Preparing to launch app");
const { findOutputFile, getPackageName, install, selectDevice, start } =
await import("@rnx-kit/tools-android");
const { configuration = "Debug" } = buildParams;
const apks = findOutputFile(projectDir, configuration);
if (apks.length === 0) {
logger.fail("Failed to find the APK that was just built");
process.exitCode = 1;
return;
}
if (apks.length > 1) {
const currentStatus = logger.text;
const choices = apks.map((p) => path.basename(p)).join(", ");
logger.info(`Multiple APKs were found; picking the first one: ${choices}`);
logger.info("If this is wrong, remove the others and try again");
logger.start(currentStatus);
}
const apk = apks[0];
const info = getPackageName(apk);
if (info instanceof Error) {
logger.fail(info.message);
process.exitCode = 1;
return;
}
const device = await selectDevice(buildParams.device, logger);
if (!device) {
logger.fail("Failed to launch app: Could not find an appropriate device");
process.exitCode = 1;
return;
}
logger.start(`Installing ${apk}`);
const { packageName, activityName } = info;
const error = await install(device, apk, packageName);
if (error) {
logger.fail(error.message);
process.exitCode = 1;
return;
}
logger.text = `Starting ${packageName}`;
await start(device, packageName, activityName);
logger.succeed(`Started ${packageName}`);
}

Просмотреть файл

@ -1,8 +1,8 @@
import type { Config } from "@react-native-community/cli-types";
import * as path from "node:path";
import ora from "ora";
import type { InputParams } from "../build/apple";
import { buildIOS } from "../build/ios";
import type { InputParams } from "../build/types";
export async function runIOS(config: Config, buildParams: InputParams) {
const { platform } = buildParams;
@ -17,7 +17,7 @@ export async function runIOS(config: Config, buildParams: InputParams) {
return;
}
logger.start("Preparing to launch app...");
logger.start("Preparing to launch app");
const {
getBuildSettings,
@ -52,7 +52,7 @@ export async function runIOS(config: Config, buildParams: InputParams) {
settings.buildSettings;
const app = path.join(TARGET_BUILD_DIR, EXECUTABLE_FOLDER_PATH);
logger.start(`Installing '${FULL_PRODUCT_NAME}' on ${device.name}...`);
logger.start(`Installing '${FULL_PRODUCT_NAME}' on ${device.name}`);
const installError = await install(device, app);
if (installError) {

Просмотреть файл

@ -1,10 +1,10 @@
import type { Config } from "@react-native-community/cli-types";
import * as path from "node:path";
import ora from "ora";
import type { InputParams } from "../build/apple";
import { buildMacOS } from "../build/macos";
import type { AppleInputParams } from "../build/types";
export async function runMacOS(config: Config, buildParams: InputParams) {
export async function runMacOS(config: Config, buildParams: AppleInputParams) {
const logger = ora();
const result = await buildMacOS(config, buildParams, logger);
@ -14,7 +14,7 @@ export async function runMacOS(config: Config, buildParams: InputParams) {
const { getBuildSettings, open } = await import("@rnx-kit/tools-apple");
logger.start("Launching app...");
logger.start("Launching app");
const settings = await getBuildSettings(result.xcworkspace, result.args);
if (!settings) {
@ -26,7 +26,7 @@ export async function runMacOS(config: Config, buildParams: InputParams) {
const { FULL_PRODUCT_NAME, TARGET_BUILD_DIR } = settings.buildSettings;
const appPath = path.join(TARGET_BUILD_DIR, FULL_PRODUCT_NAME);
logger.text = `Launching '${FULL_PRODUCT_NAME}'...`;
logger.text = `Launching '${FULL_PRODUCT_NAME}'`;
const { stderr, status } = await open(appPath);
if (status !== 0) {

Просмотреть файл

@ -15,15 +15,17 @@ import * as tools from "@rnx-kit/tools-android";
<!-- The following table can be updated by running `yarn update-readme` -->
<!-- @rnx-kit/api start -->
| Category | Function | Description |
| -------- | ------------------------------------------- | -------------------------------------------------------------------------------- |
| - | `getBuildToolsPath()` | Returns the path to Android SDK Build-Tools. |
| - | `getDevices()` | Returns a list of attached physical Android devices. |
| - | `getEmulators()` | Returns a list of available Android virtual devices. |
| - | `getPackageName(apk)` | Returns the package name and the first launchable activity of the specified APK. |
| - | `install(device, apk, packageName)` | Installs the specified APK on specified emulator or physical device. |
| - | `launchEmulator(emulatorName)` | Launches the emulator with the specified name. |
| - | `selectDevice(emulatorName, logger)` | Returns the emulator or physical device with the specified name. |
| - | `start(options, packageName, activityName)` | Starts the specified activity on specified emulator or physical device. |
| Category | Function | Description |
| -------- | ------------------------------------------------ | -------------------------------------------------------------------------------- |
| apk | `getPackageName(apk)` | Returns the package name and the first launchable activity of the specified APK. |
| apk | `install(device, apk, packageName)` | Installs the specified APK on specified emulator or physical device. |
| apk | `start(options, packageName, activityName)` | Starts the specified activity on specified emulator or physical device. |
| device | `getDevices()` | Returns a list of attached physical Android devices. |
| device | `getEmulators()` | Returns a list of available Android virtual devices. |
| device | `launchEmulator(emulatorName)` | Launches the emulator with the specified name. |
| device | `selectDevice(emulatorName, logger)` | Returns the emulator or physical device with the specified name. |
| gradle | `assemble(projectDir, buildParams)` | Invokes Gradle build. |
| gradle | `findOutputFile(projectDir, buildConfiguration)` | Tries to find Gradle build output file. |
| sdk | `getBuildToolsPath()` | Returns the path to Android SDK Build-Tools. |
<!-- @rnx-kit/api end -->

Просмотреть файл

@ -0,0 +1,73 @@
import { idle } from "@rnx-kit/tools-shell/async";
import { makeCommandSync } from "@rnx-kit/tools-shell/command";
import * as path from "node:path";
import { adb, getBuildToolsPath } from "./sdk.js";
import type { DeviceInfo, PackageInfo } from "./types.js";
/**
* Returns the package name and the first launchable activity of the
* specified APK.
*/
export function getPackageName(apk: string): PackageInfo | Error {
const buildToolsPath = getBuildToolsPath();
if (!buildToolsPath) {
return new Error("Could not find Android SDK Build-Tools");
}
const aapt = makeCommandSync(path.join(buildToolsPath, "aapt2"));
const { stdout } = aapt("dump", "badging", apk);
const packageMatch = stdout.match(/package: name='(.*?)'/);
if (!packageMatch) {
return new Error("Could not find package name");
}
const activityMatch = stdout.match(/launchable-activity: name='(.*?)'/);
if (!activityMatch) {
return new Error("Could not find any launchable activities");
}
return { packageName: packageMatch[1], activityName: activityMatch[1] };
}
/**
* Installs the specified APK on specified emulator or physical device.
*
* @remarks
* This function automatically uninstalls the existing app if an
* `INSTALL_FAILED_UPDATE_INCOMPATIBLE` error is encountered.
*/
export async function install(
device: DeviceInfo,
apk: string,
packageName: string
): Promise<Error | null> {
const { stderr, status } = await adb("-s", device.serial, "install", apk);
if (status !== 0) {
if (stderr.includes("device offline")) {
await idle(1000);
return install(device, apk, packageName);
} else if (stderr.includes("INSTALL_FAILED_UPDATE_INCOMPATIBLE")) {
await adb("uninstall", packageName);
return install(device, apk, packageName);
}
return new Error(stderr);
}
return null;
}
/**
* Starts the specified activity on specified emulator or physical device.
* @param options
* @param packageName
* @param activityName
*/
export function start(
{ serial }: DeviceInfo,
packageName: string,
activityName: string
) {
const activity = `${packageName}/${activityName}`;
return adb("-s", serial, "shell", "am", "start", "-n", activity);
}

Просмотреть файл

@ -0,0 +1,113 @@
import { retry } from "@rnx-kit/tools-shell/async";
import { ensure, makeCommand } from "@rnx-kit/tools-shell/command";
import { spawn } from "node:child_process";
import * as path from "node:path";
import { adb, ANDROID_HOME } from "./sdk.js";
import type { DeviceInfo, Logger } from "./types.js";
const EMULATOR_BIN = path.join(ANDROID_HOME, "emulator", "emulator");
const MAX_ATTEMPTS = 8;
/**
* Returns a list of attached physical Android devices.
*/
export async function getDevices(): Promise<DeviceInfo[]> {
// https://developer.android.com/studio/command-line/adb#devicestatus
const { stdout } = await adb("devices", "-l");
return stdout
.split("\n")
.splice(1) // First line is 'List of devices attached'
.map((device: string) => {
const [serial, state, ...props] = device.split(/\s+/);
return {
serial,
state,
description: Object.fromEntries(
props.map((prop) => prop.split(":"))
) as DeviceInfo["description"],
};
});
}
/**
* Returns a list of available Android virtual devices.
*/
export async function getEmulators(): Promise<string[]> {
const emulator = makeCommand(EMULATOR_BIN);
const result = await emulator("-list-avds");
// Make sure we don't include lines like:
// INFO | Storing crashdata in: /tmp/android-user/emu-crash-34.2.13.db
return ensure(result)
.split("\n")
.map((device: string) => device.trim())
.filter((line) => line && !line.includes(" | "));
}
/**
* Launches the emulator with the specified name.
*/
export async function launchEmulator(
emulatorName: string
): Promise<DeviceInfo | Error> {
spawn(EMULATOR_BIN, ["@" + emulatorName], {
detached: true,
stdio: "ignore",
}).unref();
const result = await retry(async () => {
const devices = await getDevices();
return devices.find((device) => device.state === "device") || null;
}, MAX_ATTEMPTS);
return result || new Error("Timed out waiting for the emulator");
}
/**
* Returns the emulator or physical device with the specified name.
*
* @remarks
* If an emulator is found, it is also booted if necessary.
*/
export async function selectDevice(
emulatorName: string | undefined,
logger: Logger
): Promise<DeviceInfo | null> {
const attachedDevices = await getDevices();
if (!emulatorName) {
const physicalDevice = attachedDevices.find(
(device) => device.state === "device" && "usb" in device.description
);
if (physicalDevice) {
logger.info(`Found Android device ${physicalDevice.serial}`);
return physicalDevice;
}
}
// There is currently no way to get the emulator name based on the list of
// attached devices. If we find an emulator, we'll have to assume it's the
// one the user wants.
const attachedEmulator = attachedDevices.find(
(device) => device.state === "device" && !("usb" in device.description)
);
if (attachedEmulator) {
logger.info("An Android emulator is already attached");
return attachedEmulator;
}
const avd = emulatorName || (await getEmulators())[0];
if (!avd) {
logger.warn("No emulators were found");
return null;
}
logger.start(`Booting Android emulator @${avd}`);
const emulator = await launchEmulator(avd);
if (emulator instanceof Error) {
logger.fail();
logger.fail(emulator.message);
return null;
}
logger.succeed(`Booted @${avd}`);
return emulator;
}

Просмотреть файл

@ -0,0 +1,53 @@
import { spawn } from "node:child_process";
import * as nodefs from "node:fs";
import * as path from "node:path";
import type { BuildParams } from "./types.js";
/**
* Invokes Gradle build.
* @param projectDir
* @param buildParams
*/
export function assemble(
projectDir: string,
{ configuration = "Debug", archs }: BuildParams
) {
const args = [`assemble${configuration}`];
if (archs) {
args.push(`-PreactNativeArchitectures=${archs}`);
}
const gradlew = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
return spawn(gradlew, args, { cwd: projectDir });
}
/**
* Tries to find Gradle build output file.
* @remarks This function may return several files.
*/
export function findOutputFile(
projectDir: string,
buildConfiguration: string,
/** @internal */ fs = nodefs
): string[] {
const apks: string[] = [];
const configName = buildConfiguration.toLowerCase();
for (const moduleName of fs.readdirSync(projectDir)) {
const outputFile = path.join(
projectDir,
moduleName,
"build",
"outputs",
"apk",
configName,
`${moduleName}-${configName}.apk`
);
if (fs.existsSync(outputFile)) {
apks.push(outputFile);
}
}
return apks;
}

Просмотреть файл

@ -1,257 +1,19 @@
import { idle, retry } from "@rnx-kit/tools-shell/async";
import {
ensure,
makeCommand,
makeCommandSync,
} from "@rnx-kit/tools-shell/command";
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
type Logger = {
start: (str?: string) => void;
succeed: (str?: string) => void;
fail: (str?: string) => void;
info: (str: string) => void;
warn: (str: string) => void;
};
export type EmulatorInfo = {
product: string;
model: string;
device: string;
transport_id: string;
};
export type PhysicalDeviceInfo = {
usb: string;
product: string;
model: string;
device: string;
transport_id: string;
};
export type DeviceInfo = {
serial: string;
state: "offline" | "device" | string;
description: EmulatorInfo | PhysicalDeviceInfo;
};
export type PackageInfo = {
packageName: string;
activityName: string;
};
const ANDROID_HOME = (() => {
const home = process.env.ANDROID_HOME;
if (!home) {
throw new Error(
"ANDROID_HOME is not set and is required to install and launch APKs"
);
}
return home;
})();
const ADB_BIN = path.join(ANDROID_HOME, "platform-tools", "adb");
const BUILD_TOOLS_DIR = path.join(ANDROID_HOME, "build-tools");
const EMULATOR_BIN = path.join(ANDROID_HOME, "emulator", "emulator");
const MAX_ATTEMPTS = 8;
const adb = makeCommand(ADB_BIN);
function latestVersion(versions: string[]): string {
let latestVersion = "0.0.0";
let maxValue = 0;
for (const version of versions) {
const [major, minor = 0, patch = 0] = version.split(".");
const value =
Number(major) * 1000000 + Number(minor) * 1000 + Number(patch);
if (maxValue < value) {
latestVersion = version;
maxValue = value;
}
}
return latestVersion;
}
/**
* Returns the path to Android SDK Build-Tools.
*/
export function getBuildToolsPath(): string | null {
if (!fs.existsSync(BUILD_TOOLS_DIR)) {
return null;
}
const versions = fs.readdirSync(BUILD_TOOLS_DIR);
return path.join(BUILD_TOOLS_DIR, latestVersion(versions));
}
/**
* Returns a list of attached physical Android devices.
*/
export async function getDevices(): Promise<DeviceInfo[]> {
// https://developer.android.com/studio/command-line/adb#devicestatus
const { stdout } = await adb("devices", "-l");
return stdout
.split("\n")
.splice(1) // First line is 'List of devices attached'
.map((device: string) => {
const [serial, state, ...props] = device.split(/\s+/);
return {
serial,
state,
description: Object.fromEntries(
props.map((prop) => prop.split(":"))
) as DeviceInfo["description"],
};
});
}
/**
* Returns a list of available Android virtual devices.
*/
export async function getEmulators(): Promise<string[]> {
const emulator = makeCommand(EMULATOR_BIN);
const result = await emulator("-list-avds");
return ensure(result)
.split("\n")
.map((device: string) => device.trim())
.filter(Boolean);
}
/**
* Returns the package name and the first launchable activity of the
* specified APK.
*/
export function getPackageName(apk: string): PackageInfo | Error {
const buildToolsPath = getBuildToolsPath();
if (!buildToolsPath) {
return new Error("Could not find Android SDK Build-Tools");
}
const aapt = makeCommandSync(path.join(buildToolsPath, "aapt2"));
const { stdout } = aapt("dump", "badging", apk);
const packageMatch = stdout.match(/package: name='(.*?)'/);
if (!packageMatch) {
return new Error("Could not find package name");
}
const activityMatch = stdout.match(/launchable-activity: name='(.*?)'/);
if (!activityMatch) {
return new Error("Could not find launchable activity");
}
return { packageName: packageMatch[1], activityName: activityMatch[1] };
}
/**
* Installs the specified APK on specified emulator or physical device.
*
* @remarks
* This function automatically uninstalls the existing app if an
* `INSTALL_FAILED_UPDATE_INCOMPATIBLE` error is encountered.
*/
export async function install(
device: DeviceInfo,
apk: string,
packageName: string
): Promise<Error | null> {
const { stderr, status } = await adb("-s", device.serial, "install", apk);
if (status !== 0) {
if (stderr.includes("device offline")) {
await idle(1000);
return install(device, apk, packageName);
} else if (stderr.includes("INSTALL_FAILED_UPDATE_INCOMPATIBLE")) {
await adb("uninstall", packageName);
return install(device, apk, packageName);
}
return new Error(stderr);
}
return null;
}
/**
* Launches the emulator with the specified name.
*/
export async function launchEmulator(
emulatorName: string
): Promise<DeviceInfo | Error> {
spawn(EMULATOR_BIN, ["@" + emulatorName], {
detached: true,
stdio: "ignore",
}).unref();
const result = await retry(async () => {
const devices = await getDevices();
return devices.find((device) => device.state === "device") || null;
}, MAX_ATTEMPTS);
return result || new Error("Timed out waiting for the emulator");
}
/**
* Returns the emulator or physical device with the specified name.
*
* @remarks
* If an emulator is found, it is also booted if necessary.
*/
export async function selectDevice(
emulatorName: string | undefined,
logger: Logger
): Promise<DeviceInfo | null> {
const attachedDevices = await getDevices();
if (!emulatorName) {
const physicalDevice = attachedDevices.find(
(device) => device.state === "device" && "usb" in device.description
);
if (physicalDevice) {
logger.info(`Found Android device ${physicalDevice.serial}`);
return physicalDevice;
}
}
// There is currently no way to get the emulator name based on the list of
// attached devices. If we find an emulator, we'll have to assume it's the
// one the user wants.
const attachedEmulator = attachedDevices.find(
(device) => device.state === "device" && !("usb" in device.description)
);
if (attachedEmulator) {
logger.info("An Android emulator is already attached");
return attachedEmulator;
}
const avd = emulatorName || (await getEmulators())[0];
if (!avd) {
logger.warn("No emulators were found");
return null;
}
logger.start(`Booting Android emulator @${avd}`);
const emulator = await launchEmulator(avd);
if (emulator instanceof Error) {
logger.fail();
logger.fail(emulator.message);
return null;
}
logger.succeed(`Booted @${avd}`);
return emulator;
}
/**
* Starts the specified activity on specified emulator or physical device.
* @param options
* @param packageName
* @param activityName
*/
export function start(
{ serial }: DeviceInfo,
packageName: string,
activityName: string
) {
const activity = `${packageName}/${activityName}`;
return adb("-s", serial, "shell", "am", "start", "-n", activity);
}
export { getPackageName, install, start } from "./apk.js";
export {
getDevices,
getEmulators,
launchEmulator,
selectDevice,
} from "./device.js";
export { assemble, findOutputFile } from "./gradle.js";
export { getBuildToolsPath } from "./sdk.js";
export type {
BuildConfiguration,
BuildParams,
DeviceInfo,
DeviceType,
EmulatorInfo,
Logger,
PackageInfo,
PhysicalDeviceInfo,
} from "./types.js";

Просмотреть файл

@ -0,0 +1,47 @@
import { makeCommand } from "@rnx-kit/tools-shell/command";
import * as fs from "node:fs";
import * as path from "node:path";
export const ANDROID_HOME = (() => {
const home = process.env.ANDROID_HOME;
if (!home) {
throw new Error(
"ANDROID_HOME is not set and is required to install and launch APKs"
);
}
return home;
})();
const ADB_BIN = path.join(ANDROID_HOME, "platform-tools", "adb");
const BUILD_TOOLS_DIR = path.join(ANDROID_HOME, "build-tools");
export const adb = makeCommand(ADB_BIN);
function latestVersion(versions: string[]): string {
let latestVersion = "0.0.0";
let maxValue = 0;
for (const version of versions) {
const [major, minor = 0, patch = 0] = version.split(".");
const value =
Number(major) * 1000000 + Number(minor) * 1000 + Number(patch);
if (maxValue < value) {
latestVersion = version;
maxValue = value;
}
}
return latestVersion;
}
/**
* Returns the path to Android SDK Build-Tools.
*/
export function getBuildToolsPath(): string | null {
if (!fs.existsSync(BUILD_TOOLS_DIR)) {
return null;
}
const versions = fs.readdirSync(BUILD_TOOLS_DIR);
return path.join(BUILD_TOOLS_DIR, latestVersion(versions));
}

Просмотреть файл

@ -0,0 +1,44 @@
export type BuildConfiguration = "Debug" | "Release";
export type DeviceType = "device" | "emulator" | "simulator";
export type EmulatorInfo = {
product: string;
model: string;
device: string;
transport_id: string;
};
export type Logger = {
start: (str?: string) => void;
succeed: (str?: string) => void;
fail: (str?: string) => void;
info: (str: string) => void;
warn: (str: string) => void;
};
export type PhysicalDeviceInfo = {
usb: string;
product: string;
model: string;
device: string;
transport_id: string;
};
export type BuildParams = {
platform: "android";
destination?: DeviceType;
configuration?: BuildConfiguration;
archs?: string;
};
export type DeviceInfo = {
serial: string;
state: "offline" | "device" | string;
description: EmulatorInfo | PhysicalDeviceInfo;
};
export type PackageInfo = {
packageName: string;
activityName: string;
};

Просмотреть файл

@ -3819,6 +3819,7 @@ __metadata:
"@rnx-kit/metro-service": "npm:^3.1.6"
"@rnx-kit/scripts": "npm:*"
"@rnx-kit/third-party-notices": "npm:^1.3.4"
"@rnx-kit/tools-android": "npm:^0.1.0"
"@rnx-kit/tools-apple": "npm:^0.1.2"
"@rnx-kit/tools-filesystem": "npm:*"
"@rnx-kit/tools-language": "npm:^2.0.0"