diff --git a/oxlint.config.ts b/oxlint.config.ts index e6dcf7be2c..f5c95cb857 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -53,7 +53,9 @@ const LEGACY_FILE_LINE_CAPS = { 'src/daemon-client/__tests__/daemon-client.test.ts': 1340, 'src/mcp/__tests__/command-tools.test.ts': 1029, 'test/integration/provider-scenarios/android-lifecycle.test.ts': 1227, - 'packages/command-registry/src/registry.ts': 2179, + // A command must add its descriptor to RAW_COMMAND_DESCRIPTORS, so this cap is re-pinned per + // added command; the statement-neutral split (ADR 0027) is the path to shrinking it. + 'packages/command-registry/src/registry.ts': 2191, 'packages/host-kit/src/internal/exec.ts': 1032, 'packages/react-native/src/metro/client-metro.ts': 1123, 'src/cli/commands/connection-runtime.ts': 1019, diff --git a/packages/command-registry/src/__tests__/command-result.test.ts b/packages/command-registry/src/__tests__/command-result.test.ts index 2884e64567..d50eb5010f 100644 --- a/packages/command-registry/src/__tests__/command-result.test.ts +++ b/packages/command-registry/src/__tests__/command-result.test.ts @@ -13,6 +13,7 @@ import type { import type { AppSwitcherCommandResult, BackCommandResult, + HardwareKeyCommandResult, HomeCommandResult, OrientationCommandResult, TvRemoteCommandResult, @@ -62,6 +63,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ const keyboard: Equal, KeyboardCommandResult> = true; const tvRemote: Equal, TvRemoteCommandResult> = true; const telephony: Equal, TelephonyCommandResult> = true; + const hardwareKey: Equal, HardwareKeyCommandResult> = true; const wait: Equal, WaitCommandResult> = true; const prepare: Equal, PrepareCommandResult> = true; const push: Equal, PushCommandResult> = true; @@ -93,6 +95,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ keyboard, tvRemote, telephony, + hardwareKey, wait, prepare, push, @@ -136,6 +139,7 @@ test('CommandResultMap is seeded only from already-existing contract result type | 'keyboard' | 'tv-remote' | 'telephony' + | 'hardware-key' | 'wait' | 'scroll' | 'prepare' diff --git a/packages/command-registry/src/command-result.ts b/packages/command-registry/src/command-result.ts index 56e2805d3c..a994be800e 100644 --- a/packages/command-registry/src/command-result.ts +++ b/packages/command-registry/src/command-result.ts @@ -15,6 +15,7 @@ import type { AppSwitcherCommandResult, BackCommandResult, FoldCommandResult, + HardwareKeyCommandResult, HomeCommandResult, OrientationCommandResult, TvRemoteCommandResult, @@ -72,6 +73,7 @@ export interface CommandResultMap { fill: FillCommandResponseData; find: FindCommandResponseData; fold: FoldCommandResult; + 'hardware-key': HardwareKeyCommandResult; home: HomeCommandResult; hover: HoverCommandResponseData; keyboard: KeyboardCommandResult; diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index b3c319ce16..6af3568fc2 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -58,6 +58,7 @@ import { focusRuntimeUse, gestureRuntimePlanUses, gestureViewportRuntimeUse, + hardwareKeyRuntimeUse, homeRuntimeUse, hoverRuntimeUses, keyboardRuntimePlanUses, @@ -1494,6 +1495,17 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...GENERIC_MUTATING_COMMAND_TRAITS, platformExecution: { kind: 'device-runtime', uses: [tvRemoteRuntimeUse] }, }, + { + name: 'hardware-key', + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), + catalog: { group: 'public', key: 'hardwareKey' }, + frameworkTier: 'extended', + // Admission is the owner's standalone `hardwareKey` fact. No post-action observation, as for + // `action-button`: the key goes to the system and the media session rather than the session + // app, and power/sleep leave the display off. + ...GENERIC_MUTATING_COMMAND_TRAITS, + platformExecution: { kind: 'device-runtime', uses: [hardwareKeyRuntimeUse] }, + }, { name: 'orientation', ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), diff --git a/packages/contracts/src/client-system.ts b/packages/contracts/src/client-system.ts index 62932a49de..6b907677e3 100644 --- a/packages/contracts/src/client-system.ts +++ b/packages/contracts/src/client-system.ts @@ -7,7 +7,7 @@ import type { DeviceCommandBaseOptions } from './client-connection.ts'; import type { SettleCommandOptions } from './client-gesture.ts'; import type { DeviceRotation, SetFoldPoseInput } from './device-rotation.ts'; import type { TelephonyEventInput } from './telephony-input.ts'; -import type { TvRemoteButton } from './tv-remote.ts'; +import type { HardwareKeyToken, TvRemoteButton } from './tv-remote.ts'; export type WaitCommandTarget = | { @@ -104,6 +104,10 @@ export type TvRemoteCommandOptions = DeviceCommandBaseOptions & { export type TelephonyCommandOptions = DeviceCommandBaseOptions & TelephonyEventInput; +export type HardwareKeyCommandOptions = DeviceCommandBaseOptions & { + key: HardwareKeyToken; +}; + export type KeyboardCommandOptions = DeviceCommandBaseOptions & { action?: 'status' | 'dismiss' | 'enter' | 'return'; }; diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index 5af90eef2f..4b4bfd096f 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -120,6 +120,7 @@ export type { ClipboardCommandOptions, FoldCommandOptions, DoctorCommandOptions, + HardwareKeyCommandOptions, HomeCommandOptions, KeyboardCommandOptions, OrientationCommandOptions, diff --git a/packages/contracts/src/interactor-operation-catalog-conformance.test.ts b/packages/contracts/src/interactor-operation-catalog-conformance.test.ts index bca8508288..3dc1c54ec5 100644 --- a/packages/contracts/src/interactor-operation-catalog-conformance.test.ts +++ b/packages/contracts/src/interactor-operation-catalog-conformance.test.ts @@ -28,6 +28,7 @@ const EXPECTATIONS: { home: { method: 'home', input: {} }, setOrientation: { method: 'setOrientation', input: { rotation: 'portrait' } }, tvRemote: { method: 'tvRemote', input: { button: 'select' } }, + hardwareKey: { method: 'hardwareKey', input: { key: 'volume-up' } }, keyboardStatus: { method: 'keyboardStatus', input: {} }, keyboardDismiss: { method: 'keyboardDismiss', input: {} }, keyboardEnter: { method: 'keyboardEnter', input: {} }, diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index 80be944e06..9a8eec8955 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -10,7 +10,12 @@ import { import { KEYBOARD_ACTION_LABELS, bindKeyboardAction } from './keyboard-runtime.ts'; import { bindOrientation } from './orientation-runtime.ts'; import { bindReadSetting, bindSetSetting } from './settings-runtime.ts'; -import { TV_REMOTE_LABEL, bindTvRemote } from './tv-remote-runtime.ts'; +import { + TV_REMOTE_LABEL, + bindTvRemote, + HARDWARE_KEY_LABEL, + bindHardwareKey, +} from './tv-remote-runtime.ts'; import { localInteractorSource, providerInteractorSource, @@ -63,6 +68,7 @@ export const INTERACTOR_OPERATIONS = [ }, { operation: 'setOrientation', label: 'orientation', bind: bindOrientation }, { operation: 'tvRemote', label: TV_REMOTE_LABEL, bind: bindTvRemote }, + { operation: 'hardwareKey', label: HARDWARE_KEY_LABEL, bind: bindHardwareKey }, { operation: 'keyboardStatus', label: KEYBOARD_ACTION_LABELS.keyboardStatus, diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index a17438a9aa..034269c448 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -6,7 +6,7 @@ import type { DeviceRotation } from './device-rotation.ts'; import type { FillUnconfirmedVerification } from './fill-evidence.ts'; import type { ScrollDirection } from './scroll-gesture.ts'; import type { ScrollExecutionOptions } from './scroll-command.ts'; -import type { TvRemoteButton } from './tv-remote.ts'; +import type { HardwareKeyToken, TvRemoteButton } from './tv-remote.ts'; import type { GesturePlan } from './gesture-plan-types.ts'; import type { ReadSettingResult, SettingOptions } from './settings.ts'; import type { ReadableSetting } from './settings-catalog.ts'; @@ -419,6 +419,13 @@ export type Interactor = { * no-op. */ tvRemote?(button: TvRemoteButton, durationMs?: number): Promise; + /** + * Optional (parity with `tvRemote`): presses one volume, power, or media key, a control only + * owners with a key-event channel carry. An owner without it leaves it undefined; its fact + * refuses the press before binding, and the hardware-key binder fails closed rather than + * resolving an absent member as a silent no-op. + */ + hardwareKey?(key: HardwareKeyToken): Promise; /** * Optional (parity with `keyboardDismiss`): presses the iPhone/iPad Action Button, hardware only * the Apple owner carries. An owner without the button leaves it undefined; its fact refuses the diff --git a/packages/contracts/src/navigation.ts b/packages/contracts/src/navigation.ts index fca6600b99..738f7811f1 100644 --- a/packages/contracts/src/navigation.ts +++ b/packages/contracts/src/navigation.ts @@ -2,7 +2,7 @@ import type { BackMode } from './back-mode.ts'; import type { DeviceRotation, FoldPose } from './device-rotation.ts'; import type { FoldScreenReport } from './fold-runtime.ts'; import type { SettleObservation } from './interaction.ts'; -import type { TvRemoteButton } from './tv-remote.ts'; +import type { HardwareKeyToken, TvRemoteButton } from './tv-remote.ts'; /** * Closed results of the navigation/global action commands. Each mirrors its @@ -89,3 +89,10 @@ export type TvRemoteCommandResult = { durationMs?: number; message: string; }; + +/** `hardware-key` — `{ action: 'hardware-key', key, message }`. */ +export type HardwareKeyCommandResult = { + action: 'hardware-key'; + key: HardwareKeyToken; + message: string; +}; diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 06b378487c..7045fae808 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -25,7 +25,10 @@ import type { BackRuntimeOperations } from './back-runtime.ts'; import type { OrientationRuntimeOperations } from './orientation-runtime.ts'; import type { FoldRuntimeOperations } from './fold-runtime.ts'; import type { TelephonyRuntimeOperations } from './telephony-runtime.ts'; -import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts'; +import type { + TvRemoteRuntimeOperations, + HardwareKeyRuntimeOperations, +} from './tv-remote-runtime.ts'; import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; import type { SystemButtonRuntimeOperations } from './system-button-runtime.ts'; @@ -81,6 +84,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & FoldRuntimeOperations & TelephonyRuntimeOperations & TvRemoteRuntimeOperations & + HardwareKeyRuntimeOperations & KeyboardRuntimeOperations & ClipboardRuntimeOperations & SystemButtonRuntimeOperations & @@ -117,6 +121,7 @@ export const orientationRuntimeUse = defineUse({ required: ['setOrientation'] }) export const foldRuntimeUse = defineUse({ required: ['setFoldPose'] }); export const telephonyRuntimeUse = defineUse({ required: ['sendTelephonyEvent'] }); export const tvRemoteRuntimeUse = defineUse({ required: ['tvRemote'] }); +export const hardwareKeyRuntimeUse = defineUse({ required: ['hardwareKey'] }); export const keyboardStatusUse = defineUse({ required: ['keyboardStatus'] }); export const keyboardDismissUse = defineUse({ required: ['keyboardDismiss'] }); export const keyboardEnterUse = defineUse({ required: ['keyboardEnter'] }); diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index d479d6e852..33286099a5 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -42,6 +42,7 @@ const UNAVAILABLE_FACTS: UnavailablePlatformRuntimeFacts = { back: { available: false, reason: 'unsupported-provider-mode' }, orientation: { available: false, reason: 'unsupported-provider-mode' }, tvRemote: { available: false, reason: 'unsupported-provider-mode' }, + hardwareKey: { available: false, reason: 'unsupported-provider-mode' }, keyboard: { available: false, reason: 'unsupported-provider-mode' }, clipboard: { available: false, reason: 'unsupported-provider-mode' }, systemButton: { available: false, reason: 'unsupported-provider-mode' }, diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 758153f01c..ac2f686b79 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -22,7 +22,10 @@ import { typeTextRuntimeOperationFacts } from './type-text-runtime.ts'; import { elementTextRuntimeOperationFacts } from './element-text-runtime.ts'; import { backRuntimeOperationFacts } from './back-runtime.ts'; import { orientationRuntimeOperationFacts } from './orientation-runtime.ts'; -import { tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts'; +import { + tvRemoteRuntimeOperationFacts, + hardwareKeyRuntimeOperationFacts, +} from './tv-remote-runtime.ts'; import { keyboardRuntimeOperationFacts } from './keyboard-runtime.ts'; import { clipboardRuntimeOperationFacts } from './clipboard-runtime.ts'; import { systemButtonRuntimeOperationFacts } from './system-button-runtime.ts'; @@ -60,6 +63,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ fold: RuntimeOperationUnavailability; telephony: RuntimeOperationUnavailability; tvRemote: RuntimeOperationUnavailability; + hardwareKey: RuntimeOperationUnavailability; keyboard: RuntimeOperationUnavailability; clipboard: RuntimeOperationUnavailability; systemButton: RuntimeOperationUnavailability; @@ -117,6 +121,7 @@ const UNAVAILABLE_CELLS = { fold: true, telephony: true, tvRemote: true, + hardwareKey: true, keyboard: true, clipboard: true, systemButton: true, @@ -243,6 +248,7 @@ export function createUnavailablePlatformRuntimeFacts( setFoldPose: frozen.fold, sendTelephonyEvent: frozen.telephony, ...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }), + ...hardwareKeyRuntimeOperationFacts({ hardwareKey: frozen.hardwareKey }), ...keyboardRuntimeOperationFacts({ unsupported: frozen.keyboard }), ...clipboardRuntimeOperationFacts({ unsupported: frozen.clipboard }), ...systemButtonRuntimeOperationFacts({ unsupported: frozen.systemButton }), diff --git a/packages/contracts/src/runtime-operation-names.ts b/packages/contracts/src/runtime-operation-names.ts index 32cdf3b31a..0c2f3c30e3 100644 --- a/packages/contracts/src/runtime-operation-names.ts +++ b/packages/contracts/src/runtime-operation-names.ts @@ -41,6 +41,7 @@ export const RUNTIME_OPERATION_NAMES = [ 'findText', 'focusPoint', 'gestureViewport', + 'hardwareKey', 'home', 'hoverPoint', 'hoverRef', diff --git a/packages/contracts/src/tv-remote-hardware-key.test.ts b/packages/contracts/src/tv-remote-hardware-key.test.ts new file mode 100644 index 0000000000..51ed8af930 --- /dev/null +++ b/packages/contracts/src/tv-remote-hardware-key.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { HARDWARE_KEYS, parseHardwareKey, toAndroidHardwareKeycode } from './tv-remote.ts'; + +test('every hardware key maps to its Android KeyEvent keycode', () => { + assert.deepEqual( + Object.fromEntries(HARDWARE_KEYS.map((key) => [key, toAndroidHardwareKeycode(key)])), + { + 'volume-up': 24, + 'volume-down': 25, + mute: 164, + power: 26, + sleep: 223, + wake: 224, + 'media-play-pause': 85, + 'media-next': 87, + 'media-previous': 88, + 'media-rewind': 89, + 'media-fast-forward': 90, + 'channel-up': 166, + 'channel-down': 167, + 'last-channel': 229, + guide: 172, + info: 165, + captions: 175, + 'tv-input': 178, + 'color-red': 183, + 'color-green': 184, + 'color-yellow': 185, + 'color-blue': 186, + }, + ); +}); + +test('hardware key names parse case-insensitively', () => { + assert.equal(parseHardwareKey('Volume-Up'), 'volume-up'); + assert.equal(parseHardwareKey('MEDIA-PLAY-PAUSE'), 'media-play-pause'); +}); + +test('a raw Android keycode parses to its token and sends that keycode', () => { + assert.equal(parseHardwareKey('android-keycode:300'), 'android-keycode:300'); + assert.equal(parseHardwareKey('ANDROID-KEYCODE:1'), 'android-keycode:1'); + assert.equal(toAndroidHardwareKeycode('android-keycode:300'), 300); + assert.equal( + toAndroidHardwareKeycode(parseHardwareKey('android-keycode:2147483647')), + 2147483647, + ); +}); + +test.each([ + undefined, + '', + 'volume', + 'home', + 'toString', + 'KEYCODE_VOLUME_UP', + '24', + 'android-keycode:', + 'android-keycode:0', + 'android-keycode:007', + 'android-keycode:-1', + 'android-keycode:1.5', + 'android-keycode:2147483648', + 'android-keycode:24 ', + 'keycode:24', +])('an unknown hardware key %j is INVALID_ARGS naming every key', (value) => { + assert.throws(() => parseHardwareKey(value), { + code: 'INVALID_ARGS', + message: `hardware-key key must be one of: ${HARDWARE_KEYS.join(', ')}, or android-keycode: with n from 1 to 2147483647.`, + }); +}); diff --git a/packages/contracts/src/tv-remote-runtime-hardware-key.test.ts b/packages/contracts/src/tv-remote-runtime-hardware-key.test.ts new file mode 100644 index 0000000000..a0e5611883 --- /dev/null +++ b/packages/contracts/src/tv-remote-runtime-hardware-key.test.ts @@ -0,0 +1,58 @@ +import { expect, test, vi } from 'vitest'; +import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource } from './interactor-operation-binding.ts'; +import { bindHardwareKey, hardwareKeyRuntimeOperationFacts } from './tv-remote-runtime.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; +const available = { available: true } as const; +const unsupported = { available: false, reason: 'unsupported-platform-leaf' } as const; + +test('the hardware-key facts carry one cell', () => { + expect(hardwareKeyRuntimeOperationFacts({ hardwareKey: available })).toEqual({ + hardwareKey: available, + }); + expect(hardwareKeyRuntimeOperationFacts({ hardwareKey: unsupported })).toEqual({ + hardwareKey: unsupported, + }); +}); + +test('a local hardware-key binding presses the interactor once with the parsed key', async () => { + const hardwareKey = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ hardwareKey }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindHardwareKey(signal, localInteractorSource({ device, resolveInteractor })); + await operations.hardwareKey({ + key: 'media-play-pause', + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: 'hardware-key-1' }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: 'hardware-key-1', + appBundleId: 'com.example.app', + signal, + }); + expect(hardwareKey).toHaveBeenCalledExactlyOnceWith('media-play-pause'); +}); + +test('an admitted hardware key whose interactor lacks the member fails closed', async () => { + const resolveInteractor = vi.fn(async () => ({}) as unknown as Interactor); + const operations = bindHardwareKey( + new AbortController().signal, + localInteractorSource({ device, resolveInteractor }), + ); + + await expect(operations.hardwareKey({ key: 'volume-up' })).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: 'hardware-key was admitted but its bound interactor has no implementation.', + details: { reason: 'interactor-method-missing' }, + }); +}); diff --git a/packages/contracts/src/tv-remote-runtime.ts b/packages/contracts/src/tv-remote-runtime.ts index 88922013bf..61e8f0caf3 100644 --- a/packages/contracts/src/tv-remote-runtime.ts +++ b/packages/contracts/src/tv-remote-runtime.ts @@ -1,5 +1,5 @@ import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { TvRemoteButton } from './tv-remote.ts'; +import type { HardwareKeyToken, TvRemoteButton } from './tv-remote.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; @@ -63,3 +63,62 @@ export function bindTvRemote( }, }); } + +/** + * Neutral intent for one hardware key press. `key` is already parsed and validated by the + * caller; the operation names no command, request, session, or CLI flag. + */ +export type HardwareKeyInput = Readonly<{ + key: HardwareKeyToken; + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** + * A hardware key press returns nothing. The Android interactor answers with the adb result and + * every caller discards it, so a result type here would be a surface the command never had. + */ +export type HardwareKeyRuntimeOperations = Readonly<{ + hardwareKey(input: HardwareKeyInput): Promise; +}>; + +export type HardwareKeyRuntimeOperationFacts = Readonly<{ + hardwareKey: RuntimeOperationFact; +}>; + +export function hardwareKeyRuntimeOperationFacts( + input: Readonly<{ hardwareKey: RuntimeOperationFact }>, +): HardwareKeyRuntimeOperationFacts { + return Object.freeze({ hardwareKey: input.hardwareKey }); +} + +/** How a fail-closed refusal names the hardware key press to the caller, matching the command. */ +export const HARDWARE_KEY_LABEL = 'hardware-key'; + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding and + * presses one hardware key through it. Owners without a key-event channel refuse through facts, + * so a missing interactor method at press time is a runtime-contract error, not a normal refusal. + */ +export function bindHardwareKey( + signal: AbortSignal, + resolveInteractor: (runner: RunnerContext) => Promise, +): HardwareKeyRuntimeOperations { + return Object.freeze({ + hardwareKey: async (input: HardwareKeyInput) => { + signal.throwIfAborted(); + const interactor = await resolveInteractor({ + ...input.execution, + appBundleId: input.options?.appBundleId, + signal, + }); + // Loaded on the call rather than at module evaluation because the façades reaching this + // binder hold their eager closure at merge-base size (`eager-closure-budgets`); a static + // edge would grow it. + const { requireInteractorMethod } = await import('./interactor-operation-binding.ts'); + const hardwareKey = requireInteractorMethod(interactor.hardwareKey, HARDWARE_KEY_LABEL); + await hardwareKey.call(interactor, input.key); + }, + }); +} diff --git a/packages/contracts/src/tv-remote.ts b/packages/contracts/src/tv-remote.ts index c341731aec..48966ec2c6 100644 --- a/packages/contracts/src/tv-remote.ts +++ b/packages/contracts/src/tv-remote.ts @@ -117,3 +117,80 @@ const TV_REMOTE_BUTTON_ALIAS_MAP = new Map( TV_REMOTE_BUTTON_DEFINITIONS[button].aliases.map((alias) => [alias, button] as const), ), ); + +/** + * Volume, power, media, and TV keys: named presses that, like the TV remote buttons, each map + * to one platform key event. Only Android sends them; no other owner carries a key-event channel. + */ +const HARDWARE_KEY_DEFINITIONS = { + 'volume-up': { androidKeycode: 24 }, + 'volume-down': { androidKeycode: 25 }, + mute: { androidKeycode: 164 }, + power: { androidKeycode: 26 }, + sleep: { androidKeycode: 223 }, + wake: { androidKeycode: 224 }, + 'media-play-pause': { androidKeycode: 85 }, + 'media-next': { androidKeycode: 87 }, + 'media-previous': { androidKeycode: 88 }, + 'media-rewind': { androidKeycode: 89 }, + 'media-fast-forward': { androidKeycode: 90 }, + 'channel-up': { androidKeycode: 166 }, + 'channel-down': { androidKeycode: 167 }, + 'last-channel': { androidKeycode: 229 }, + guide: { androidKeycode: 172 }, + info: { androidKeycode: 165 }, + captions: { androidKeycode: 175 }, + 'tv-input': { androidKeycode: 178 }, + 'color-red': { androidKeycode: 183 }, + 'color-green': { androidKeycode: 184 }, + 'color-yellow': { androidKeycode: 185 }, + 'color-blue': { androidKeycode: 186 }, +} as const satisfies Record; + +export const HARDWARE_KEYS = Object.keys( + HARDWARE_KEY_DEFINITIONS, +) as (keyof typeof HARDWARE_KEY_DEFINITIONS)[]; + +const ANDROID_KEYCODE_PREFIX = 'android-keycode:'; + +/** A Java `int`, the type of an Android `KeyEvent` keycode. */ +const MAX_ANDROID_KEYCODE = 2_147_483_647; + +export const HARDWARE_KEY_USAGE = `<${HARDWARE_KEYS.join('|')}|${ANDROID_KEYCODE_PREFIX}>`; + +export type HardwareKey = (typeof HARDWARE_KEYS)[number]; + +/** + * A named key, or a raw Android keycode for a vendor remote button that no name covers, such as + * an operator-specific channel or app key. + */ +export type HardwareKeyToken = HardwareKey | `${typeof ANDROID_KEYCODE_PREFIX}${number}`; + +export function parseHardwareKey(value: string | undefined): HardwareKeyToken { + const normalized = value?.toLowerCase(); + if (isHardwareKeyToken(normalized)) return normalized; + throw new AppError( + 'INVALID_ARGS', + `hardware-key key must be one of: ${HARDWARE_KEYS.join(', ')}, or ${ANDROID_KEYCODE_PREFIX} with n from 1 to ${MAX_ANDROID_KEYCODE}.`, + ); +} + +export function isHardwareKeyToken(value: unknown): value is HardwareKeyToken { + if (typeof value !== 'string') return false; + if (isHardwareKey(value)) return true; + const digits = value.startsWith(ANDROID_KEYCODE_PREFIX) + ? value.slice(ANDROID_KEYCODE_PREFIX.length) + : ''; + return /^[1-9]\d{0,9}$/.test(digits) && Number(digits) <= MAX_ANDROID_KEYCODE; +} + +function isHardwareKey(value: string): value is HardwareKey { + return Object.hasOwn(HARDWARE_KEY_DEFINITIONS, value); +} + +/** The `KeyEvent` keycode `adb shell input keyevent` sends for the key. */ +export function toAndroidHardwareKeycode(key: HardwareKeyToken): number { + return isHardwareKey(key) + ? HARDWARE_KEY_DEFINITIONS[key].androidKeycode + : Number(key.slice(ANDROID_KEYCODE_PREFIX.length)); +} diff --git a/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts b/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts index 0020116cca..944e847a68 100644 --- a/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts +++ b/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts @@ -34,6 +34,7 @@ import { } from '../../execution/system-button-runtime.ts'; import { resolveBoundTelephonyRuntime } from '../../execution/telephony-runtime.ts'; import { resolveBoundTvRemoteRuntime } from '../../execution/tv-remote-runtime.ts'; +import { resolveBoundHardwareKeyRuntime } from '../../execution/hardware-key-runtime.ts'; import { resolveBoundTypeTextRuntime } from '../../execution/type-text-runtime.ts'; import { resolveBoundViewportRuntime } from '../../execution/viewport-runtime.ts'; import type { DaemonFailureResponse } from '@agent-device/kernel/contracts'; @@ -142,6 +143,12 @@ export const conformedRuntimeBindings = { resolve: async (device, bindings) => refusable(await resolveBoundTvRemoteRuntime({ device, positionals: ['down'], ...bindings })), }, + 'hardware-key': { + resolve: async (device, bindings) => + refusable( + await resolveBoundHardwareKeyRuntime({ device, positionals: ['volume-up'], ...bindings }), + ), + }, type: { resolve: (device, bindings) => resolveBoundTypeTextRuntime({ device, ...bindings }), }, diff --git a/packages/daemon/src/core/runtime-operation-effects.ts b/packages/daemon/src/core/runtime-operation-effects.ts index dc5fa7d2e3..595f061c7d 100644 --- a/packages/daemon/src/core/runtime-operation-effects.ts +++ b/packages/daemon/src/core/runtime-operation-effects.ts @@ -48,6 +48,7 @@ export const RUNTIME_OPERATION_EFFECTS: Readonly< findText: 'repeatable', focusPoint: 'mutates', gestureViewport: 'repeatable', + hardwareKey: 'mutates', home: 'mutates', hoverPoint: 'mutates', hoverRef: 'mutates', diff --git a/packages/daemon/src/execution/__tests__/hardware-key-runtime.test.ts b/packages/daemon/src/execution/__tests__/hardware-key-runtime.test.ts new file mode 100644 index 0000000000..0b8a0ecc23 --- /dev/null +++ b/packages/daemon/src/execution/__tests__/hardware-key-runtime.test.ts @@ -0,0 +1,213 @@ +import { createTestHostEnvironment } from '@agent-device/test-kit'; +import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; +import { expect, test, vi } from 'vitest'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type DeviceRuntimeGateway, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform-runtime'; +import { + hardwareKeyRuntimeUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import { hardwareKeyRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { deviceShape } from '@agent-device/kernel/device'; +import { makeSession } from '../../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import { activateCompleteRefFrame, refFrameState } from '../../core/ref-frame.ts'; +import type { + BindDeviceRuntime, + InspectDeviceRuntimeFacts, +} from '../../core/request-runtime-binding.ts'; +import type { GenericPlatformExecutionParams } from '../../core/generic-leaf-execution.ts'; +import { resolveBoundHardwareKeyRuntime } from '../hardware-key-runtime.ts'; +import { expectRefusesUnavailableExactOwnerFact } from '../../__tests__/support/runtime-binding-conformance.ts'; +import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; +import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; + +// File-scoped id: the request-router test reaches the on-disk device-claim admission, so a shared +// literal risks a claim collision under parallel test-file execution. +const androidEmulator = { + id: 'hardware-key-runtime-5554', + name: 'Pixel', + platform: 'android', + kind: 'emulator', + target: 'mobile', + booted: true, +} as const; +const iosSimulator = { + id: 'hardware-key-runtime-ios-simulator', + name: 'iPhone', + platform: 'apple', + appleOs: 'ios', + kind: 'simulator', + target: 'mobile', + booted: true, +} as const; +const available = Object.freeze({ available: true } as const); +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf' as const, +}); + +function hardwareKeyExecutionParams(key: string): GenericPlatformExecutionParams { + const session = makeSession('hardware-key-runtime', { device: androidEmulator }); + return { + session, + sessionName: session.name, + logPath: '/tmp/daemon.log', + command: 'hardware-key', + request: { command: 'hardware-key', positionals: [key], token: 't', session: session.name }, + positionals: [key], + out: undefined, + dispatchContext: {}, + }; +} + +function runtimeHarness(fact: RuntimeOperationFact = available) { + const hardwareKey = vi.fn(async () => undefined); + const facts: RuntimeFacts = { + device: { ...deviceShape(androidEmulator), providerMode: 'local' }, + operations: { hardwareKey: fact } as RuntimeFacts['operations'], + }; + const binding = { + device: androidEmulator, + owner: localRuntimeOwner('android'), + facts, + operations: { hardwareKey }, + [Symbol.asyncDispose]: async () => {}, + } satisfies DeviceBinding; + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts); + const bindDevice = vi.fn(async (_device, use) => + narrowDeviceBinding(binding, use), + ) as unknown as BindDeviceRuntime; + const bind = vi.fn(async () => binding); + const gateway: DeviceRuntimeGateway = { + inspectFacts, + bind, + shutdown: async () => {}, + }; + return { hardwareKey, inspectFacts, bindDevice, bind, gateway }; +} + +test('resolves one admitted binding and presses the parsed key', async () => { + const harness = runtimeHarness( + hardwareKeyRuntimeOperationFacts({ hardwareKey: available }).hardwareKey, + ); + + const resolved = await resolveBoundHardwareKeyRuntime({ + platformServices: daemonPlatformServicesFixture(), + device: androidEmulator, + positionals: ['volume-up'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(harness.bindDevice).toHaveBeenCalledWith(androidEmulator, hardwareKeyRuntimeUse); + expect(await resolved.execute(hardwareKeyExecutionParams('volume-up'))).toEqual({ + action: 'hardware-key', + key: 'volume-up', + message: 'Pressed hardware key volume-up', + }); + expect(harness.hardwareKey).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ key: 'volume-up' }), + ); +}); + +test('a raw Android keycode token reaches the operation unchanged', async () => { + const harness = runtimeHarness(); + + const resolved = await resolveBoundHardwareKeyRuntime({ + platformServices: daemonPlatformServicesFixture(), + device: androidEmulator, + positionals: ['android-keycode:300'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(await resolved.execute(hardwareKeyExecutionParams('android-keycode:300'))).toEqual({ + action: 'hardware-key', + key: 'android-keycode:300', + message: 'Pressed hardware key android-keycode:300', + }); + expect(harness.hardwareKey).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ key: 'android-keycode:300' }), + ); +}); + +test.each([ + ['no key', []], + ['two keys', ['volume-up', 'mute']], + ['an unknown key', ['volume']], +])('rejects %s before inspection or binding', async (_name, positionals) => { + const harness = runtimeHarness(); + + await expect( + resolveBoundHardwareKeyRuntime({ + platformServices: daemonPlatformServicesFixture(), + device: androidEmulator, + positionals, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS' }); + expect(harness.inspectFacts).not.toHaveBeenCalled(); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +test('rejects an iOS simulator through its unavailable exact-owner fact', async () => { + await expectRefusesUnavailableExactOwnerFact({ + command: 'hardware-key', + device: iosSimulator, + unavailable, + }); +}); + +test('request router joins hardware-key admission to execution and ref invalidation', async () => { + const harness = runtimeHarness(); + const sessionStore = makeSessionStore('agent-device-hardware-key-generic-'); + const session = makeSession('hardware-key-runtime', { device: androidEmulator }); + activateCompleteRefFrame(session); + sessionStore.publish(session.name, session); + const handler = createRequestHandler({ + host: createTestHostEnvironment(), + createCommandSurface: fakeCreateCommandSurface, + logPath: '/tmp/daemon.log', + token: 't', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: harness.gateway, + trackDownloadableArtifact: () => 'artifact', + }); + + const response = await handler({ + command: 'hardware-key', + positionals: ['media-play-pause'], + token: 't', + session: session.name, + flags: {}, + meta: { requestId: 'hardware-key-router-join' }, + }); + + expect(response).toMatchObject({ + ok: true, + data: { + action: 'hardware-key', + key: 'media-play-pause', + message: 'Pressed hardware key media-play-pause', + }, + }); + expect(refFrameState(session)).toBe('expired'); + expect(harness.bind).toHaveBeenCalledTimes(1); + expect(harness.hardwareKey).toHaveBeenCalledTimes(1); +}); diff --git a/packages/daemon/src/execution/generic-runtime-execution.ts b/packages/daemon/src/execution/generic-runtime-execution.ts index 28ca400a6d..c1f02a1211 100644 --- a/packages/daemon/src/execution/generic-runtime-execution.ts +++ b/packages/daemon/src/execution/generic-runtime-execution.ts @@ -36,6 +36,7 @@ export async function resolveGenericRuntimeExecution( }> & ScreenshotRuntimeBindings, ): Promise { + const positionals = params.req.positionals ?? []; if (isSystemButtonCommand(params.req.command)) { return await resolveBoundSystemButtonRuntime(params.req.command, { device: params.session.device, @@ -54,7 +55,7 @@ export async function resolveGenericRuntimeExecution( case 'focus': return await resolveBoundFocusRuntime({ device: params.session.device, - positionals: params.req.positionals ?? [], + positionals, inspectFacts: params.inspectFacts, platformServices: params.platformServices, bindDevice: params.bindDevice, @@ -62,7 +63,7 @@ export async function resolveGenericRuntimeExecution( case 'scroll': return await resolveBoundScrollRuntime({ device: params.session.device, - positionals: params.req.positionals ?? [], + positionals, context: params.context, // The scroll's own observation is decided here rather than in its execution closure: this is // the last moment the session's stored tree is still the newest observation, before the @@ -76,7 +77,7 @@ export async function resolveGenericRuntimeExecution( case 'viewport': return await resolveBoundViewportRuntime({ device: params.session.device, - positionals: params.req.positionals ?? [], + positionals, inspectFacts: params.inspectFacts, platformServices: params.platformServices, bindDevice: params.bindDevice, @@ -91,7 +92,7 @@ export async function resolveGenericRuntimeExecution( case 'orientation': return await resolveBoundOrientationRuntime({ device: params.session.device, - positionals: params.req.positionals ?? [], + positionals, inspectFacts: params.inspectFacts, platformServices: params.platformServices, bindDevice: params.bindDevice, @@ -100,7 +101,7 @@ export async function resolveGenericRuntimeExecution( return await resolveBoundFoldRuntime({ keyframes: params.req.flags?.keyframes, device: params.session.device, - positionals: params.req.positionals ?? [], + positionals, inspectFacts: params.inspectFacts, platformServices: params.platformServices, bindDevice: params.bindDevice, @@ -110,7 +111,7 @@ export async function resolveGenericRuntimeExecution( const { resolveBoundTelephonyRuntime } = await import('./telephony-runtime.ts'); return await resolveBoundTelephonyRuntime({ device: params.session.device, - positionals: params.req.positionals ?? [], + positionals, inspectFacts: params.inspectFacts, platformServices: params.platformServices, bindDevice: params.bindDevice, @@ -119,12 +120,23 @@ export async function resolveGenericRuntimeExecution( case 'tv-remote': return await resolveBoundTvRemoteRuntime({ device: params.session.device, - positionals: params.req.positionals ?? [], + positionals, durationMs: params.req.flags?.durationMs, inspectFacts: params.inspectFacts, platformServices: params.platformServices, bindDevice: params.bindDevice, }); + case 'hardware-key': { + // Deferred to keep the daemon's eager closure within scripts/__tests__/eager-closure-budgets.ts. + const { resolveBoundHardwareKeyRuntime } = await import('./hardware-key-runtime.ts'); + return await resolveBoundHardwareKeyRuntime({ + device: params.session.device, + positionals, + inspectFacts: params.inspectFacts, + platformServices: params.platformServices, + bindDevice: params.bindDevice, + }); + } default: return { ok: false, diff --git a/packages/daemon/src/execution/hardware-key-runtime.ts b/packages/daemon/src/execution/hardware-key-runtime.ts new file mode 100644 index 0000000000..4110edfa1a --- /dev/null +++ b/packages/daemon/src/execution/hardware-key-runtime.ts @@ -0,0 +1,68 @@ +import type { HardwareKeyInput } from '@agent-device/contracts/tv-remote-runtime'; +import { hardwareKeyRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import { parseHardwareKey, type HardwareKeyToken } from '@agent-device/contracts/tv-remote'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { successText } from '@agent-device/kernel/success-text'; +import type { DaemonCommandContext } from '../core/context.ts'; +import type { ResolvedGenericExecution } from '../core/generic-leaf-execution.ts'; +import { + resolveBoundGenericRuntime, + type RuntimeAdmissionBindings, +} from '../core/runtime-admission.ts'; +import { runtimeExecutionFromContext } from '../core/snapshot-runtime-capture-input.ts'; + +/** `hardware-key `: one key, parsed the same way the CLI positional is. */ +function readHardwareKey(positionals: readonly string[]): HardwareKeyToken { + if (positionals.length !== 1) { + throw new AppError('INVALID_ARGS', 'hardware-key requires exactly one key'); + } + return parseHardwareKey(positionals[0]); +} + +/** The neutral intent one key press carries, projected from a resolved command context. */ +function hardwareKeyInput(key: HardwareKeyToken, context: DaemonCommandContext): HardwareKeyInput { + return { + key, + ...(context.appBundleId === undefined ? {} : { options: { appBundleId: context.appBundleId } }), + execution: runtimeExecutionFromContext(context), + }; +} + +/** + * The one place `hardware-key` reaches a device (ADR 0019). The key is parsed before admission, so + * a malformed request never inspects or binds a device. Admission inspects the exact owner's + * `hardwareKey` fact and binds once, before the dispatcher runs, so an owner without a key-event + * channel is refused rather than discovered mid-execution. + */ +export async function resolveBoundHardwareKeyRuntime( + params: { device: DeviceInfo; positionals: readonly string[] } & RuntimeAdmissionBindings, +): Promise { + const key = readHardwareKey(params.positionals); + return await resolveBoundGenericRuntime( + { + command: 'hardware-key', + device: params.device, + use: hardwareKeyRuntimeUse, + inspectFacts: params.inspectFacts, + platformServices: params.platformServices, + bindDevice: params.bindDevice, + }, + (runtime, context) => executeHardwareKey(runtime, key, context), + ); +} + +/** + * The ONE place a bound `hardwareKey` executes (R45). Typed off `typeof hardwareKeyRuntimeUse` + * rather than a hand-restated operations shape, so a change to what `hardwareKey` binds can't + * silently drift here. + */ +async function executeHardwareKey( + runtime: BoundDeviceRuntime, + key: HardwareKeyToken, + context: DaemonCommandContext, +): Promise> { + await runtime.operations.hardwareKey(hardwareKeyInput(key, context)); + return { action: 'hardware-key', key, ...successText(`Pressed hardware key ${key}`) }; +} diff --git a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts index baf31e5b4b..fde7c7030a 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts @@ -463,6 +463,7 @@ function sourceRuntimeFacts( back: unavailable, setOrientation: unavailable, tvRemote: unavailable, + hardwareKey: unavailable, ...keyboardRuntimeOperationFacts({ unsupported: unavailable }), ...clipboardRuntimeOperationFacts({ unsupported: unavailable }), ...systemButtonRuntimeOperationFacts({ unsupported: unavailable }), diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts index 8936f90d2f..91920ec882 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts @@ -74,6 +74,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () back: { available: false, reason: 'unsupported-platform-leaf' }, orientation: { available: false, reason: 'unsupported-platform-leaf' }, tvRemote: { available: false, reason: 'unsupported-platform-leaf' }, + hardwareKey: { available: false, reason: 'unsupported-platform-leaf' }, keyboard: { available: false, reason: 'unsupported-platform-leaf' }, clipboard: { available: false, reason: 'unsupported-platform-leaf' }, systemButton: { available: false, reason: 'unsupported-platform-leaf' }, diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts index 71e6154ba0..56f258b7bf 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts @@ -500,6 +500,7 @@ test('boot rejects --headless outside Android directly', async () => { back: { available: false, reason: 'owner-capability-missing' }, orientation: { available: false, reason: 'owner-capability-missing' }, tvRemote: { available: false, reason: 'owner-capability-missing' }, + hardwareKey: { available: false, reason: 'owner-capability-missing' }, keyboard: { available: false, reason: 'owner-capability-missing' }, clipboard: { available: false, reason: 'owner-capability-missing' }, systemButton: { available: false, reason: 'owner-capability-missing' }, diff --git a/packages/platform-android/src/__tests__/input-actions.test.ts b/packages/platform-android/src/__tests__/input-actions.test.ts index 6d83948130..9e06f7d62c 100644 --- a/packages/platform-android/src/__tests__/input-actions.test.ts +++ b/packages/platform-android/src/__tests__/input-actions.test.ts @@ -6,6 +6,7 @@ import { longPressAndroid, pressAndroid, pressAndroidEnter, + pressAndroidHardwareKey, pressAndroidTvRemote, probeAndroidDisplayRotation, scrollAndroid, @@ -414,6 +415,24 @@ test('pressAndroidTvRemote sends D-pad keyevents, and --longpress for a positive ); }); +test('pressAndroidHardwareKey sends the numeric keyevent for volume, power, and media keys', async () => { + await withFakeAdb( + () => undefined, + async ({ calls, device }) => { + await pressAndroidHardwareKey(device, 'volume-down'); + await pressAndroidHardwareKey(device, 'sleep'); + await pressAndroidHardwareKey(device, 'media-fast-forward'); + await pressAndroidHardwareKey(device, 'android-keycode:300'); + assert.deepEqual(calls, [ + ['shell', 'input', 'keyevent', '25'], + ['shell', 'input', 'keyevent', '223'], + ['shell', 'input', 'keyevent', '90'], + ['shell', 'input', 'keyevent', '300'], + ]); + }, + ); +}); + test('homeAndroid presses keyevent 3', async () => { await withFakeAdb( () => undefined, diff --git a/packages/platform-android/src/input-actions.ts b/packages/platform-android/src/input-actions.ts index a36d5fbfeb..6422c87dc0 100644 --- a/packages/platform-android/src/input-actions.ts +++ b/packages/platform-android/src/input-actions.ts @@ -20,7 +20,12 @@ import { clipScrollViewportAboveKeyboard, scrollKeyboardOccludesSurfaceError, } from '@agent-device/contracts/scroll-gesture'; -import { type TvRemoteButton, toAndroidTvRemoteKeyevent } from '@agent-device/contracts/tv-remote'; +import { + type HardwareKeyToken, + type TvRemoteButton, + toAndroidHardwareKeycode, + toAndroidTvRemoteKeyevent, +} from '@agent-device/contracts/tv-remote'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; @@ -85,6 +90,13 @@ export async function pressAndroidTvRemote( await runAndroidShell(device, ['input', ...keyeventArgs, keyevent]); } +export async function pressAndroidHardwareKey( + device: DeviceInfo, + key: HardwareKeyToken, +): Promise { + await runAndroidShell(device, ['input', 'keyevent', toAndroidHardwareKeycode(key)]); +} + export async function backAndroid( device: DeviceInfo, options?: AndroidInputDispatchOptions, diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index a175cb35e8..3a3d21e35a 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -183,6 +183,7 @@ export { longPressAndroid, pressAndroid, pressAndroidEnter, + pressAndroidHardwareKey, pressAndroidTvRemote, scrollAndroid, setAndroidOrientation, diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index b0eb155957..cdc3059a5d 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -199,6 +199,23 @@ test('admits Android tv-remote only for a real TV target', async () => { expect(binding.operations.tvRemote).toBeTypeOf('function'); }); +test.each([ + ['emulator', ANDROID_EMULATOR], + ['device', { ...ANDROID_EMULATOR, kind: 'device' as const }], + ['unknown', UNKNOWN_KIND_DEVICE], +])( + 'admits the Android %s hardware-key fact through the shared touch gate', + async (_name, device) => { + const binding = await bindOrdinary( + createAndroidPlatformRuntime(androidNavigationHost()), + device, + ); + + expect(binding.facts.operations.hardwareKey).toEqual({ available: true }); + expect(binding.operations.hardwareKey).toBeTypeOf('function'); + }, +); + test('Android refuses the action-button fact on every kind', async () => { for (const runtimeDevice of [ ANDROID_EMULATOR, @@ -330,6 +347,7 @@ test('the synthetic Android simulator cell refuses back/home/orientation/keyboar 'home', 'setOrientation', 'tvRemote', + 'hardwareKey', 'keyboardStatus', 'keyboardDismiss', 'keyboardEnter', diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 05d66bd00b..ece0b7b666 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -47,7 +47,10 @@ import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboar import { bindLocalInteractorOperationSet } from '@agent-device/contracts/local-interactor-operation-set'; import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; -import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { + tvRemoteRuntimeOperationFacts, + hardwareKeyRuntimeOperationFacts, +} from '@agent-device/contracts/tv-remote-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AndroidSetting } from './settings.ts'; import { createHostAudioProbeCaptureOperations } from '@agent-device/capture-kit'; @@ -90,9 +93,9 @@ const keyboardKindUnavailable = unavailableFact( ); const hoverUnavailable = unavailableFact('unsupported-platform-leaf'); /** - * `home` and `app-switcher` are the system buttons `input keyevent` can press. Any other (the - * iPhone Action Button today) is hardware with no Android key event behind it, so there is no adb - * path to admit even on the kinds every other Android cell admits. + * `home`, `app-switcher`, and the hardware keys are the system controls `input keyevent` can + * press. Any other (the iPhone Action Button today) is hardware with no Android key event behind + * it, so there is no adb path to admit even on the kinds every other Android cell admits. */ const systemButtonUnavailable = unavailableFact( 'unsupported-platform-leaf', @@ -342,7 +345,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor }), ...backRuntimeOperationFacts({ back: androidTouchFact(device) }), // `home` and `app-switcher` are one `input keyevent` each, admitted wherever the retired - // `ANDROID_ALL` bucket admitted them. + // `ANDROID_ALL` bucket admitted `home`. ...systemButtonRuntimeOperationFacts({ unsupported: systemButtonUnavailable, home: androidTouchFact(device), @@ -382,6 +385,9 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor }), ...orientationRuntimeOperationFacts({ orientation: androidTouchFact(device) }), ...tvRemoteRuntimeOperationFacts({ tvRemote: androidTvRemoteFact(device) }), + // Every hardware key is one `input keyevent`, admitted wherever the retired + // `ANDROID_ALL` bucket admitted `home`. + ...hardwareKeyRuntimeOperationFacts({ hardwareKey: androidTouchFact(device) }), // The only owner with a live IME status read; dismiss/enter share every other // interaction cell's kind gate (parity with the retired `keyboard` bucket). ...keyboardRuntimeOperationFacts({ diff --git a/packages/platform-apple/src/navigation/runtime.ts b/packages/platform-apple/src/navigation/runtime.ts index dba50102a3..f6eaa2e31b 100644 --- a/packages/platform-apple/src/navigation/runtime.ts +++ b/packages/platform-apple/src/navigation/runtime.ts @@ -5,7 +5,10 @@ import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orient import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import { systemButtonRuntimeOperationFacts } from '@agent-device/contracts/system-button-runtime'; -import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { + tvRemoteRuntimeOperationFacts, + hardwareKeyRuntimeOperationFacts, +} from '@agent-device/contracts/tv-remote-runtime'; import { hasAppleActionButton, isTvOsDevice, @@ -131,12 +134,17 @@ function appleKeyboardEnterFact(device: DeviceInfo): RuntimeOperationFact { return appleMobileInputEligible(device) ? available : keyboardCellUnavailable; } -/** Every system button this owner knows is named below; the family denial covers none today. */ const systemButtonUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', hint: 'No Apple leaf carries this system button.', } as const); +/** The runner presses no volume, power, or media key, so no Apple leaf admits one. */ +const hardwareKeyUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'hardware-key is supported on Android (ADB or Appium).', +} as const); const actionButtonKindUnavailable = Object.freeze({ available: false, reason: 'unsupported-device-kind', @@ -174,6 +182,7 @@ export function appleNavigationFacts(device: DeviceInfo) { }), ...orientationRuntimeOperationFacts({ orientation: appleOrientationFact(device) }), ...tvRemoteRuntimeOperationFacts({ tvRemote: appleTvRemoteFact(device) }), + ...hardwareKeyRuntimeOperationFacts({ hardwareKey: hardwareKeyUnavailable }), ...keyboardRuntimeOperationFacts({ unsupported: keyboardCellUnavailable, status: appleKeyboardStatusFact(device), diff --git a/packages/platform-apple/src/runner-demand.ts b/packages/platform-apple/src/runner-demand.ts index 0e3ecc47fa..3c60cd4465 100644 --- a/packages/platform-apple/src/runner-demand.ts +++ b/packages/platform-apple/src/runner-demand.ts @@ -54,6 +54,7 @@ const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< readSetting: 'simulator', setFoldPose: 'simulator', // Refused on every Apple leaf at admission, so nothing waits on the runner. + hardwareKey: 'simulator', sendTelephonyEvent: 'simulator', // Observation: the AX bridge presents regular and raw trees; custom actions need XCTest. captureSnapshot: 'simulator', diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index f1fdb5444b..d531e5a2b3 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -350,6 +350,29 @@ test.each(Object.entries(leaves))( }, ); +test.each(Object.entries(leaves))( + 'refuses the hardware-key fact for the %s leaf', + async (_name, device) => { + const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + // The runner presses only the home and Action Button controls, so no Apple leaf admits a + // volume, power, or media key: the hardwareKey cell states that refusal. + expect(binding.facts.operations.hardwareKey).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'hardware-key is supported on Android (ADB or Appium).', + }); + expect(binding.operations.hardwareKey).toBeUndefined(); + }, +); + test.each(Object.entries(leaves))( 'classifies the fold fact for the %s leaf', async (_name, device) => { diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 0325c77f0b..3322902667 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -266,6 +266,10 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor }), ...orientationRuntimeOperationFacts({ orientation: harmonyPlatformLeafUnavailable }), ...tvRemoteRuntimeOperationFacts({ tvRemote: harmonyPlatformLeafUnavailable }), + // HDC has no volume, power, or media key event to press. Stated directly rather than + // through `hardwareKeyRuntimeOperationFacts`, so this owner does not evaluate the + // hardware-key binder for a leaf that never admits it (eager-closure budgets). + hardwareKey: harmonyPlatformLeafUnavailable, // HDC drives dismissal and the enter key; any other keyboard operation is a leaf gap. ...keyboardRuntimeOperationFacts({ unsupported: harmonyPlatformLeafUnavailable, diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index 67f8b8c9fe..389e36b18d 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -220,6 +220,8 @@ function expectLinuxNavigationAndKeyboardFacts( 'appSwitcher', // The Action Button is iPhone/iPad hardware; the Linux desktop has no equivalent control. 'actionButton', + // Volume, power, and media keys go through the Android key-event channel only. + 'hardwareKey', // Nor does it have a foldable hinge to pose. 'setFoldPose', // Nor a modem to simulate a call or text message on. diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index c04c74bfd2..1291183e90 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -201,6 +201,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts back: backKindUnavailable, orientation: linuxPlatformLeafUnavailable, tvRemote: linuxPlatformLeafUnavailable, + hardwareKey: linuxPlatformLeafUnavailable, clipboard: clipboardKindUnavailable, // `home` is the one system button with a desktop cell, declared below; the Linux interactor's // own `appSwitcher` throws unsupported, and no Linux leaf has hardware buttons. diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index 2236f8ff4e..092135b7a0 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -200,6 +200,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts back: backUnavailable, orientation: orientationUnavailable, tvRemote: tvRemoteUnavailable, + hardwareKey: systemButtonUnavailable, clipboard: clipboardUnavailable, systemButton: systemButtonUnavailable, fold: foldUnavailable, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index e9eac1a1c3..a75f28e656 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -214,6 +214,8 @@ test('clipboard, the app switcher, app events, settings and alerts carry no web 'appSwitcher', // The Action Button is iPhone/iPad hardware with no web analogue at all. 'actionButton', + // So are volume, power, and media keys. + 'hardwareKey', // A foldable hinge is posed through the host's iOS simulator HID helper; the web target has // none. 'setFoldPose', diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index b571fb1a36..5155160a44 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -414,6 +414,10 @@ function webRuntimeFacts( profileReport: navigationUnavailable, }), ...systemButtonRuntimeOperationFacts({ unsupported: navigationUnavailable }), + // No browser key-event channel exists. Stated directly rather than through + // `hardwareKeyRuntimeOperationFacts`, so this owner does not evaluate the hardware-key + // binder for a leaf that never admits it (eager-closure budgets). + hardwareKey: navigationUnavailable, setFoldPose: navigationUnavailable, sendTelephonyEvent: navigationUnavailable, ...appEventRuntimeOperationFacts({ triggerAppEvent: navigationUnavailable }), diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 81abca07d3..e8c357deea 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -115,6 +115,7 @@ export function createLimrunPlatformRuntimeOwner( back: liveSessionUnavailable, orientation: liveSessionUnavailable, tvRemote: liveSessionUnavailable, + hardwareKey: liveSessionUnavailable, keyboard: liveSessionUnavailable, clipboard: liveSessionUnavailable, systemButton: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 0ffe116439..6e3398affa 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -22,6 +22,7 @@ import { limrunSettingsOperationFacts, limrunAlertOperationFacts, limrunSystemButtonOperationFacts, + limrunHardwareKeyOperationFacts, limrunFoldOperationFacts, limrunTelephonyOperationFacts, limrunClipboardOperationFacts, @@ -186,6 +187,7 @@ export function limrunAppLogFacts( ...limrunKeyboardOperationFacts(device), ...limrunClipboardOperationFacts(), ...limrunSystemButtonOperationFacts(device), + ...limrunHardwareKeyOperationFacts(device), ...limrunFoldOperationFacts(), ...limrunTelephonyOperationFacts(), ...limrunAppEventOperationFacts(device), @@ -250,6 +252,7 @@ export function limrunAppLogRecoveryFacts( ...limrunKeyboardOperationFacts(device, liveSessionUnavailable), ...limrunClipboardOperationFacts(liveSessionUnavailable), ...limrunSystemButtonOperationFacts(device, liveSessionUnavailable), + ...limrunHardwareKeyOperationFacts(device, liveSessionUnavailable), ...limrunFoldOperationFacts(), ...limrunTelephonyOperationFacts(), ...limrunAppEventOperationFacts(device, liveSessionUnavailable), diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts index 5f88785bea..de15f996ba 100644 --- a/packages/provider-limrun/src/interaction-operations.test.ts +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -8,6 +8,7 @@ import { limrunAlertOperationFacts, limrunSettingsOperationFacts, limrunSystemButtonOperationFacts, + limrunHardwareKeyOperationFacts, limrunClipboardOperationFacts, limrunInteractionOperationFacts, limrunNavigationOperationFacts, @@ -90,6 +91,19 @@ test('home is admitted on both legs, app-switcher only on Android, the action bu expect(stale.actionButton).toEqual(liveSessionUnavailable); }); +test('hardware keys ride the Android key-event channel, and each other leg refuses', () => { + expect(limrunHardwareKeyOperationFacts(androidMobileDevice).hardwareKey).toEqual({ + available: true, + }); + expect(limrunHardwareKeyOperationFacts(iosDevice).hardwareKey).toMatchObject({ + available: false, + reason: 'unsupported-provider-mode', + }); + expect( + limrunHardwareKeyOperationFacts(androidMobileDevice, liveSessionUnavailable).hardwareKey, + ).toEqual(liveSessionUnavailable); +}); + // R57: app-event delivery is the one system leaf BOTH direct-session legs serve, because each // implements `open` and a deep link is exactly what that method routes. test('app-event delivery is admitted on both direct-session legs', () => { diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 1bafd5127f..79f4a83c0f 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -30,7 +30,10 @@ import { bindProviderSnapshotInteractor, type SnapshotRuntimePresenter, } from '@agent-device/contracts/snapshot-runtime'; -import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { + tvRemoteRuntimeOperationFacts, + hardwareKeyRuntimeOperationFacts, +} from '@agent-device/contracts/tv-remote-runtime'; import { bindProviderTypeTextInteractor, typeTextRuntimeOperationFacts, @@ -131,7 +134,7 @@ const appSwitcherUnavailableIos = unavailableFact( 'unsupported-provider-mode', 'Limrun iOS direct sessions do not expose app switcher yet.', ); -/** No Limrun session exposes the action button; only `appSwitcher` still splits by leg. */ +/** No Limrun session exposes the action button; `appSwitcher` and `hardwareKey` split by leg. */ const systemButtonUnavailable = unavailableFact( 'unsupported-provider-mode', 'No Limrun session exposes this system button.', @@ -286,9 +289,9 @@ export function limrunAlertOperationFacts( } /** - * The Android leg rides the local family's own interactor factory for `home` and `appSwitcher`. - * The iOS direct session presses `home` through Limrun's button actions and refuses - * `appSwitcher`. No Limrun session exposes the action button. + * The Android leg rides the local family's own interactor factory for `home` and `appSwitcher`, + * the same `input keyevent` channel. The iOS direct session presses `home` through Limrun's button + * actions and refuses the rest. No Limrun session exposes the action button. */ export function limrunSystemButtonOperationFacts( device: DeviceInfo, @@ -311,6 +314,24 @@ export function limrunSystemButtonOperationFacts( }); } +/** + * `hardwareKey` rides the same `input keyevent` channel as `home` on the Android leg, so every + * Android session admits it. The iOS direct session's button actions carry no volume, power, or + * media key, and a live session binds no interactor at all. + */ +export function limrunHardwareKeyOperationFacts( + device: DeviceInfo, + liveSessionUnavailable?: RuntimeOperationUnavailability, +) { + if (liveSessionUnavailable) { + return hardwareKeyRuntimeOperationFacts({ hardwareKey: liveSessionUnavailable }); + } + if (device.platform === 'android') { + return hardwareKeyRuntimeOperationFacts({ hardwareKey: available }); + } + return hardwareKeyRuntimeOperationFacts({ hardwareKey: systemButtonUnavailable }); +} + /** * `fold` sends a HID hinge event through a helper the daemon host spawns inside one of its own iOS * simulators with `simctl spawn`; a Limrun session's device is not a host simulator. diff --git a/packages/provider-webdriver/src/capabilities.ts b/packages/provider-webdriver/src/capabilities.ts index f0e5d456d1..ee68eb62e9 100644 --- a/packages/provider-webdriver/src/capabilities.ts +++ b/packages/provider-webdriver/src/capabilities.ts @@ -20,6 +20,7 @@ export type CloudWebDriverOperation = | 'orientation' | 'appSwitcher' | 'tvRemote' + | 'hardwareKey' | 'clipboard.read' | 'clipboard.write' | 'settings' @@ -60,6 +61,21 @@ export type CloudWebDriverCapabilityOverrides = Partial< const supported: CloudWebDriverOperationCapability = { support: 'supported' }; const unsupported: CloudWebDriverOperationCapability = { support: 'unsupported' }; +/** + * Android presses a KeyEvent through `mobile: pressKey`, the route the keyboard Enter press + * already uses. XCUITest exposes no key press, so the iOS leg refuses. + */ +const HARDWARE_KEY_CAPABILITY_BY_PLATFORM: Record< + CloudWebDriverPlatform, + CloudWebDriverOperationCapability +> = { + android: { + support: 'partial', + note: 'Android presses a KeyEvent through `mobile: pressKey`, the same route the keyboard Enter press uses.', + }, + ios: unsupported, +}; + const BASE_WEBDRIVER_CAPABILITIES: CloudWebDriverCapabilityMap = { lease: supported, inventory: { @@ -103,6 +119,8 @@ const BASE_WEBDRIVER_CAPABILITIES: CloudWebDriverCapabilityMap = { note: 'Uses provider/Appium mobile pressButton support where available.', }, tvRemote: unsupported, + // Overridden per platform by `createCloudWebDriverCapabilities`; XCUITest exposes no key press. + hardwareKey: unsupported, 'clipboard.read': { support: 'partial', note: 'Uses provider/Appium clipboard extension support where available.', @@ -136,7 +154,10 @@ export function createCloudWebDriverCapabilities(options: { platform: options.platform, snapshotBackend: options.platform === 'ios' ? 'xctest' : 'android', snapshotSource: 'appium-page-source', - operations: applyCapabilityOverrides(BASE_WEBDRIVER_CAPABILITIES, options.overrides), + operations: applyCapabilityOverrides(BASE_WEBDRIVER_CAPABILITIES, { + hardwareKey: HARDWARE_KEY_CAPABILITY_BY_PLATFORM[options.platform], + ...options.overrides, + }), }; } diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 70132f51af..c8b1230812 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -285,6 +285,8 @@ test('captures through only the active exact WebDriver interactor', async () => 'keyboardEnter', 'keyboardDismiss', 'keyboardStatus', + // Android declares `mobile: pressKey`, so the hardware-key leg rides the same gate. + 'hardwareKey', ] as const) { expect(binding.facts.operations[operation]).toEqual({ available: true }); expect(binding.operations[operation]).toBeTypeOf('function'); @@ -358,6 +360,8 @@ test.each([ expect(facts.operations.writeClipboard.available).toBe(state.isSessionActive()); expect(facts.operations.appSwitcher.available).toBe(state.isSessionActive()); expect(facts.operations.triggerAppEvent.available).toBe(state.isSessionActive()); + // Android declares the key press, so an active session's hardware-key cell rides reachability. + expect(facts.operations.hardwareKey.available).toBe(state.isSessionActive()); for (const operation of [ 'tvRemote', 'setSetting', diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 26cdea38a9..51ffbafa25 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -29,7 +29,10 @@ import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings- import { systemButtonRuntimeOperationFacts } from '@agent-device/contracts/system-button-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; -import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; +import { + tvRemoteRuntimeOperationFacts, + hardwareKeyRuntimeOperationFacts, +} from '@agent-device/contracts/tv-remote-runtime'; import { type DeviceBinding, type RuntimeFacts, @@ -214,6 +217,11 @@ const systemButtonUnavailable = unavailableFact( 'unsupported-provider-mode', 'No WebDriver backend presses this system button.', ); +/** Android sends keys through `mobile: pressKey`; XCUITest has no such route and iOS refuses here. */ +const hardwareKeyUnavailable = unavailableFact( + 'unsupported-provider-mode', + 'hardware-key is supported on Android (ADB or Appium).', +); /** No WebDriver `mobile:` script poses a foldable hinge; the refusal is unconditional like the Action Button's. */ const foldUnavailable = unavailableFact( @@ -510,6 +518,7 @@ function webDriverFacts( back: backUnavailable, orientation: orientationUnavailable, tvRemote: tvRemoteUnavailable, + hardwareKey: hardwareKeyUnavailable, keyboard: keyboardUnavailable, clipboard: clipboardUnavailable, systemButton: systemButtonUnavailable, @@ -624,6 +633,11 @@ function webDriverFacts( home: declared('home', homeUnavailable), appSwitcher: declared('appSwitcher', appSwitcherUnavailable), }), + // The same `mobile: pressKey` route the keyboard Enter press uses, so the declared + // capability is the gate; the capability map is per-platform and iOS refuses there. + ...hardwareKeyRuntimeOperationFacts({ + hardwareKey: declared('hardwareKey', hardwareKeyUnavailable), + }), setFoldPose: foldUnavailable, sendTelephonyEvent: telephonyUnavailable, // The deep link opens through the same reachable interactor `open` every lifecycle command diff --git a/packages/provider-webdriver/src/webdriver-interactor-hardware-key.test.ts b/packages/provider-webdriver/src/webdriver-interactor-hardware-key.test.ts new file mode 100644 index 0000000000..13b7f7db18 --- /dev/null +++ b/packages/provider-webdriver/src/webdriver-interactor-hardware-key.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { HardwareKeyToken } from '@agent-device/contracts/tv-remote'; +import { createCloudWebDriverCapabilities } from './capabilities.ts'; +import type { WebDriverClient } from './webdriver-client.ts'; +import { createWebDriverInteractor } from './webdriver-interactor.ts'; + +function keyPressWorld(platform: 'android' | 'ios') { + const transcript: string[] = []; + const client = { + executeScript: async (script: string, args: unknown[]) => { + transcript.push(`${script} ${JSON.stringify(args)}`); + }, + } as unknown as WebDriverClient; + const interactor = createWebDriverInteractor({ + client, + backend: platform === 'android' ? 'android' : 'xctest', + capabilities: createCloudWebDriverCapabilities({ provider: 'test', platform }), + }); + return { transcript, interactor }; +} + +test.each([ + ['volume-down', 25], + ['android-keycode:300', 300], +] as const satisfies readonly (readonly [HardwareKeyToken, number])[])( + 'hardware key %s presses keycode %i through the driver on Android', + async (key, keycode) => { + const world = keyPressWorld('android'); + + await world.interactor.hardwareKey!(key); + + assert.deepEqual(world.transcript, [`mobile: pressKey [{"keycode":${keycode}}]`]); + }, +); + +test('a hardware key refuses on iOS, where the capability map declares no key press', async () => { + const world = keyPressWorld('ios'); + + await assert.rejects( + () => world.interactor.hardwareKey!('volume-down'), + (error: unknown) => + (error as { code?: string }).code === 'UNSUPPORTED_OPERATION' && + (error as { details?: { operation?: string } }).details?.operation === 'hardwareKey', + ); + assert.deepEqual(world.transcript, []); +}); diff --git a/packages/provider-webdriver/src/webdriver-interactor.ts b/packages/provider-webdriver/src/webdriver-interactor.ts index 09c8a2aad9..6b7c1fff40 100644 --- a/packages/provider-webdriver/src/webdriver-interactor.ts +++ b/packages/provider-webdriver/src/webdriver-interactor.ts @@ -15,7 +15,7 @@ import { type ScrollDirection, buildScrollGesturePlan, } from '@agent-device/contracts/scroll-gesture'; -import type { TvRemoteButton } from '@agent-device/contracts/tv-remote'; +import type { HardwareKeyToken, TvRemoteButton } from '@agent-device/contracts/tv-remote'; import type { SettingOptions } from '@agent-device/contracts/settings'; import { snapshotViewportSizeFrom } from '@agent-device/kernel/rect'; import { AppError } from '@agent-device/kernel/errors'; @@ -424,6 +424,15 @@ class WebDriverInteractor implements Interactor { this.unsupported('tvRemote'); } + /** Android presses the key through `mobile: pressKey`; the iOS capability map refuses it. */ + async hardwareKey(key: HardwareKeyToken): Promise { + this.requireSupport('hardwareKey'); + const { toAndroidHardwareKeycode } = await import('@agent-device/contracts/tv-remote'); + await this.client.executeScript('mobile: pressKey', [ + { keycode: toAndroidHardwareKeycode(key) }, + ]); + } + /** Android only: the Appium keyboard probe is the IME probe this result shape names. */ async keyboardStatus(): Promise { this.requireSupport('keyboard'); diff --git a/packages/session-journal/src/__tests__/session-event-action.test.ts b/packages/session-journal/src/__tests__/session-event-action.test.ts index 4f3be01054..d43a4ec04e 100644 --- a/packages/session-journal/src/__tests__/session-event-action.test.ts +++ b/packages/session-journal/src/__tests__/session-event-action.test.ts @@ -228,6 +228,29 @@ test('structural action events preserve navigation, viewport, keyboard, and gest }); }); +test('hardware-key action events name only a known key', () => { + const pressed = action('hardware-key', { + action: 'hardware-key', + key: 'media-play-pause', + message: 'Pressed hardware key media-play-pause', + }); + const unknown = action('hardware-key', { action: 'hardware-key', key: 'eject' }); + + assert.equal(buildActionSummary(pressed), 'Pressed hardware key media-play-pause'); + assert.deepEqual(buildActionDetails(pressed), { + command: 'hardware-key', + key: 'media-play-pause', + }); + const raw = action('hardware-key', { action: 'hardware-key', key: 'android-keycode:300' }); + assert.equal(buildActionSummary(raw), 'Pressed hardware key android-keycode:300'); + assert.deepEqual(buildActionDetails(raw), { + command: 'hardware-key', + key: 'android-keycode:300', + }); + assert.equal(buildActionSummary(unknown), 'Pressed hardware key'); + assert.deepEqual(buildActionDetails(unknown), { command: 'hardware-key' }); +}); + // #1598: the keyboard-dismiss mechanism must show up in both the human // summary and the structured details, so a transcript reader can tell a // vouched-for dismiss-key tap; unrecognized mechanisms are dropped (#1606). diff --git a/packages/session-journal/src/session-event-action-presentation.ts b/packages/session-journal/src/session-event-action-presentation.ts index 461050cab3..c2713a0498 100644 --- a/packages/session-journal/src/session-event-action-presentation.ts +++ b/packages/session-journal/src/session-event-action-presentation.ts @@ -1,7 +1,7 @@ import type { SessionAction } from '@agent-device/contracts/session'; import { DEVICE_ROTATIONS, FOLD_POSES, TELEPHONY_ACTIONS } from '@agent-device/contracts/device'; import { BACK_MODES } from '@agent-device/contracts/back-mode'; -import { TV_REMOTE_BUTTONS } from '@agent-device/contracts/tv-remote'; +import { isHardwareKeyToken, TV_REMOTE_BUTTONS } from '@agent-device/contracts/tv-remote'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { isKeyboardAction } from './keyboard-actions.ts'; import { @@ -53,6 +53,8 @@ export function buildStructuredActionSummary(action: SessionAction): string | un return buildKeyboardActionSummary(result); case PUBLIC_COMMANDS.tvRemote: return buildTvRemoteActionSummary(result); + case PUBLIC_COMMANDS.hardwareKey: + return buildHardwareKeyActionSummary(result); case PUBLIC_COMMANDS.gesture: return buildGestureActionSummary(result); case PUBLIC_COMMANDS.swipe: @@ -108,6 +110,8 @@ export function buildStructuredActionDetails(action: SessionAction): Record): string { return button ? `Pressed TV remote ${button}` : 'Used TV remote'; } +function readHardwareKeyToken(value: unknown): string | undefined { + return isHardwareKeyToken(value) ? value : undefined; +} + +function buildHardwareKeyActionSummary(result: Record): string { + const key = readHardwareKeyToken(result.key); + return key ? `Pressed hardware key ${key}` : 'Pressed hardware key'; +} + function buildGestureActionSummary(result: Record): string { const kind = readEnum(result.kind, ['fling', 'pan', 'pinch', 'rotate', 'transform']); return kind ? `Ran ${kind} gesture` : 'Ran gesture'; diff --git a/packages/test-kit/src/runtime-operation-facts.ts b/packages/test-kit/src/runtime-operation-facts.ts index a82a646a52..bfc72b9985 100644 --- a/packages/test-kit/src/runtime-operation-facts.ts +++ b/packages/test-kit/src/runtime-operation-facts.ts @@ -65,6 +65,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre back: unavailable, setOrientation: unavailable, tvRemote: unavailable, + hardwareKey: unavailable, ...keyboardRuntimeOperationFacts({ unsupported: unavailable }), ...clipboardRuntimeOperationFacts({ unsupported: unavailable }), ...systemButtonRuntimeOperationFacts({ unsupported: unavailable }), diff --git a/src/__tests__/agent-device-client-system-commands.test.ts b/src/__tests__/agent-device-client-system-commands.test.ts index bce4a69aca..f8d9e9f9f7 100644 --- a/src/__tests__/agent-device-client-system-commands.test.ts +++ b/src/__tests__/agent-device-client-system-commands.test.ts @@ -63,6 +63,12 @@ const SYSTEM_COMMAND_CALLS: readonly { command: 'tv-remote', positionals: ['select'], }, + { + method: 'hardwareKey', + invoke: async (client) => await client.command.hardwareKey({ key: 'volume-up' }), + command: 'hardware-key', + positionals: ['volume-up'], + }, ]; for (const call of SYSTEM_COMMAND_CALLS) { diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 9c08f76857..fdb0367f61 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -198,6 +198,8 @@ export function createAgentDeviceClient( await executeCommand>('tv-remote', options), telephony: async (options) => await executeCommand>('telephony', options), + hardwareKey: async (options) => + await executeCommand>('hardware-key', options), reactNative: async (options) => await executeCommand('react-native', options), doctor: async (options = {}) => await executeCommand>('doctor', options), diff --git a/src/client/client-types.ts b/src/client/client-types.ts index e51dfa8956..b831b2e277 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -31,20 +31,12 @@ export type { // Contracts/kernel types re-exported into the PUBLISHED surface: `agent-device-client.ts` picks // these up via `export type *`, and that is their only job — every internal consumer imports -// them from the declaring module instead. Fallow therefore sees no consumer, which is exactly -// right and exactly not actionable: deleting them would remove names from the package's public -// types. Suppressed per name rather than baselined so the reason travels with the code. -// fallow-ignore-next-line unused-type +// them from the declaring module instead. export type { TargetShutdownResult } from '@agent-device/contracts/device'; -// fallow-ignore-next-line unused-type export type { MetroBridgeScope } from '@agent-device/contracts/remote'; -// fallow-ignore-next-line unused-type export type { AppsFilter } from '@agent-device/contracts/device'; -// fallow-ignore-next-line unused-type export type { AlertAction } from '@agent-device/contracts/alert-contract'; -// fallow-ignore-next-line unused-type export type { AppleOS } from '@agent-device/kernel/device'; -// fallow-ignore-next-line unused-type export type { JsonObject } from '@agent-device/contracts/client'; export type { BatchRunResult } from '@agent-device/command-registry/batch'; @@ -69,6 +61,7 @@ import type { AppPushOptions, ActionButtonCommandOptions, FoldCommandOptions, + HardwareKeyCommandOptions, AppStateCommandOptions, AppSwitcherCommandOptions, AppTriggerEventOptions, @@ -173,6 +166,7 @@ export type AgentDeviceCommandClient = { actionButton: (options?: ActionButtonCommandOptions) => Promise>; tvRemote: (options: TvRemoteCommandOptions) => Promise>; telephony: (options: TelephonyCommandOptions) => Promise>; + hardwareKey: (options: HardwareKeyCommandOptions) => Promise>; wait: (options: WaitCommandOptions) => Promise>; alert: (options?: AlertCommandOptions) => Promise; appState: (options?: AppStateCommandOptions) => Promise>; diff --git a/src/commands/replay/replay-script.fixtures.ts b/src/commands/replay/replay-script.fixtures.ts index e670560a12..3bab5d94e8 100644 --- a/src/commands/replay/replay-script.fixtures.ts +++ b/src/commands/replay/replay-script.fixtures.ts @@ -180,6 +180,7 @@ const REPLAY_SCRIPT_LINE_PLANS = { trace: GENERIC_REPLAY_LINE, 'trigger-app-event': GENERIC_REPLAY_LINE, 'tv-remote': GENERIC_REPLAY_LINE, + 'hardware-key': GENERIC_REPLAY_LINE, uninstall: GENERIC_REPLAY_LINE, viewport: GENERIC_REPLAY_LINE, human_control: { waived: 'host-local control commands are never recorded in replay scripts' }, diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 84f2fbd017..55b0437b5d 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -1,4 +1,5 @@ import { listCliCommandNames } from '@agent-device/command-registry/catalog'; +import { HARDWARE_KEY_USAGE } from '@agent-device/contracts/tv-remote'; import { getReplayBackendDeclaration } from '@agent-device/replay-port/replay-backend-declarations'; import { normalizeCliCommandAlias } from '@agent-device/command-registry/cli-command-aliases'; import { helpBody } from '@agent-device/command-registry/command-text'; @@ -267,7 +268,7 @@ Buttons: Vega OS uses the exact hold duration through inputd-cli in the VVD. Android TV: - Android TV uses ADB keyevents behind agent-device tv-remote. Keep command plans on agent-device; do not switch to raw adb keyevent. + Android TV uses ADB keyevents behind agent-device tv-remote. hardware-key ${HARDWARE_KEY_USAGE} uses ADB keyevents or Appium mobile: pressKey on Android. Keep command plans on agent-device; do not switch to raw adb keyevent. Use --target tv when a host has both phone/tablet and TV emulators/devices. tvOS: diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts index 478346eb8f..0d5c7da1d3 100644 --- a/src/commands/system/index.test.ts +++ b/src/commands/system/index.test.ts @@ -5,6 +5,7 @@ import type { AppSwitcherCommandOptions, BackCommandOptions, FoldCommandOptions, + HardwareKeyCommandOptions, HomeCommandOptions, OrientationCommandOptions, TelephonyCommandOptions, @@ -21,6 +22,8 @@ import { clipboardDaemonWriter, foldCliReader, foldDaemonWriter, + hardwareKeyCliReader, + hardwareKeyDaemonWriter, keyboardCliReader, keyboardDaemonWriter, orientationCliReader, @@ -69,6 +72,9 @@ describe('system command interface', () => { expectTypeOf().toEqualTypeOf< (options: TvRemoteCommandOptions) => Promise> >(); + expectTypeOf().toEqualTypeOf< + (options: HardwareKeyCommandOptions) => Promise> + >(); }); const parameterless = ['appstate', 'home', 'app-switcher', 'action-button'] as const; @@ -380,6 +386,33 @@ describe('system command interface', () => { ); expectInvalidArgs(() => tvRemoteCliReader(['blue'], flags()), 'button must be one of'); }); + + test('hardware-key reader parses one key and the writer forwards it', () => { + expect(hardwareKeyCliReader(['volume-down'], flags())).toMatchObject({ key: 'volume-down' }); + expect(hardwareKeyCliReader(['Media-Next'], flags())).toMatchObject({ key: 'media-next' }); + expect(hardwareKeyCliReader(['android-keycode:300'], flags())).toMatchObject({ + key: 'android-keycode:300', + }); + expect(hardwareKeyDaemonWriter({ key: 'wake' } as Record).positionals).toEqual( + ['wake'], + ); + }); + + test('hardware-key reader rejects a missing, extra, or unknown key', () => { + expectInvalidArgs( + () => hardwareKeyCliReader([], flags()), + 'hardware-key requires exactly one key', + ); + expectInvalidArgs( + () => hardwareKeyCliReader(['mute', 'power'], flags()), + 'hardware-key requires exactly one key', + ); + expectInvalidArgs(() => hardwareKeyCliReader(['camera'], flags()), 'key must be one of'); + expectInvalidArgs( + () => hardwareKeyCliReader(['android-keycode:0'], flags()), + 'key must be one of', + ); + }); }); test('fold keyframes survive CLI and daemon projection without a preset', () => { diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index ad24cee650..8e39b7efa2 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -21,8 +21,11 @@ import { } from '@agent-device/contracts/device'; import { type BackMode, BACK_MODES } from '@agent-device/contracts/back-mode'; import { + HARDWARE_KEYS, + HARDWARE_KEY_USAGE, TV_REMOTE_BUTTONS, TV_REMOTE_BUTTON_USAGE, + parseHardwareKey, parseTvRemoteButton, tvRemoteDurationMode, } from '@agent-device/contracts/tv-remote'; @@ -40,6 +43,7 @@ import { import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; import type { JsonSchema } from '../command-contract.ts'; import { + customField, enumField, integerField, requiredField, @@ -53,7 +57,7 @@ import { objectSchema, stringSchema, } from '../command-input.ts'; -import { compactRecord } from '../input-readers.ts'; +import { compactRecord, optionalString as optionalInputString } from '../input-readers.ts'; import { defineCommandFacet, defineCommandFamilyFromFacets, @@ -78,6 +82,7 @@ const CLIPBOARD_COMMAND_NAME = 'clipboard'; const TV_REMOTE_COMMAND_NAME = 'tv-remote'; const TELEPHONY_COMMAND_NAME = 'telephony'; const TV_REMOTE_LONGPRESS_PRESET_MS = 500; +const HARDWARE_KEY_COMMAND_NAME = 'hardware-key'; const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; const KEYBOARD_METADATA_ACTION_VALUES = ['status', 'dismiss', 'enter', 'return'] as const; @@ -175,6 +180,14 @@ export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = { ), ], }, + 'hardware-key': objectSchema( + { + action: constSchema('hardware-key'), + key: stringSchema('The key name, or android-keycode:N for a raw Android keycode N.'), + message: stringSchema(), + }, + ['action', 'key', 'message'], + ), // packages/contracts/src/clipboard.ts — discriminated union on `action`. clipboard: { type: 'object', @@ -251,6 +264,7 @@ export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = { | 'action-button' | 'tv-remote' | 'telephony' + | 'hardware-key' | 'clipboard' | 'appstate' | 'keyboard' @@ -303,6 +317,8 @@ const actionButtonCommandDescription = 'Press the iPhone or iPad Action Button once. The press is dispatched without activating the session app and nothing is re-observed afterwards, so the app keeps the state the press found. What the system does with the press is not observed by this command: Simulators run no Shortcuts or App Intents, so delivery to an assigned Shortcut is verifiable only on a physical iPhone.'; const tvRemoteCommandDescription = 'Press or long-press a TV remote or D-pad button on Android TV, tvOS, or Vega OS. Choose the button and optional hold duration through the input fields. The aliases ok, center, and enter all map to select.'; +const hardwareKeyCommandDescription = + 'Press one hardware key once: a named key such as volume-up, or a raw Android keycode as android-keycode:N. power toggles the screen; sleep and wake turn it off or on. The key goes to the system and the foreground media session, and nothing is re-observed afterwards. Supported on Android through ADB key events (local or Limrun) or Appium mobile: pressKey.'; const telephonyCommandDescription = 'Simulate an incoming phone call or SMS on an Android emulator through its console over adb: call rings from the number, accept answers, cancel ends the call, and sms delivers a text message. The call screen or notification can cover the app, so re-snapshot afterwards.'; @@ -434,6 +450,24 @@ const telephonyCommandMetadata = { }, }; +const hardwareKeyCommandMetadata = defineFieldCommandMetadata( + HARDWARE_KEY_COMMAND_NAME, + hardwareKeyCommandDescription, + { + key: requiredField( + customField( + stringSchema( + `Key to press: ${HARDWARE_KEYS.join(', ')}, or android-keycode:N for a vendor remote button no name covers.`, + ), + (record, key) => { + const value = optionalInputString(record, key); + return value === undefined ? undefined : parseHardwareKey(value); + }, + ), + ), + }, +); + const backCliSchema = { usageOverride: 'back [--in-app|--system] [--settle]', usageFlags: [], @@ -477,6 +511,12 @@ const telephonyCliSchema = { allowsExtraPositionals: true, } as const satisfies CommandSchemaOverride; +const hardwareKeyCliSchema = { + usageOverride: `hardware-key ${HARDWARE_KEY_USAGE}`, + listUsageOverride: 'hardware-key ', + positionalArgs: ['key'], +} as const satisfies CommandSchemaOverride; + export const backCliReader: CliReader = (_positionals, flags) => ({ ...commonInputFromFlags(flags), mode: flags.backMode, @@ -515,6 +555,11 @@ export const telephonyCliReader: CliReader = (positionals, flags) => ({ ...parseTelephonyPositionals(positionals), }); +export const hardwareKeyCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + key: readHardwareKeyPositional(positionals), +}); + export const backDaemonWriter: DaemonWriter = (input) => request(BACK_COMMAND_NAME, [], { ...input, @@ -549,6 +594,10 @@ export const telephonyDaemonWriter: DaemonWriter = direct(TELEPHONY_COMMAND_NAME telephonyPositionals(parseTelephonyEventInput(input)), ); +export const hardwareKeyDaemonWriter: DaemonWriter = direct(HARDWARE_KEY_COMMAND_NAME, (input) => [ + requiredDaemonString(input.key, 'hardware-key requires key'), +]); + const appStateCommandFacet = defineParameterlessCommandFacet({ name: APPSTATE_COMMAND_NAME, description: appStateCommandDescription, @@ -686,6 +735,20 @@ const telephonyCommandFacet = defineCommandFacet({ cliOutputFormatter: systemCliOutputFormatters.telephony, }); +const hardwareKeyCommandFacet = defineCommandFacet({ + name: HARDWARE_KEY_COMMAND_NAME, + text: { + summary: 'Press a volume, power, media, or TV hardware key', + cliDetail: 'Android only: ADB key events (local or Limrun) or Appium mobile: pressKey.', + }, + metadata: hardwareKeyCommandMetadata, + run: (client, input) => client.command.hardwareKey(input), + cliSchema: hardwareKeyCliSchema, + cliReader: hardwareKeyCliReader, + daemonWriter: hardwareKeyDaemonWriter, + cliOutputFormatter: systemCliOutputFormatters['hardware-key'], +}); + export const systemCommandFamily = defineCommandFamilyFromFacets({ name: 'system', commands: [ @@ -700,6 +763,7 @@ export const systemCommandFamily = defineCommandFamilyFromFacets({ clipboardCommandFacet, tvRemoteCommandFacet, telephonyCommandFacet, + hardwareKeyCommandFacet, ], }); @@ -756,6 +820,16 @@ function readTvRemoteInput( }); } +function readHardwareKeyPositional(positionals: string[]) { + if (positionals.length !== 1) { + throw new AppError( + 'INVALID_ARGS', + `hardware-key requires exactly one key: ${HARDWARE_KEY_USAGE}.`, + ); + } + return parseHardwareKey(positionals[0]); +} + function readKeyboardAction( value: string | undefined, ): 'status' | 'dismiss' | 'enter' | 'return' | undefined { diff --git a/src/commands/system/output.ts b/src/commands/system/output.ts index e0ce129983..ccd0b3f5b8 100644 --- a/src/commands/system/output.ts +++ b/src/commands/system/output.ts @@ -53,6 +53,7 @@ export const systemCliOutputFormatters = withSettleCapableNotes({ clipboard: resultOutput(clipboardCliOutput), 'tv-remote': messageOutput, telephony: messageOutput, + 'hardware-key': messageOutput, } satisfies Record); function formatAppState(data: AppStateCommandResult): string { diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 0f3cc2f431..8d51fe26d6 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -17,6 +17,7 @@ import { doubleTapAndroid, pressAndroid, pressAndroidEnter, + pressAndroidHardwareKey, pressAndroidTvRemote, readAndroidClipboardText, readAndroidGestureViewport, @@ -123,6 +124,7 @@ export function createAndroidInteractor( setOrientation: (orientation) => setAndroidOrientation(device, orientation), appSwitcher: () => appSwitcherAndroid(device), tvRemote: (button, durationMs) => pressAndroidTvRemote(device, button, durationMs), + hardwareKey: (key) => pressAndroidHardwareKey(device, key), keyboardStatus: async () => ({ kind: 'ime-probe', ...(await getAndroidKeyboardState(device)) }), keyboardDismiss: async () => ({ kind: 'ime-probe', ...(await dismissAndroidKeyboard(device)) }), keyboardEnter: async () => { diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index 118fc9a912..de814bc885 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -53,6 +53,7 @@ describe('composed platform runtime gateway', () => { back: unavailable, orientation: unavailable, tvRemote: unavailable, + hardwareKey: unavailable, keyboard: unavailable, clipboard: unavailable, systemButton: unavailable, @@ -152,6 +153,7 @@ describe('composed platform runtime gateway', () => { back: unavailable, orientation: unavailable, tvRemote: unavailable, + hardwareKey: unavailable, keyboard: unavailable, clipboard: unavailable, systemButton: unavailable, diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index 1a534b7e4c..13abb2c262 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -17,12 +17,14 @@ import { ANDROID_AUDIO_CONTRACT_EVIDENCE, ANDROID_APPLICATION_LIFECYCLE_CONTRACT_EVIDENCE, ANDROID_FOLD_RUNTIME_CONTRACT_EVIDENCE, + ANDROID_HARDWARE_KEY_RUNTIME_CONTRACT_EVIDENCE, ANDROID_HOVER_RUNTIME_CONTRACT_EVIDENCE, ANDROID_TELEPHONY_RUNTIME_CONTRACT_EVIDENCE, ANDROID_TV_REMOTE_RUNTIME_CONTRACT_EVIDENCE, ANDROID_UNINSTALL_CONTRACT_EVIDENCE, ANDROID_VIEWPORT_RUNTIME_CONTRACT_EVIDENCE, APPLE_ACTION_BUTTON_FACT_EVIDENCE, + APPLE_HARDWARE_KEY_DENIAL_EVIDENCE, APPLE_HOVER_DENIAL_EVIDENCE, APPLE_NAVIGATION_FACTS_EVIDENCE, LINUX_HOVER_DENIAL_EVIDENCE, @@ -1404,6 +1406,37 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'the exact-owner runtime fact refuses an Action Button press on the Linux desktop', ), }, + [C.hardwareKey]: { + androidEmulator: androidEmulator.contract( + ANDROID_HARDWARE_KEY_RUNTIME_CONTRACT_EVIDENCE, + 'the Android runtime fact admits volume, power, and media key events on every real kind', + ), + iosSimulator: iosSimulator.contract( + APPLE_HARDWARE_KEY_DENIAL_EVIDENCE.path, + APPLE_HARDWARE_KEY_DENIAL_EVIDENCE.test, + 'the exact-owner runtime fact refuses hardware-key on the iOS leaf; the runner exposes no volume or power press', + ), + macos: macos.contract( + APPLE_HARDWARE_KEY_DENIAL_EVIDENCE.path, + APPLE_HARDWARE_KEY_DENIAL_EVIDENCE.test, + 'the exact-owner runtime fact refuses hardware-key on the macOS host leaf', + ), + tvos: tvos.contract( + APPLE_HARDWARE_KEY_DENIAL_EVIDENCE.path, + APPLE_HARDWARE_KEY_DENIAL_EVIDENCE.test, + 'the exact-owner runtime fact refuses hardware-key on the tvOS leaf', + ), + web: web.contract( + WEB_SYSTEM_SURFACE_DENIAL_EVIDENCE.path, + WEB_SYSTEM_SURFACE_DENIAL_EVIDENCE.test, + 'the exact-owner runtime fact refuses a hardware key press on the web target', + ), + linux: linux.contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact refuses a hardware key press on every Linux leaf', + ), + }, [C.fold]: { androidEmulator: androidEmulator.contract( ANDROID_FOLD_RUNTIME_CONTRACT_EVIDENCE, diff --git a/test/integration/command-coverage/evidence.ts b/test/integration/command-coverage/evidence.ts index 1d8d661b62..76c9cc4347 100644 --- a/test/integration/command-coverage/evidence.ts +++ b/test/integration/command-coverage/evidence.ts @@ -43,6 +43,12 @@ export const ANDROID_ACTION_BUTTON_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvi [C.actionButton], 'Android refuses the action-button fact on every kind', ); +export const ANDROID_HARDWARE_KEY_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvidence = + defineAndroidContractEvidence( + 'packages/platform-android/src/runtime.test.ts', + [C.hardwareKey], + 'admits the Android %s hardware-key fact through the shared touch gate', + ); export const ANDROID_FOLD_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvidence = defineAndroidContractEvidence( 'packages/platform-android/src/runtime.test.ts', @@ -93,6 +99,10 @@ export const APPLE_ACTION_BUTTON_FACT_EVIDENCE: RepositoryEvidence = { path: 'packages/platform-apple/src/runtime.test.ts', test: 'classifies the action-button fact for the %s leaf', }; +export const APPLE_HARDWARE_KEY_DENIAL_EVIDENCE: RepositoryEvidence = { + path: 'packages/platform-apple/src/runtime.test.ts', + test: 'refuses the hardware-key fact for the %s leaf', +}; export const APPLE_HOVER_DENIAL_EVIDENCE: RepositoryEvidence = { path: 'packages/platform-apple/src/runtime.test.ts', test: 'hover has no Apple interactor route on macOS, iOS, or tvOS; the touch family reports its typed denial', diff --git a/test/integration/provider-scenarios/android-hardware-key.test.ts b/test/integration/provider-scenarios/android-hardware-key.test.ts new file mode 100644 index 0000000000..ce1bc2e4d7 --- /dev/null +++ b/test/integration/provider-scenarios/android-hardware-key.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AndroidAdbProvider } from '@agent-device/platform-android/mechanics'; +import { assertCommandCall, assertRpcError } from './assertions.ts'; +import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; +import { runProviderScenario } from './scenario.ts'; + +const PROVIDER_SCENARIO_ANDROID_TV: DeviceInfo = { + platform: 'android', + id: 'emulator-5556', + name: 'Android TV', + kind: 'emulator', + target: 'tv', + booted: true, +}; + +test('Provider-backed integration Android hardware-key flow sends named and raw keyevents', async () => { + const adbCalls: string[][] = []; + const adbProvider: AndroidAdbProvider = { + exec: async (args) => { + adbCalls.push([...args]); + if (args.join(' ') === 'shell ime list -a -s') { + return { + stdout: 'com.callstack.agentdevice.imehelper/.TestInputMethodService\n', + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID_TV], + }), + async (daemon) => { + await daemon.client().apps.open({ + app: 'com.android.settings', + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID_TV.id, + testIme: false, + }); + + await runProviderScenario(daemon, [ + { + name: 'press a named key', + command: 'hardware-key', + positionals: ['volume-down'], + flags: { platform: 'android', target: 'tv' }, + expectData: { + action: 'hardware-key', + key: 'volume-down', + message: 'Pressed hardware key volume-down', + }, + }, + { + name: 'press a raw keycode', + command: 'hardware-key', + positionals: ['android-keycode:300'], + flags: { platform: 'android', target: 'tv' }, + expectData: { + action: 'hardware-key', + key: 'android-keycode:300', + message: 'Pressed hardware key android-keycode:300', + }, + }, + ]); + + assertCommandCall(adbCalls, ['shell', 'input', 'keyevent', '25']); + assertCommandCall(adbCalls, ['shell', 'input', 'keyevent', '300']); + + const callsBeforeBadKey = adbCalls.length; + assertRpcError( + await daemon.callCommand('hardware-key', ['volume-sideways'], { + platform: 'android', + target: 'tv', + }), + 400, + 'INVALID_ARGS', + /hardware-key key must be one of/, + ); + assert.equal(adbCalls.length, callsBeforeBadKey); + }, + ); +}); diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index e8d4b5230f..ebf31f020e 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -129,6 +129,7 @@ const DRIVEN_COMMANDS: Record = { [PUBLIC_COMMANDS.tvRemote]: () => one(['select']), [PUBLIC_COMMANDS.appSwitcher]: () => one(), [PUBLIC_COMMANDS.actionButton]: () => one(), + [PUBLIC_COMMANDS.hardwareKey]: () => one(['volume-up']), [PUBLIC_COMMANDS.fold]: () => one(['open']), [PUBLIC_COMMANDS.telephony]: () => one(['call', '5551234']), diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 9174343b84..82317bead3 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -242,6 +242,7 @@ await client.command.tvRemote({ await client.command.appSwitcher(); await client.command.actionButton(); +await client.command.hardwareKey({ key: 'media-play-pause' }); await client.command.fold({ pose: 'open' }); await client.command.telephony({ action: 'call', number: '5551234' }); await client.command.telephony({ action: 'sms', number: '5551234', text: 'Your code is 1234' }); @@ -283,6 +284,7 @@ Supported command methods: - `keyboard` - `clipboard` - `tvRemote` +- `hardwareKey` - `reactNative` - `doctor` - `prepare` diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 13bcea49e2..7fbac410b2 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -55,6 +55,9 @@ agent-device orientation portrait agent-device orientation landscape-left agent-device app-switcher agent-device action-button +agent-device hardware-key volume-down +agent-device hardware-key media-play-pause +agent-device hardware-key android-keycode:300 agent-device fold closed agent-device fold half-open agent-device fold open @@ -118,6 +121,14 @@ Back, orientation, and hardware buttons: - `orientation portrait|portrait-upside-down|landscape-left|landscape-right` works on iOS and Android mobile targets only. - `action-button` presses the iPhone or iPad Action Button once. It takes no arguments, no hold duration, and no `--settle`, and it doesn't activate the session app or drive Camera Control. Other platforms refuse it, and a model without the button (an iPhone SE, most iPad simulators) fails with `UNSUPPORTED_OPERATION`. - `action-button` reports that the press was dispatched, not what it triggered. Simulators run no Shortcuts or App Intents, so verify the outcome on a physical iPhone. Expect a press to take about five seconds on a Simulator. +- `hardware-key ` presses one volume, power, media, or TV key: + - Volume: `volume-up`, `volume-down`, `mute`. + - Power: `power` toggles the screen; `sleep` and `wake` turn it off or on. + - Media: `media-play-pause`, `media-next`, `media-previous`, `media-rewind`, `media-fast-forward`. + - TV remote: `channel-up`, `channel-down`, `last-channel`, `guide`, `info`, `captions`, `tv-input`, `color-red`, `color-green`, `color-yellow`, `color-blue`. +- Use `hardware-key` to check volume and mute handling, standby and wake during playback, and media and channel keys, on a phone or an Android TV target. +- For a vendor remote button that no name covers, pass its Android keycode as `android-keycode:`, for example `hardware-key android-keycode:300`. `n` is an integer from 1 to 2147483647; agent-device sends it as is and does not check what the device maps it to. +- `hardware-key` works on Android emulators and devices over ADB (`input keyevent`), including Limrun Android sessions and Android sessions on the Appium (WebDriver) backend, which presses the keycode through `mobile: pressKey`. iOS, tvOS, macOS, web, Linux, Vega OS, HarmonyOS, and Limrun iOS report `UNSUPPORTED_OPERATION`. The key goes to the system and the foreground media session, and nothing is re-observed afterwards, so take a snapshot or screenshot to check the effect. Fold a foldable device: