From e11d312477ca5f1620b1dfa5d447db095cd418cf Mon Sep 17 00:00:00 2001 From: Tommy Nguyen <4123478+tido64@users.noreply.github.com> Date: Thu, 29 Aug 2024 17:18:23 +0200 Subject: [PATCH] feat(tools-android): add primitives for build and run commands --- .changeset/breezy-mirrors-attend.md | 7 + .changeset/thin-eyes-judge.md | 5 + packages/cli/package.json | 1 + packages/cli/src/build.ts | 16 +- packages/cli/src/build/android.ts | 39 ++++ packages/cli/src/build/apple.ts | 38 +--- packages/cli/src/build/ios.ts | 5 +- packages/cli/src/build/macos.ts | 5 +- packages/cli/src/build/types.ts | 50 +++++ packages/cli/src/run.ts | 7 +- packages/cli/src/run/android.ts | 68 +++++++ packages/cli/src/run/ios.ts | 6 +- packages/cli/src/run/macos.ts | 8 +- packages/tools-android/README.md | 22 ++- packages/tools-android/src/apk.ts | 73 +++++++ packages/tools-android/src/device.ts | 113 +++++++++++ packages/tools-android/src/gradle.ts | 53 +++++ packages/tools-android/src/index.ts | 276 ++------------------------- packages/tools-android/src/sdk.ts | 47 +++++ packages/tools-android/src/types.ts | 44 +++++ yarn.lock | 1 + 21 files changed, 565 insertions(+), 319 deletions(-) create mode 100644 .changeset/breezy-mirrors-attend.md create mode 100644 .changeset/thin-eyes-judge.md create mode 100644 packages/cli/src/build/android.ts create mode 100644 packages/cli/src/build/types.ts create mode 100644 packages/cli/src/run/android.ts create mode 100644 packages/tools-android/src/apk.ts create mode 100644 packages/tools-android/src/device.ts create mode 100644 packages/tools-android/src/gradle.ts create mode 100644 packages/tools-android/src/sdk.ts create mode 100644 packages/tools-android/src/types.ts diff --git a/.changeset/breezy-mirrors-attend.md b/.changeset/breezy-mirrors-attend.md new file mode 100644 index 000000000..965252755 --- /dev/null +++ b/.changeset/breezy-mirrors-attend.md @@ -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. diff --git a/.changeset/thin-eyes-judge.md b/.changeset/thin-eyes-judge.md new file mode 100644 index 000000000..32794588f --- /dev/null +++ b/.changeset/thin-eyes-judge.md @@ -0,0 +1,5 @@ +--- +"@rnx-kit/tools-android": patch +--- + +Added primitives for building 'build' and 'run' commands diff --git a/packages/cli/package.json b/packages/cli/package.json index 532e0fc63..6e39f85a2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index a99a4ac13..b1de80dbd 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -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 ", - description: "Name of scheme to build", + description: "Name of scheme to build (Apple platforms only)", }, { name: "--configuration ", diff --git a/packages/cli/src/build/android.ts b/packages/cli/src/build/android.ts new file mode 100644 index 000000000..abecf91fa --- /dev/null +++ b/packages/cli/src/build/android.ts @@ -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 { + 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); + } + }); + }); + }); +} diff --git a/packages/cli/src/build/apple.ts b/packages/cli/src/build/apple.ts index 60c8e3cd7..7520f451f 100644 --- a/packages/cli/src/build/apple.ts +++ b/packages/cli/src/build/apple.ts @@ -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 { return import("@rnx-kit/tools-apple").then(({ xcodebuild }) => { return new Promise((resolve) => { - logger.start("Building..."); + logger.start("Building"); const errors: Buffer[] = []; const proc = xcodebuild(xcworkspace, buildParams, (text) => { diff --git a/packages/cli/src/build/ios.ts b/packages/cli/src/build/ios.ts index c2c453ac6..983dd22e0 100644 --- a/packages/cli/src/build/ios.ts +++ b/packages/cli/src/build/ios.ts @@ -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 { const { platform } = buildParams; diff --git a/packages/cli/src/build/macos.ts b/packages/cli/src/build/macos.ts index 8977a0f71..7b10bf61b 100644 --- a/packages/cli/src/build/macos.ts +++ b/packages/cli/src/build/macos.ts @@ -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 { if (workspace) { diff --git a/packages/cli/src/build/types.ts b/packages/cli/src/build/types.ts new file mode 100644 index 000000000..1c893ba9e --- /dev/null +++ b/packages/cli/src/build/types.ts @@ -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; diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index b57212fbf..69fceba72 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -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); } diff --git a/packages/cli/src/run/android.ts b/packages/cli/src/run/android.ts new file mode 100644 index 000000000..6307a07ef --- /dev/null +++ b/packages/cli/src/run/android.ts @@ -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}`); +} diff --git a/packages/cli/src/run/ios.ts b/packages/cli/src/run/ios.ts index 282a33322..6f26ff87e 100644 --- a/packages/cli/src/run/ios.ts +++ b/packages/cli/src/run/ios.ts @@ -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) { diff --git a/packages/cli/src/run/macos.ts b/packages/cli/src/run/macos.ts index b7ed97db6..dca65ae96 100644 --- a/packages/cli/src/run/macos.ts +++ b/packages/cli/src/run/macos.ts @@ -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) { diff --git a/packages/tools-android/README.md b/packages/tools-android/README.md index 678846e3e..56e9dc6d3 100644 --- a/packages/tools-android/README.md +++ b/packages/tools-android/README.md @@ -15,15 +15,17 @@ import * as tools from "@rnx-kit/tools-android"; -| 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. | diff --git a/packages/tools-android/src/apk.ts b/packages/tools-android/src/apk.ts new file mode 100644 index 000000000..4e24ae774 --- /dev/null +++ b/packages/tools-android/src/apk.ts @@ -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 { + 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); +} diff --git a/packages/tools-android/src/device.ts b/packages/tools-android/src/device.ts new file mode 100644 index 000000000..a96bd68cd --- /dev/null +++ b/packages/tools-android/src/device.ts @@ -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 { + // 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 { + 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 { + 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 { + 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; +} diff --git a/packages/tools-android/src/gradle.ts b/packages/tools-android/src/gradle.ts new file mode 100644 index 000000000..c9ea48f05 --- /dev/null +++ b/packages/tools-android/src/gradle.ts @@ -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; +} diff --git a/packages/tools-android/src/index.ts b/packages/tools-android/src/index.ts index 69b6ded21..c7c266754 100644 --- a/packages/tools-android/src/index.ts +++ b/packages/tools-android/src/index.ts @@ -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 { - // 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 { - 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 { - 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 { - 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 { - 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"; diff --git a/packages/tools-android/src/sdk.ts b/packages/tools-android/src/sdk.ts new file mode 100644 index 000000000..4d2904b06 --- /dev/null +++ b/packages/tools-android/src/sdk.ts @@ -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)); +} diff --git a/packages/tools-android/src/types.ts b/packages/tools-android/src/types.ts new file mode 100644 index 000000000..bc1ddf907 --- /dev/null +++ b/packages/tools-android/src/types.ts @@ -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; +}; diff --git a/yarn.lock b/yarn.lock index 560a96564..2df629892 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"