From 5ea047e34e337ed742d55256aaedf55fca9fecb9 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:10:20 +0000 Subject: [PATCH] fix(emulator): detect Android devices when only adb is installed (#37128) * fix(emulator): detect Android devices with only adb installed SDK discovery required both adb and the emulator binary under an SDK root, so hosts with platform-tools only, or a standalone adb such as a distro android-tools package, saw no Android devices at all. Discovery now needs only adb, falls back to adb on PATH, and AVD listing and boot are skipped when the emulator package is missing. Fixes #32126 Co-authored-by: s546126 <268420947+s546126@users.noreply.github.com> * test(emulator): drop type assertion in adb-only inventory test --------- Co-authored-by: s546126 <268420947+s546126@users.noreply.github.com> --- skill-guides/orca-emulator-android.md | 14 +++--- src/cli/bundled-skill-guides.ts | 14 +++--- .../android/android-avd-boot-identity.test.ts | 3 +- src/main/emulator/android/android-avd-boot.ts | 12 +++-- .../android-capability-operations.test.ts | 3 +- .../android/android-device-inventory.test.ts | 15 +++++- .../android/android-device-inventory.ts | 12 +++-- .../android/android-input-commands.test.ts | 2 +- .../android/android-sdk-discovery.test.ts | 47 +++++++++++++++++++ .../emulator/android/android-sdk-discovery.ts | 37 +++++++-------- .../android/android-sdk-host-discovery.ts | 9 +++- .../android/scrcpy-stream-session.test.ts | 2 +- .../backends/android-emulator-backend.test.ts | 3 +- .../backends/android-emulator-backend.ts | 2 +- 14 files changed, 123 insertions(+), 52 deletions(-) create mode 100644 src/main/emulator/android/android-sdk-discovery.test.ts 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()