mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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>`.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user