diff --git a/docs/CLI.md b/docs/CLI.md index 6cdad893..d04e6e63 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1626,7 +1626,9 @@ from the device profile's tag: `android-tv` is `tv`, `android-wear` is `watch`, `android-automotive` and its variants are `auto`, `android-desktop` is `desktop`, and a profile with no tag is `phone`. A custom profile found only in `devices.xml` takes its class from the tag Android Studio saved -for it, and is `phone` when it saved none. A profile Simlock ships carries its own class. A model whose tooling reports nothing usable has +for it, and is `phone` when it saved none. When a custom name is one an SDK or +Simlock-shipped profile already answers to, the class is that profile's, since a +lease of that name creates its device. A profile Simlock ships carries its own class. A model whose tooling reports nothing usable has no entry, and is still listed and leasable by name. `classDefaults` says, for a class, which model Simlock would create for it on diff --git a/src/drivers/android/AGENTS.md b/src/drivers/android/AGENTS.md index 43e1d458..5d64ab70 100644 --- a/src/drivers/android/AGENTS.md +++ b/src/drivers/android/AGENTS.md @@ -79,9 +79,9 @@ started, then `avdmanager delete avd`, frees the port. `listManaged`: `/*.avd` + `adb devices` + `ro.boot.qemu.avd_name` per serial; only root members count. Profiles — library (`profiles/`), SDK (`avdmanager list device`), devices.xml -(`$ANDROID_USER_HOME/devices.xml`, else `.android/devices.xml` under `ANDROID_PREFS_ROOT`, -`ANDROID_SDK_HOME` or home). First case-insensitive name match wins. A devices.xml name the -library shadows → advisory `device-profile-hidden`. SDK class from `Tag`: none `phone`, +(`$ANDROID_USER_HOME/devices.xml`, else `.android/devices.xml` under `ANDROID_PREFS_ROOT`, `ANDROID_SDK_HOME` +or home). First case-insensitive name match wins and classes the model by the profile its name resolves to. A +devices.xml name the library shadows → advisory `device-profile-hidden`. SDK class from `Tag`: none `phone`, `android-tv` `tv`, `android-wear` `watch`, `android-automotive*` `auto`, `android-desktop` `desktop`. Fit — `fits(profile, image)`: API range, image kind (tag), Play Store, tablet. The catalog's diff --git a/src/drivers/android/device-profile-source.test.ts b/src/drivers/android/device-profile-source.test.ts index 01571aef..273874a7 100644 --- a/src/drivers/android/device-profile-source.test.ts +++ b/src/drivers/android/device-profile-source.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import type { DeviceClass } from "../../core/index.js"; import { MemoryFilesystem, ScriptedProcessRunner } from "../../ports/index.js"; import { BuiltinDeviceProfileSource, @@ -9,6 +10,7 @@ import { UserDeviceProfileSource, type DeviceProfileSourceDiagnostic, } from "./device-profile-source.js"; +import { LibraryDeviceProfileSource } from "./profiles/index.js"; const avdmanager = "/android-sdk/cmdline-tools/latest/bin/avdmanager"; const pixelDevices = `Available devices:\nid: 0 or "pixel_8"\n Name: Pixel 8\n OEM : Google\n`; @@ -869,6 +871,75 @@ describe("device classes", () => { expect(classes).toEqual({ "My Custom Phone": "phone", "Pixel 8": "phone" }); }); + it("classes a listed model by the profile its name resolves to, not the one it was listed from", async () => { + // why: the three devices.xml profiles are named after profile ids the library or the SDK + // ships -- `tv_1080p` and `wearos_large_round` resolve to the library's TV and Wear OS + // profiles, `tv_720p` to an SDK-only one -- so a lease of each name creates that TV or + // Wear OS device; the class the catalog lists must follow the profile the name resolves + // to, not the untagged devices.xml entry the name was listed from. + const collidingDevicesXml = ` + tv_1080p2048 + tv_720p + wearos_large_round + My Custom Phone + `; + const sdkTelevision = + 'id: 58 or "tv_1080p"\n Name: Television (1080p)\n OEM : Google\n Tag : android-tv\n'; + const sdk720 = + 'id: 60 or "tv_720p"\n Name: Television (720p)\n OEM : Google\n Tag : android-tv\n'; + const sdkWear = + 'id: 61 or "wearos_large_round"\n Name: Wear OS Large Round\n OEM : Google\n Tag : android-wear\n'; + const sdkOutput = + `Available devices:\n${sdkTelevision}---------\n${sdk720}---------\n${sdkWear}---------\n` + + `id: 43 or "pixel_8"\n Name: Pixel 8\n OEM : Google\n---------\n`; + // why: `avdmanager list device` is read once for the catalog and once more for each resolve + // of a name the library lacks (`Television (720p)`, `tv_720p`, `My Custom Phone`, and the + // `tv_720p` resolve below). + const runner = new ScriptedProcessRunner( + Array.from({ length: 5 }, () => processResult(sdkOutput)), + ); + const registry = new DeviceProfileRegistry([ + new LibraryDeviceProfileSource(), + new BuiltinDeviceProfileSource(avdmanager, runner), + new UserDeviceProfileSource( + devicesXmlPath, + await filesystemWithDevicesXml(collidingDevicesXml), + ), + ]); + + const catalog = await registry.catalog(); + const expected: Record = {}; + for (const model of catalog.models) { + const resolved = await registry.resolve(model); + expected[model] = + resolved.kind === "properties" + ? resolved.imageKind === "android-tv" + ? "tv" + : resolved.imageKind === "android-wear" + ? "watch" + : resolved.imageKind === undefined + ? "phone" + : undefined + : resolved.deviceClass; + } + expect(catalog.modelClasses).toEqual(expected); + await expect(registry.resolve("tv_1080p")).resolves.toMatchObject({ + deviceClass: "tv", + kind: "library", + name: "Television (1080p)", + }); + await expect(registry.resolve("tv_720p")).resolves.toMatchObject({ + avdmanagerId: "tv_720p", + deviceClass: "tv", + kind: "builtin", + }); + await expect(registry.resolve("wearos_large_round")).resolves.toMatchObject({ + deviceClass: "watch", + kind: "library", + name: "Wear OS Large Round", + }); + }); + it("lists no class for a profile whose tag is outside the table, and keeps the model", async () => { const output = entry("odd", "Odd Device", "android-toaster") + entry("pixel_8", "Pixel 8"); const registry = new DeviceProfileRegistry([ diff --git a/src/drivers/android/device-profile-source.ts b/src/drivers/android/device-profile-source.ts index 64650d6b..bfe71519 100644 --- a/src/drivers/android/device-profile-source.ts +++ b/src/drivers/android/device-profile-source.ts @@ -131,11 +131,18 @@ export class DeviceProfileRegistry { const profile = matchProfile(profiles, model); return profile !== undefined && isCustom(profile); }); + // why: a lease of a name creates the profile that name resolves to, so its class in + // the catalog must come from that profile, not the one it happened to be listed from. const modelClasses: Record = {}; - for (const profile of listed.values()) { + for (const model of models) { + const profile = matchProfile(profiles, model); const deviceClass = - profile.kind === "properties" ? classOfTag(profile.imageKind) : profile.deviceClass; - if (deviceClass !== undefined) modelClasses[profile.name] = deviceClass; + profile === undefined + ? undefined + : profile.kind === "properties" + ? classOfTag(profile.imageKind) + : profile.deviceClass; + if (deviceClass !== undefined) modelClasses[model] = deviceClass; } const modelProfiles = new Map(); for (const model of models) {