diff --git a/skill-guides/orca-emulator-android.md b/skill-guides/orca-emulator-android.md index 567be607920..5d96e3d65f1 100644 --- a/skill-guides/orca-emulator-android.md +++ b/skill-guides/orca-emulator-android.md @@ -16,8 +16,8 @@ license: Apache-2.0 ## Command surface -The Android backend shells out to the Android SDK (`adb`, `emulator`, `avdmanager`) that -Android Studio installs, so it runs on Windows, Linux, and macOS. Input uses +The Android backend shells out to `adb`, plus the SDK `emulator` for AVDs, so it runs on +Windows, Linux, and macOS. Input uses `adb shell input`, with no extra streaming server. `ORCA emulator --help` lists the wrapped verbs. Anything else goes through @@ -35,11 +35,11 @@ drive the SDK and devices on the host that runs the Orca runtime, including a he ## Prerequisites -- Android Studio or the Android SDK installed, with `ANDROID_HOME` or `ANDROID_SDK_ROOT` - set. Orca also checks the per-OS default location (`%LOCALAPPDATA%\Android\Sdk`, - `~/Library/Android/sdk`, `~/Android/Sdk`). -- `adb` and `emulator` on the SDK path, plus at least one AVD (Android Studio ▸ Device - Manager) or a connected device with USB debugging. +- `adb` from the Android SDK (`ANDROID_HOME`, `ANDROID_SDK_ROOT`, or the per-OS default + `%LOCALAPPDATA%\Android\Sdk`, `~/Library/Android/sdk`, `~/Android/Sdk`), or a standalone + `adb` on `PATH` such as a distro `android-tools` package. +- A connected device with USB debugging, or at least one AVD (Android Studio ▸ Device + Manager). AVDs need the SDK `emulator` package; without it only running devices are listed. - A booted, adb-visible device before any input or capability command. A shutdown AVD is listed with `state: shutdown` and must be started first, by `ORCA emulator attach`, Android Studio, or `emulator @`. diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 274aafc58ff..053b98a1629 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -1227,8 +1227,8 @@ const ORCA_EMULATOR_ANDROID_MARKDOWN = [ "", "## Command surface", "", - "The Android backend shells out to the Android SDK (`adb`, `emulator`, `avdmanager`) that", - "Android Studio installs, so it runs on Windows, Linux, and macOS. Input uses", + "The Android backend shells out to `adb`, plus the SDK `emulator` for AVDs, so it runs on", + "Windows, Linux, and macOS. Input uses", "`adb shell input`, with no extra streaming server.", "", "`ORCA emulator --help` lists the wrapped verbs. Anything else goes through", @@ -1246,11 +1246,11 @@ const ORCA_EMULATOR_ANDROID_MARKDOWN = [ "", "## Prerequisites", "", - "- Android Studio or the Android SDK installed, with `ANDROID_HOME` or `ANDROID_SDK_ROOT`", - " set. Orca also checks the per-OS default location (`%LOCALAPPDATA%\\Android\\Sdk`,", - " `~/Library/Android/sdk`, `~/Android/Sdk`).", - "- `adb` and `emulator` on the SDK path, plus at least one AVD (Android Studio ▸ Device", - " Manager) or a connected device with USB debugging.", + "- `adb` from the Android SDK (`ANDROID_HOME`, `ANDROID_SDK_ROOT`, or the per-OS default", + " `%LOCALAPPDATA%\\Android\\Sdk`, `~/Library/Android/sdk`, `~/Android/Sdk`), or a standalone", + " `adb` on `PATH` such as a distro `android-tools` package.", + "- A connected device with USB debugging, or at least one AVD (Android Studio ▸ Device", + " Manager). AVDs need the SDK `emulator` package; without it only running devices are listed.", "- A booted, adb-visible device before any input or capability command. A shutdown AVD is", " listed with `state: shutdown` and must be started first, by `ORCA emulator attach`,", " Android Studio, or `emulator @`.", diff --git a/src/main/emulator/android/android-avd-boot-identity.test.ts b/src/main/emulator/android/android-avd-boot-identity.test.ts index 5be85f3b337..e011fe7f118 100644 --- a/src/main/emulator/android/android-avd-boot-identity.test.ts +++ b/src/main/emulator/android/android-avd-boot-identity.test.ts @@ -9,8 +9,7 @@ vi.mock('node:child_process', () => ({ const sdk: AndroidSdkPaths = { sdkRoot: 'sdk', adb: 'adb', - emulator: 'emulator', - avdmanager: 'avdmanager' + emulator: 'emulator' } type Device = { serial: string; name: string; booted: boolean } const other: Device = { serial: 'emulator-5556', name: 'Other', booted: true } diff --git a/src/main/emulator/android/android-avd-boot.ts b/src/main/emulator/android/android-avd-boot.ts index ecfcb90e1aa..80f7301e800 100644 --- a/src/main/emulator/android/android-avd-boot.ts +++ b/src/main/emulator/android/android-avd-boot.ts @@ -4,8 +4,12 @@ import { emulatorFailureText } from '../../../shared/emulator-failure-copy' import type { AndroidCommandRunner } from './android-command-runner' import type { AndroidSdkPaths } from './android-sdk-discovery' import { bootCompletedArgs, isBootCompleted } from './adb-devices' -import { bootAvdArgs, listAvdsArgs, parseAvdList } from './avd-manager' -import { findRunningAvdSerial, listRunningAdbDevices } from './android-device-inventory' +import { bootAvdArgs } from './avd-manager' +import { + findRunningAvdSerial, + listAvdNames, + listRunningAdbDevices +} from './android-device-inventory' import { emulatorProbeError } from '../emulator-probe' export type AndroidBootOptions = { @@ -29,8 +33,8 @@ export async function bootAndroidDevice( return existing } // Reject stale serials before launching an invalid AVD and waiting out the boot timeout. - const avds = parseAvdList((await runner(sdk.emulator, listAvdsArgs)).stdout) - if (!avds.includes(deviceOrName)) { + const avds = await listAvdNames(runner, sdk) + if (!sdk.emulator || !avds.includes(deviceOrName)) { throw new EmulatorError( 'emulator_device_not_found', emulatorFailureText('unknownDeviceOrAvd', { device: deviceOrName }) diff --git a/src/main/emulator/android/android-capability-operations.test.ts b/src/main/emulator/android/android-capability-operations.test.ts index a888c9d7fc9..fd4e8acb3dd 100644 --- a/src/main/emulator/android/android-capability-operations.test.ts +++ b/src/main/emulator/android/android-capability-operations.test.ts @@ -10,8 +10,7 @@ import { const SDK: AndroidSdkPaths = { sdkRoot: '/sdk', adb: '/sdk/adb', - emulator: '/sdk/emulator', - avdmanager: '/sdk/avdmanager' + emulator: '/sdk/emulator' } const ok = (stdout: string): AndroidCommandResult => ({ stdout, stderr: '', code: 0 }) diff --git a/src/main/emulator/android/android-device-inventory.test.ts b/src/main/emulator/android/android-device-inventory.test.ts index 8a6ea3c5a46..82f9faff96c 100644 --- a/src/main/emulator/android/android-device-inventory.test.ts +++ b/src/main/emulator/android/android-device-inventory.test.ts @@ -12,8 +12,7 @@ import { parseAdbDevices } from './adb-devices' const SDK: AndroidSdkPaths = { sdkRoot: '/sdk', adb: '/sdk/adb', - emulator: '/sdk/emulator', - avdmanager: '/sdk/avdmanager' + emulator: '/sdk/emulator' } const ok = (stdout: string): AndroidCommandResult => ({ stdout, stderr: '', code: 0 }) @@ -78,6 +77,18 @@ describe('listAndroidDevices', () => { expect(devices).toHaveLength(1) expect(devices[0]).toMatchObject({ id: 'emulator-5554', name: 'Pixel_7', state: 'booted' }) }) + + it('lists USB devices without running the emulator binary on adb-only hosts', async () => { + const binaries: string[] = [] + const fake = runner((binary, a) => { + binaries.push(binary) + return a === 'devices -l' ? 'List of devices attached\n721a6f41\tdevice model:M2007J17C' : '' + }) + const adbOnly: AndroidSdkPaths = { sdkRoot: null, adb: '/usr/bin/adb', emulator: null } + const devices = await listAndroidDevices(fake, adbOnly) + expect(devices).toEqual([expect.objectContaining({ id: '721a6f41', detail: 'device' })]) + expect(binaries.every((binary) => binary === '/usr/bin/adb')).toBe(true) + }) }) describe('listConfirmedAdbSerials', () => { diff --git a/src/main/emulator/android/android-device-inventory.ts b/src/main/emulator/android/android-device-inventory.ts index 76717147104..cd3fc909ae6 100644 --- a/src/main/emulator/android/android-device-inventory.ts +++ b/src/main/emulator/android/android-device-inventory.ts @@ -60,15 +60,21 @@ export async function listAndroidDevices( runner: AndroidCommandRunner, sdk: AndroidSdkPaths ): Promise { - const [running, avdsResult] = await Promise.all([ + const [running, avds] = await Promise.all([ listRunningAdbDevices(runner, sdk), - runner(sdk.emulator, listAvdsArgs) + listAvdNames(runner, sdk) ]) - const avds = parseAvdList(avdsResult.stdout) const runningAvdBySerial = await resolveRunningAvdNames(runner, sdk, running) return mergeAndroidDevices(running, avds, runningAvdBySerial) } +export async function listAvdNames( + runner: AndroidCommandRunner, + sdk: AndroidSdkPaths +): Promise { + return sdk.emulator ? parseAvdList((await runner(sdk.emulator, listAvdsArgs)).stdout) : [] +} + // adb appends an OK status after the AVD name. function firstNonStatusLine(stdout: string): string | null { for (const raw of stdout.split('\n')) { diff --git a/src/main/emulator/android/android-input-commands.test.ts b/src/main/emulator/android/android-input-commands.test.ts index 0404738df5b..3bad940847e 100644 --- a/src/main/emulator/android/android-input-commands.test.ts +++ b/src/main/emulator/android/android-input-commands.test.ts @@ -36,7 +36,7 @@ describe.runIf(process.platform !== 'win32')('Android text shell boundary', () = } await androidTypeText( runner, - { sdkRoot: 'sdk', adb: 'adb', emulator: 'emulator', avdmanager: 'avdmanager' }, + { sdkRoot: 'sdk', adb: 'adb', emulator: 'emulator' }, 'emulator-5558', text ) diff --git a/src/main/emulator/android/android-sdk-discovery.test.ts b/src/main/emulator/android/android-sdk-discovery.test.ts new file mode 100644 index 00000000000..4977f8aa3f5 --- /dev/null +++ b/src/main/emulator/android/android-sdk-discovery.test.ts @@ -0,0 +1,47 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { discoverAndroidSdk } from './android-sdk-discovery' + +const home = '/home/dev' +const defaultRoot = join(home, 'Android', 'Sdk') +const adbIn = (root: string): string => join(root, 'platform-tools', 'adb') +const emulatorIn = (root: string): string => join(root, 'emulator', 'emulator') + +function discover(files: string[], env: NodeJS.ProcessEnv = {}, pathAdb: string | null = null) { + const present = new Set(files) + return discoverAndroidSdk({ + env, + platform: 'linux', + homedir: home, + exists: (path) => present.has(path), + findAdbOnPath: () => pathAdb + }) +} + +describe('discoverAndroidSdk', () => { + it('accepts an SDK root with platform-tools but no emulator package', () => { + expect(discover([adbIn(defaultRoot)])).toEqual({ + sdkRoot: defaultRoot, + adb: adbIn(defaultRoot), + emulator: null + }) + }) + + it('prefers a full SDK over an earlier adb-only root', () => { + const adbOnly = '/opt/platform-tools-only' + expect( + discover([adbIn(adbOnly), adbIn(defaultRoot), emulatorIn(defaultRoot)], { + ANDROID_HOME: adbOnly + }) + ).toEqual({ sdkRoot: defaultRoot, adb: adbIn(defaultRoot), emulator: emulatorIn(defaultRoot) }) + }) + + it('falls back to a standalone adb on PATH when no SDK root has one', () => { + expect(discover([], {}, '/usr/bin/adb')).toEqual({ + sdkRoot: null, + adb: '/usr/bin/adb', + emulator: null + }) + expect(discover([])).toBeNull() + }) +}) diff --git a/src/main/emulator/android/android-sdk-discovery.ts b/src/main/emulator/android/android-sdk-discovery.ts index d852422f650..b890391bad2 100644 --- a/src/main/emulator/android/android-sdk-discovery.ts +++ b/src/main/emulator/android/android-sdk-discovery.ts @@ -1,10 +1,11 @@ import { join } from 'node:path' export type AndroidSdkPaths = { - sdkRoot: string + /** Null when only a standalone adb (e.g. distro android-tools) was found. */ + sdkRoot: string | null adb: string - emulator: string - avdmanager: string + /** Null without the SDK emulator package: physical devices work, AVDs are unavailable. */ + emulator: string | null } export type DiscoverAndroidSdkOptions = { @@ -12,21 +13,26 @@ export type DiscoverAndroidSdkOptions = { platform: NodeJS.Platform homedir: string exists: (path: string) => boolean + findAdbOnPath: () => string | null } export function discoverAndroidSdk(options: DiscoverAndroidSdkOptions): AndroidSdkPaths | null { const { env, platform, homedir, exists } = options const win32 = platform === 'win32' - for (const sdkRoot of candidateSdkRoots(env, platform, homedir)) { - const paths = resolveToolPaths(sdkRoot, win32) - // Both adb and the emulator binary are required for backend operations. - if (exists(paths.adb) && exists(paths.emulator)) { - return paths - } + const rootsWithAdb = candidateSdkRoots(env, platform, homedir) + .map((sdkRoot) => resolveToolPaths(sdkRoot, win32)) + .filter((paths) => exists(paths.adb)) + // Prefer a full SDK so AVDs stay available when an adb-only root comes first. + const full = rootsWithAdb.find((paths) => paths.emulator && exists(paths.emulator)) + if (full) { + return full } - - return null + if (rootsWithAdb[0]) { + return { ...rootsWithAdb[0], emulator: null } + } + const adb = options.findAdbOnPath() + return adb ? { sdkRoot: null, adb, emulator: null } : null } function candidateSdkRoots( @@ -64,13 +70,6 @@ function resolveToolPaths(sdkRoot: string, win32: boolean): AndroidSdkPaths { return { sdkRoot, adb: join(sdkRoot, 'platform-tools', win32 ? 'adb.exe' : 'adb'), - emulator: join(sdkRoot, 'emulator', win32 ? 'emulator.exe' : 'emulator'), - avdmanager: join( - sdkRoot, - 'cmdline-tools', - 'latest', - 'bin', - win32 ? 'avdmanager.bat' : 'avdmanager' - ) + emulator: join(sdkRoot, 'emulator', win32 ? 'emulator.exe' : 'emulator') } } diff --git a/src/main/emulator/android/android-sdk-host-discovery.ts b/src/main/emulator/android/android-sdk-host-discovery.ts index a7f93acbfa2..a0e427fecf6 100644 --- a/src/main/emulator/android/android-sdk-host-discovery.ts +++ b/src/main/emulator/android/android-sdk-host-discovery.ts @@ -1,6 +1,7 @@ import { homedir, platform } from 'node:os' import { existsSync } from 'node:fs' import { discoverAndroidSdk, type AndroidSdkPaths } from './android-sdk-discovery' +import { resolveExecutableCommand } from '../../../shared/node-cli-command-resolution' let configuredSdkPath: string | null = null @@ -13,7 +14,13 @@ export function setConfiguredAndroidSdkPath(path: string | null): void { export function discoverAndroidSdkFromHost(): AndroidSdkPaths | null { const env = configuredSdkPath ? { ...process.env, ANDROID_HOME: configuredSdkPath } : process.env try { - return discoverAndroidSdk({ env, platform: platform(), homedir: homedir(), exists: existsSync }) + return discoverAndroidSdk({ + env, + platform: platform(), + homedir: homedir(), + exists: existsSync, + findAdbOnPath: () => resolveExecutableCommand('adb') + }) } catch { return null } diff --git a/src/main/emulator/android/scrcpy-stream-session.test.ts b/src/main/emulator/android/scrcpy-stream-session.test.ts index c7dcdc6d28b..6c6dc632c7d 100644 --- a/src/main/emulator/android/scrcpy-stream-session.test.ts +++ b/src/main/emulator/android/scrcpy-stream-session.test.ts @@ -46,7 +46,7 @@ async function startSession() { const started = ScrcpyStreamSession.start( { runner, - sdk: { sdkRoot: 'sdk', adb: 'adb', emulator: 'emulator', avdmanager: 'avdmanager' }, + sdk: { sdkRoot: 'sdk', adb: 'adb', emulator: 'emulator' }, serial: 'test-device', localJarPath: 'server.jar' }, diff --git a/src/main/emulator/backends/android-emulator-backend.test.ts b/src/main/emulator/backends/android-emulator-backend.test.ts index 8111f200797..093949dc563 100644 --- a/src/main/emulator/backends/android-emulator-backend.test.ts +++ b/src/main/emulator/backends/android-emulator-backend.test.ts @@ -14,8 +14,7 @@ vi.mock('node:child_process', async (importOriginal) => { const SDK: AndroidSdkPaths = { sdkRoot: '/sdk', adb: '/sdk/adb', - emulator: '/sdk/emulator', - avdmanager: '/sdk/avdmanager' + emulator: '/sdk/emulator' } const ok = (stdout: string): AndroidCommandResult => ({ stdout, stderr: '', code: 0 }) diff --git a/src/main/emulator/backends/android-emulator-backend.ts b/src/main/emulator/backends/android-emulator-backend.ts index 4ad1425015c..7f4661a0856 100644 --- a/src/main/emulator/backends/android-emulator-backend.ts +++ b/src/main/emulator/backends/android-emulator-backend.ts @@ -111,7 +111,7 @@ export class AndroidEmulatorBackend implements EmulatorBackend { if (!sdk) { return { available: false, devices: [], ...emulatorCodedMessage('androidSdkMissing') } } - const sdkPath = sdk.sdkRoot + const sdkPath = sdk.sdkRoot ?? sdk.adb let devices: EmulatorDevice[] = [] try { devices = await this.listDevices()