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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <setting> 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 <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.
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/client-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/settings-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand Down
14 changes: 12 additions & 2 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <wifi|airplane|location|animations> <on|off>',
'settings <wifi|airplane|mobile-data|location|animations> <on|off>',
'settings location set <lat> <lon>',
'settings appearance <light|dark|toggle>',
`settings text-size [${TEXT_SIZE_CATEGORIES.join('|')}]`,
Expand All @@ -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 <wifi\|airplane\|location\|animations> <on\|off>, .*, or permission <grant\|reset> <accessibility\|screen-recording\|input-monitoring>$/,
/^settings requires <wifi\|airplane\|mobile-data\|location\|animations> <on\|off>, .*, or permission <grant\|reset> <accessibility\|screen-recording\|input-monitoring>$/,
);
});

Expand Down Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ function settingsFacts(device: DeviceInfo) {
write: {
wifi: available,
airplane: available,
'mobile-data': available,
location: available,
animations: available,
appearance: available,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
166 changes: 164 additions & 2 deletions packages/platform-android/src/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void>): Promise<void> {
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);
},
);
});
1 change: 1 addition & 0 deletions packages/platform-android/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
write: {
wifi: available,
airplane: available,
'mobile-data': available,
location: available,
animations: available,
appearance: available,
Expand Down
Loading
Loading