Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@
- `settings` refuses, before touching the device, a state its setting does not accept (`Invalid <setting> 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 <app> <extra>`). 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 <number>` and `telephony sms <number> <text>`, 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.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
AppStateCommandResult,
BootCommandResult,
ShutdownCommandResult,
TelephonyCommandResult,
TriggerAppEventCommandResult,
} from '@agent-device/contracts/device';
import type {
Expand Down Expand Up @@ -60,6 +61,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ
const appstate: Equal<CommandResult<'appstate'>, AppStateCommandResult> = true;
const keyboard: Equal<CommandResult<'keyboard'>, KeyboardCommandResult> = true;
const tvRemote: Equal<CommandResult<'tv-remote'>, TvRemoteCommandResult> = true;
const telephony: Equal<CommandResult<'telephony'>, TelephonyCommandResult> = true;
const wait: Equal<CommandResult<'wait'>, WaitCommandResult> = true;
const prepare: Equal<CommandResult<'prepare'>, PrepareCommandResult> = true;
const push: Equal<CommandResult<'push'>, PushCommandResult> = true;
Expand Down Expand Up @@ -90,6 +92,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ
appstate,
keyboard,
tvRemote,
telephony,
wait,
prepare,
push,
Expand Down Expand Up @@ -132,6 +135,7 @@ test('CommandResultMap is seeded only from already-existing contract result type
| 'appstate'
| 'keyboard'
| 'tv-remote'
| 'telephony'
| 'wait'
| 'scroll'
| 'prepare'
Expand Down
2 changes: 2 additions & 0 deletions packages/command-registry/src/command-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
AppStateCommandResult,
BootCommandResult,
ShutdownCommandResult,
TelephonyCommandResult,
TriggerAppEventCommandResult,
} from '@agent-device/contracts/device';
import type {
Expand Down Expand Up @@ -82,6 +83,7 @@ export interface CommandResultMap {
replay: ReplayCommandResult;
scroll: ScrollCommandResult;
shutdown: ShutdownCommandResult;
telephony: TelephonyCommandResult;
test: ReplaySuiteResult;
trace: TraceCommandResult;
'trigger-app-event': TriggerAppEventCommandResult;
Expand Down
13 changes: 13 additions & 0 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
alertRuntimePlanUses,
actionButtonRuntimeUse,
foldRuntimeUse,
telephonyRuntimeUse,
appEventRuntimeUse,
appStateRuntimeUses,
appSwitcherRuntimeUse,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/contracts/src/client-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -101,6 +101,8 @@ export type TvRemoteCommandOptions = DeviceCommandBaseOptions & {
durationMs?: number;
};

export type TelephonyCommandOptions = DeviceCommandBaseOptions & TelephonyEventInput;

export type KeyboardCommandOptions = DeviceCommandBaseOptions & {
action?: 'status' | 'dismiss' | 'enter' | 'return';
};
Expand Down
73 changes: 73 additions & 0 deletions packages/contracts/src/device-rotation-telephony.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
110 changes: 110 additions & 0 deletions packages/contracts/src/device-rotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <number> | telephony sms <number> <text>';

/**
* 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 <action> <number> [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;
}
1 change: 1 addition & 0 deletions packages/contracts/src/facades/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export type {
OrientationCommandOptions,
PrepareCommandOptions,
ReactNativeCommandOptions,
TelephonyCommandOptions,
TvRemoteCommandOptions,
ViewportCommandOptions,
WaitCommandOptions,
Expand Down
8 changes: 8 additions & 0 deletions packages/contracts/src/facades/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,22 @@ export {
isDeviceRotation,
parseDeviceRotation,
parseFoldPose,
TELEPHONY_ACTIONS,
TELEPHONY_CALL_ACTIONS,
TELEPHONY_USAGE,
parseTelephonyEventInput,
parseTelephonyPositionals,
telephonyPositionals,
} from '../device-rotation.ts';
export type {
DeviceRotation,
FoldPose,
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,
Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/platform-runtime-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -77,6 +78,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations &
BackRuntimeOperations &
OrientationRuntimeOperations &
FoldRuntimeOperations &
TelephonyRuntimeOperations &
TvRemoteRuntimeOperations &
KeyboardRuntimeOperations &
ClipboardRuntimeOperations &
Expand Down Expand Up @@ -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'] });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
back: RuntimeOperationUnavailability;
orientation: RuntimeOperationUnavailability;
fold: RuntimeOperationUnavailability;
telephony: RuntimeOperationUnavailability;
tvRemote: RuntimeOperationUnavailability;
keyboard: RuntimeOperationUnavailability;
clipboard: RuntimeOperationUnavailability;
Expand Down Expand Up @@ -114,6 +115,7 @@ const UNAVAILABLE_CELLS = {
back: true,
orientation: true,
fold: true,
telephony: true,
tvRemote: true,
keyboard: true,
clipboard: true,
Expand Down Expand Up @@ -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 }),
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/runtime-operation-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const RUNTIME_OPERATION_NAMES = [
'screenRecordingStart',
'scrollDirection',
'sendPushNotification',
'sendTelephonyEvent',
'setFoldPose',
'setOrientation',
'setSetting',
Expand Down
Loading
Loading