Skip to content

Commit 87fe3ea

Browse files
feat(settings): add Android mobile-data on|off (#3636)
* feat(settings): add Android mobile-data on|off `settings mobile-data on|off` switches mobile data for the default data SIM on adb-backed Android targets through `svc data enable|disable`. It then reads the stored switch back until the value matches the request, within a 2 s budget, and reports `mobileData`. On one SIM the switch is read from the global `mobile_data` row. On a multi-SIM device it is read from `mobile_data<subId>` for the default data subscription (`multi_sim_data_call`). A switch that never settles fails with COMMAND_FAILED instead of reporting a change that did not happen. QA automation can use it to move a streaming session from Wi-Fi to cellular and back, and to check how the app warns about streaming on mobile data. On iOS, tvOS, and visionOS, the daemon refuses the setting before admission with UNSUPPORTED_OPERATION (reason setting-unsupported-on-leaf), because agent-device has no mobile-data control for Apple targets and simulators have no cellular radio. * fix(settings): settle the mobile-data row by modem count and refuse every non-Android target Mobile-data read-back: - Choose the stored row before `svc data` runs, from the active modem count (`persist.radio.multisim.config`), never from which rows exist. A stale `mobile_data<subId>` row on a single-modem device, or a seeded plain row on a multi-modem device, no longer decides the result. - Each sample reads one row. - Sample through absent or unreadable values, using a `Deadline` and the request's abort signal. - Report `mobile-data-not-applied` or `mobile-data-unreadable` as typed reasons, with `settingKey` and `storedValue` in the details. - A failure before `svc data` discloses `dispatched: no`; every failure after it discloses `unknown`. Refusals: - The daemon refuses mobile-data on every non-Android target before admission, through one shared leaf-refusal response, so a HarmonyOS or Linux session keeps its ref frame. - The Apple and Limrun iOS owners throw the same typed refusal. - `--app` is forwarded and refused with `setting_app_not_consumed`, because the switch is device-wide. - The macOS unsupported list now includes mobile-data. - Help and the commands and Limrun docs describe the refusal accurately for physical iPhones. * docs(settings): list mobile-data under setting_app_not_consumed * docs(settings): name mobile-data in the app field description * refactor(settings): slim Android mobile-data to the single-SIM read-back Read back only the global mobile_data row and document that multi-SIM devices are not covered until a dual-SIM run shows which row to read. Drop the Apple and Limrun owner refusals, mobileDataUnsupportedError, the SettingLeafRefusal rename and withDispatchDisclosure; the daemon's pre-admission check is now the only non-Android refusal. Check the request signal before svc data and pass it, with a per-read timeout bounded by the settle budget, to every adb call. Move the poll into a shared sampleUntilSettled helper that the two alert loops now use too. Leave a bad state with --app to the owner's INVALID_ARGS, cover vega and web in the daemon refusal test, collapse the Android tests into tables, and shorten the help and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(settings): inline the mobile-data poll loop and trim its tests and help * refactor(settings): keep mobile-data to the verified local adb path and trim its docs * fix(settings): serve mobile-data on Limrun Android and drop stale macOS error note * refactor(android): route the mobile-data read-back through pollUntilSettled --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5007540 commit 87fe3ea

15 files changed

Lines changed: 354 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- 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.
56
- 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.
67
- `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`.
78
- `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.

‎packages/contracts/src/client-settings.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export type SettingsUpdateOptions =
4343
state: 'clear';
4444
app?: string;
4545
})
46+
/** Android only. Naming an app is refused with `setting_app_not_consumed`. */
47+
| (DeviceCommandBaseOptions & {
48+
setting: 'mobile-data';
49+
state: 'on' | 'off';
50+
app?: string;
51+
})
4652
/** On Apple simulators `on`/`off` is a location-permission change for `app` (or the session app). */
4753
| (DeviceCommandBaseOptions & {
4854
setting: 'location';

‎packages/contracts/src/settings-catalog.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ export const SETTINGS = {
105105
forms: [{ kind: 'on-off' }],
106106
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.',
107107
},
108+
'mobile-data': {
109+
forms: [{ kind: 'on-off' }],
110+
app: 'input',
111+
appScope: { mobile: 'device-level' },
112+
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.',
113+
},
108114
location: {
109115
forms: [
110116
{ kind: 'on-off', appScope: { apple: 'app-scoped', mobile: 'device-level' } },

‎packages/contracts/src/settings.test.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ function expectInvalidArgs(run: () => unknown, message: string): void {
4141
describe('settings usage', () => {
4242
test('folds settings that share a form into one line', () => {
4343
expect(SETTINGS_USAGE_OVERRIDE.split(' | ')).toEqual([
44-
'settings <wifi|airplane|location|animations> <on|off>',
44+
'settings <wifi|airplane|mobile-data|location|animations> <on|off>',
4545
'settings location set <lat> <lon>',
4646
'settings appearance <light|dark|toggle>',
4747
`settings text-size [${TEXT_SIZE_CATEGORIES.join('|')}]`,
@@ -56,7 +56,7 @@ describe('settings usage', () => {
5656

5757
test('the invalid-args message lists the same forms, with the last one as an alternative', () => {
5858
expect(SETTINGS_INVALID_ARGS_MESSAGE).toMatch(
59-
/^settings requires <wifi\|airplane\|location\|animations> <on\|off>, .*, or permission <grant\|reset> <accessibility\|screen-recording\|input-monitoring>$/,
59+
/^settings requires <wifi\|airplane\|mobile-data\|location\|animations> <on\|off>, .*, or permission <grant\|reset> <accessibility\|screen-recording\|input-monitoring>$/,
6060
);
6161
});
6262

@@ -238,6 +238,16 @@ describe('settingsAppScope', () => {
238238
});
239239
});
240240

241+
describe('settingsAppScope mobile-data', () => {
242+
test('mobile-data switches the whole device wherever it is served', () => {
243+
expect(settingsAppScope('mobile', 'mobile-data', 'on')).toBe('device-level');
244+
expect(settingsAppScope('mobile', 'mobile-data', 'off')).toBe('device-level');
245+
// No Apple target serves it; its refusal belongs to the leaf, not to the app.
246+
expect(settingsAppScope('apple', 'mobile-data', 'on')).toBe('unknown');
247+
expect(settingsAppScope('macos-host', 'mobile-data', 'on')).toBe('unknown');
248+
});
249+
});
250+
241251
describe('settingsAppNotConsumedRefusal', () => {
242252
test('refuses by naming the mutation and quoting the app back', () => {
243253
const refusal = settingsAppNotConsumedRefusal('location', 'on', 'com.example.app');

‎packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ function settingsFacts(device: DeviceInfo) {
180180
write: {
181181
wifi: available,
182182
airplane: available,
183+
'mobile-data': available,
183184
location: available,
184185
animations: available,
185186
appearance: available,

‎packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,50 @@ test('settings iOS location set refuses a named app before reaching the owner',
637637
});
638638

639639
// A macOS permission is a host-level TCC grant, so naming an app is refused rather than dropped.
640+
test('settings mobile-data reaches the Android owner as an on/off mutation', async () => {
641+
const sessionStore = makeSessionStore();
642+
const sessionName = 'android-mobile-data';
643+
sessionStore.publish(sessionName, makeSession(sessionName, androidDevice));
644+
645+
const response = await handleSnapshotCommands({
646+
createCommandSurface: fakeCreateCommandSurface,
647+
platformServices: daemonPlatformServicesFixture(),
648+
req: snapshotRequest(sessionName, 'settings', { positionals: ['mobile-data', 'off'] }),
649+
sessionName,
650+
logPath: '/tmp/daemon.log',
651+
sessionStore,
652+
});
653+
654+
expect(response?.ok).toBe(true);
655+
expect(fixtureSettingsMutations.at(-1)).toMatchObject({ setting: 'mobile-data', state: 'off' });
656+
});
657+
658+
test('settings Android mobile-data refuses a named app before reaching the owner', async () => {
659+
const sessionStore = makeSessionStore();
660+
const sessionName = 'android-mobile-data-app-refused';
661+
sessionStore.publish(sessionName, makeSession(sessionName, androidDevice));
662+
663+
const response = await handleSnapshotCommands({
664+
createCommandSurface: fakeCreateCommandSurface,
665+
platformServices: daemonPlatformServicesFixture(),
666+
req: snapshotRequest(sessionName, 'settings', {
667+
positionals: ['mobile-data', 'off'],
668+
input: { app: 'com.example.app' },
669+
}),
670+
sessionName,
671+
logPath: '/tmp/daemon.log',
672+
sessionStore,
673+
});
674+
675+
expect(response?.ok).toBe(false);
676+
expect(fixtureSettingsMutations).toHaveLength(0);
677+
if (response && !response.ok) {
678+
expect(response.error.code).toBe('INVALID_ARGS');
679+
expect(response.error.details?.reason).toBe('setting_app_not_consumed');
680+
expect(response.error.details?.dispatched).toBe('no');
681+
}
682+
});
683+
640684
test('settings macOS permission grant refuses a named app before reaching the owner', async () => {
641685
const sessionStore = makeSessionStore();
642686
const sessionName = 'macos-permission-app-refused';

‎packages/platform-android/src/__tests__/settings.test.ts‎

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { test } from 'vitest';
1+
import { test, vi } from 'vitest';
22
import assert from 'node:assert/strict';
33
import { SETTINGS_APP_NOT_PACKAGE_REASON } from '@agent-device/contracts/settings';
44
import { TEXT_SIZE_CATEGORIES } from '@agent-device/contracts/settings-catalog';
5-
import { PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors';
5+
import { AppError, PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors';
66
import { readAndroidSetting, setAndroidSetting } from '../settings.ts';
77
import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts';
88
import { assertRejectsAppError } from './test-utils/app-error.ts';
@@ -490,3 +490,165 @@ test('readAndroidSetting text-size fails on a multiplier it cannot order', async
490490
});
491491
}
492492
});
493+
494+
// --- settings mobile-data: `svc data` with a read-back of the global `mobile_data` row ---
495+
496+
const GET_MOBILE_DATA = 'shell settings get global mobile_data';
497+
498+
/**
499+
* A single-SIM telephony service holding `held` in `mobile_data` (`null` when absent). It stores a
500+
* `svc data` write after `visibleAfterReads` further reads, or never with `honorWrites: false`.
501+
*/
502+
function telephonyService(
503+
held: string,
504+
options: { honorWrites?: boolean; visibleAfterReads?: number } = {},
505+
): FakeAdbScript {
506+
let stored = held;
507+
let pending: string | undefined;
508+
let readsUntilVisible = 0;
509+
return (args) => {
510+
const flat = args.join(' ');
511+
if (flat === GET_MOBILE_DATA) {
512+
if (pending !== undefined && readsUntilVisible-- <= 0) {
513+
stored = pending;
514+
pending = undefined;
515+
}
516+
return stored;
517+
}
518+
if (flat === 'shell svc data enable' || flat === 'shell svc data disable') {
519+
if (options.honorWrites !== false) {
520+
pending = flat.endsWith('enable') ? '1' : '0';
521+
readsUntilVisible = options.visibleAfterReads ?? 0;
522+
}
523+
return '';
524+
}
525+
return { stderr: `unexpected args: ${flat}`, exitCode: 1 };
526+
};
527+
}
528+
529+
/** Advances the faked wall clock past the settle budget on every read of the row. */
530+
function expiringOnRead(script: FakeAdbScript): FakeAdbScript {
531+
return (args, options) => {
532+
if (args.join(' ') === GET_MOBILE_DATA) vi.setSystemTime(Date.now() + 2_500);
533+
return script(args, options);
534+
};
535+
}
536+
537+
async function withFakeWallClock(run: () => Promise<void>): Promise<void> {
538+
vi.useFakeTimers({ now: 0, toFake: ['Date'] });
539+
try {
540+
await run();
541+
} finally {
542+
vi.useRealTimers();
543+
}
544+
}
545+
546+
const flatCalls = (calls: (readonly string[])[]) => calls.map((args) => args.join(' '));
547+
548+
test('setAndroidSetting mobile-data switches through svc data and reports the stored state', async () => {
549+
for (const { state, held, verb, mobileData, visibleAfterReads } of [
550+
{ state: 'off', held: '1', verb: 'disable', mobileData: 'disabled', visibleAfterReads: 0 },
551+
{ state: 'on', held: '0', verb: 'enable', mobileData: 'enabled', visibleAfterReads: 0 },
552+
// The telephony service can store the switch after `svc data` returns, so the row is read again.
553+
{ state: 'on', held: 'null', verb: 'enable', mobileData: 'enabled', visibleAfterReads: 1 },
554+
]) {
555+
const signal = new AbortController().signal;
556+
const readOptions: { signal?: AbortSignal; timeoutMs?: number }[] = [];
557+
const script = telephonyService(held, { visibleAfterReads });
558+
await withFakeAdb(
559+
(args, options) => {
560+
if (args.join(' ') === GET_MOBILE_DATA) readOptions.push(options ?? {});
561+
return script(args, options);
562+
},
563+
async ({ calls, device }) => {
564+
const result = await setAndroidSetting(
565+
device,
566+
'mobile-data',
567+
state,
568+
undefined,
569+
undefined,
570+
signal,
571+
);
572+
assert.deepEqual(result, { mobileData });
573+
assert.deepEqual(flatCalls(calls), [
574+
`shell svc data ${verb}`,
575+
...Array(visibleAfterReads + 1).fill(GET_MOBILE_DATA),
576+
]);
577+
},
578+
);
579+
for (const options of readOptions) {
580+
assert.equal(options.signal, signal);
581+
assert.ok(options.timeoutMs !== undefined && options.timeoutMs <= 2000);
582+
}
583+
}
584+
});
585+
586+
test('setAndroidSetting mobile-data fails on the last read when the switch never settles', async () => {
587+
for (const { held, reason, mobileData } of [
588+
{ held: '0', reason: 'mobile-data-not-applied', mobileData: 'disabled' },
589+
{ held: 'null', reason: 'mobile-data-unreadable', mobileData: undefined },
590+
]) {
591+
await withFakeWallClock(async () => {
592+
const script = expiringOnRead(telephonyService(held, { honorWrites: false }));
593+
await withFakeAdb(script, async ({ device }) => {
594+
await assert.rejects(setAndroidSetting(device, 'mobile-data', 'on'), (error: unknown) => {
595+
assert.ok(error instanceof AppError);
596+
assert.equal(error.code, 'COMMAND_FAILED');
597+
assert.equal(error.details?.reason, reason);
598+
assert.equal(error.details?.storedValue, held);
599+
assert.equal(error.details?.mobileData, mobileData);
600+
assert.equal(error.details?.dispatched, 'unknown');
601+
return true;
602+
});
603+
});
604+
});
605+
}
606+
});
607+
608+
test('setAndroidSetting mobile-data discloses a possible switch when svc data fails', async () => {
609+
await withFakeAdb(
610+
(args) =>
611+
args.join(' ') === 'shell svc data enable'
612+
? { stderr: "cmd: Can't find service: phone", exitCode: 20 }
613+
: undefined,
614+
async ({ calls, device }) => {
615+
await assertRejectsAppError(() => setAndroidSetting(device, 'mobile-data', 'on'), {
616+
code: 'COMMAND_FAILED',
617+
dispatched: 'unknown',
618+
});
619+
assert.deepEqual(flatCalls(calls), ['shell svc data enable']);
620+
},
621+
);
622+
});
623+
624+
test('setAndroidSetting mobile-data runs nothing for a bad state or a request cancelled up front', async () => {
625+
const controller = new AbortController();
626+
controller.abort();
627+
await withFakeAdb(telephonyService('1'), async ({ calls, device }) => {
628+
await assertRejectsAppError(() => setAndroidSetting(device, 'mobile-data', 'cellular'), {
629+
code: 'INVALID_ARGS',
630+
message: /Invalid setting state: cellular/,
631+
});
632+
await assert.rejects(
633+
setAndroidSetting(device, 'mobile-data', 'off', undefined, undefined, controller.signal),
634+
);
635+
assert.deepEqual(calls, []);
636+
});
637+
});
638+
639+
test('setAndroidSetting mobile-data stops sampling when the request is cancelled', async () => {
640+
const controller = new AbortController();
641+
const script = telephonyService('0', { honorWrites: false });
642+
await withFakeAdb(
643+
(args, options) => {
644+
if (args.join(' ') === GET_MOBILE_DATA) controller.abort();
645+
return script(args, options);
646+
},
647+
async ({ calls, device }) => {
648+
await assert.rejects(
649+
setAndroidSetting(device, 'mobile-data', 'on', undefined, undefined, controller.signal),
650+
);
651+
assert.equal(flatCalls(calls).filter((call) => call === GET_MOBILE_DATA).length, 1);
652+
},
653+
);
654+
});

‎packages/platform-android/src/runtime.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
357357
write: {
358358
wifi: available,
359359
airplane: available,
360+
'mobile-data': available,
360361
location: available,
361362
animations: available,
362363
appearance: available,

0 commit comments

Comments
 (0)