diff --git a/docs/resources/(resources)/editors-ides/android-cli.mdx b/docs/resources/(resources)/editors-ides/android-cli.mdx new file mode 100644 index 00000000..13b768af --- /dev/null +++ b/docs/resources/(resources)/editors-ides/android-cli.mdx @@ -0,0 +1,90 @@ +--- +title: android-cli +description: Reference pages for the Android CLI resources +--- + +The Android CLI resources install and configure [Android CLI](https://developer.android.com/tools/agents/android-cli), Google's command-line tool for managing the Android development environment. Two resources are provided: one for installing the CLI and managing SDK packages, and one for provisioning Android Virtual Devices (AVDs). + +--- + +## android-cli + +Installs the `android` command-line tool and manages Android SDK packages declaratively. On macOS, Android CLI is installed via Homebrew (`android/tap`). On Linux (AMD64 only), it is installed via the official curl script to `~/.local/bin`. + +### Parameters + +- **sdkPath**: *(string)* Path to the Android SDK directory. Written to `~/.androidrc` as `--sdk=`. If not specified, the android CLI uses its default SDK location. +- **packages**: *(string[])* Android SDK packages to install. Package paths use forward-slash notation matching the `android sdk install` command (e.g. `platforms/android-35`, `build-tools/35.0.0`, `cmdline-tools/latest`, `platform-tools`, `system-images/android-35/google_apis_playstore/x86_64`). + +### Example usage + +```json title="codify.jsonc" +[ + { + "type": "android-cli", + "packages": [ + "cmdline-tools/latest", + "platform-tools", + "platforms/android-35", + "build-tools/35.0.0" + ] + } +] +``` + +### Notes + +- Linux ARM64 is **not** supported by Android CLI. Only AMD64/x86_64 is supported on Linux. +- Run `android sdk list --all` to see all available package identifiers. +- Run `android info` to display the default SDK path in use. + +--- + +## android-emulator + +Creates and manages an Android Virtual Device (AVD) using `android emulator create`. Each emulator declaration is an independent resource, identified by its `profile` and optional `name`. + +Depends on `android-cli` being installed. + +> **Note:** There is no `android emulator delete` command. Destroy uses `avdmanager delete avd` (from the `cmdline-tools` package) if available, or falls back to removing AVD files directly from `~/.android/avd/`. + +### Parameters + +- **profile** *(required)*: *(string)* Android hardware profile for the emulator (e.g. `medium_phone`, `pixel_9`). Run `android emulator create --list-profiles` to see available profiles. +- **name**: *(string)* Custom name for the Android Virtual Device. Defaults to the profile name. + +### Example usage + +```json title="codify.jsonc" +[ + { + "type": "android-cli", + "packages": [ + "cmdline-tools/latest", + "platform-tools", + "platforms/android-35", + "system-images/android-35/google_apis_playstore/x86_64" + ] + }, + { + "type": "android-emulator", + "profile": "pixel_9" + } +] +``` + +### Common profiles + +| Profile | Description | +|---------|-------------| +| `medium_phone` | Generic medium phone (default) | +| `small_phone` | Generic small phone | +| `foldable` | Foldable form factor | +| `medium_tablet` | Generic medium tablet | +| `pixel_9` | Google Pixel 9 | +| `pixel_9_pro` | Google Pixel 9 Pro | +| `pixel_9_pro_fold` | Google Pixel 9 Pro Fold | +| `pixel_8` | Google Pixel 8 | +| `wear_os_large_round` | Wear OS round watch | +| `tv_1080p` | Android TV 1080p | +| `automotive_1024p_landscape` | Android Automotive | diff --git a/src/index.ts b/src/index.ts index 0cb3831b..57787256 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ import { Plugin, runPlugin } from '@codifycli/plugin-core'; +import { AndroidCliResource } from './resources/android/android-cli/android-cli.js'; +import { AndroidEmulatorResource } from './resources/android/android-cli/android-emulator.js'; import { AndroidStudioResource } from './resources/android/android-studio.js'; import { AptResource } from './resources/apt/apt.js'; import { AsdfResource } from './resources/asdf/asdf.js'; @@ -113,6 +115,8 @@ runPlugin(Plugin.create( new GitRepositoryResource(), new GitRepositoriesResource(), new AndroidStudioResource(), + new AndroidCliResource(), + new AndroidEmulatorResource(), new AsdfResource(), new AsdfPluginResource(), new AsdfInstallResource(), diff --git a/src/resources/android/android-cli/android-cli.ts b/src/resources/android/android-cli/android-cli.ts new file mode 100644 index 00000000..4fc38406 --- /dev/null +++ b/src/resources/android/android-cli/android-cli.ts @@ -0,0 +1,165 @@ +import { + CreatePlan, + DestroyPlan, + ModifyPlan, + PackageManager, + ParameterChange, + Resource, + ResourceSettings, + SpawnStatus, + Utils, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { AndroidSdkPackagesParameter } from './android-sdk-packages-parameter.js'; +import { exampleAndroidCliBasic, exampleAndroidCliFullSetup } from './examples.js'; + +export const schema = z + .object({ + sdkPath: z + .string() + .describe( + 'Path to the Android SDK directory. Written to ~/.androidrc as --sdk=. Defaults to the android CLI default location.' + ) + .optional(), + packages: z + .array(z.string()) + .describe( + 'Android SDK packages to install. Examples: "platforms/android-35", "build-tools/35.0.0", "platform-tools", "cmdline-tools/latest", "system-images/android-35/google_apis_playstore/x86_64".' + ) + .optional(), + }) + .describe('Android CLI — installs the android command-line tool and manages the Android SDK environment'); + +export type AndroidCliConfig = z.infer; + +const ANDROIDRC_PATH = path.join(os.homedir(), '.androidrc'); + +const defaultConfig: Partial = { + packages: [], +}; + +export class AndroidCliResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'android-cli', + defaultConfig, + exampleConfigs: { + example1: exampleAndroidCliBasic, + example2: exampleAndroidCliFullSetup, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + parameterSettings: { + sdkPath: { type: 'directory', canModify: true }, + packages: { type: 'stateful', definition: new AndroidSdkPackagesParameter() }, + }, + }; + } + + async refresh(params: Partial): Promise | null> { + const $ = getPty(); + + const { status } = await $.spawnSafe('which android'); + if (status === SpawnStatus.ERROR) return null; + + const result: Partial = {}; + + if (params.sdkPath) { + try { + const rcContent = await fs.readFile(ANDROIDRC_PATH, 'utf8'); + const sdkLine = rcContent.split('\n').find((l) => l.startsWith('--sdk=')); + if (!sdkLine) return null; + result.sdkPath = sdkLine.replace('--sdk=', '').trim(); + } catch { + return null; + } + } + + return result; + } + + async create(plan: CreatePlan): Promise { + const $ = getPty(); + + if (Utils.isMacOS()) { + await $.spawnSafe('brew tap android/tap', { + env: { HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_ASK: '1', NONINTERACTIVE: '1' }, + }); + await Utils.installViaPkgMgr('android-cli', undefined, PackageManager.BREW); + } else { + if (await Utils.isArmArch()) { + throw new Error( + 'Android CLI does not support Linux ARM64. Only AMD64/x86_64 is supported on Linux.' + ); + } + await $.spawn( + 'curl -fsSL https://dl.google.com/android/cli/latest/linux_x86_64/install.sh | bash', + { interactive: true } + ); + } + + if (plan.desiredConfig.sdkPath) { + await this.setSdkPath(plan.desiredConfig.sdkPath); + } + } + + async modify(pc: ParameterChange, _plan: ModifyPlan): Promise { + if (pc.name === 'sdkPath') { + if (pc.newValue) { + await this.setSdkPath(pc.newValue as string); + } else { + await this.removeSdkPath(); + } + } + } + + async destroy(plan: DestroyPlan): Promise { + if (Utils.isMacOS()) { + await Utils.uninstallViaPkgMgr('android-cli', undefined, PackageManager.BREW); + } else { + const androidBinPath = path.join(os.homedir(), '.local', 'bin', 'android'); + await fs.rm(androidBinPath, { force: true }); + } + + if (plan.currentConfig.sdkPath) { + await this.removeSdkPath(); + } + } + + private async setSdkPath(sdkPath: string): Promise { + let rcContent = ''; + try { + rcContent = await fs.readFile(ANDROIDRC_PATH, 'utf8'); + } catch { /* file doesn't exist yet */ } + + const lines = rcContent.split('\n').filter(Boolean); + const sdkIndex = lines.findIndex((l) => l.startsWith('--sdk=')); + + if (sdkIndex >= 0) { + lines[sdkIndex] = `--sdk=${sdkPath}`; + } else { + lines.push(`--sdk=${sdkPath}`); + } + + await fs.writeFile(ANDROIDRC_PATH, lines.join('\n') + '\n', 'utf8'); + } + + private async removeSdkPath(): Promise { + try { + const rcContent = await fs.readFile(ANDROIDRC_PATH, 'utf8'); + const remaining = rcContent.split('\n').filter((l) => !l.startsWith('--sdk=')); + + if (remaining.filter(Boolean).length === 0) { + await fs.rm(ANDROIDRC_PATH, { force: true }); + } else { + await fs.writeFile(ANDROIDRC_PATH, remaining.join('\n') + '\n', 'utf8'); + } + } catch { /* file doesn't exist, nothing to do */ } + } +} diff --git a/src/resources/android/android-cli/android-emulator.ts b/src/resources/android/android-cli/android-emulator.ts new file mode 100644 index 00000000..15e642c1 --- /dev/null +++ b/src/resources/android/android-cli/android-emulator.ts @@ -0,0 +1,116 @@ +import { + CreatePlan, + DestroyPlan, + Resource, + ResourceSettings, + SpawnStatus, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { exampleAndroidEmulatorBasic, exampleAndroidEmulatorPixel } from './examples.js'; + +export const schema = z + .object({ + profile: z + .string() + .describe( + 'Android hardware profile for the emulator (e.g. "medium_phone", "pixel_9"). Run `android emulator create --list-profiles` to see all available profiles.' + ), + name: z + .string() + .describe( + 'Custom name for the Android Virtual Device. Defaults to the profile name if not specified.' + ) + .optional(), + }) + .describe('Create and manage an Android Virtual Device (AVD) using the android CLI'); + +export type AndroidEmulatorConfig = z.infer; + +const AVD_DIR = path.join(os.homedir(), '.android', 'avd'); + +const defaultConfig: Partial = { + profile: '', +}; + +export class AndroidEmulatorResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'android-emulator', + defaultConfig, + exampleConfigs: { + example1: exampleAndroidEmulatorBasic, + example2: exampleAndroidEmulatorPixel, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + dependencies: ['android-cli'], + parameterSettings: { + profile: {}, + name: { canModify: false }, + }, + allowMultiple: { + identifyingParameters: ['profile', 'name'], + }, + }; + } + + async refresh(params: Partial): Promise | null> { + const $ = getPty(); + + const { status, data } = await $.spawnSafe('android emulator list', { interactive: true }); + if (status === SpawnStatus.ERROR) return null; + + const avdName = this.resolveAvdName(params); + if (!avdName) return null; + + const lines = data.split('\n').map((l) => l.trim()).filter(Boolean); + const found = lines.some( + (l) => l.toLowerCase() === avdName.toLowerCase() || l.toLowerCase().startsWith(avdName.toLowerCase() + ' ') + ); + + if (!found) return null; + + return { + profile: params.profile, + ...(params.name ? { name: params.name } : {}), + }; + } + + async create(plan: CreatePlan): Promise { + const $ = getPty(); + const { profile, name } = plan.desiredConfig; + + let cmd = `android emulator create --profile="${profile}"`; + if (name) { + // The android CLI may support --name in future releases; include it if provided. + cmd += ` --name="${name}"`; + } + + await $.spawn(cmd, { interactive: true }); + } + + async destroy(plan: DestroyPlan): Promise { + const $ = getPty(); + const avdName = this.resolveAvdName(plan.currentConfig); + if (!avdName) return; + + // Try avdmanager first (available when cmdline-tools is installed) + const { status } = await $.spawnSafe(`avdmanager delete avd -n "${avdName}"`, { interactive: true }); + + if (status === SpawnStatus.ERROR) { + // Fallback: remove AVD files directly + await fs.rm(path.join(AVD_DIR, `${avdName}.avd`), { recursive: true, force: true }); + await fs.rm(path.join(AVD_DIR, `${avdName}.ini`), { force: true }); + } + } + + private resolveAvdName(params: Partial): string | undefined { + return params.name ?? params.profile; + } +} diff --git a/src/resources/android/android-cli/android-sdk-packages-parameter.ts b/src/resources/android/android-cli/android-sdk-packages-parameter.ts new file mode 100644 index 00000000..15574018 --- /dev/null +++ b/src/resources/android/android-cli/android-sdk-packages-parameter.ts @@ -0,0 +1,27 @@ +import { ArrayStatefulParameter, getPty, SpawnStatus } from '@codifycli/plugin-core'; + +import { AndroidCliConfig } from './android-cli.js'; + +export class AndroidSdkPackagesParameter extends ArrayStatefulParameter { + async refresh(_desired: string[] | null): Promise { + const $ = getPty(); + + const { status, data } = await $.spawnSafe('android sdk list'); + if (status === SpawnStatus.ERROR) return null; + + return data + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('-') && !l.startsWith('Path') && !l.startsWith('Installed') && !l.includes('|')); + } + + async addItem(item: string): Promise { + const $ = getPty(); + await $.spawn(`android sdk install "${item}"`, { interactive: true }); + } + + async removeItem(item: string): Promise { + const $ = getPty(); + await $.spawn(`android sdk remove "${item}"`, { interactive: true }); + } +} diff --git a/src/resources/android/android-cli/completions/android-cli.packages.ts b/src/resources/android/android-cli/completions/android-cli.packages.ts new file mode 100644 index 00000000..d997e902 --- /dev/null +++ b/src/resources/android/android-cli/completions/android-cli.packages.ts @@ -0,0 +1,22 @@ +export default async function loadAndroidSdkPackages(): Promise { + const response = await fetch('https://dl.google.com/android/repository/repository2-3.xml', { + headers: { 'User-Agent': 'codify-completions-cron' }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Android SDK repository: ${response.status} ${response.statusText}`); + } + + const xml = await response.text(); + + // Extract path attributes from package elements and convert ; separators to / + const paths = new Set(); + const regex = /\bpath="([^"]+)"/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(xml)) !== null) { + // Convert legacy semicolon path separators to the android CLI forward-slash format + paths.add(match[1].replace(/;/g, '/')); + } + + return [...paths].sort(); +} diff --git a/src/resources/android/android-cli/completions/android-emulator.profile.ts b/src/resources/android/android-cli/completions/android-emulator.profile.ts new file mode 100644 index 00000000..c36ad06a --- /dev/null +++ b/src/resources/android/android-cli/completions/android-emulator.profile.ts @@ -0,0 +1,54 @@ +// Known Android hardware profiles from the AVD Manager device definitions. +// These correspond to profiles accepted by `android emulator create --profile=`. +export default async function loadAndroidEmulatorProfiles(): Promise { + return [ + // Generic form factors + 'medium_phone', + 'small_phone', + 'foldable', + 'medium_tablet', + 'resizable', + 'desktop_medium', + + // Pixel phones + 'pixel_9', + 'pixel_9_pro', + 'pixel_9_pro_xl', + 'pixel_9_pro_fold', + 'pixel_8', + 'pixel_8_pro', + 'pixel_7', + 'pixel_7_pro', + 'pixel_7a', + 'pixel_6', + 'pixel_6_pro', + 'pixel_6a', + 'pixel_5', + 'pixel_4', + 'pixel_4_xl', + 'pixel_4a', + 'pixel_3', + 'pixel_3_xl', + 'pixel_3a', + 'pixel_3a_xl', + + // Pixel tablets / foldables + 'pixel_tablet', + 'pixel_fold', + + // Wear OS + 'wear_os_large_round', + 'wear_os_small_round', + 'wear_os_square', + 'wear_os_rect', + + // Android TV + 'tv_1080p', + 'tv_720p', + 'tv_4k', + + // Automotive + 'automotive_1024p_landscape', + 'automotive_portrait', + ]; +} diff --git a/src/resources/android/android-cli/examples.ts b/src/resources/android/android-cli/examples.ts new file mode 100644 index 00000000..fbc74874 --- /dev/null +++ b/src/resources/android/android-cli/examples.ts @@ -0,0 +1,64 @@ +import { ExampleConfig } from '@codifycli/plugin-core'; + +export const exampleAndroidCliBasic: ExampleConfig = { + title: 'Android development environment', + description: 'Install the Android CLI with essential SDK packages for building Android apps — platform, build tools, and ADB.', + configs: [ + { + type: 'android-cli', + packages: ['cmdline-tools/latest', 'platform-tools', 'platforms/android-35', 'build-tools/35.0.0'], + }, + ], +}; + +export const exampleAndroidCliFullSetup: ExampleConfig = { + title: 'Android environment with emulator', + description: 'Install Android CLI with SDK packages and provision a Pixel 9 emulator for local development and testing.', + configs: [ + { + type: 'android-cli', + packages: [ + 'cmdline-tools/latest', + 'platform-tools', + 'platforms/android-35', + 'build-tools/35.0.0', + 'system-images/android-35/google_apis_playstore/x86_64', + ], + }, + { + type: 'android-emulator', + profile: 'pixel_9', + }, + ], +}; + +export const exampleAndroidEmulatorBasic: ExampleConfig = { + title: 'Medium phone emulator', + description: 'Create a standard medium phone AVD — the default Android emulator profile, good for general app testing.', + configs: [ + { + type: 'android-cli', + packages: [ + 'cmdline-tools/latest', + 'platform-tools', + 'platforms/android-35', + 'system-images/android-35/google_apis_playstore/x86_64', + ], + }, + { + type: 'android-emulator', + profile: 'medium_phone', + }, + ], +}; + +export const exampleAndroidEmulatorPixel: ExampleConfig = { + title: 'Pixel 9 emulator', + description: 'Provision a Pixel 9 emulator matching current flagship hardware for testing on the latest Android profile.', + configs: [ + { + type: 'android-emulator', + profile: 'pixel_9', + }, + ], +}; diff --git a/test/android/android-cli.test.ts b/test/android/android-cli.test.ts new file mode 100644 index 00000000..c3bdef98 --- /dev/null +++ b/test/android/android-cli.test.ts @@ -0,0 +1,71 @@ +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import { SpawnStatus } from '@codifycli/schemas'; +import * as path from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('Android CLI integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + beforeAll(async () => { + const result = await testSpawn('which android'); + if (result.status === SpawnStatus.SUCCESS) { + await PluginTester.uninstall(pluginPath, [{ type: 'android-cli' }]); + } + }, 120_000); + + it('Can install and uninstall Android CLI', { timeout: 300_000 }, async () => { + await PluginTester.fullTest( + pluginPath, + [{ type: 'android-cli' }], + { + validateApply: async () => { + const result = await testSpawn('which android'); + expect(result.status).toBe(SpawnStatus.SUCCESS); + }, + validateDestroy: async () => { + const result = await testSpawn('which android'); + expect(result.status).toBe(SpawnStatus.ERROR); + }, + } + ); + }); + + it('Can install Android CLI with SDK packages', { timeout: 600_000 }, async () => { + await PluginTester.fullTest( + pluginPath, + [ + { + type: 'android-cli', + packages: ['cmdline-tools/latest', 'platform-tools'], + }, + ], + { + validateApply: async () => { + const which = await testSpawn('which android'); + expect(which.status).toBe(SpawnStatus.SUCCESS); + + const list = await testSpawn('android sdk list'); + expect(list.status).toBe(SpawnStatus.SUCCESS); + expect(list.data).toContain('platform-tools'); + }, + testModify: { + modifiedConfigs: [ + { + type: 'android-cli', + packages: ['platform-tools'], + }, + ], + validateModify: async () => { + const list = await testSpawn('android sdk list'); + expect(list.status).toBe(SpawnStatus.SUCCESS); + expect(list.data).toContain('platform-tools'); + }, + }, + validateDestroy: async () => { + const result = await testSpawn('which android'); + expect(result.status).toBe(SpawnStatus.ERROR); + }, + } + ); + }); +});