diff --git a/CHANGELOG.md b/CHANGELOG.md index e87f570c69..f002b145b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,4 +10,6 @@ - `settings` refuses, before touching the device, a state its setting does not accept (`Invalid state: …`), a permission action or mode its target does not take (such as `permission deny accessibility`), and a stray extra argument (such as `clear-app-state `). The CLI and the daemon accept the same state spellings, in any casing or surrounding whitespace. - `alert wait 0` on Android reports an alert that is already visible instead of timing out. - `settings --help` describes each setting on its own line, and its usage folds settings that share a form. +- Added (android): `telephony call|accept|cancel ` and `telephony sms `, plus `client.command.telephony({ action, number, text })`, simulate an incoming phone call or SMS on an Android emulator through the emulator console (`adb emu gsm …`, `adb emu sms send …`), so a test can check how an app behaves when a call or message interrupts it, such as video playback pausing and resuming. The number must be up to 15 digits with an optional leading `+`; SMS text is sent as one adb argument, with backslashes and line breaks escaped for the console; the emulator drops characters outside the Basic Multilingual Plane, such as emoji. A console refusal (for example `accept` for a number with no call) fails with `COMMAND_FAILED` and `reason: telephony-console-refused`. Physical Android devices, iOS, every other target, and the WebDriver (Appium) and Limrun providers refuse with `UNSUPPORTED_OPERATION`. +- `fold` on Android emulators: the posture and hinge-angle console calls now time out after 10 s instead of waiting on a console that stopped answering, and a failed call names only its first two words (for example `adb emu sensor get`). - Carry provider keyboard facts through snapshots. Appium captures, including wait polls and fill pre-captures, add one sequential keyboard visibility probe with a 500 ms timeout. Probe results preserve tap-time docked-tree checks; unavailable probes leave visibility unknown. diff --git a/packages/command-registry/src/__tests__/command-result.test.ts b/packages/command-registry/src/__tests__/command-result.test.ts index 4cc3f6a7db..2884e64567 100644 --- a/packages/command-registry/src/__tests__/command-result.test.ts +++ b/packages/command-registry/src/__tests__/command-result.test.ts @@ -7,6 +7,7 @@ import type { AppStateCommandResult, BootCommandResult, ShutdownCommandResult, + TelephonyCommandResult, TriggerAppEventCommandResult, } from '@agent-device/contracts/device'; import type { @@ -60,6 +61,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ const appstate: Equal, AppStateCommandResult> = true; const keyboard: Equal, KeyboardCommandResult> = true; const tvRemote: Equal, TvRemoteCommandResult> = true; + const telephony: Equal, TelephonyCommandResult> = true; const wait: Equal, WaitCommandResult> = true; const prepare: Equal, PrepareCommandResult> = true; const push: Equal, PushCommandResult> = true; @@ -90,6 +92,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ appstate, keyboard, tvRemote, + telephony, wait, prepare, push, @@ -132,6 +135,7 @@ test('CommandResultMap is seeded only from already-existing contract result type | 'appstate' | 'keyboard' | 'tv-remote' + | 'telephony' | 'wait' | 'scroll' | 'prepare' diff --git a/packages/command-registry/src/command-result.ts b/packages/command-registry/src/command-result.ts index 11f9d271e9..fa22d62e74 100644 --- a/packages/command-registry/src/command-result.ts +++ b/packages/command-registry/src/command-result.ts @@ -7,6 +7,7 @@ import type { AppStateCommandResult, BootCommandResult, ShutdownCommandResult, + TelephonyCommandResult, TriggerAppEventCommandResult, } from '@agent-device/contracts/device'; import type { @@ -82,6 +83,7 @@ export interface CommandResultMap { replay: ReplayCommandResult; scroll: ScrollCommandResult; shutdown: ShutdownCommandResult; + telephony: TelephonyCommandResult; test: ReplaySuiteResult; trace: TraceCommandResult; 'trigger-app-event': TriggerAppEventCommandResult; diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 303a38e35c..76de176176 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -43,6 +43,7 @@ import { alertRuntimePlanUses, actionButtonRuntimeUse, foldRuntimeUse, + telephonyRuntimeUse, appEventRuntimeUse, appStateRuntimeUses, appSwitcherRuntimeUse, @@ -1514,6 +1515,18 @@ export const RAW_COMMAND_DESCRIPTORS = [ timeoutPolicy: FOLD_TIMEOUT_POLICY, platformExecution: { kind: 'device-runtime', uses: [foldRuntimeUse] }, }, + { + name: 'telephony', + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), + catalog: { group: 'public' }, + frameworkTier: 'extended', + // Admission is the owner's `sendTelephonyEvent` fact. The event goes to the emulator console + // rather than the screen, so no blocking-dialog guard applies; the call screen or message + // notification it raises can still cover the app, so refs may be invalidated. + ...GENERIC_MUTATING_COMMAND_TRAITS, + daemon: { ...GENERIC_MUTATING_COMMAND_TRAITS.daemon, androidBlockingDialogGuard: false }, + platformExecution: { kind: 'device-runtime', uses: [telephonyRuntimeUse] }, + }, { name: 'scroll', ...(ownerFilesEnabled diff --git a/packages/contracts/src/client-system.ts b/packages/contracts/src/client-system.ts index 1bf5dd6ca5..401cb9b0be 100644 --- a/packages/contracts/src/client-system.ts +++ b/packages/contracts/src/client-system.ts @@ -5,7 +5,7 @@ import type { BackMode } from './back-mode.ts'; import type { SelectorSnapshotCommandOptions } from './client-capture.ts'; import type { DeviceCommandBaseOptions } from './client-connection.ts'; import type { SettleCommandOptions } from './client-gesture.ts'; -import type { DeviceRotation, SetFoldPoseInput } from './device-rotation.ts'; +import type { DeviceRotation, SetFoldPoseInput, TelephonyEventInput } from './device-rotation.ts'; import type { TvRemoteButton } from './tv-remote.ts'; export type WaitCommandTarget = @@ -101,6 +101,8 @@ export type TvRemoteCommandOptions = DeviceCommandBaseOptions & { durationMs?: number; }; +export type TelephonyCommandOptions = DeviceCommandBaseOptions & TelephonyEventInput; + export type KeyboardCommandOptions = DeviceCommandBaseOptions & { action?: 'status' | 'dismiss' | 'enter' | 'return'; }; diff --git a/packages/contracts/src/device-rotation-telephony.test.ts b/packages/contracts/src/device-rotation-telephony.test.ts new file mode 100644 index 0000000000..8ac4b870b3 --- /dev/null +++ b/packages/contracts/src/device-rotation-telephony.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from 'vitest'; +import { + parseTelephonyEventInput, + parseTelephonyPositionals, + telephonyPositionals, +} from './device-rotation.ts'; + +test('parses each action with its number, normalizing the action and trimming the number', () => { + expect(parseTelephonyEventInput({ action: ' CALL ', number: ' 5551234 ' })).toEqual({ + action: 'call', + number: '5551234', + }); + expect(parseTelephonyEventInput({ action: 'accept', number: '+15551234567' })).toEqual({ + action: 'accept', + number: '+15551234567', + }); + expect(parseTelephonyEventInput({ action: 'call', number: `+${'1'.repeat(15)}` })).toEqual({ + action: 'call', + number: `+${'1'.repeat(15)}`, + }); + expect(parseTelephonyEventInput({ action: 'cancel', number: '5551234' })).toEqual({ + action: 'cancel', + number: '5551234', + }); + expect(parseTelephonyEventInput({ action: 'sms', number: '5551234', text: ' hi ' })).toEqual({ + action: 'sms', + number: '5551234', + text: ' hi ', + }); +}); + +test('keeps SMS text verbatim, including quotes, backslashes, and line breaks', () => { + const text = `It's "on"\nC:\\videos $HOME`; + expect(parseTelephonyEventInput({ action: 'sms', number: '5551234', text })).toMatchObject({ + text, + }); +}); + +test('normalizes a CRLF line break to LF', () => { + expect( + parseTelephonyEventInput({ action: 'sms', number: '5551234', text: 'Line 1\r\nLine 2' }), + ).toMatchObject({ text: 'Line 1\nLine 2' }); +}); + +test.each([ + [{}, 'telephony requires an action'], + [{ action: 'ring', number: '5551234' }, 'Invalid telephony action: ring'], + [{ action: 'call' }, 'telephony call requires a phone number'], + [{ action: 'call', number: ' ' }, 'telephony call requires a phone number'], + [{ action: 'call', number: '555 1234' }, 'Invalid phone number'], + [{ action: 'call', number: '+1-555-1234' }, 'Invalid phone number'], + [{ action: 'call', number: '1'.repeat(16) }, 'Invalid phone number'], + [{ action: 'cancel', number: '5551234', text: 'hi' }, 'takes only a phone number'], + [{ action: 'sms', number: '5551234' }, 'telephony sms requires message text'], + [{ action: 'sms', number: '5551234', text: '' }, 'telephony sms requires message text'], + [{ action: 'sms', number: '5551234', text: 'a\rb' }, 'must not contain control characters'], + [{ action: 'sms', number: '5551234', text: 'a\u0000b' }, 'must not contain control characters'], + [{ action: 'sms', number: '5551234', text: 'a\u0085b' }, 'must not contain control characters'], +])('refuses %o with INVALID_ARGS', (input, message) => { + expect(() => parseTelephonyEventInput(input)).toThrow( + expect.objectContaining({ code: 'INVALID_ARGS', message: expect.stringContaining(message) }), + ); +}); + +test('positionals join the SMS words and round-trip through the daemon positionals', () => { + const sms = parseTelephonyPositionals(['sms', '5551234', 'Your', 'code', 'is', '42']); + expect(sms).toEqual({ action: 'sms', number: '5551234', text: 'Your code is 42' }); + expect(telephonyPositionals(sms)).toEqual(['sms', '5551234', 'Your code is 42']); + expect(parseTelephonyPositionals(telephonyPositionals(sms))).toEqual(sms); + + const call = parseTelephonyPositionals(['call', '5551234']); + expect(telephonyPositionals(call)).toEqual(['call', '5551234']); +}); diff --git a/packages/contracts/src/device-rotation.ts b/packages/contracts/src/device-rotation.ts index 64b1411725..b97040a8bd 100644 --- a/packages/contracts/src/device-rotation.ts +++ b/packages/contracts/src/device-rotation.ts @@ -206,3 +206,113 @@ export function parseFoldKeyframesJson(value: string): readonly FoldKeyframe[] { } return parseFoldKeyframes(decoded); } + +// ---- Telephony ------------------------------------------------------------------------------- +// The CLI and session journal load this module eagerly; moving these values to a module of their +// own would grow their eager closures past scripts/__tests__/eager-closure-budgets.ts. The types +// live in telephony-runtime.ts. + +/** + * The telephony events an emulator can be asked to simulate: an incoming voice call (`call`), + * answering it (`accept`), ending it (`cancel`), and an inbound text message (`sms`). + */ +export const TELEPHONY_CALL_ACTIONS = ['call', 'accept', 'cancel'] as const; +export const TELEPHONY_ACTIONS = [...TELEPHONY_CALL_ACTIONS, 'sms'] as const; +type TelephonyAction = (typeof TELEPHONY_ACTIONS)[number]; +type TelephonyCallAction = (typeof TELEPHONY_CALL_ACTIONS)[number]; + +export const TELEPHONY_USAGE = + 'telephony call|accept|cancel | telephony sms '; + +/** + * A caller number: digits with an optional leading `+`, at most the 15 digits of an E.164 number, + * which also fits the emulator modem's 16-character call number field. + */ +const TELEPHONY_NUMBER_PATTERN = /^\+?\d{1,15}$/; +/** + * The emulator console reads one command per line, so a line break inside the text would end the + * `sms send` command and start another. Line breaks are escaped for the console instead (a CRLF + * pair counts as one); every other control character, including a lone CR, is refused. + */ +// eslint-disable-next-line no-control-regex +const TELEPHONY_TEXT_FORBIDDEN_CHARACTERS = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/; + +/** Neutral intent for one telephony event, already validated by {@link parseTelephonyEventInput}. */ +export type TelephonyEventInput = + | Readonly<{ action: TelephonyCallAction; number: string }> + | Readonly<{ action: 'sms'; number: string; text: string }>; + +function parseTelephonyAction(input: unknown): TelephonyAction { + if (input === undefined) { + throw new AppError('INVALID_ARGS', `telephony requires an action. Use ${TELEPHONY_USAGE}.`); + } + const normalized = typeof input === 'string' ? input.trim().toLowerCase() : undefined; + const action = TELEPHONY_ACTIONS.find((candidate) => candidate === normalized); + if (!action) { + throw new AppError( + 'INVALID_ARGS', + `Invalid telephony action: ${String(input)}. Use ${TELEPHONY_ACTIONS.join(', ')}.`, + ); + } + return action; +} + +export function parseTelephonyEventInput( + input: Readonly<{ action?: unknown; number?: unknown; text?: unknown }>, +): TelephonyEventInput { + const action = parseTelephonyAction(input.action); + const number = parseTelephonyNumber(input.number, action); + if (action !== 'sms') { + if (input.text !== undefined) { + throw new AppError('INVALID_ARGS', `telephony ${action} takes only a phone number.`); + } + return { action, number }; + } + return { action, number, text: parseTelephonySmsText(input.text) }; +} + +/** + * `telephony [text…]` as the CLI and the daemon receive it. The words after the + * number are the SMS text, joined with single spaces the way an unquoted shell argument list splits. + */ +export function parseTelephonyPositionals(positionals: readonly string[]): TelephonyEventInput { + const [action, number, ...words] = positionals; + return parseTelephonyEventInput({ + action, + number, + text: words.length > 0 ? words.join(' ') : undefined, + }); +} + +/** The daemon positionals for one validated event; the inverse of {@link parseTelephonyPositionals}. */ +export function telephonyPositionals(input: TelephonyEventInput): string[] { + return input.action === 'sms' + ? [input.action, input.number, input.text] + : [input.action, input.number]; +} + +function parseTelephonyNumber(input: unknown, action: TelephonyAction): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new AppError('INVALID_ARGS', `telephony ${action} requires a phone number.`); + } + const number = input.trim(); + if (!TELEPHONY_NUMBER_PATTERN.test(number)) { + throw new AppError('INVALID_ARGS', `Invalid phone number: ${number}.`, { + hint: 'Use digits with an optional leading +, for example 5551234 or +15551234567.', + }); + } + return number; +} + +function parseTelephonySmsText(input: unknown): string { + if (typeof input !== 'string' || input === '') { + throw new AppError('INVALID_ARGS', 'telephony sms requires message text.'); + } + const text = input.replaceAll('\r\n', '\n'); + if (TELEPHONY_TEXT_FORBIDDEN_CHARACTERS.test(text)) { + throw new AppError('INVALID_ARGS', 'telephony sms text must not contain control characters.', { + hint: 'Line breaks (LF or CRLF) are allowed; tabs, lone carriage returns, and other control characters are not.', + }); + } + return text; +} diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index d751df86ee..78499ed2da 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -121,6 +121,7 @@ export type { OrientationCommandOptions, PrepareCommandOptions, ReactNativeCommandOptions, + TelephonyCommandOptions, TvRemoteCommandOptions, ViewportCommandOptions, WaitCommandOptions, diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index 5df44feb85..0318a025a4 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -45,6 +45,12 @@ export { isDeviceRotation, parseDeviceRotation, parseFoldPose, + TELEPHONY_ACTIONS, + TELEPHONY_CALL_ACTIONS, + TELEPHONY_USAGE, + parseTelephonyEventInput, + parseTelephonyPositionals, + telephonyPositionals, } from '../device-rotation.ts'; export type { DeviceRotation, @@ -52,7 +58,9 @@ export type { FoldKeyframe, FoldScreenCoordinateSpace, SetFoldPoseInput, + TelephonyEventInput, } from '../device-rotation.ts'; +export type { TelephonyCommandResult, TelephonyRuntimeOperations } from '../telephony-runtime.ts'; export type { BootCommandResult, ShutdownCommandResult } from '../device.ts'; export type { ProviderDeviceInstallOptions, diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 3d11d8e2bf..a3c04d59c5 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -23,6 +23,7 @@ import type { ElementTextRuntimeOperations } from './element-text-runtime.ts'; 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 { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; @@ -77,6 +78,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & BackRuntimeOperations & OrientationRuntimeOperations & FoldRuntimeOperations & + TelephonyRuntimeOperations & TvRemoteRuntimeOperations & KeyboardRuntimeOperations & ClipboardRuntimeOperations & @@ -112,6 +114,7 @@ export const backRuntimeUse = defineUse({ required: ['back'] }); export const homeRuntimeUse = defineUse({ required: ['home'] }); 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 keyboardStatusUse = defineUse({ required: ['keyboardStatus'] }); export const keyboardDismissUse = defineUse({ required: ['keyboardDismiss'] }); diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index e54cf1516b..d479d6e852 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -46,6 +46,7 @@ const UNAVAILABLE_FACTS: UnavailablePlatformRuntimeFacts = { clipboard: { available: false, reason: 'unsupported-provider-mode' }, systemButton: { available: false, reason: 'unsupported-provider-mode' }, fold: { available: false, reason: 'unsupported-provider-mode' }, + telephony: { available: false, reason: 'unsupported-provider-mode' }, triggerAppEvent: { available: false, reason: 'unsupported-provider-mode' }, settings: { available: false, reason: 'unsupported-provider-mode' }, readAlert: { 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 8fe5ff79ee..758153f01c 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -58,6 +58,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ back: RuntimeOperationUnavailability; orientation: RuntimeOperationUnavailability; fold: RuntimeOperationUnavailability; + telephony: RuntimeOperationUnavailability; tvRemote: RuntimeOperationUnavailability; keyboard: RuntimeOperationUnavailability; clipboard: RuntimeOperationUnavailability; @@ -114,6 +115,7 @@ const UNAVAILABLE_CELLS = { back: true, orientation: true, fold: true, + telephony: true, tvRemote: true, keyboard: true, clipboard: true, @@ -239,6 +241,7 @@ export function createUnavailablePlatformRuntimeFacts( // Stated directly rather than through `foldRuntimeOperationFacts`, so this hub does not // evaluate the fold contract for owners that never pose a hinge (eager-closure budgets). setFoldPose: frozen.fold, + sendTelephonyEvent: frozen.telephony, ...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }), ...keyboardRuntimeOperationFacts({ unsupported: frozen.keyboard }), ...clipboardRuntimeOperationFacts({ unsupported: frozen.clipboard }), diff --git a/packages/contracts/src/runtime-operation-names.ts b/packages/contracts/src/runtime-operation-names.ts index 55d799875e..32cdf3b31a 100644 --- a/packages/contracts/src/runtime-operation-names.ts +++ b/packages/contracts/src/runtime-operation-names.ts @@ -76,6 +76,7 @@ export const RUNTIME_OPERATION_NAMES = [ 'screenRecordingStart', 'scrollDirection', 'sendPushNotification', + 'sendTelephonyEvent', 'setFoldPose', 'setOrientation', 'setSetting', diff --git a/packages/contracts/src/telephony-runtime.ts b/packages/contracts/src/telephony-runtime.ts new file mode 100644 index 0000000000..74ff59c5ec --- /dev/null +++ b/packages/contracts/src/telephony-runtime.ts @@ -0,0 +1,26 @@ +import type { TelephonyEventInput } from './device-rotation.ts'; + +/** + * One simulated telephony event. The owner returns nothing: the emulator console acknowledges the + * event, and what the app does with the call or message is observed through the next capture. + */ +export type TelephonyRuntimeOperations = Readonly<{ + sendTelephonyEvent(input: TelephonyEventInput): Promise; +}>; + +/** + * Closed result of the `telephony` command: a discriminated union on `action`. The call actions + * echo the number; `sms` also reports the length of the text it delivered. + */ +export type TelephonyCommandResult = + | { + action: Exclude; + number: string; + message: string; + } + | { + action: 'sms'; + number: string; + textLength: number; + message: string; + }; diff --git a/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts b/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts index c2c89de1c7..0020116cca 100644 --- a/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts +++ b/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts @@ -32,6 +32,7 @@ import { resolveBoundSystemButtonRuntime, type SystemButtonCommand, } from '../../execution/system-button-runtime.ts'; +import { resolveBoundTelephonyRuntime } from '../../execution/telephony-runtime.ts'; import { resolveBoundTvRemoteRuntime } from '../../execution/tv-remote-runtime.ts'; import { resolveBoundTypeTextRuntime } from '../../execution/type-text-runtime.ts'; import { resolveBoundViewportRuntime } from '../../execution/viewport-runtime.ts'; @@ -107,6 +108,16 @@ export const conformedRuntimeBindings = { resolve: async (device, bindings) => refusable(await resolveBoundFoldRuntime({ device, positionals: ['open'], ...bindings })), }, + telephony: { + resolve: async (device, bindings) => + refusable( + await resolveBoundTelephonyRuntime({ + device, + positionals: ['call', '5551234'], + ...bindings, + }), + ), + }, focus: { resolve: async (device, bindings) => refusable(await resolveBoundFocusRuntime({ device, positionals: ['40', '90'], ...bindings })), diff --git a/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts b/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts index cbc621a946..355866dc23 100644 --- a/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts +++ b/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts @@ -168,6 +168,7 @@ test('daemon command registry preserves Android modal and lock-policy traits', ( } assert.equal(shouldGuardAndroidBlockingDialog(PUBLIC_COMMANDS.get), false); + assert.equal(shouldGuardAndroidBlockingDialog(PUBLIC_COMMANDS.telephony), false); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.apps), true); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.capabilities), true); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.devices), true); diff --git a/packages/daemon/src/core/runtime-operation-effects.ts b/packages/daemon/src/core/runtime-operation-effects.ts index 5fcb5d109a..dc5fa7d2e3 100644 --- a/packages/daemon/src/core/runtime-operation-effects.ts +++ b/packages/daemon/src/core/runtime-operation-effects.ts @@ -83,6 +83,7 @@ export const RUNTIME_OPERATION_EFFECTS: Readonly< screenRecordingStart: 'repeatable', scrollDirection: 'mutates', sendPushNotification: 'mutates', + sendTelephonyEvent: 'mutates', setFoldPose: 'mutates', setOrientation: 'mutates', setSetting: 'mutates', diff --git a/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts b/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts new file mode 100644 index 0000000000..696bb3029d --- /dev/null +++ b/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts @@ -0,0 +1,147 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; +import { expect, test, vi } from 'vitest'; + +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform-runtime'; +import { + telephonyRuntimeUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; +import { makeSession } from '../../__tests__/support/session-factories.ts'; +import type { + BindDeviceRuntime, + InspectDeviceRuntimeFacts, +} from '../../core/request-runtime-binding.ts'; +import type { GenericPlatformExecutionParams } from '../../core/generic-leaf-execution.ts'; +import { resolveBoundTelephonyRuntime } from '../telephony-runtime.ts'; +import { expectRefusesUnavailableExactOwnerFact } from '../../__tests__/support/runtime-binding-conformance.ts'; + +// File-scoped id: this owner binding's `local-family` kind reaches the real on-disk device-claim +// admission, so a shared id risks a cross-file claim collision under parallel execution. +const testDevice = { + id: 'telephony-runtime-device', + name: 'Pixel', + platform: 'android', + kind: 'emulator', + target: 'mobile', + booted: true, +} as const satisfies DeviceInfo; +const available = Object.freeze({ available: true } as const); +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind' as const, +}); + +function telephonyExecutionParams(positionals: string[]): GenericPlatformExecutionParams { + const session = makeSession('telephony-runtime', { device: testDevice }); + return { + session, + sessionName: session.name, + logPath: '/tmp/daemon.log', + command: 'telephony', + request: { command: 'telephony', positionals, token: 't', session: session.name }, + positionals, + out: undefined, + dispatchContext: {}, + }; +} + +function runtimeHarness(fact: RuntimeOperationFact = available) { + const sendTelephonyEvent = vi.fn(async () => {}); + const facts: RuntimeFacts = { + device: { ...deviceShape(testDevice), providerMode: 'local' }, + operations: { + sendTelephonyEvent: fact, + } as RuntimeFacts['operations'], + }; + const binding = { + device: testDevice, + owner: localRuntimeOwner('android'), + facts, + operations: { sendTelephonyEvent }, + [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; + return { sendTelephonyEvent, inspectFacts, bindDevice }; +} + +async function runTelephony(positionals: string[], harness = runtimeHarness()) { + const resolved = await resolveBoundTelephonyRuntime({ + platformServices: daemonPlatformServicesFixture(), + device: testDevice, + positionals, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + if (!resolved.ok) throw new Error('telephony was not admitted'); + return await resolved.execute(telephonyExecutionParams(positionals)); +} + +test.each([ + [['call', '5551234'], { action: 'call', number: '5551234' }, 'Simulated an incoming call'], + [['accept', '5551234'], { action: 'accept', number: '5551234' }, 'Accepted the call'], + [['cancel', '5551234'], { action: 'cancel', number: '5551234' }, 'Ended the call'], +] as const)('%o sends %o to the admitted owner', async (positionals, input, message) => { + const harness = runtimeHarness(); + expect(await runTelephony([...positionals], harness)).toEqual({ + ...input, + message: `${message} from 5551234`, + }); + expect(harness.bindDevice).toHaveBeenCalledWith(testDevice, telephonyRuntimeUse); + expect(harness.sendTelephonyEvent).toHaveBeenCalledExactlyOnceWith(input); +}); + +test('sms joins the words after the number into one message and reports its length', async () => { + const harness = runtimeHarness(); + expect(await runTelephony(['sms', '+15551234567', 'Your', 'code', 'is 42'], harness)).toEqual({ + action: 'sms', + number: '+15551234567', + textLength: 'Your code is 42'.length, + message: 'Simulated an incoming SMS from +15551234567', + }); + expect(harness.sendTelephonyEvent).toHaveBeenCalledWith({ + action: 'sms', + number: '+15551234567', + text: 'Your code is 42', + }); +}); + +test.each([ + [[], 'telephony requires an action'], + [['ring', '5551234'], 'Invalid telephony action'], + [['call'], 'telephony call requires a phone number'], + [['call', '555-1234'], 'Invalid phone number'], + [['call', '5551234', 'extra'], 'takes only a phone number'], + [['sms', '5551234'], 'telephony sms requires message text'], + [['sms', '5551234', 'tab\there'], 'must not contain control characters'], +])('refuses %o before inspection or binding', async (positionals, message) => { + const harness = runtimeHarness(); + await expect( + resolveBoundTelephonyRuntime({ + platformServices: daemonPlatformServicesFixture(), + device: testDevice, + positionals, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS', message: expect.stringContaining(message) }); + expect(harness.inspectFacts).not.toHaveBeenCalled(); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +test('rejects an unavailable exact-owner fact before binding', async () => { + await expectRefusesUnavailableExactOwnerFact({ + command: 'telephony', + device: testDevice, + unavailable, + }); +}); diff --git a/packages/daemon/src/execution/generic-runtime-execution.ts b/packages/daemon/src/execution/generic-runtime-execution.ts index 8aa38b4749..0ff52d6d71 100644 --- a/packages/daemon/src/execution/generic-runtime-execution.ts +++ b/packages/daemon/src/execution/generic-runtime-execution.ts @@ -45,8 +45,7 @@ export async function resolveGenericRuntimeExecution( switch (params.req.command) { case 'screenshot': { // This branch alone owns an in-process command surface and the interaction implementations - // beneath it; the other leaves here are bound runtimes the dispatcher reaches directly, so - // only this one's module is deferred out of the daemon's startup closure. + // beneath it, so its module is deferred out of the daemon's startup closure. const { resolveScreenshotGenericExecution } = await import('./screenshot-runtime.ts'); return await resolveScreenshotGenericExecution(params); } @@ -104,6 +103,17 @@ export async function resolveGenericRuntimeExecution( platformServices: params.platformServices, bindDevice: params.bindDevice, }); + case 'telephony': { + // Deferred to keep the daemon's eager closure within scripts/__tests__/eager-closure-budgets.ts. + const { resolveBoundTelephonyRuntime } = await import('./telephony-runtime.ts'); + return await resolveBoundTelephonyRuntime({ + device: params.session.device, + positionals: params.req.positionals ?? [], + inspectFacts: params.inspectFacts, + platformServices: params.platformServices, + bindDevice: params.bindDevice, + }); + } case 'tv-remote': return await resolveBoundTvRemoteRuntime({ device: params.session.device, diff --git a/packages/daemon/src/execution/telephony-runtime.ts b/packages/daemon/src/execution/telephony-runtime.ts new file mode 100644 index 0000000000..b402f80efb --- /dev/null +++ b/packages/daemon/src/execution/telephony-runtime.ts @@ -0,0 +1,63 @@ +import { telephonyRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import { + parseTelephonyPositionals, + type TelephonyCommandResult, + type TelephonyEventInput, +} from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { ResolvedGenericExecution } from '../core/generic-leaf-execution.ts'; +import { + resolveBoundGenericRuntime, + type RuntimeAdmissionBindings, +} from '../core/runtime-admission.ts'; + +/** + * The one place `telephony` reaches a device (ADR 0019). The event is parsed before admission, so + * an invalid number or text is refused before any owner is inspected or bound. + */ +export async function resolveBoundTelephonyRuntime( + params: { + device: DeviceInfo; + positionals: readonly string[]; + } & RuntimeAdmissionBindings, +): Promise { + const input = parseTelephonyPositionals(params.positionals); + return await resolveBoundGenericRuntime( + { + command: 'telephony', + device: params.device, + use: telephonyRuntimeUse, + inspectFacts: params.inspectFacts, + platformServices: params.platformServices, + bindDevice: params.bindDevice, + }, + (runtime) => executeTelephonyEvent(runtime, input), + ); +} + +const CALL_EVENT_MESSAGES = { + call: 'Simulated an incoming call from', + accept: 'Accepted the call from', + cancel: 'Ended the call from', +} as const satisfies Record, string>; + +async function executeTelephonyEvent( + runtime: BoundDeviceRuntime, + input: TelephonyEventInput, +): Promise { + await runtime.operations.sendTelephonyEvent(input); + if (input.action === 'sms') { + return { + action: input.action, + number: input.number, + textLength: Array.from(input.text).length, + message: `Simulated an incoming SMS from ${input.number}`, + }; + } + return { + action: input.action, + number: input.number, + message: `${CALL_EVENT_MESSAGES[input.action]} ${input.number}`, + }; +} 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 0b0ebb07dc..186e6f6547 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts @@ -465,6 +465,7 @@ function sourceRuntimeFacts( ...clipboardRuntimeOperationFacts({ unsupported: unavailable }), ...systemButtonRuntimeOperationFacts({ unsupported: unavailable }), setFoldPose: unavailable, + sendTelephonyEvent: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, readSetting: 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 a6b70410e2..21bc8ffffe 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 @@ -71,6 +71,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () clipboard: { available: false, reason: 'unsupported-platform-leaf' }, systemButton: { available: false, reason: 'unsupported-platform-leaf' }, fold: { available: false, reason: 'unsupported-platform-leaf' }, + telephony: { available: false, reason: 'unsupported-platform-leaf' }, triggerAppEvent: { available: false, reason: 'unsupported-platform-leaf' }, settings: { available: false, reason: 'unsupported-platform-leaf' }, readAlert: { 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 0b593bd6d6..fae6b779c1 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts @@ -486,6 +486,7 @@ test('boot rejects --headless outside Android directly', async () => { clipboard: { available: false, reason: 'owner-capability-missing' }, systemButton: { available: false, reason: 'owner-capability-missing' }, fold: { available: false, reason: 'owner-capability-missing' }, + telephony: { available: false, reason: 'owner-capability-missing' }, triggerAppEvent: { available: false, reason: 'owner-capability-missing' }, settings: { available: false, reason: 'owner-capability-missing' }, readAlert: { available: false, reason: 'owner-capability-missing' }, diff --git a/packages/platform-android/src/emulator-console.test.ts b/packages/platform-android/src/emulator-console.test.ts new file mode 100644 index 0000000000..6d806fb3ae --- /dev/null +++ b/packages/platform-android/src/emulator-console.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('./adb.ts', () => ({ runAndroidAdb: vi.fn() })); + +import { runAndroidAdb } from './adb.ts'; +import { runEmulatorConsole } from './emulator-console.ts'; +import { ANDROID_EMULATOR } from './runtime.fixtures.ts'; + +const mockAdb = vi.mocked(runAndroidAdb); + +function answer(stdout: string, exitCode = 0) { + return { stdout, stderr: '', exitCode }; +} + +beforeEach(() => { + mockAdb.mockReset(); +}); + +test('runs the command under adb emu with the caller signal and a bounded timeout', async () => { + mockAdb.mockResolvedValueOnce(answer('OK\n')); + const { signal } = new AbortController(); + await expect(runEmulatorConsole(ANDROID_EMULATOR, ['posture', '3'], signal)).resolves.toEqual({ + stdout: 'OK\n', + }); + expect(mockAdb).toHaveBeenCalledExactlyOnceWith(ANDROID_EMULATOR, ['emu', 'posture', '3'], { + signal, + allowFailure: true, + timeoutMs: 10_000, + }); +}); + +test.each([ + ['KO: bad phone number format\n', 'bad phone number format'], + ['some banner\nKO: no call\n', 'no call'], + ['KO\nnext line\n', ''], +])( + 'returns the KO reply %j as a refusal, which adb reports with exit 0', + async (stdout, refusal) => { + mockAdb.mockResolvedValueOnce(answer(stdout)); + await expect( + runEmulatorConsole(ANDROID_EMULATOR, ['gsm', 'accept', '5551234'], undefined), + ).resolves.toEqual({ stdout, refusal }); + }, +); + +test('throws the adb failure for a failed exit, naming the command but not its operands', async () => { + mockAdb.mockResolvedValueOnce({ stdout: '', stderr: 'error: device offline', exitCode: 1 }); + const failure = runEmulatorConsole( + ANDROID_EMULATOR, + ['sms', 'send', '5551234', 'secret'], + undefined, + ); + await expect(failure).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: 'adb emu sms send exited with code 1', + details: expect.objectContaining({ deviceId: ANDROID_EMULATOR.id, exitCode: 1 }), + }); +}); diff --git a/packages/platform-android/src/emulator-console.ts b/packages/platform-android/src/emulator-console.ts new file mode 100644 index 0000000000..b8d24fbd2a --- /dev/null +++ b/packages/platform-android/src/emulator-console.ts @@ -0,0 +1,41 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; + +import { runAndroidAdb } from './adb.ts'; +import { androidAdbResultError } from './adb-failure.ts'; + +/** A console command answers in well under a second; this bounds a console that stopped answering. */ +const EMULATOR_CONSOLE_TIMEOUT_MS = 10_000; + +type EmulatorConsoleReply = Readonly<{ + stdout: string; + /** The reason after `KO:` when the console refused the command (empty for a bare `KO`). */ + refusal?: string; +}>; + +/** + * Runs one emulator console command (`adb emu `). adb exits 0 whether the console answers + * `OK` or `KO: `, so a refusal comes back in the reply for the caller to classify with its + * own reason; only a failed adb exit throws, naming the first two command words. + */ +export async function runEmulatorConsole( + device: DeviceInfo, + command: readonly string[], + signal: AbortSignal | undefined, +): Promise { + const result = await runAndroidAdb(device, ['emu', ...command], { + signal, + allowFailure: true, + timeoutMs: EMULATOR_CONSOLE_TIMEOUT_MS, + }); + if (result.exitCode !== 0) { + throw androidAdbResultError( + `adb emu ${command.slice(0, 2).join(' ')} exited with code ${result.exitCode}`, + result, + { deviceId: device.id }, + ); + } + const refusal = /^KO\b:?[ \t]*(.*)$/m.exec(result.stdout); + return refusal + ? { stdout: result.stdout, refusal: refusal[1]!.trim() } + : { stdout: result.stdout }; +} diff --git a/packages/platform-android/src/foldable/pose.ts b/packages/platform-android/src/foldable/pose.ts index 56b050de38..2b9ec9b852 100644 --- a/packages/platform-android/src/foldable/pose.ts +++ b/packages/platform-android/src/foldable/pose.ts @@ -5,7 +5,8 @@ import { sleep } from '@agent-device/host-kit/retry'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { runAndroidAdb, runAndroidShell } from '../adb.ts'; +import { runAndroidShell } from '../adb.ts'; +import { runEmulatorConsole } from '../emulator-console.ts'; import { androidAdbResultError } from '../adb-failure.ts'; import type { AndroidAdbExecutorResult } from '../adb-transport.ts'; @@ -138,15 +139,14 @@ async function readDeviceStates( return states; } -/** The console answers `OK` or `KO: …` on stdout and exits 0 either way, so the reply is the verdict. */ async function sendEmulatorPosture( device: DeviceInfo, pose: FoldPose, postureId: string, signal: AbortSignal | undefined, ): Promise { - const sent = await emulatorConsole(device, ['emu', 'posture', postureId], signal); - if (/^KO\b/m.test(sent.stdout)) { + const sent = await runEmulatorConsole(device, ['posture', postureId], signal); + if (sent.refusal !== undefined) { throw new AppError('COMMAND_FAILED', `Unable to set the emulator posture for ${pose}`, { deviceId: device.id, reason: 'fold-posture-dispatch-failed', @@ -254,11 +254,7 @@ async function readHingeAngle( device: DeviceInfo, signal: AbortSignal | undefined, ): Promise { - const { stdout } = await emulatorConsole( - device, - ['emu', 'sensor', 'get', 'hinge-angle0'], - signal, - ); + const { stdout } = await runEmulatorConsole(device, ['sensor', 'get', 'hinge-angle0'], signal); const match = /hinge-angle0\s*=\s*(-?\d+(?:\.\d+)?)/.exec(stdout); if (!match) { throw new AppError('COMMAND_FAILED', `${device.name} reports no hinge angle`, { @@ -291,22 +287,3 @@ async function shell( } return result; } - -/** The emulator console through adb, with the same exit-code check as {@link shell}. */ -async function emulatorConsole( - device: DeviceInfo, - args: readonly string[], - signal: AbortSignal | undefined, -): Promise { - const result = await runAndroidAdb(device, args, { signal, allowFailure: true }); - if (result.exitCode !== 0) { - throw androidAdbResultError( - `adb ${args.join(' ')} exited with code ${result.exitCode}`, - result, - { - deviceId: device.id, - }, - ); - } - return result; -} diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 74066ba81c..66bbfab378 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -246,6 +246,31 @@ test('Android admits the fold fact on emulators and refuses every other kind', a } }); +test('Android admits the telephony fact on emulators and refuses every other kind', async () => { + const emulator = await bindOrdinary( + createAndroidPlatformRuntime(androidNavigationHost()), + ANDROID_EMULATOR, + ); + expect(emulator.facts.operations.sendTelephonyEvent).toEqual({ available: true }); + expect(emulator.operations.sendTelephonyEvent).toEqual(expect.any(Function)); + for (const runtimeDevice of [ + { ...ANDROID_EMULATOR, kind: 'device' as const }, + { ...ANDROID_EMULATOR, kind: 'simulator' as const }, + UNKNOWN_KIND_DEVICE, + ]) { + const binding = await bindOrdinary( + createAndroidPlatformRuntime(androidNavigationHost()), + runtimeDevice, + ); + expect(binding.facts.operations.sendTelephonyEvent).toMatchObject({ + available: false, + reason: 'unsupported-device-kind', + hint: expect.stringContaining('physical device has no console'), + }); + expect(binding.operations.sendTelephonyEvent).toBeUndefined(); + } +}); + // R55 parity: the retired `clipboard` bucket was `ANDROID_ALL` (emulator/device/unknown) with no // Android admission closure, so `cmd clipboard get/set text` is admitted on every real kind and // refused only on the synthetic `simulator` row the bucket never listed. (`unknown` is the diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index a5267bcabb..3db16e55f1 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -52,6 +52,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AndroidSetting } from './settings.ts'; import { createHostAudioProbeCaptureOperations } from '@agent-device/capture-kit'; import { androidFoldableFacts, createAndroidFoldableOperations } from './foldable/runtime.ts'; +import { androidTelephonyFacts, createAndroidTelephonyOperations } from './telephony/runtime.ts'; import { androidAudioProbeCaptureFact } from './audio-runtime.ts'; import { ANDROID_CLIPBOARD_SHELL_COMMAND_UNAVAILABLE_HINT } from './clipboard-shell-response.ts'; import { createAndroidPerfOperations } from './perf/runtime.ts'; @@ -348,6 +349,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor appSwitcher: androidTouchFact(device), }), ...androidFoldableFacts(device), + ...androidTelephonyFacts(device), // The deep link opens through `am start`, admitted wherever the retired `ANDROID_ALL` // bucket admitted it. ...appEventRuntimeOperationFacts({ triggerAppEvent: androidTouchFact(device) }), @@ -476,6 +478,10 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor device: request.device, signal: request.scope.signal, }), + ...createAndroidTelephonyOperations({ + device: request.device, + signal: request.scope.signal, + }), ensureReady: async (input: EnsureReadyInput) => await ensureAndroidReady( host, diff --git a/packages/platform-android/src/telephony/console-event.test.ts b/packages/platform-android/src/telephony/console-event.test.ts new file mode 100644 index 0000000000..942b35c957 --- /dev/null +++ b/packages/platform-android/src/telephony/console-event.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('../adb.ts', () => ({ runAndroidAdb: vi.fn() })); + +import { runAndroidAdb } from '../adb.ts'; +import { ANDROID_EMULATOR } from '../runtime.fixtures.ts'; +import { sendAndroidTelephonyEvent } from './console-event.ts'; + +const mockAdb = vi.mocked(runAndroidAdb); + +function answer(stdout: string) { + return { stdout, stderr: '', exitCode: 0 }; +} + +function sentArgs(): readonly string[] | undefined { + return mockAdb.mock.calls[0]?.[1]; +} + +beforeEach(() => { + mockAdb.mockReset(); + mockAdb.mockResolvedValue(answer('OK\n')); +}); + +test.each([ + [{ action: 'call', number: '5551234' }, ['emu', 'gsm', 'call', '5551234']], + [{ action: 'accept', number: '5551234' }, ['emu', 'gsm', 'accept', '5551234']], + [{ action: 'cancel', number: '+15551234567' }, ['emu', 'gsm', 'cancel', '+15551234567']], + [{ action: 'sms', number: '5551234', text: 'hello' }, ['emu', 'sms', 'send', '5551234', 'hello']], +] as const)('sends %o to the emulator console as %o', async (input, args) => { + const { signal } = new AbortController(); + await sendAndroidTelephonyEvent(ANDROID_EMULATOR, input, signal); + expect(mockAdb).toHaveBeenCalledExactlyOnceWith( + ANDROID_EMULATOR, + args, + expect.objectContaining({ signal }), + ); +}); + +test('passes SMS text with spaces, quotes, and shell metacharacters as one argument', async () => { + const text = `Don't "pause" $HOME; echo $(id) & *`; + await sendAndroidTelephonyEvent( + ANDROID_EMULATOR, + { action: 'sms', number: '5551234', text }, + new AbortController().signal, + ); + expect(sentArgs()).toEqual(['emu', 'sms', 'send', '5551234', text]); +}); + +test('escapes backslashes and line breaks for the console instead of ending its command line', async () => { + await sendAndroidTelephonyEvent( + ANDROID_EMULATOR, + { + action: 'sms', + number: '5551234', + text: 'line one\nC:\\path\\n', + }, + new AbortController().signal, + ); + expect(sentArgs()).toEqual(['emu', 'sms', 'send', '5551234', String.raw`line one\nC:\\path\\n`]); +}); + +test.each([ + ['KO: no current call for this number\n', { consoleReason: 'no current call for this number' }], + ['KO\n', {}], +])('reports the console reply %j as telephony-console-refused', async (stdout, extra) => { + mockAdb.mockResolvedValueOnce(answer(stdout)); + const refusal = sendAndroidTelephonyEvent( + ANDROID_EMULATOR, + { + action: 'accept', + number: '5551234', + }, + new AbortController().signal, + ); + await expect(refusal).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + deviceId: ANDROID_EMULATOR.id, + reason: 'telephony-console-refused', + hint: expect.stringContaining('telephony call '), + ...extra, + }, + }); + if (!('consoleReason' in extra)) { + await expect(refusal).rejects.not.toMatchObject({ + details: { consoleReason: expect.anything() }, + }); + } +}); diff --git a/packages/platform-android/src/telephony/console-event.ts b/packages/platform-android/src/telephony/console-event.ts new file mode 100644 index 0000000000..b1e8631ebe --- /dev/null +++ b/packages/platform-android/src/telephony/console-event.ts @@ -0,0 +1,47 @@ +import type { TelephonyEventInput } from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +import { runEmulatorConsole } from '../emulator-console.ts'; + +const CONSOLE_REFUSED_HINT = + 'The emulator console refused the event. accept needs a call from that same number (start one with telephony call ), and an emulator image without a modem has no telephony to simulate.'; + +/** Simulates one telephony event through the emulator console (`gsm …` / `sms send …`). */ +export async function sendAndroidTelephonyEvent( + device: DeviceInfo, + input: TelephonyEventInput, + signal: AbortSignal, +): Promise { + const reply = await runEmulatorConsole(device, consoleCommand(input), signal); + if (reply.refusal !== undefined) { + throw new AppError('COMMAND_FAILED', `The emulator console refused telephony ${input.action}`, { + deviceId: device.id, + reason: 'telephony-console-refused', + stdout: reply.stdout.trim(), + ...(reply.refusal ? { consoleReason: reply.refusal } : {}), + hint: CONSOLE_REFUSED_HINT, + }); + } +} + +function consoleCommand(input: TelephonyEventInput): string[] { + switch (input.action) { + case 'call': + case 'accept': + case 'cancel': + return ['gsm', input.action, input.number]; + case 'sms': + return ['sms', 'send', input.number, escapeConsoleSmsText(input.text)]; + } +} + +/** + * `adb emu` writes its arguments to the console as one space-joined line, and `sms send` takes the + * rest of that line as the message. The console decodes `\\`, `\n`, `\xNN`, and `\uNNNN` escapes + * in it and rejects any other backslash, so a literal backslash is doubled and a line break is + * sent as `\n` rather than ending the console command. + */ +function escapeConsoleSmsText(text: string): string { + return text.replaceAll('\\', String.raw`\\`).replaceAll('\n', String.raw`\n`); +} diff --git a/packages/platform-android/src/telephony/runtime.test.ts b/packages/platform-android/src/telephony/runtime.test.ts new file mode 100644 index 0000000000..1968fa5983 --- /dev/null +++ b/packages/platform-android/src/telephony/runtime.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('./console-event.ts', () => ({ sendAndroidTelephonyEvent: vi.fn() })); + +import { ANDROID_EMULATOR } from '../runtime.fixtures.ts'; +import { sendAndroidTelephonyEvent } from './console-event.ts'; +import { createAndroidTelephonyOperations } from './runtime.ts'; + +const mockSend = vi.mocked(sendAndroidTelephonyEvent); + +beforeEach(() => { + mockSend.mockReset(); + mockSend.mockResolvedValue(undefined); +}); + +test('the emulator operation sends the event with the bound request signal', async () => { + const { signal } = new AbortController(); + const operations = createAndroidTelephonyOperations({ device: ANDROID_EMULATOR, signal }); + + const input = { action: 'sms', number: '5551234', text: 'hello' } as const; + await operations.sendTelephonyEvent?.(input); + expect(mockSend).toHaveBeenCalledExactlyOnceWith(ANDROID_EMULATOR, input, signal); +}); + +test('an aborted scope never reaches the emulator console', async () => { + const controller = new AbortController(); + const operations = createAndroidTelephonyOperations({ + device: ANDROID_EMULATOR, + signal: controller.signal, + }); + controller.abort(); + await expect( + operations.sendTelephonyEvent?.({ action: 'call', number: '5551234' }), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(mockSend).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-android/src/telephony/runtime.ts b/packages/platform-android/src/telephony/runtime.ts new file mode 100644 index 0000000000..2d725a545f --- /dev/null +++ b/packages/platform-android/src/telephony/runtime.ts @@ -0,0 +1,38 @@ +import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; +import { whenAdmitted } from '@agent-device/contracts/platform-runtime'; +import type { TelephonyEventInput } from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +import { sendAndroidTelephonyEvent } from './console-event.ts'; + +const available = Object.freeze({ available: true } as const); + +const telephonyKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'telephony simulates calls and SMS through the Android emulator console; a physical device has no console, so place a real call or send a real message to it instead.', +} as const); + +/** The emulator console is the only telephony control adb has, so the kind is the whole admission. */ +function androidTelephonyFact(device: DeviceInfo): RuntimeOperationFact { + return device.kind === 'emulator' ? available : telephonyKindUnavailable; +} + +/** The telephony cell: `sendTelephonyEvent`. */ +export function androidTelephonyFacts(device: DeviceInfo) { + return Object.freeze({ sendTelephonyEvent: androidTelephonyFact(device) }); +} + +/** Binds `sendTelephonyEvent` when {@link androidTelephonyFacts} admitted it. */ +export function createAndroidTelephonyOperations(params: { + device: DeviceInfo; + signal: AbortSignal; +}) { + const { device, signal } = params; + return whenAdmitted(androidTelephonyFacts(device).sendTelephonyEvent, () => ({ + sendTelephonyEvent: async (input: TelephonyEventInput) => { + signal.throwIfAborted(); + await sendAndroidTelephonyEvent(device, input, signal); + }, + })); +} diff --git a/packages/platform-apple/src/runner-demand.ts b/packages/platform-apple/src/runner-demand.ts index b665535be3..0e3ecc47fa 100644 --- a/packages/platform-apple/src/runner-demand.ts +++ b/packages/platform-apple/src/runner-demand.ts @@ -53,6 +53,8 @@ const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< setSetting: 'simulator', readSetting: 'simulator', setFoldPose: 'simulator', + // Refused on every Apple leaf at admission, so nothing waits on the runner. + sendTelephonyEvent: 'simulator', // Observation: the AX bridge presents regular and raw trees; custom actions need XCTest. captureSnapshot: 'simulator', captureSnapshotWithoutActiveApp: 'simulator', diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index 3c5f7df86f..7e61078a51 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -374,6 +374,27 @@ test.each(Object.entries(leaves))( }, ); +test.each([...Object.entries(leaves), ['ios physical device', appleDevice({ kind: 'device' })]])( + 'refuses the telephony 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: () => {} }, + }, + }); + expect(binding.facts.operations.sendTelephonyEvent).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: expect.stringContaining('Android emulator console'), + }); + expect(binding.operations.sendTelephonyEvent).toBeUndefined(); + }, +); + // A simulator scoped to a non-default set is refused by the owner's own fact, before any display // probe or HID effect: CoreDevice's display inventory and hinge-angle readback cannot resolve it, // so ADR 0025's post-dispatch verification is impossible. The default-set iOS leaf stays admitted. diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index a536d89d22..9f387781d0 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -104,6 +104,11 @@ const focusKindUnavailable = unavailableFact( 'unsupported-device-kind', 'focus is supported on Apple simulators and physical devices.', ); +/** Neither simctl nor CoreDevice injects a call or text message into an Apple target. */ +const telephonyUnavailable = unavailableFact( + 'unsupported-platform-leaf', + 'telephony simulates calls and SMS through the Android emulator console; Apple simulators and devices expose no host control that injects a call or text message.', +); const appStateUnavailable = unavailableFact( 'unsupported-platform-leaf', "Apple appstate is unavailable here: the runner reads the session app's XCUIApplication state on iOS-family simulators and physical devices only, and the Apple target answers no sessionless foreground probe. The per-command answer is the targetActivation disclosure, which a capture carries when its command had to re-activate the session app (#2682).", @@ -309,6 +314,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR ...elementTextRuntimeOperationFacts({ readTextAtPoint: appleElementTextFact(device) }), ...appleNavigationFacts(device), ...appleFoldableFacts(device), + sendTelephonyEvent: telephonyUnavailable, ...appleSystemFacts(device), ...audioProbeRuntimeOperationFacts({ capture: appleAudioProbeCaptureFact(device), diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 5f590a9f25..45ccb4fbfd 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -102,6 +102,8 @@ test.each([ expect(binding.operations.actionButton).toBeUndefined(); expect(facts.operations.setFoldPose).toMatchObject({ available: false }); expect(binding.operations.setFoldPose).toBeUndefined(); + expect(facts.operations.sendTelephonyEvent).toMatchObject({ available: false }); + expect(binding.operations.sendTelephonyEvent).toBeUndefined(); // Public orientation and TV-remote operations remain unavailable unconditionally. expect(facts.operations.setOrientation).toEqual({ available: false, diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 1063b035e2..0325c77f0b 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -248,6 +248,8 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor }), // HarmonyOS devices have no foldable hinge control for HDC to pose. setFoldPose: harmonyPlatformLeafUnavailable, + // HDC has no telephony control to simulate a call or text message. + sendTelephonyEvent: harmonyPlatformLeafUnavailable, // HarmonyOS has no trigger-app-event implementation. ...appEventRuntimeOperationFacts({ triggerAppEvent: harmonyPlatformLeafUnavailable }), ...settingsRuntimeOperationFacts({ diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index 9da74e6353..67f8b8c9fe 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -222,6 +222,8 @@ function expectLinuxNavigationAndKeyboardFacts( 'actionButton', // Nor does it have a foldable hinge to pose. 'setFoldPose', + // Nor a modem to simulate a call or text message on. + 'sendTelephonyEvent', // R57: the retired `trigger-app-event` descriptor declared `linux: {}` too. 'triggerAppEvent', // R58/R59: and so did `settings` and `alert`. diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 603ec8fc71..c04c74bfd2 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -206,6 +206,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts // own `appSwitcher` throws unsupported, and no Linux leaf has hardware buttons. systemButton: linuxPlatformLeafUnavailable, fold: linuxPlatformLeafUnavailable, + telephony: linuxPlatformLeafUnavailable, // The retired `trigger-app-event` descriptor declared `linux: {}`. triggerAppEvent: linuxPlatformLeafUnavailable, // The retired `settings` descriptor declared `linux: {}` too. diff --git a/packages/platform-vega/src/runtime.test.ts b/packages/platform-vega/src/runtime.test.ts index 82b25904cc..14727c4e07 100644 --- a/packages/platform-vega/src/runtime.test.ts +++ b/packages/platform-vega/src/runtime.test.ts @@ -196,6 +196,12 @@ test.each([ }); expect(binding.operations[button]).toBeUndefined(); } + expect(binding.facts.operations.sendTelephonyEvent).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'telephony is not supported on Vega OS.', + }); + expect(binding.operations.sendTelephonyEvent).toBeUndefined(); expect(binding.facts.operations.triggerAppEvent).toMatchObject({ available: false, reason: 'unsupported-platform-leaf', diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index 5f8274091b..2236f8ff4e 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -153,6 +153,10 @@ const foldUnavailable = unavailableFact( 'unsupported-platform-leaf', 'fold is not supported on Vega OS.', ); +const telephonyUnavailable = unavailableFact( + 'unsupported-platform-leaf', + 'telephony is not supported on Vega OS.', +); const clipboardUnavailable = unavailableFact( 'unsupported-platform-leaf', 'clipboard is not supported on Vega OS.', @@ -199,6 +203,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts clipboard: clipboardUnavailable, systemButton: systemButtonUnavailable, fold: foldUnavailable, + telephony: telephonyUnavailable, triggerAppEvent: appEventUnavailable, settings: settingsUnavailable, readAlert: alertUnavailable, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index 129df46109..e9eac1a1c3 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -217,6 +217,8 @@ test('clipboard, the app switcher, app events, settings and alerts carry no web // A foldable hinge is posed through the host's iOS simulator HID helper; the web target has // none. 'setFoldPose', + // Telephony events go through an Android emulator console, which a browser has no analogue of. + 'sendTelephonyEvent', 'triggerAppEvent', // R58/R59: the retired `settings` and `alert` descriptors declared no web leaf either. 'setSetting', diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 8042097880..b571fb1a36 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -415,6 +415,7 @@ function webRuntimeFacts( }), ...systemButtonRuntimeOperationFacts({ unsupported: navigationUnavailable }), setFoldPose: navigationUnavailable, + sendTelephonyEvent: navigationUnavailable, ...appEventRuntimeOperationFacts({ triggerAppEvent: navigationUnavailable }), ...settingsRuntimeOperationFacts({ target: navigationUnavailable, diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 1f93bc24e6..81abca07d3 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -3,6 +3,7 @@ import type { AppsFilter, ProviderPortReverseOptions } from '@agent-device/contr import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import { LIMRUN_FOLD_UNAVAILABLE, + LIMRUN_TELEPHONY_UNAVAILABLE, bindLimrunInteractionOperations, } from './interaction-operations.ts'; import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; @@ -118,6 +119,7 @@ export function createLimrunPlatformRuntimeOwner( clipboard: liveSessionUnavailable, systemButton: liveSessionUnavailable, fold: LIMRUN_FOLD_UNAVAILABLE, + telephony: LIMRUN_TELEPHONY_UNAVAILABLE, triggerAppEvent: liveSessionUnavailable, settings: liveSessionUnavailable, readAlert: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 02d0fc798e..0ffe116439 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -23,6 +23,7 @@ import { limrunAlertOperationFacts, limrunSystemButtonOperationFacts, limrunFoldOperationFacts, + limrunTelephonyOperationFacts, limrunClipboardOperationFacts, limrunNavigationOperationFacts, } from './interaction-operations.ts'; @@ -186,6 +187,7 @@ export function limrunAppLogFacts( ...limrunClipboardOperationFacts(), ...limrunSystemButtonOperationFacts(device), ...limrunFoldOperationFacts(), + ...limrunTelephonyOperationFacts(), ...limrunAppEventOperationFacts(device), ...limrunSettingsOperationFacts(device), ...limrunAlertOperationFacts(device), @@ -249,6 +251,7 @@ export function limrunAppLogRecoveryFacts( ...limrunClipboardOperationFacts(liveSessionUnavailable), ...limrunSystemButtonOperationFacts(device, liveSessionUnavailable), ...limrunFoldOperationFacts(), + ...limrunTelephonyOperationFacts(), ...limrunAppEventOperationFacts(device, liveSessionUnavailable), ...limrunSettingsOperationFacts(device, liveSessionUnavailable), ...limrunAlertOperationFacts(device, liveSessionUnavailable), diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 6410609599..48b5c66519 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -324,11 +324,21 @@ export const LIMRUN_FOLD_UNAVAILABLE = unavailableFact( 'fold runs a HID helper through simctl spawn inside a foldable iPhone simulator on the daemon host; a Limrun session has no such simulator.', ); +export const LIMRUN_TELEPHONY_UNAVAILABLE = unavailableFact( + 'unsupported-provider-mode', + "telephony sends calls and SMS to an Android emulator's console through local adb; agent-device does not route it through a Limrun session.", +); + /** The fold refusal both Limrun legs share. */ export function limrunFoldOperationFacts() { return Object.freeze({ setFoldPose: LIMRUN_FOLD_UNAVAILABLE }); } +/** The telephony refusal both Limrun legs share. */ +export function limrunTelephonyOperationFacts() { + return Object.freeze({ sendTelephonyEvent: LIMRUN_TELEPHONY_UNAVAILABLE }); +} + /** * `trigger-app-event` is the one system leaf both direct-session legs genuinely serve: each * implements `open`, and a deep link is exactly what that method routes (`openUrl` on iOS, the diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index d1b29c95b1..b00a6df9f2 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -222,6 +222,11 @@ const foldUnavailable = unavailableFact( 'fold runs a HID helper through simctl spawn inside a foldable iPhone simulator on the daemon host; a WebDriver backend has no such simulator.', ); +const telephonyUnavailable = unavailableFact( + 'unsupported-provider-mode', + "telephony sends calls and SMS to an Android emulator's console through local adb; agent-device does not drive a WebDriver backend's own call or SMS simulation.", +); + /** * The WebDriver interactor supplies no alert members at all: ADR 0019 keeps a stub that throws * `unsupported` out of an interactor, and the provider's capability map declares @@ -486,6 +491,7 @@ function webDriverFacts( return createUnavailablePlatformRuntimeFacts(device, options.owner, { ...createFullyUnavailablePlatformRuntimeFacts(inactiveSession), fold: foldUnavailable, + telephony: telephonyUnavailable, }); } const deployment = options.deployment?.fact(device) ?? deploymentUnavailable; @@ -509,6 +515,7 @@ function webDriverFacts( clipboard: clipboardUnavailable, systemButton: systemButtonUnavailable, fold: foldUnavailable, + telephony: telephonyUnavailable, triggerAppEvent: appEventUnavailable, settings: settingsUnavailable, readAlert: alertUnavailable, @@ -620,6 +627,7 @@ function webDriverFacts( appSwitcher: declared('appSwitcher', appSwitcherUnavailable), }), setFoldPose: foldUnavailable, + sendTelephonyEvent: telephonyUnavailable, // The deep link opens through the same reachable interactor `open` every lifecycle command // drives on this provider. ...appEventRuntimeOperationFacts({ 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 211a562e54..4f3be01054 100644 --- a/packages/session-journal/src/__tests__/session-event-action.test.ts +++ b/packages/session-journal/src/__tests__/session-event-action.test.ts @@ -300,3 +300,35 @@ test('record and trace events expose only bounded artifact basenames', () => { showTouches: false, }); }); + +test('telephony action events name the simulated event and carry neither number nor text', () => { + const sms = action( + 'telephony', + { + action: 'sms', + number: '5551234', + textLength: 11, + message: 'Simulated an incoming SMS from 5551234', + }, + { positionals: ['sms', '5551234', 'private msg'] }, + ); + const call = action('telephony', { action: 'call', number: '5551234' }); + const cancel = action('telephony', { action: 'cancel', number: '5551234' }); + + assert.equal(buildActionSummary(sms), 'Simulated an incoming SMS'); + assert.equal(buildActionSummary(call), 'Simulated an incoming call'); + assert.equal( + buildActionSummary(action('telephony', { action: 'accept', number: '5551234' })), + 'Accepted the simulated call', + ); + assert.equal(buildActionSummary(cancel), 'Ended the simulated call'); + assert.equal(buildActionSummary(action('telephony', {})), 'Simulated a telephony event'); + assert.deepEqual(buildActionDetails(sms), { + command: 'telephony', + action: 'sms', + positionals: ['', '', ''], + }); + const serialized = JSON.stringify(buildActionDetails(sms)); + assert.equal(serialized.includes('5551234'), false); + assert.equal(serialized.includes('private msg'), false); +}); diff --git a/packages/session-journal/src/session-event-action-presentation.ts b/packages/session-journal/src/session-event-action-presentation.ts index ee196cf3b7..461050cab3 100644 --- a/packages/session-journal/src/session-event-action-presentation.ts +++ b/packages/session-journal/src/session-event-action-presentation.ts @@ -1,5 +1,5 @@ import type { SessionAction } from '@agent-device/contracts/session'; -import { DEVICE_ROTATIONS, FOLD_POSES } from '@agent-device/contracts/device'; +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 { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; @@ -43,6 +43,8 @@ export function buildStructuredActionSummary(action: SessionAction): string | un return buildOrientationActionSummary(result); case PUBLIC_COMMANDS.fold: return buildFoldActionSummary(result); + case PUBLIC_COMMANDS.telephony: + return buildTelephonyActionSummary(result); case PUBLIC_COMMANDS.viewport: return buildViewportActionSummary(result); case PUBLIC_COMMANDS.clipboard: @@ -90,6 +92,8 @@ export function buildStructuredActionDetails(action: SessionAction): Record): string { return pose ? `Folded to ${pose}` : 'Changed fold pose'; } +function buildTelephonyActionSummary(result: Record): string { + switch (readEnum(result.action, TELEPHONY_ACTIONS)) { + case 'call': + return 'Simulated an incoming call'; + case 'accept': + return 'Accepted the simulated call'; + case 'cancel': + return 'Ended the simulated call'; + case 'sms': + return 'Simulated an incoming SMS'; + case undefined: + return 'Simulated a telephony event'; + } +} + function buildOrientationActionSummary(result: Record): string { const orientation = readEnum(result.orientation, DEVICE_ROTATIONS); if (!orientation) return 'Changed orientation'; diff --git a/packages/test-kit/src/runtime-operation-facts.ts b/packages/test-kit/src/runtime-operation-facts.ts index 778a1a7ed2..a82a646a52 100644 --- a/packages/test-kit/src/runtime-operation-facts.ts +++ b/packages/test-kit/src/runtime-operation-facts.ts @@ -69,6 +69,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre ...clipboardRuntimeOperationFacts({ unsupported: unavailable }), ...systemButtonRuntimeOperationFacts({ unsupported: unavailable }), setFoldPose: unavailable, + sendTelephonyEvent: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, readSetting: unavailable, diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 84550644a7..421b6ddc9f 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -172,6 +172,8 @@ export function createAgentDeviceClient( await executeCommand>('clipboard', options), tvRemote: async (options) => await executeCommand>('tv-remote', options), + telephony: async (options) => + await executeCommand>('telephony', 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 77bb3be7ea..e51dfa8956 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -127,6 +127,7 @@ import type { SettingsUpdateOptions, SwipeGestureOptions, SwipeOptions, + TelephonyCommandOptions, TraceOptions, TransformGestureOptions, TvRemoteCommandOptions, @@ -171,6 +172,7 @@ export type AgentDeviceCommandClient = { appSwitcher: (options?: AppSwitcherCommandOptions) => Promise>; actionButton: (options?: ActionButtonCommandOptions) => Promise>; tvRemote: (options: TvRemoteCommandOptions) => Promise>; + telephony: (options: TelephonyCommandOptions) => 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 e763d7005a..e670560a12 100644 --- a/src/commands/replay/replay-script.fixtures.ts +++ b/src/commands/replay/replay-script.fixtures.ts @@ -133,6 +133,7 @@ const REPLAY_SCRIPT_LINE_PLANS = { 'app-switcher': GENERIC_REPLAY_LINE, 'action-button': GENERIC_REPLAY_LINE, fold: GENERIC_REPLAY_LINE, + telephony: GENERIC_REPLAY_LINE, apps: GENERIC_REPLAY_LINE, appstate: GENERIC_REPLAY_LINE, artifacts: GENERIC_REPLAY_LINE, diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts index ea17878188..acb6a3c78f 100644 --- a/src/commands/system/index.test.ts +++ b/src/commands/system/index.test.ts @@ -7,10 +7,12 @@ import type { FoldCommandOptions, HomeCommandOptions, OrientationCommandOptions, + TelephonyCommandOptions, TvRemoteCommandOptions, } from '../../client/client-types.ts'; import type { CommandResult } from '@agent-device/command-registry/command-result'; import { readInputFromCli } from '../cli-grammar/registry.ts'; +import { findCommandMetadata } from '../command-metadata.ts'; import type { CliFlags } from '@agent-device/contracts/command'; import { backCliReader, @@ -23,6 +25,8 @@ import { keyboardDaemonWriter, orientationCliReader, orientationDaemonWriter, + telephonyCliReader, + telephonyDaemonWriter, tvRemoteCliReader, tvRemoteDaemonWriter, systemCommandFamily, @@ -380,3 +384,61 @@ test('fold keyframes survive CLI and daemon projection without a preset', () => }); expect(() => foldCliReader(['open'], flags({ keyframes: JSON.stringify(keyframes) }))).toThrow(); }); + +test('the typed client exposes telephony with its closed result', () => { + expectTypeOf().toEqualTypeOf< + (options: TelephonyCommandOptions) => Promise> + >(); +}); + +test('the CLI reader parses each action and joins unquoted SMS words', () => { + expect(telephonyCliReader(['call', '5551234'], flags({ platform: 'android' }))).toMatchObject({ + platform: 'android', + action: 'call', + number: '5551234', + }); + expect(telephonyCliReader(['Accept', '5551234'], flags())).toMatchObject({ action: 'accept' }); + expect(telephonyCliReader(['cancel', '5551234'], flags())).toMatchObject({ action: 'cancel' }); + expect(telephonyCliReader(['sms', '5551234', 'Meet', 'at', '8'], flags())).toMatchObject({ + action: 'sms', + number: '5551234', + text: 'Meet at 8', + }); +}); + +test('the CLI reader refuses a malformed event', () => { + expect(() => telephonyCliReader(['sms', '5551234'], flags())).toThrow( + expect.objectContaining({ code: 'INVALID_ARGS', message: expect.stringContaining('text') }), + ); + expect(() => telephonyCliReader(['call', 'abc'], flags())).toThrow( + expect.objectContaining({ code: 'INVALID_ARGS', message: 'Invalid phone number: abc.' }), + ); +}); + +test('the daemon writer sends the SMS text as one positional, quotes and all', () => { + const text = `Don't "stop" the stream`; + expect(telephonyDaemonWriter({ action: 'sms', number: '5551234', text })).toMatchObject({ + command: 'telephony', + positionals: ['sms', '5551234', text], + }); + expect(telephonyDaemonWriter({ action: 'cancel', number: '5551234' })).toMatchObject({ + command: 'telephony', + positionals: ['cancel', '5551234'], + }); + expect(() => telephonyDaemonWriter({ action: 'call', number: '5551234', text: 'x' })).toThrow( + expect.objectContaining({ code: 'INVALID_ARGS' }), + ); +}); + +test('structured input validates through the same parser as the CLI', () => { + const metadata = findCommandMetadata('telephony'); + expect(metadata.readInput({ action: 'sms', number: '+15551234567', text: 'hi' })).toMatchObject({ + action: 'sms', + number: '+15551234567', + text: 'hi', + }); + expect(() => metadata.readInput({ action: 'call', number: '555 1234' })).toThrow( + expect.objectContaining({ code: 'INVALID_ARGS' }), + ); + expect(metadata.inputSchema).toMatchObject({ required: ['action', 'number'] }); +}); diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 2c0e56e05a..7f17fd3da9 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -12,6 +12,12 @@ import { FOLD_POSES, FOLD_POSE_USAGE, parseDeviceRotation, + TELEPHONY_ACTIONS, + TELEPHONY_CALL_ACTIONS, + TELEPHONY_USAGE, + parseTelephonyEventInput, + parseTelephonyPositionals, + telephonyPositionals, } from '@agent-device/contracts/device'; import { type BackMode, BACK_MODES } from '@agent-device/contracts/back-mode'; import { @@ -70,6 +76,7 @@ const ACTION_BUTTON_COMMAND_NAME = 'action-button'; const KEYBOARD_COMMAND_NAME = 'keyboard'; 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 CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; @@ -145,6 +152,29 @@ export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = { }, ['action', 'button', 'message'], ), + // packages/contracts/src/device-rotation.ts — discriminated union on `action`. + telephony: { + type: 'object', + oneOf: [ + objectSchema( + { + action: enumSchema(TELEPHONY_CALL_ACTIONS), + number: stringSchema(), + message: stringSchema(), + }, + ['action', 'number', 'message'], + ), + objectSchema( + { + action: constSchema('sms'), + number: stringSchema(), + textLength: numberSchema('Length of the delivered message text.'), + message: stringSchema(), + }, + ['action', 'number', 'textLength', 'message'], + ), + ], + }, // packages/contracts/src/clipboard.ts — discriminated union on `action`. clipboard: { type: 'object', @@ -220,6 +250,7 @@ export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = { | 'fold' | 'action-button' | 'tv-remote' + | 'telephony' | 'clipboard' | 'appstate' | 'keyboard' @@ -273,6 +304,9 @@ const actionButtonCommandDescription = 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 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.'; + const backCommandMetadata = defineFieldCommandMetadata(BACK_COMMAND_NAME, backCommandDescription, { mode: enumField(BACK_MODES), ...postActionObservationFields(BACK_COMMAND_NAME), @@ -362,6 +396,44 @@ const tvRemoteCommandMetadata = defineFieldCommandMetadata( }, ); +const telephonyFields = { + action: requiredField( + enumField( + TELEPHONY_ACTIONS, + 'call starts an incoming call, accept answers it, cancel ends it, sms delivers a text message.', + ), + ), + number: requiredField( + stringField('Caller or sender phone number: digits with an optional leading +.'), + ), + text: stringField( + 'Message text for sms; required for sms and refused for the call actions. The emulator drops characters outside the Basic Multilingual Plane, such as emoji.', + ), +}; + +const telephonyFieldMetadata = defineFieldCommandMetadata( + TELEPHONY_COMMAND_NAME, + telephonyCommandDescription, + telephonyFields, + { + readInput: (input) => { + const { action, number, text, ...common } = readFieldInput(input, telephonyFields); + return { ...common, ...parseTelephonyEventInput({ action, number, text }) }; + }, + }, +); + +const telephonyCommandMetadata = { + ...telephonyFieldMetadata, + inputSchema: { + ...telephonyFieldMetadata.inputSchema, + oneOf: [ + { properties: { action: constSchema('sms') }, required: ['text'] }, + { properties: { action: enumSchema(TELEPHONY_CALL_ACTIONS) }, not: { required: ['text'] } }, + ], + }, +}; + const backCliSchema = { usageOverride: 'back [--in-app|--system] [--settle]', usageFlags: [], @@ -398,6 +470,13 @@ const tvRemoteCliSchema = { allowedFlags: ['durationMs'], } as const satisfies CommandSchemaOverride; +const telephonyCliSchema = { + usageOverride: TELEPHONY_USAGE, + listUsageOverride: 'telephony call|accept|cancel|sms [text]', + positionalArgs: ['call|accept|cancel|sms', 'number', 'text?'], + allowsExtraPositionals: true, +} as const satisfies CommandSchemaOverride; + export const backCliReader: CliReader = (_positionals, flags) => ({ ...commonInputFromFlags(flags), mode: flags.backMode, @@ -431,6 +510,11 @@ export const tvRemoteCliReader: CliReader = (positionals, flags) => ({ ...readTvRemoteInput(positionals, flags.durationMs), }); +export const telephonyCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...parseTelephonyPositionals(positionals), +}); + export const backDaemonWriter: DaemonWriter = (input) => request(BACK_COMMAND_NAME, [], { ...input, @@ -461,6 +545,10 @@ export const tvRemoteDaemonWriter: DaemonWriter = direct(TV_REMOTE_COMMAND_NAME, requiredDaemonString(input.button, 'tv-remote requires button'), ]); +export const telephonyDaemonWriter: DaemonWriter = direct(TELEPHONY_COMMAND_NAME, (input) => + telephonyPositionals(parseTelephonyEventInput(input)), +); + const appStateCommandFacet = defineParameterlessCommandFacet({ name: APPSTATE_COMMAND_NAME, description: appStateCommandDescription, @@ -583,6 +671,21 @@ const tvRemoteCommandFacet = defineCommandFacet({ cliOutputFormatter: systemCliOutputFormatters['tv-remote'], }); +const telephonyCommandFacet = defineCommandFacet({ + name: TELEPHONY_COMMAND_NAME, + text: { + summary: 'Simulate an incoming call or SMS on an Android emulator over adb', + cliDetail: + 'Android emulators through the local adb executor; WebDriver (Appium) and Limrun sessions refuse it. Quote the SMS text, or pass it as the remaining words. Re-snapshot after the event: the call screen or notification can cover the app.', + }, + metadata: telephonyCommandMetadata, + run: (client, input) => client.command.telephony(input), + cliSchema: telephonyCliSchema, + cliReader: telephonyCliReader, + daemonWriter: telephonyDaemonWriter, + cliOutputFormatter: systemCliOutputFormatters.telephony, +}); + export const systemCommandFamily = defineCommandFamilyFromFacets({ name: 'system', commands: [ @@ -596,6 +699,7 @@ export const systemCommandFamily = defineCommandFamilyFromFacets({ keyboardCommandFacet, clipboardCommandFacet, tvRemoteCommandFacet, + telephonyCommandFacet, ], }); diff --git a/src/commands/system/output.ts b/src/commands/system/output.ts index d95661d5e3..e0ce129983 100644 --- a/src/commands/system/output.ts +++ b/src/commands/system/output.ts @@ -52,6 +52,7 @@ export const systemCliOutputFormatters = withSettleCapableNotes({ keyboard: resultOutput(keyboardCliOutput), clipboard: resultOutput(clipboardCliOutput), 'tv-remote': messageOutput, + telephony: messageOutput, } satisfies Record); function formatAppState(data: AppStateCommandResult): string { diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index e7706fdc37..118fc9a912 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -57,6 +57,7 @@ describe('composed platform runtime gateway', () => { clipboard: unavailable, systemButton: unavailable, fold: unavailable, + telephony: unavailable, triggerAppEvent: unavailable, settings: unavailable, readAlert: unavailable, @@ -155,6 +156,7 @@ describe('composed platform runtime gateway', () => { clipboard: unavailable, systemButton: unavailable, fold: unavailable, + telephony: unavailable, triggerAppEvent: unavailable, settings: unavailable, readAlert: unavailable, diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index ee8247810d..801377b72d 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -18,6 +18,7 @@ import { ANDROID_APPLICATION_LIFECYCLE_CONTRACT_EVIDENCE, ANDROID_FOLD_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, @@ -1431,6 +1432,37 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'the exact-owner runtime fact refuses a hinge pose on the Linux desktop', ), }, + [C.telephony]: { + androidEmulator: androidEmulator.contract( + ANDROID_TELEPHONY_RUNTIME_CONTRACT_EVIDENCE, + 'the Android emulator advertises the console telephony event and binds it; a physical device is refused by the fact', + ), + iosSimulator: iosSimulator.contract( + 'packages/platform-apple/src/runtime.test.ts', + 'refuses the telephony fact for the %s leaf', + 'the exact-owner runtime fact refuses a simulated call or SMS on the iOS simulator leaf', + ), + macos: macos.contract( + 'packages/platform-apple/src/runtime.test.ts', + 'refuses the telephony fact for the %s leaf', + 'the exact-owner runtime fact refuses a simulated call or SMS on the macOS host leaf', + ), + tvos: tvos.contract( + 'packages/platform-apple/src/runtime.test.ts', + 'refuses the telephony fact for the %s leaf', + 'the exact-owner runtime fact refuses a simulated call or SMS on the tvOS leaf', + ), + web: web.contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', + 'the exact-owner runtime fact refuses a simulated call or SMS on the web target', + ), + linux: linux.contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact refuses a simulated call or SMS on the Linux desktop', + ), + }, [C.installFromSource]: { androidEmulator: androidEmulator.contract( ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE, diff --git a/test/integration/command-coverage/evidence.ts b/test/integration/command-coverage/evidence.ts index 79edf41383..342a1d2273 100644 --- a/test/integration/command-coverage/evidence.ts +++ b/test/integration/command-coverage/evidence.ts @@ -55,6 +55,12 @@ export const ANDROID_UNINSTALL_CONTRACT_EVIDENCE: AndroidContractEvidence = [C.uninstall], 'Provider-backed integration Android uninstall removes an installed package and no-ops on a missing one', ); +export const ANDROID_TELEPHONY_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvidence = + defineAndroidContractEvidence( + 'packages/platform-android/src/runtime.test.ts', + [C.telephony], + 'Android admits the telephony fact on emulators and refuses every other kind', + ); export const ANDROID_VIEWPORT_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvidence = defineAndroidContractEvidence( 'packages/daemon/src/execution/__tests__/viewport-runtime.test.ts', diff --git a/test/integration/provider-scenarios/android-telephony.test.ts b/test/integration/provider-scenarios/android-telephony.test.ts new file mode 100644 index 0000000000..5a5bef4476 --- /dev/null +++ b/test/integration/provider-scenarios/android-telephony.test.ts @@ -0,0 +1,154 @@ +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 { assertRpcError } from './assertions.ts'; +import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; +import { runProviderScenario } from './scenario.ts'; + +const PROVIDER_SCENARIO_ANDROID_EMULATOR: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +const PROVIDER_SCENARIO_ANDROID_PHONE: DeviceInfo = { + ...PROVIDER_SCENARIO_ANDROID_EMULATOR, + id: 'R5CT1234567', + name: 'Pixel 9', + kind: 'device', +}; + +function recordingAdbProvider(reply: (args: readonly string[]) => string = () => 'OK\n') { + const calls: string[][] = []; + const provider: AndroidAdbProvider = { + exec: async (args) => { + calls.push([...args]); + return { stdout: reply(args), stderr: '', exitCode: 0 }; + }, + }; + return { calls, provider }; +} + +const SMS_TEXT = `Don't "stop"\nC:\\videos`; + +function emulatorConsoleCalls(calls: readonly string[][]): string[][] { + return calls.map((call) => call.slice(call.indexOf('emu'))).filter((call) => call[0] === 'emu'); +} + +test('Provider-backed integration Android emulator telephony flow drives the emulator console', async () => { + const adb = recordingAdbProvider(); + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adb.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID_EMULATOR], + }), + async (daemon) => { + daemon.publishSession('default', { + name: 'default', + device: PROVIDER_SCENARIO_ANDROID_EMULATOR, + createdAt: Date.now(), + actions: [], + }); + + await runProviderScenario(daemon, [ + { + name: 'ring', + command: 'telephony', + positionals: ['call', '5551234'], + flags: { platform: 'android' }, + expectData: { + action: 'call', + number: '5551234', + message: 'Simulated an incoming call from 5551234', + }, + }, + { + name: 'hang up', + command: 'telephony', + positionals: ['cancel', '5551234'], + flags: { platform: 'android' }, + expectData: { action: 'cancel', number: '5551234' }, + }, + { + name: 'text', + command: 'telephony', + positionals: ['sms', '+15551234567', SMS_TEXT], + flags: { platform: 'android' }, + expectData: { action: 'sms', number: '+15551234567', textLength: SMS_TEXT.length }, + }, + ]); + + assert.deepEqual(emulatorConsoleCalls(adb.calls), [ + ['emu', 'gsm', 'call', '5551234'], + ['emu', 'gsm', 'cancel', '5551234'], + ['emu', 'sms', 'send', '+15551234567', String.raw`Don't "stop"\nC:\\videos`], + ]); + assert.deepEqual( + daemon.session('default')?.actions.map((action) => action.command), + ['telephony', 'telephony', 'telephony'], + ); + }, + ); +}); + +test('Provider-backed integration Android telephony reports a console refusal with its reason', async () => { + const adb = recordingAdbProvider(() => 'KO: no call with this number\n'); + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adb.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID_EMULATOR], + }), + async (daemon) => { + daemon.publishSession('default', { + name: 'default', + device: PROVIDER_SCENARIO_ANDROID_EMULATOR, + createdAt: Date.now(), + actions: [], + }); + + const data = assertRpcError( + await daemon.callCommand('telephony', ['accept', '5551234'], { platform: 'android' }), + 'COMMAND_FAILED', + /refused telephony accept/, + ); + assert.equal((data.details as Record)?.reason, 'telephony-console-refused'); + assert.deepEqual(emulatorConsoleCalls(adb.calls), [['emu', 'gsm', 'accept', '5551234']]); + }, + ); +}); + +test('Provider-backed integration Android telephony refuses a physical device before adb', async () => { + const adb = recordingAdbProvider(); + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adb.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID_PHONE], + }), + async (daemon) => { + daemon.publishSession('default', { + name: 'default', + device: PROVIDER_SCENARIO_ANDROID_PHONE, + createdAt: Date.now(), + actions: [], + }); + + const data = assertRpcError( + await daemon.callCommand('telephony', ['call', '5551234'], { platform: 'android' }), + 'UNSUPPORTED_OPERATION', + /telephony is not supported on this device/, + ); + assert.equal((data.details as Record)?.reason, 'unsupported-device-kind'); + assert.deepEqual(emulatorConsoleCalls(adb.calls), []); + }, + ); +}); 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 fe2ccee83b..e34429a78f 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.appSwitcher]: () => one(), [PUBLIC_COMMANDS.actionButton]: () => one(), [PUBLIC_COMMANDS.fold]: () => one(['open']), + [PUBLIC_COMMANDS.telephony]: () => one(['call', '5551234']), // -- orchestration (drive to an error response; still scanned) -- [PUBLIC_COMMANDS.artifacts]: () => one(), diff --git a/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts b/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts index af1a15f125..5d496b981b 100644 --- a/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts +++ b/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts @@ -273,6 +273,8 @@ function providerScenarioRuntimeFacts( // A provider session has no host simulator for the fold HID helper, so no fixture leg poses // a hinge. setFoldPose: fakeProviderUnavailable, + // A provider device exposes no emulator console on the daemon host. + sendTelephonyEvent: fakeProviderUnavailable, // Provider-owned iOS keyboard actions ride the same runner transport the shared interactor // does (#1297): a fixture scenario that can drive the interactor at all can drive these. ...keyboardRuntimeOperationFacts({ diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 77dc2d31c3..d9d506a74a 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -199,6 +199,8 @@ await client.command.tvRemote({ await client.command.appSwitcher(); await client.command.actionButton(); 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' }); await client.command.fold({ keyframes: [ { atMs: 0, angle: 0 }, @@ -233,6 +235,7 @@ Supported command methods: - `appSwitcher` - `actionButton` - `fold` +- `telephony` - `keyboard` - `clipboard` - `tvRemote` diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b154a7e8c1..09a0137b6b 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -58,6 +58,9 @@ agent-device action-button agent-device fold closed agent-device fold half-open agent-device fold open +agent-device telephony call 5551234 +agent-device telephony cancel 5551234 +agent-device telephony sms 5551234 "Your code is 1234" agent-device device status --platform android --serial emulator-5554 agent-device device release --stale agent-device daemon stop --clean @@ -120,6 +123,15 @@ Fold a foldable device: - `UNSUPPORTED_OPERATION` covers single-panel devices (`single-panel-device` on Android) and simulators in a non-default `--ios-simulator-device-set` (`unsupported-device-scope`; use the default set). Physical devices are refused. - On Android, `fold` confirms the pose from the device state the emulator commits and reports `hingeAngleDegrees` as the hinge sensor reads it: the angle the AVD profile defines for the posture, such as 0°, 90°, and 180° on the Pixel folds or 15°, 90°, and 165° on the generic 7.6in Foldable. It also dismisses the lock screen that folding to the cover display can raise. For panels, poses, and timings, see `agent-device help foldable`. +Simulate an incoming call or SMS: + +- `telephony call ` starts an incoming call from `` on an Android emulator; `telephony accept ` answers it and `telephony cancel ` ends it. `telephony sms ` delivers a text message from ``. Use it to check how an app reacts when a call or message arrives, for example whether video playback pauses and resumes. +- The events go through the emulator console (`adb emu gsm call|accept|cancel`, `adb emu sms send`), so they work on Android emulators reached through the local adb executor. Other adb executors, such as an ADB provider, have not been verified. Physical Android devices refuse with `UNSUPPORTED_OPERATION` and `reason: unsupported-device-kind`; iOS, macOS, tvOS, web, Linux, HarmonyOS, Vega, and Limrun and WebDriver (Appium) sessions refuse with `UNSUPPORTED_OPERATION` too: agent-device sends telephony only through adb and does not drive Appium's own call or SMS simulation. +- `` is digits with an optional leading `+`, up to 15 digits. Quote the SMS text, or pass it as the remaining words; the text is sent as one argument, with backslashes and line breaks (LF, or CRLF sent as LF) escaped for the emulator console. Tabs, lone carriage returns, and other C0 or C1 control characters are refused. +- The emulator console answers some events with an error, such as `accept` for a number with no call. That fails with `COMMAND_FAILED`, `reason: telephony-console-refused`, and the console's own `consoleReason`. `cancel` for a number with no call succeeds, because the console answers it with `OK`. +- The emulator drops SMS characters outside the Basic Multilingual Plane, such as emoji, and delivers the rest of the text. `textLength` counts the text as sent. +- The call screen or message notification can cover the app. Re-snapshot before using refs from before the event. + Remote daemons and tenants: - `--tenant`, `--session-isolation tenant`, `--run-id`, and `--lease-id` enforce lease admission on tenant-scoped daemons.