diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc292f9a0..ce7f2a7068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide. - Limrun iOS snapshots no longer fail when the instance reports a numeric or boolean accessibility value (the system photo picker's tabs report `1`/`0`): the provider seam normalizes variant scalars to text, the way the Apple tree does. Limrun nodes now also publish `selected: true` for nodes carrying the `Selected` trait, so snapshot output and `selected:` selectors can tell a picked photo from an unpicked one. - `settings` refuses a setting the target does not serve before anything changes, with `UNSUPPORTED_OPERATION`, `settings is not supported on this device`, and the target's `error.details.reason` (such as `unsupported-platform-leaf`). This replaces the macOS `INVALID_ARGS` refusal and the Apple `setting-unsupported-on-leaf` reason, and now also covers settings that used to reach the device first, such as Android `reset-keychain` or Apple `animations`. - `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. diff --git a/packages/contracts/src/client-settings.ts b/packages/contracts/src/client-settings.ts index a3e399b4e4..8489df6ed6 100644 --- a/packages/contracts/src/client-settings.ts +++ b/packages/contracts/src/client-settings.ts @@ -43,6 +43,12 @@ export type SettingsUpdateOptions = state: 'clear'; app?: string; }) + /** Android only. Naming an app is refused with `setting_app_not_consumed`. */ + | (DeviceCommandBaseOptions & { + setting: 'mobile-data'; + state: 'on' | 'off'; + app?: string; + }) /** On Apple simulators `on`/`off` is a location-permission change for `app` (or the session app). */ | (DeviceCommandBaseOptions & { setting: 'location'; diff --git a/packages/contracts/src/settings-catalog.ts b/packages/contracts/src/settings-catalog.ts index cbf4521c61..58c6be1570 100644 --- a/packages/contracts/src/settings-catalog.ts +++ b/packages/contracts/src/settings-catalog.ts @@ -105,6 +105,12 @@ export const SETTINGS = { forms: [{ kind: 'on-off' }], help: 'Android applies it through the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing anything. An Apple simulator overrides only its status bar.', }, + 'mobile-data': { + forms: [{ kind: 'on-off' }], + app: 'input', + appScope: { mobile: 'device-level' }, + help: 'Android only. Runs svc data and reports the stored mobileData state, or fails with mobile-data-not-applied or mobile-data-unreadable. Multi-SIM read-back is not covered yet, and --app is refused.', + }, location: { forms: [ { kind: 'on-off', appScope: { apple: 'app-scoped', mobile: 'device-level' } }, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 36fe6f3b99..209b698966 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -41,7 +41,7 @@ function expectInvalidArgs(run: () => unknown, message: string): void { describe('settings usage', () => { test('folds settings that share a form into one line', () => { expect(SETTINGS_USAGE_OVERRIDE.split(' | ')).toEqual([ - 'settings ', + 'settings ', 'settings location set ', 'settings appearance ', `settings text-size [${TEXT_SIZE_CATEGORIES.join('|')}]`, @@ -56,7 +56,7 @@ describe('settings usage', () => { test('the invalid-args message lists the same forms, with the last one as an alternative', () => { expect(SETTINGS_INVALID_ARGS_MESSAGE).toMatch( - /^settings requires , .*, or permission $/, + /^settings requires , .*, or permission $/, ); }); @@ -238,6 +238,16 @@ describe('settingsAppScope', () => { }); }); +describe('settingsAppScope mobile-data', () => { + test('mobile-data switches the whole device wherever it is served', () => { + expect(settingsAppScope('mobile', 'mobile-data', 'on')).toBe('device-level'); + expect(settingsAppScope('mobile', 'mobile-data', 'off')).toBe('device-level'); + // No Apple target serves it; its refusal belongs to the leaf, not to the app. + expect(settingsAppScope('apple', 'mobile-data', 'on')).toBe('unknown'); + expect(settingsAppScope('macos-host', 'mobile-data', 'on')).toBe('unknown'); + }); +}); + describe('settingsAppNotConsumedRefusal', () => { test('refuses by naming the mutation and quoting the app back', () => { const refusal = settingsAppNotConsumedRefusal('location', 'on', 'com.example.app'); diff --git a/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts b/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts index cdb04ceffb..1b47864824 100644 --- a/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts +++ b/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts @@ -180,6 +180,7 @@ function settingsFacts(device: DeviceInfo) { write: { wifi: available, airplane: available, + 'mobile-data': available, location: available, animations: available, appearance: available, diff --git a/packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts b/packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts index 4646fa5f5e..af78e832b2 100644 --- a/packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts +++ b/packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts @@ -637,6 +637,50 @@ test('settings iOS location set refuses a named app before reaching the owner', }); // A macOS permission is a host-level TCC grant, so naming an app is refused rather than dropped. +test('settings mobile-data reaches the Android owner as an on/off mutation', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-mobile-data'; + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); + + const response = await handleSnapshotCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: snapshotRequest(sessionName, 'settings', { positionals: ['mobile-data', 'off'] }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + expect(fixtureSettingsMutations.at(-1)).toMatchObject({ setting: 'mobile-data', state: 'off' }); +}); + +test('settings Android mobile-data refuses a named app before reaching the owner', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-mobile-data-app-refused'; + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); + + const response = await handleSnapshotCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: snapshotRequest(sessionName, 'settings', { + positionals: ['mobile-data', 'off'], + input: { app: 'com.example.app' }, + }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(false); + expect(fixtureSettingsMutations).toHaveLength(0); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.details?.reason).toBe('setting_app_not_consumed'); + expect(response.error.details?.dispatched).toBe('no'); + } +}); + test('settings macOS permission grant refuses a named app before reaching the owner', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-permission-app-refused'; diff --git a/packages/platform-android/src/__tests__/settings.test.ts b/packages/platform-android/src/__tests__/settings.test.ts index c52336b409..7e5d35716e 100644 --- a/packages/platform-android/src/__tests__/settings.test.ts +++ b/packages/platform-android/src/__tests__/settings.test.ts @@ -1,8 +1,8 @@ -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { SETTINGS_APP_NOT_PACKAGE_REASON } from '@agent-device/contracts/settings'; import { TEXT_SIZE_CATEGORIES } from '@agent-device/contracts/settings-catalog'; -import { PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors'; +import { AppError, PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors'; import { readAndroidSetting, setAndroidSetting } from '../settings.ts'; import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts'; import { assertRejectsAppError } from './test-utils/app-error.ts'; @@ -490,3 +490,165 @@ test('readAndroidSetting text-size fails on a multiplier it cannot order', async }); } }); + +// --- settings mobile-data: `svc data` with a read-back of the global `mobile_data` row --- + +const GET_MOBILE_DATA = 'shell settings get global mobile_data'; + +/** + * A single-SIM telephony service holding `held` in `mobile_data` (`null` when absent). It stores a + * `svc data` write after `visibleAfterReads` further reads, or never with `honorWrites: false`. + */ +function telephonyService( + held: string, + options: { honorWrites?: boolean; visibleAfterReads?: number } = {}, +): FakeAdbScript { + let stored = held; + let pending: string | undefined; + let readsUntilVisible = 0; + return (args) => { + const flat = args.join(' '); + if (flat === GET_MOBILE_DATA) { + if (pending !== undefined && readsUntilVisible-- <= 0) { + stored = pending; + pending = undefined; + } + return stored; + } + if (flat === 'shell svc data enable' || flat === 'shell svc data disable') { + if (options.honorWrites !== false) { + pending = flat.endsWith('enable') ? '1' : '0'; + readsUntilVisible = options.visibleAfterReads ?? 0; + } + return ''; + } + return { stderr: `unexpected args: ${flat}`, exitCode: 1 }; + }; +} + +/** Advances the faked wall clock past the settle budget on every read of the row. */ +function expiringOnRead(script: FakeAdbScript): FakeAdbScript { + return (args, options) => { + if (args.join(' ') === GET_MOBILE_DATA) vi.setSystemTime(Date.now() + 2_500); + return script(args, options); + }; +} + +async function withFakeWallClock(run: () => Promise): Promise { + vi.useFakeTimers({ now: 0, toFake: ['Date'] }); + try { + await run(); + } finally { + vi.useRealTimers(); + } +} + +const flatCalls = (calls: (readonly string[])[]) => calls.map((args) => args.join(' ')); + +test('setAndroidSetting mobile-data switches through svc data and reports the stored state', async () => { + for (const { state, held, verb, mobileData, visibleAfterReads } of [ + { state: 'off', held: '1', verb: 'disable', mobileData: 'disabled', visibleAfterReads: 0 }, + { state: 'on', held: '0', verb: 'enable', mobileData: 'enabled', visibleAfterReads: 0 }, + // The telephony service can store the switch after `svc data` returns, so the row is read again. + { state: 'on', held: 'null', verb: 'enable', mobileData: 'enabled', visibleAfterReads: 1 }, + ]) { + const signal = new AbortController().signal; + const readOptions: { signal?: AbortSignal; timeoutMs?: number }[] = []; + const script = telephonyService(held, { visibleAfterReads }); + await withFakeAdb( + (args, options) => { + if (args.join(' ') === GET_MOBILE_DATA) readOptions.push(options ?? {}); + return script(args, options); + }, + async ({ calls, device }) => { + const result = await setAndroidSetting( + device, + 'mobile-data', + state, + undefined, + undefined, + signal, + ); + assert.deepEqual(result, { mobileData }); + assert.deepEqual(flatCalls(calls), [ + `shell svc data ${verb}`, + ...Array(visibleAfterReads + 1).fill(GET_MOBILE_DATA), + ]); + }, + ); + for (const options of readOptions) { + assert.equal(options.signal, signal); + assert.ok(options.timeoutMs !== undefined && options.timeoutMs <= 2000); + } + } +}); + +test('setAndroidSetting mobile-data fails on the last read when the switch never settles', async () => { + for (const { held, reason, mobileData } of [ + { held: '0', reason: 'mobile-data-not-applied', mobileData: 'disabled' }, + { held: 'null', reason: 'mobile-data-unreadable', mobileData: undefined }, + ]) { + await withFakeWallClock(async () => { + const script = expiringOnRead(telephonyService(held, { honorWrites: false })); + await withFakeAdb(script, async ({ device }) => { + await assert.rejects(setAndroidSetting(device, 'mobile-data', 'on'), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details?.reason, reason); + assert.equal(error.details?.storedValue, held); + assert.equal(error.details?.mobileData, mobileData); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }); + }); + }); + } +}); + +test('setAndroidSetting mobile-data discloses a possible switch when svc data fails', async () => { + await withFakeAdb( + (args) => + args.join(' ') === 'shell svc data enable' + ? { stderr: "cmd: Can't find service: phone", exitCode: 20 } + : undefined, + async ({ calls, device }) => { + await assertRejectsAppError(() => setAndroidSetting(device, 'mobile-data', 'on'), { + code: 'COMMAND_FAILED', + dispatched: 'unknown', + }); + assert.deepEqual(flatCalls(calls), ['shell svc data enable']); + }, + ); +}); + +test('setAndroidSetting mobile-data runs nothing for a bad state or a request cancelled up front', async () => { + const controller = new AbortController(); + controller.abort(); + await withFakeAdb(telephonyService('1'), async ({ calls, device }) => { + await assertRejectsAppError(() => setAndroidSetting(device, 'mobile-data', 'cellular'), { + code: 'INVALID_ARGS', + message: /Invalid setting state: cellular/, + }); + await assert.rejects( + setAndroidSetting(device, 'mobile-data', 'off', undefined, undefined, controller.signal), + ); + assert.deepEqual(calls, []); + }); +}); + +test('setAndroidSetting mobile-data stops sampling when the request is cancelled', async () => { + const controller = new AbortController(); + const script = telephonyService('0', { honorWrites: false }); + await withFakeAdb( + (args, options) => { + if (args.join(' ') === GET_MOBILE_DATA) controller.abort(); + return script(args, options); + }, + async ({ calls, device }) => { + await assert.rejects( + setAndroidSetting(device, 'mobile-data', 'on', undefined, undefined, controller.signal), + ); + assert.equal(flatCalls(calls).filter((call) => call === GET_MOBILE_DATA).length, 1); + }, + ); +}); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 0641f47ca1..a5267bcabb 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -357,6 +357,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor write: { wifi: available, airplane: available, + 'mobile-data': available, location: available, animations: available, appearance: available, diff --git a/packages/platform-android/src/settings.ts b/packages/platform-android/src/settings.ts index 0bfd36ee05..f31ad5cfb3 100644 --- a/packages/platform-android/src/settings.ts +++ b/packages/platform-android/src/settings.ts @@ -1,5 +1,7 @@ import { AppError, + createRequestCanceledError, + discloseDispatch, sessionAppRequiredDetails, summarizeCommandAttemptFailures, type CommandAttemptFailure, @@ -24,6 +26,7 @@ import { type SettingName, } from '@agent-device/contracts/settings-catalog'; import { runAndroidAdb, runAndroidShell } from './adb.ts'; +import { Deadline, pollUntilSettled } from '@agent-device/host-kit/retry'; import { setAndroidAirplaneMode } from './settings-airplane.ts'; import { androidAdbResultError } from './adb-executor.ts'; import { resolveAndroidApp } from './app-deployment-resolution.ts'; @@ -37,6 +40,7 @@ import { export const ANDROID_SETTINGS = [ 'wifi', 'airplane', + 'mobile-data', 'location', 'animations', 'appearance', @@ -85,6 +89,7 @@ export async function setAndroidSetting( state: string, appPackage?: string, options?: SettingOptions, + signal?: AbortSignal, ): Promise | void> { const normalized = ANDROID_SETTINGS.find((name) => name === setting.toLowerCase()); if (normalized === undefined) @@ -98,6 +103,9 @@ export async function setAndroidSetting( case 'airplane': { return await setAndroidAirplaneMode(device, parseSettingState(state)); } + case 'mobile-data': { + return await setAndroidMobileData(device, parseSettingState(state), signal); + } case 'location': { if (state.toLowerCase() === 'set') { if (device.kind !== 'emulator') { @@ -285,6 +293,86 @@ function nearestTextSizeCategory(scale: number): (typeof TEXT_SIZE_CATEGORIES)[n return nearest; } +type AndroidMobileData = 'enabled' | 'disabled'; + +/** `svc data` can return before the telephony service stores the switch, so the read-back polls. */ +const MOBILE_DATA_SETTLE_MS = 2000; +const MOBILE_DATA_SAMPLE_INTERVAL_MS = 100; +const MOBILE_DATA_NOT_APPLIED_REASON = 'mobile-data-not-applied'; +const MOBILE_DATA_UNREADABLE_REASON = 'mobile-data-unreadable'; + +/** + * Reads back only the global `mobile_data` row, where a single-SIM device stores the switch. A + * multi-SIM device keeps it per subscription; that read-back is uncovered until a dual-SIM run shows + * which row to read. + */ +async function setAndroidMobileData( + device: DeviceInfo, + enabled: boolean, + signal: AbortSignal | undefined, +): Promise<{ mobileData: AndroidMobileData }> { + const requested: AndroidMobileData = enabled ? 'enabled' : 'disabled'; + if (signal?.aborted) throw createRequestCanceledError(); + try { + await runAndroidShell(device, ['svc', 'data', enabled ? 'enable' : 'disable'], { signal }); + return { mobileData: await awaitAndroidMobileData(device, requested, signal) }; + } catch (error) { + throw error instanceof AppError ? discloseDispatch(error, 'unknown') : error; + } +} + +async function awaitAndroidMobileData( + device: DeviceInfo, + requested: AndroidMobileData, + signal: AbortSignal | undefined, +): Promise { + const deadline = Deadline.fromTimeoutMs(MOBILE_DATA_SETTLE_MS); + const { settled, value } = await pollUntilSettled({ + probe: async () => { + const { stdout } = await runAndroidShell( + device, + ['settings', 'get', 'global', 'mobile_data'], + { signal, timeoutMs: Math.max(1, deadline.remainingMs()) }, + ); + const storedValue = stdout.trim(); + return { storedValue, mobileData: parseAndroidMobileData(storedValue) }; + }, + settled: (read) => read.mobileData === requested, + deadline, + intervalMs: MOBILE_DATA_SAMPLE_INTERVAL_MS, + signal, + }); + if (settled) return requested; + throw androidMobileDataSettleError(device, requested, value.mobileData, value.storedValue); +} + +function parseAndroidMobileData(storedValue: string): AndroidMobileData | undefined { + if (storedValue === '1') return 'enabled'; + if (storedValue === '0') return 'disabled'; + return undefined; +} + +function androidMobileDataSettleError( + device: DeviceInfo, + requested: AndroidMobileData, + mobileData: AndroidMobileData | undefined, + storedValue: string, +): AppError { + const message = + mobileData === undefined + ? `Android reported an unusable mobile_data value after requesting mobile data ${requested}: ${storedValue || '(empty)'}` + : `Android mobile data reads ${mobileData} after requesting ${requested}.`; + return new AppError('COMMAND_FAILED', message, { + deviceId: device.id, + reason: + mobileData === undefined ? MOBILE_DATA_UNREADABLE_REASON : MOBILE_DATA_NOT_APPLIED_REASON, + requested, + ...(mobileData === undefined ? {} : { mobileData }), + storedValue, + hint: 'Run `adb shell settings get global mobile_data` to see what the device holds. A target without an active SIM, or with more than one, can accept svc data without changing that row.', + }); +} + type AndroidFingerprintAction = 'match' | 'nonmatch'; function parseAndroidFingerprintAction(state: string): AndroidFingerprintAction { diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 91171d7413..6410609599 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -359,6 +359,7 @@ export function limrunSettingsOperationFacts( write: { wifi: available, airplane: available, + 'mobile-data': available, location: available, animations: available, appearance: available, diff --git a/src/commands/capture/settings.test.ts b/src/commands/capture/settings.test.ts index 6d68843ffc..f5ab1c5f3a 100644 --- a/src/commands/capture/settings.test.ts +++ b/src/commands/capture/settings.test.ts @@ -95,6 +95,27 @@ describe('settings CLI permission', () => { }); }); + test('reads mobile-data on|off and writes it as two positionals', () => { + for (const state of ['on', 'off'] as const) { + const input = settingsCliReader(['mobile-data', state], flags()); + expect(input).toEqual({ setting: 'mobile-data', state }); + expect(settingsDaemonWriter(input)).toMatchObject({ + command: 'settings', + positionals: ['mobile-data', state], + }); + } + }); + + test('forwards --app on mobile-data so the daemon refuses it instead of dropping it', () => { + const input = settingsCliReader(['mobile-data', 'off'], { + targetApp: 'com.example.app', + } as CliFlags); + expect(settingsDaemonWriter(input)).toMatchObject({ + positionals: ['mobile-data', 'off'], + input: { app: 'com.example.app' }, + }); + }); + test('carries an app on an iOS location toggle to the daemon request input', () => { const input = settingsCliReader(['location', 'on'], { targetApp: 'com.example.app', diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 50b610c090..195fb1a7c4 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -36,7 +36,7 @@ const settingsCommandMetadata = defineFieldCommandMetadata( // A readable setting answers with no state; the parse decides per setting. state: stringField(), app: stringField( - 'App the change targets, by bundle id, package name, or app name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. A name resolves to the installed app as `open` resolves it: an iOS simulator matches display names and accepts only an installed bundle id, Android accepts only an installed package and refuses a name that resolves to an intent with `setting_app_not_package`, and a Limrun iOS session resolves only the `settings` alias. One that resolves to nothing is refused with `app_not_installed` before anything changes. The app need not be running. Without it the session app is used; a device-wide change (`location set` on any target, the Android `location on|off` toggle, macOS permissions) refuses one with `setting_app_not_consumed`. The CLI reads it only from this invocation, never from `AGENT_DEVICE_TARGET_APP` or config `targetApp`.', + 'App the change targets, by bundle id, package name, or app name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. A name resolves to the installed app as `open` resolves it: an iOS simulator matches display names and accepts only an installed bundle id, Android accepts only an installed package and refuses a name that resolves to an intent with `setting_app_not_package`, and a Limrun iOS session resolves only the `settings` alias. One that resolves to nothing is refused with `app_not_installed` before anything changes. The app need not be running. Without it the session app is used; a device-wide change (`location set` on any target, the Android `location on|off` toggle, `mobile-data`, macOS permissions) refuses one with `setting_app_not_consumed`. The CLI reads it only from this invocation, never from `AGENT_DEVICE_TARGET_APP` or config `targetApp`.', ), latitude: numberField(), longitude: numberField(), diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index d46a9bf5fb..e61b5f94af 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -125,7 +125,7 @@ export function createAndroidInteractor( readClipboard: () => readAndroidClipboardText(device), writeClipboard: (text) => writeAndroidClipboardText(device, text), setSetting: (setting, state, appId, options) => - setAndroidSetting(device, setting, state, appId, options), + setAndroidSetting(device, setting, state, appId, options, runnerContext?.signal), readSetting: (setting) => readAndroidSetting(device, setting), // R59: Android's alert legs read the same presented accessibility tree `snapshot` publishes // and own their own polling, so the family supplies the node capture rather than the daemon. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 56e495d187..8eb1deea1e 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -622,6 +622,8 @@ agent-device settings wifi on agent-device settings wifi off agent-device settings airplane on agent-device settings airplane off +agent-device settings mobile-data off +agent-device settings mobile-data on agent-device settings location on agent-device settings location off agent-device settings location set 37.3349 -122.009 @@ -668,6 +670,9 @@ Device settings: - `settings animations off|on` (Android) sets the window, transition, and animator scales. Turn animations off to stabilize runs with heavy animation, and back on afterward. - `settings airplane on|off` (Android) needs the connectivity service's `cmd connectivity airplane-mode` command, available from Android 11. It reports the resulting `airplaneMode`; a build without that command fails with `UNSUPPORTED_OPERATION` without changing anything. Connectivity takes a moment to settle, so poll the app rather than asserting offline behavior immediately. +- `settings mobile-data on|off` (Android over adb) switches mobile data with `svc data enable|disable` and reports the resulting `mobileData` from the global `mobile_data` row. If that row does not settle to the request within 2 seconds, it fails with `COMMAND_FAILED`: `error.details.reason` is `mobile-data-unreadable` if the final read is neither `0` nor `1`, or `mobile-data-not-applied` if it is the opposite state. Pair it with `settings wifi off` to move a session such as a video stream from Wi-Fi to cellular, and back with `settings wifi on`. Poll the app while the network hands over. +- The read-back covers single-SIM targets only. A multi-SIM device stores the switch per subscription and can leave `mobile_data` unchanged, so the command may fail there even when the switch took effect. A target without an active SIM can also accept `svc data` without changing the row, and with Wi-Fi off it is offline rather than on cellular. +- Mobile data is device-wide, so `--app` is refused with `setting_app_not_consumed`. Every target without adb refuses `mobile-data` with `UNSUPPORTED_OPERATION` before anything runs. - `settings text-size` reads the current size; `settings text-size ` sets one: `extra-small`, `small`, `medium`, `large`, `extra-large`, `extra-extra-large`, `extra-extra-extra-large`, `accessibility-medium`, `accessibility-large`, `accessibility-extra-large`, `accessibility-extra-extra-large`, or `accessibility-extra-extra-extra-large`. It works on iPhone and iPad simulators and on Android; every other target refuses both read and write. The read reports `category` and the target's own `platformValue` (iOS may capitalize it, for example `extra-Small`). - Android has only a font-scale multiplier, mapped so `large` is `1.0` and `accessibility-extra-extra-extra-large` is `3.2`. A value between rungs reads as the nearest category, with the exact multiplier in `platformValue`; an unset scale reads as `large`. - A running app adopts a new text size only at its next configuration change. Relaunch it (`open --relaunch`) before asserting rendered sizes. @@ -692,7 +697,7 @@ Permissions: - `app_not_installed` (`APP_NOT_INSTALLED`): the app resolves to nothing installed. - `session_app_required`: no session app and none named. Run `open `, or name the app. - `setting_app_not_package` (`INVALID_ARGS`, Android): the name is an intent alias such as `settings`, not a package. - - `setting_app_not_consumed`: the change is device-wide and cannot take an app: `location set`, the Android `location on|off` toggle, and macOS permissions. + - `setting_app_not_consumed`: the change is device-wide and cannot take an app: `location set`, the Android `location on|off` toggle, macOS permissions, and `mobile-data`. - iOS targets: `all`, `camera`, `microphone`, `photos` (`full` or `limited`), `contacts`, `contacts-limited`, `notifications`, `calendar`, `location`, `location-always`, `media-library`, `motion`, `reminders`, `siri`. The simulator decides which it can change; one it refuses fails with `UNSUPPORTED_OPERATION` naming it. On current runtimes that is `notifications`, which `all` also leaves untouched. - Android targets: `all`, `calendar`, `camera`, `contacts`, `location`, `media-library`, `microphone`, `notifications`, `photos`. Targets covering several permissions apply only the ones the app declares and fail if it declares none; `all` applies every declared permission. The response reports `permission` (what you asked for) and `permissions` (what was changed). - macOS targets: `accessibility`, `screen-recording`, `input-monitoring`. `grant` checks or requests access and opens System Settings when needed; it never grants silently. `deny` is unsupported. diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 242c058f51..d95345efa1 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -73,7 +73,7 @@ On Android, agent-device connects to Limrun over an ADB tunnel. Snapshots use th On iOS, agent-device supports app lifecycle commands, snapshots, screenshots, taps, text input, scrolling, the home button, clipboard read and write, and app installation. `open --launch-args` relaunches the app with those arguments through simctl. iOS cannot reverse a remote device port to a local host port, so for Metro use a publicly reachable HTTPS endpoint or bridge URL instead of a local-only address. -On iOS, `settings` supports `appearance`, `permission`, `location`, and `clear-app-state`, with the same arguments as a local simulator. `clear-app-state` resets the app's data container through Limrun's soft reset. That reset relaunches the app once before agent-device stops it, so the app may run once after its data is cleared. `reset-keychain`, `wifi`, `airplane`, `faceid`, `touchid`, and `text-size` are unsupported, and you cannot read back any iOS setting. +On iOS, `settings` supports `appearance`, `permission`, `location`, and `clear-app-state`, with the same arguments as a local simulator. `clear-app-state` resets the app's data container through Limrun's soft reset. That reset relaunches the app once before agent-device stops it, so the app may run once after its data is cleared. `reset-keychain`, `wifi`, `airplane`, `mobile-data`, `faceid`, `touchid`, and `text-size` are unsupported, and you cannot read back any iOS setting. On Android, `settings mobile-data on|off` runs over the ADB tunnel like on a local device. On iOS, `fill` taps the target, waits for that field to take text-entry focus, and only then types. Apps that never report a globally focused element, such as Flutter forms, still fill and report `textEntryReadiness: "focused-element"`. If the tap does not focus a field, `fill` fails with `text_entry_focus_not_observed` instead of typing into an unknown field. Use `type` to send text to whichever field already has focus.