diff --git a/CHANGELOG.md b/CHANGELOG.md index f7b75f5c0..ffd8555d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/platform-android/src/__tests__/settings-airplane.test.ts b/packages/platform-android/src/__tests__/settings-airplane.test.ts new file mode 100644 index 000000000..a0f9a3081 --- /dev/null +++ b/packages/platform-android/src/__tests__/settings-airplane.test.ts @@ -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], + ); + }, + ); +}); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 6d00630bb..6449fe4a4 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -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 ` 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') diff --git a/packages/platform-android/src/device-input-state.ts b/packages/platform-android/src/device-input-state.ts index b8afbe2ca..546a2285f 100644 --- a/packages/platform-android/src/device-input-state.ts +++ b/packages/platform-android/src/device-input-state.ts @@ -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, @@ -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.`, diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index 71a4aebd1..7c68ee358 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -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, diff --git a/packages/platform-android/src/settings-airplane.ts b/packages/platform-android/src/settings-airplane.ts new file mode 100644 index 000000000..7fa5fd4fa --- /dev/null +++ b/packages/platform-android/src/settings-airplane.ts @@ -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 { + 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 { + 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; +} diff --git a/packages/platform-android/src/settings.ts b/packages/platform-android/src/settings.ts index 29bee0dba..30aed7ebc 100644 --- a/packages/platform-android/src/settings.ts +++ b/packages/platform-android/src/settings.ts @@ -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'; @@ -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') { diff --git a/scripts/__tests__/eager-closure-budgets.ts b/scripts/__tests__/eager-closure-budgets.ts index 3460cfe41..5c66a02cf 100644 --- a/scripts/__tests__/eager-closure-budgets.ts +++ b/scripts/__tests__/eager-closure-budgets.ts @@ -310,7 +310,7 @@ export const FACADE_BUDGETS: Readonly> = 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, diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 9e2673b01..04983a105 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -60,7 +60,7 @@ export const settingsCommandFacet = defineCommandFacet({ text: { summary: 'Change OS settings and app permissions', cliDetail: - 'macOS supports only settings appearance and settings permission ; 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 --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 and settings permission ; 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 --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, diff --git a/src/platform-runtime-android-tool-host.ts b/src/platform-runtime-android-tool-host.ts index 2cde1148b..9041bc4f1 100644 --- a/src/platform-runtime-android-tool-host.ts +++ b/src/platform-runtime-android-tool-host.ts @@ -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 { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 293e1cae2..43f7e7293 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -703,6 +703,7 @@ agent-device settings permission reset screen-recording --platform macos - `settings location set ` 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.