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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

- Fixed: `settings airplane on|off` now takes an Android device offline. It is applied through
the connectivity service (`cmd connectivity airplane-mode`), which drives the radios, instead of
writing `airplane_mode_on` and broadcasting `ACTION_AIRPLANE_MODE_CHANGED` — a broadcast Android
refuses for non-system callers, so the old path failed *after* writing the setting and left the
device reporting airplane mode with the network still up (#2223). The response now reports the
`airplaneMode` the connectivity service holds after the change, and an Android build that does not
expose that command is refused with `UNSUPPORTED_OPERATION` before anything is written.
- Breaking (0.21): iOS Appium/WebDriver snapshots now expose engine-owned acquisition facts and
typed fidelity warnings. The SDK snapshot `truncated` field is optional when Appium cannot report
hierarchy completeness; regular snapshots fail closed without valid viewport evidence, while
Expand Down
157 changes: 157 additions & 0 deletions packages/platform-android/src/__tests__/settings-airplane.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { setAndroidSetting } from '../settings.ts';
import type { AndroidAirplaneMode } from '../settings-airplane.ts';
import { assertRejectsAppError } from './test-utils/app-error.ts';
import { withFakeAdb, type FakeAdbScript } from './test-utils/fake-adb.ts';

const READ = 'shell cmd connectivity airplane-mode';

/**
* An Android build whose connectivity service owns airplane mode. `honorWrites: false` models the
* service accepting the command and leaving the state alone, which is what a silently ineffective
* airplane-mode path looks like from the outside.
*/
function connectivityService(
initial: AndroidAirplaneMode,
options: { honorWrites?: boolean } = {},
): FakeAdbScript {
let state = initial;
return (args) => {
const flat = args.join(' ');
if (flat === READ) return state;
if (flat === `${READ} enable` || flat === `${READ} disable`) {
if (options.honorWrites !== false) state = flat.endsWith('enable') ? 'enabled' : 'disabled';
return '';
}
return { stderr: `unexpected args: ${flat}`, exitCode: 1 };
};
}

test('setAndroidSetting airplane on enables through connectivity and reports its state', async () => {
await withFakeAdb(connectivityService('disabled'), async ({ calls, device }) => {
const result = await setAndroidSetting(device, 'airplane', 'on');
assert.deepEqual(result, { airplaneMode: 'enabled' });
assert.deepEqual(
calls.map((args) => args.join(' ')),
[READ, `${READ} enable`, READ],
);
});
});

test('setAndroidSetting airplane off disables through connectivity and reports its state', async () => {
await withFakeAdb(connectivityService('enabled'), async ({ calls, device }) => {
const result = await setAndroidSetting(device, 'airplane', 'off');
assert.deepEqual(result, { airplaneMode: 'disabled' });
assert.deepEqual(
calls.map((args) => args.join(' ')),
[READ, `${READ} disable`, READ],
);
});
});

test('setAndroidSetting airplane reports the state connectivity holds, not the one requested', async () => {
await withFakeAdb(
connectivityService('disabled', { honorWrites: false }),
async ({ calls, device }) => {
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'on'), {
code: 'COMMAND_FAILED',
message: /reads disabled after requesting enabled/,
});
assert.deepEqual(
calls.map((args) => args.join(' ')),
[READ, `${READ} enable`, READ],
);
},
);
});

test('setAndroidSetting airplane refuses builds without the connectivity command before writing', async () => {
await withFakeAdb(
() => ({ stdout: 'Unknown command: airplane-mode', exitCode: 255 }),
async ({ calls, device }) => {
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'on'), {
code: 'UNSUPPORTED_OPERATION',
message: /no airplane-mode command/,
hint: /Android 11 \(API 30\)/,
});
assert.deepEqual(
calls.map((args) => args.join(' ')),
[READ],
);
},
);
});

test('setAndroidSetting airplane refuses unreadable state before writing', async () => {
await withFakeAdb(
() => 'Airplane mode: who knows',
async ({ calls, device }) => {
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'off'), {
code: 'COMMAND_FAILED',
message: /Failed to read Android airplane mode/,
});
assert.deepEqual(
calls.map((args) => args.join(' ')),
[READ],
);
},
);
});

test('setAndroidSetting airplane keeps a refused read a command failure, not an unsupported build', async () => {
await withFakeAdb(
() => ({
stdout: '',
stderr: 'java.lang.SecurityException: Permission Denial: not allowed to change airplane mode',
exitCode: 255,
}),
async ({ calls, device }) => {
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'on'), {
code: 'COMMAND_FAILED',
message: /Failed to read Android airplane mode/,
});
assert.deepEqual(
calls.map((args) => args.join(' ')),
[READ],
);
},
);
});

test('setAndroidSetting airplane fails the change when the state cannot be read back', async () => {
const service = connectivityService('disabled');
let changed = false;
await withFakeAdb(
(args) => {
const flat = args.join(' ');
if (flat === READ && changed)
return { stdout: 'Unknown command: airplane-mode', exitCode: 255 };
if (flat === `${READ} enable`) changed = true;
return service(args);
},
async ({ device }) => {
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'on'), {
code: 'COMMAND_FAILED',
message: /Failed to read Android airplane mode after changing it/,
});
},
);
});

test('setAndroidSetting airplane keeps adb transport failures typed as command failures', async () => {
await withFakeAdb(
() => ({ stderr: 'error: device offline', exitCode: 1 }),
async ({ calls, device }) => {
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'on'), {
code: 'COMMAND_FAILED',
message: /Failed to read Android airplane mode/,
hint: /adb reconnect/,
});
assert.deepEqual(
calls.map((args) => args.join(' ')),
[READ],
);
},
);
});
12 changes: 6 additions & 6 deletions packages/platform-android/src/adb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,18 @@ export async function runAndroidAdb(
}

/**
* Whether an adb `cmd clipboard` invocation was refused because this build ships no shell
* implementation for the clipboard service, rather than because the call itself failed.
* Whether an adb `cmd <service> <command>` invocation was refused because this build ships no
* shell implementation for it, rather than because the call itself failed.
*
* adb reports this condition in its output and nowhere else — no exit code or structured field
* separates "service has no shell command" from any other non-zero result — so this is the one
* place that reads that prose, and it hands every caller a typed answer instead.
*
* Only ever ask this about a call that *failed*. A successful `clipboard get text` returns the
* clipboard's contents on stdout, which is arbitrary user text and may quote these very phrases;
* callers must settle a zero exit as success before reaching for this.
* Only ever ask this about a call that *failed*. A command that succeeds prints its own payload
* on stdout — `clipboard get text` returns arbitrary user text, which may quote these very
* phrases — so callers must settle a zero exit before reaching for this.
*/
export function isClipboardShellUnsupported(stdout: string, stderr: string): boolean {
export function isAndroidShellCommandUnsupported(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`.toLowerCase();
return (
haystack.includes('no shell command implementation') || haystack.includes('unknown command')
Expand Down
4 changes: 2 additions & 2 deletions packages/platform-android/src/device-input-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';

import { isClipboardShellUnsupported, sleep } from './adb.ts';
import { isAndroidShellCommandUnsupported, sleep } from './adb.ts';
import {
androidAdbResultError,
resolveAndroidAdbExecutor,
Expand Down Expand Up @@ -325,7 +325,7 @@ async function runAndroidClipboardShellCommand(
// the clipboard's contents, and a user who has copied one of the missing-shell phrases must not
// have their own text mistaken for adb refusing the command.
if (result.exitCode === 0) return result.stdout;
if (isClipboardShellUnsupported(result.stdout, result.stderr)) {
if (isAndroidShellCommandUnsupported(result.stdout, result.stderr)) {
throw new AppError(
'UNSUPPORTED_OPERATION',
`Android shell clipboard ${operation} is not supported on this device.`,
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-android/src/mechanics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export {
androidAdbForwardsDeviceExitStatus,
resetAndroidAdbShellProtocolProbes,
} from './adb-shell-protocol.ts';
export { isClipboardShellUnsupported, runAndroidAdb, sleep } from './adb.ts';
export { isAndroidShellCommandUnsupported, runAndroidAdb, sleep } from './adb.ts';
export { handleAndroidAlert, type AndroidAlertResult } from './alert.ts';
export {
classifyAndroidAlertIdentifier,
Expand Down
83 changes: 83 additions & 0 deletions packages/platform-android/src/settings-airplane.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { AppError } from '@agent-device/kernel/errors';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { isAndroidShellCommandUnsupported, runAndroidAdb } from './adb.ts';
import { androidAdbResultError, type AndroidAdbExecutorResult } from './adb-executor.ts';

export type AndroidAirplaneMode = 'enabled' | 'disabled';

const AIRPLANE_MODE_ARGS = ['shell', 'cmd', 'connectivity', 'airplane-mode'] as const;

/**
* Android's connectivity service owns airplane mode: it reports the state and it drives the radios
* behind it. Writing `airplane_mode_on` and broadcasting `ACTION_AIRPLANE_MODE_CHANGED` instead
* moves the setting while connectivity stays up, and the broadcast is refused for non-system
* callers (#2223).
*
* Support is proven before the write and the state is read after it, so a build that cannot answer
* for airplane mode is refused unmutated and the reported mode is the one connectivity holds rather
* than the one that was asked for.
*/
export async function setAndroidAirplaneMode(
device: DeviceInfo,
enabled: boolean,
): Promise<{ airplaneMode: AndroidAirplaneMode }> {
const requested: AndroidAirplaneMode = enabled ? 'enabled' : 'disabled';
await requireAndroidAirplaneModeSupport(device);
await runAndroidAdb(device, [...AIRPLANE_MODE_ARGS, enabled ? 'enable' : 'disable']);
const airplaneMode = await readAndroidAirplaneMode(device);
if (airplaneMode !== requested) {
throw new AppError(
'COMMAND_FAILED',
`Android airplane mode reads ${airplaneMode} after requesting ${requested}.`,
{ deviceId: device.id, requested, airplaneMode },
);
}
return { airplaneMode };
}

async function requireAndroidAirplaneModeSupport(device: DeviceInfo): Promise<void> {
const { state, result } = await probeAndroidAirplaneMode(device);
if (state) return;
if (result.exitCode !== 0 && isAndroidShellCommandUnsupported(result.stdout, result.stderr)) {
throw new AppError(
'UNSUPPORTED_OPERATION',
'The connectivity service on this Android build has no airplane-mode command, so nothing was changed.',
{
deviceId: device.id,
hint: 'settings airplane needs cmd connectivity airplane-mode, which requires Android 11 (API 30) or newer. Use a newer device or emulator image.',
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
},
);
}
throw androidAdbResultError('Failed to read Android airplane mode', result, {
deviceId: device.id,
});
}

async function readAndroidAirplaneMode(device: DeviceInfo): Promise<AndroidAirplaneMode> {
const { state, result } = await probeAndroidAirplaneMode(device);
if (state) return state;
throw androidAdbResultError('Failed to read Android airplane mode after changing it', result, {
deviceId: device.id,
});
}

async function probeAndroidAirplaneMode(device: DeviceInfo): Promise<{
state: AndroidAirplaneMode | undefined;
result: AndroidAdbExecutorResult;
}> {
const result = await runAndroidAdb(device, [...AIRPLANE_MODE_ARGS], { allowFailure: true });
return {
state: result.exitCode === 0 ? parseAndroidAirplaneMode(result.stdout) : undefined,
result,
};
}

function parseAndroidAirplaneMode(stdout: string): AndroidAirplaneMode | undefined {
const value = stdout.trim().toLowerCase();
if (value === 'enabled') return 'enabled';
if (value === 'disabled') return 'disabled';
return undefined;
}
17 changes: 2 additions & 15 deletions packages/platform-android/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type CommandAttemptFailure,
} from './settings-parsing.ts';
import { runAndroidAdb } from './adb.ts';
import { setAndroidAirplaneMode } from './settings-airplane.ts';
import { androidAdbResultError } from './adb-executor.ts';
import { resolveAndroidApp } from './app-deployment-resolution.ts';
import { setAndroidPermission } from './settings-permission.ts';
Expand All @@ -35,21 +36,7 @@ export async function setAndroidSetting(
return;
}
case 'airplane': {
const enabled = parseSettingState(state);
const flag = enabled ? '1' : '0';
const bool = enabled ? 'true' : 'false';
await runAndroidAdb(device, ['shell', 'settings', 'put', 'global', 'airplane_mode_on', flag]);
await runAndroidAdb(device, [
'shell',
'am',
'broadcast',
'-a',
'android.intent.action.AIRPLANE_MODE',
'--ez',
'state',
bool,
]);
return;
return await setAndroidAirplaneMode(device, parseSettingState(state));
}
case 'location': {
if (state.toLowerCase() === 'set') {
Expand Down
2 changes: 1 addition & 1 deletion scripts/__tests__/eager-closure-budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
'packages/platform-android/src/adb-host.ts': 1,
// The named mechanics facet is intentionally implementation-eager once selected. Its exact
// closure is pinned so a future facade expansion is visible in review.
'packages/platform-android/src/mechanics.ts': 177,
'packages/platform-android/src/mechanics.ts': 178,

// --- @agent-device/platform-apple ---
'packages/platform-apple/src/index.ts': 1,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/capture/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const settingsCommandFacet = defineCommandFacet({
text: {
summary: 'Change OS settings and app permissions',
cliDetail:
'macOS supports only settings appearance <light|dark|toggle> and settings permission <grant|reset> <accessibility|screen-recording|input-monitoring>; wifi|airplane|location|animations remain unsupported on macOS. Mobile permission actions use the active session app. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open <app> --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one.',
'macOS supports only settings appearance <light|dark|toggle> and settings permission <grant|reset> <accessibility|screen-recording|input-monitoring>; wifi|airplane|location|animations remain unsupported on macOS. Mobile permission actions use the active session app. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open <app> --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state.',
},
metadata: settingsCommandMetadata,
definition: settingsCommandDefinition,
Expand Down
4 changes: 2 additions & 2 deletions src/platform-runtime-android-tool-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ export function createAndroidToolHost(): AndroidToolHost {
*/
probeClipboardShellSupport: async (device, signal) => {
try {
const { runAndroidAdb, isClipboardShellUnsupported } = await loadAndroidMechanics();
const { runAndroidAdb, isAndroidShellCommandUnsupported } = await loadAndroidMechanics();
const result = await runAndroidAdb(device, ['shell', 'cmd', 'clipboard', 'get', 'text'], {
allowFailure: true,
signal,
});
if (result.exitCode === 0) return 'supported';
return isClipboardShellUnsupported(result.stdout, result.stderr)
return isAndroidShellCommandUnsupported(result.stdout, result.stderr)
? 'unsupported'
: 'probe-failed';
} catch {
Expand Down
1 change: 1 addition & 0 deletions website/docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@ agent-device settings permission reset screen-recording --platform macos
- `settings location set <lat> <lon>` sets precise coordinates on iOS simulators and Android emulators.
- `settings clear-app-state [app-id]` clears the active session app data, or the provided app id. Android uses `pm clear`, which removes SharedPreferences, databases, files, and cache. iOS simulator removes the app data container contents. iOS physical devices and macOS are unsupported.
- Face ID and Touch ID controls are iOS simulator-only.
- Android `settings airplane on|off` is applied by the connectivity service (`cmd connectivity airplane-mode`, Android 11+), which drives the radios rather than only writing the `airplane_mode_on` setting. The response reports the `airplaneMode` that service holds after the change, and Android builds without that command fail without changing device state. Connectivity takes a moment to settle after the switch, so poll the app under test rather than asserting offline behavior immediately.
- Fingerprint simulation is supported on Android targets where `cmd fingerprint` or `adb emu finger` is available.
On physical Android devices, only `cmd fingerprint` is attempted.
- Permission actions are scoped to the active session app.
Expand Down
Loading