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>
This commit is contained in:
Kelvin Amoaba
2026-11-01 20:10:20 +00:00
committed by GitHub
co-authored by s546126
parent ad864a6ec7
commit 5ea047e34e
14 changed files with 123 additions and 52 deletions
+7 -7
View File
@@ -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 @<avd>`.
+7 -7
View File
@@ -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 @<avd>`.",
@@ -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 }
@@ -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 })
@@ -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 })
@@ -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', () => {
@@ -60,15 +60,21 @@ export async function listAndroidDevices(
runner: AndroidCommandRunner,
sdk: AndroidSdkPaths
): Promise<EmulatorDevice[]> {
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<string[]> {
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')) {
@@ -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
)
@@ -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()
})
})
@@ -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')
}
}
@@ -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
}
@@ -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'
},
@@ -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 })
@@ -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()