Skip to content

Commit 2c7fb93

Browse files
authored
fix(android): apply settings airplane through the connectivity service (#2234)
* fix(android): apply settings airplane through the connectivity service settings airplane wrote airplane_mode_on and then broadcast android.intent.action.AIRPLANE_MODE, which Android refuses for non-system callers. The write landed, the broadcast failed, and the device reported airplane mode with the radios still up. The connectivity service now owns the change: it is read to prove the build supports airplane mode before anything is written, driven with cmd connectivity airplane-mode enable|disable, and read again so the response reports the mode connectivity holds rather than the one requested. Builds without that command are refused unmutated with UNSUPPORTED_OPERATION. Closes #2223 * test(android): pin the mechanics eager closure at 178 modules Splitting the airplane owner out of settings.ts adds one module to the mechanics facet, which is implementation-eager by design. The row moves to the measured number in the PR that grows it. * fix(android): report only capability absence as unsupported airplane mode An unrecognized nonzero probe — a permission denial, a connectivity-service error — was answered with "requires Android 11; use a newer device". Only the prose adb prints when a build ships no shell implementation for the command now selects UNSUPPORTED_OPERATION; every other failed read stays COMMAND_FAILED with its classified hint, and the write is unreachable from both. The predicate that reads that prose already existed for the clipboard service and is now named for the question it answers, so airplane mode reuses it instead of adding a second message sniff.
1 parent 7ee1a5d commit 2c7fb93

11 files changed

Lines changed: 263 additions & 28 deletions

File tree

CHANGELOG.md

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

33
## Unreleased
44

5+
- Fixed: `settings airplane on|off` now takes an Android device offline. It is applied through
6+
the connectivity service (`cmd connectivity airplane-mode`), which drives the radios, instead of
7+
writing `airplane_mode_on` and broadcasting `ACTION_AIRPLANE_MODE_CHANGED` — a broadcast Android
8+
refuses for non-system callers, so the old path failed *after* writing the setting and left the
9+
device reporting airplane mode with the network still up (#2223). The response now reports the
10+
`airplaneMode` the connectivity service holds after the change, and an Android build that does not
11+
expose that command is refused with `UNSUPPORTED_OPERATION` before anything is written.
512
- Breaking (0.21): iOS Appium/WebDriver snapshots now expose engine-owned acquisition facts and
613
typed fidelity warnings. The SDK snapshot `truncated` field is optional when Appium cannot report
714
hierarchy completeness; regular snapshots fail closed without valid viewport evidence, while
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import { setAndroidSetting } from '../settings.ts';
4+
import type { AndroidAirplaneMode } from '../settings-airplane.ts';
5+
import { assertRejectsAppError } from './test-utils/app-error.ts';
6+
import { withFakeAdb, type FakeAdbScript } from './test-utils/fake-adb.ts';
7+
8+
const READ = 'shell cmd connectivity airplane-mode';
9+
10+
/**
11+
* An Android build whose connectivity service owns airplane mode. `honorWrites: false` models the
12+
* service accepting the command and leaving the state alone, which is what a silently ineffective
13+
* airplane-mode path looks like from the outside.
14+
*/
15+
function connectivityService(
16+
initial: AndroidAirplaneMode,
17+
options: { honorWrites?: boolean } = {},
18+
): FakeAdbScript {
19+
let state = initial;
20+
return (args) => {
21+
const flat = args.join(' ');
22+
if (flat === READ) return state;
23+
if (flat === `${READ} enable` || flat === `${READ} disable`) {
24+
if (options.honorWrites !== false) state = flat.endsWith('enable') ? 'enabled' : 'disabled';
25+
return '';
26+
}
27+
return { stderr: `unexpected args: ${flat}`, exitCode: 1 };
28+
};
29+
}
30+
31+
test('setAndroidSetting airplane on enables through connectivity and reports its state', async () => {
32+
await withFakeAdb(connectivityService('disabled'), async ({ calls, device }) => {
33+
const result = await setAndroidSetting(device, 'airplane', 'on');
34+
assert.deepEqual(result, { airplaneMode: 'enabled' });
35+
assert.deepEqual(
36+
calls.map((args) => args.join(' ')),
37+
[READ, `${READ} enable`, READ],
38+
);
39+
});
40+
});
41+
42+
test('setAndroidSetting airplane off disables through connectivity and reports its state', async () => {
43+
await withFakeAdb(connectivityService('enabled'), async ({ calls, device }) => {
44+
const result = await setAndroidSetting(device, 'airplane', 'off');
45+
assert.deepEqual(result, { airplaneMode: 'disabled' });
46+
assert.deepEqual(
47+
calls.map((args) => args.join(' ')),
48+
[READ, `${READ} disable`, READ],
49+
);
50+
});
51+
});
52+
53+
test('setAndroidSetting airplane reports the state connectivity holds, not the one requested', async () => {
54+
await withFakeAdb(
55+
connectivityService('disabled', { honorWrites: false }),
56+
async ({ calls, device }) => {
57+
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'on'), {
58+
code: 'COMMAND_FAILED',
59+
message: /reads disabled after requesting enabled/,
60+
});
61+
assert.deepEqual(
62+
calls.map((args) => args.join(' ')),
63+
[READ, `${READ} enable`, READ],
64+
);
65+
},
66+
);
67+
});
68+
69+
test('setAndroidSetting airplane refuses builds without the connectivity command before writing', async () => {
70+
await withFakeAdb(
71+
() => ({ stdout: 'Unknown command: airplane-mode', exitCode: 255 }),
72+
async ({ calls, device }) => {
73+
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'on'), {
74+
code: 'UNSUPPORTED_OPERATION',
75+
message: /no airplane-mode command/,
76+
hint: /Android 11 \(API 30\)/,
77+
});
78+
assert.deepEqual(
79+
calls.map((args) => args.join(' ')),
80+
[READ],
81+
);
82+
},
83+
);
84+
});
85+
86+
test('setAndroidSetting airplane refuses unreadable state before writing', async () => {
87+
await withFakeAdb(
88+
() => 'Airplane mode: who knows',
89+
async ({ calls, device }) => {
90+
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'off'), {
91+
code: 'COMMAND_FAILED',
92+
message: /Failed to read Android airplane mode/,
93+
});
94+
assert.deepEqual(
95+
calls.map((args) => args.join(' ')),
96+
[READ],
97+
);
98+
},
99+
);
100+
});
101+
102+
test('setAndroidSetting airplane keeps a refused read a command failure, not an unsupported build', async () => {
103+
await withFakeAdb(
104+
() => ({
105+
stdout: '',
106+
stderr: 'java.lang.SecurityException: Permission Denial: not allowed to change airplane mode',
107+
exitCode: 255,
108+
}),
109+
async ({ calls, device }) => {
110+
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'on'), {
111+
code: 'COMMAND_FAILED',
112+
message: /Failed to read Android airplane mode/,
113+
});
114+
assert.deepEqual(
115+
calls.map((args) => args.join(' ')),
116+
[READ],
117+
);
118+
},
119+
);
120+
});
121+
122+
test('setAndroidSetting airplane fails the change when the state cannot be read back', async () => {
123+
const service = connectivityService('disabled');
124+
let changed = false;
125+
await withFakeAdb(
126+
(args) => {
127+
const flat = args.join(' ');
128+
if (flat === READ && changed)
129+
return { stdout: 'Unknown command: airplane-mode', exitCode: 255 };
130+
if (flat === `${READ} enable`) changed = true;
131+
return service(args);
132+
},
133+
async ({ device }) => {
134+
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'on'), {
135+
code: 'COMMAND_FAILED',
136+
message: /Failed to read Android airplane mode after changing it/,
137+
});
138+
},
139+
);
140+
});
141+
142+
test('setAndroidSetting airplane keeps adb transport failures typed as command failures', async () => {
143+
await withFakeAdb(
144+
() => ({ stderr: 'error: device offline', exitCode: 1 }),
145+
async ({ calls, device }) => {
146+
await assertRejectsAppError(() => setAndroidSetting(device, 'airplane', 'on'), {
147+
code: 'COMMAND_FAILED',
148+
message: /Failed to read Android airplane mode/,
149+
hint: /adb reconnect/,
150+
});
151+
assert.deepEqual(
152+
calls.map((args) => args.join(' ')),
153+
[READ],
154+
);
155+
},
156+
);
157+
});

packages/platform-android/src/adb.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,18 @@ export async function runAndroidAdb(
1616
}
1717

1818
/**
19-
* Whether an adb `cmd clipboard` invocation was refused because this build ships no shell
20-
* implementation for the clipboard service, rather than because the call itself failed.
19+
* Whether an adb `cmd <service> <command>` invocation was refused because this build ships no
20+
* shell implementation for it, rather than because the call itself failed.
2121
*
2222
* adb reports this condition in its output and nowhere else — no exit code or structured field
2323
* separates "service has no shell command" from any other non-zero result — so this is the one
2424
* place that reads that prose, and it hands every caller a typed answer instead.
2525
*
26-
* Only ever ask this about a call that *failed*. A successful `clipboard get text` returns the
27-
* clipboard's contents on stdout, which is arbitrary user text and may quote these very phrases;
28-
* callers must settle a zero exit as success before reaching for this.
26+
* Only ever ask this about a call that *failed*. A command that succeeds prints its own payload
27+
* on stdout — `clipboard get text` returns arbitrary user text, which may quote these very
28+
* phrases — so callers must settle a zero exit before reaching for this.
2929
*/
30-
export function isClipboardShellUnsupported(stdout: string, stderr: string): boolean {
30+
export function isAndroidShellCommandUnsupported(stdout: string, stderr: string): boolean {
3131
const haystack = `${stdout}\n${stderr}`.toLowerCase();
3232
return (
3333
haystack.includes('no shell command implementation') || haystack.includes('unknown command')

packages/platform-android/src/device-input-state.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
33
import type { DeviceInfo } from '@agent-device/kernel/device';
44
import { AppError } from '@agent-device/kernel/errors';
55

6-
import { isClipboardShellUnsupported, sleep } from './adb.ts';
6+
import { isAndroidShellCommandUnsupported, sleep } from './adb.ts';
77
import {
88
androidAdbResultError,
99
resolveAndroidAdbExecutor,
@@ -325,7 +325,7 @@ async function runAndroidClipboardShellCommand(
325325
// the clipboard's contents, and a user who has copied one of the missing-shell phrases must not
326326
// have their own text mistaken for adb refusing the command.
327327
if (result.exitCode === 0) return result.stdout;
328-
if (isClipboardShellUnsupported(result.stdout, result.stderr)) {
328+
if (isAndroidShellCommandUnsupported(result.stdout, result.stderr)) {
329329
throw new AppError(
330330
'UNSUPPORTED_OPERATION',
331331
`Android shell clipboard ${operation} is not supported on this device.`,

packages/platform-android/src/mechanics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export {
3535
androidAdbForwardsDeviceExitStatus,
3636
resetAndroidAdbShellProtocolProbes,
3737
} from './adb-shell-protocol.ts';
38-
export { isClipboardShellUnsupported, runAndroidAdb, sleep } from './adb.ts';
38+
export { isAndroidShellCommandUnsupported, runAndroidAdb, sleep } from './adb.ts';
3939
export { handleAndroidAlert, type AndroidAlertResult } from './alert.ts';
4040
export {
4141
classifyAndroidAlertIdentifier,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { AppError } from '@agent-device/kernel/errors';
2+
import type { DeviceInfo } from '@agent-device/kernel/device';
3+
import { isAndroidShellCommandUnsupported, runAndroidAdb } from './adb.ts';
4+
import { androidAdbResultError, type AndroidAdbExecutorResult } from './adb-executor.ts';
5+
6+
export type AndroidAirplaneMode = 'enabled' | 'disabled';
7+
8+
const AIRPLANE_MODE_ARGS = ['shell', 'cmd', 'connectivity', 'airplane-mode'] as const;
9+
10+
/**
11+
* Android's connectivity service owns airplane mode: it reports the state and it drives the radios
12+
* behind it. Writing `airplane_mode_on` and broadcasting `ACTION_AIRPLANE_MODE_CHANGED` instead
13+
* moves the setting while connectivity stays up, and the broadcast is refused for non-system
14+
* callers (#2223).
15+
*
16+
* Support is proven before the write and the state is read after it, so a build that cannot answer
17+
* for airplane mode is refused unmutated and the reported mode is the one connectivity holds rather
18+
* than the one that was asked for.
19+
*/
20+
export async function setAndroidAirplaneMode(
21+
device: DeviceInfo,
22+
enabled: boolean,
23+
): Promise<{ airplaneMode: AndroidAirplaneMode }> {
24+
const requested: AndroidAirplaneMode = enabled ? 'enabled' : 'disabled';
25+
await requireAndroidAirplaneModeSupport(device);
26+
await runAndroidAdb(device, [...AIRPLANE_MODE_ARGS, enabled ? 'enable' : 'disable']);
27+
const airplaneMode = await readAndroidAirplaneMode(device);
28+
if (airplaneMode !== requested) {
29+
throw new AppError(
30+
'COMMAND_FAILED',
31+
`Android airplane mode reads ${airplaneMode} after requesting ${requested}.`,
32+
{ deviceId: device.id, requested, airplaneMode },
33+
);
34+
}
35+
return { airplaneMode };
36+
}
37+
38+
async function requireAndroidAirplaneModeSupport(device: DeviceInfo): Promise<void> {
39+
const { state, result } = await probeAndroidAirplaneMode(device);
40+
if (state) return;
41+
if (result.exitCode !== 0 && isAndroidShellCommandUnsupported(result.stdout, result.stderr)) {
42+
throw new AppError(
43+
'UNSUPPORTED_OPERATION',
44+
'The connectivity service on this Android build has no airplane-mode command, so nothing was changed.',
45+
{
46+
deviceId: device.id,
47+
hint: 'settings airplane needs cmd connectivity airplane-mode, which requires Android 11 (API 30) or newer. Use a newer device or emulator image.',
48+
stdout: result.stdout,
49+
stderr: result.stderr,
50+
exitCode: result.exitCode,
51+
},
52+
);
53+
}
54+
throw androidAdbResultError('Failed to read Android airplane mode', result, {
55+
deviceId: device.id,
56+
});
57+
}
58+
59+
async function readAndroidAirplaneMode(device: DeviceInfo): Promise<AndroidAirplaneMode> {
60+
const { state, result } = await probeAndroidAirplaneMode(device);
61+
if (state) return state;
62+
throw androidAdbResultError('Failed to read Android airplane mode after changing it', result, {
63+
deviceId: device.id,
64+
});
65+
}
66+
67+
async function probeAndroidAirplaneMode(device: DeviceInfo): Promise<{
68+
state: AndroidAirplaneMode | undefined;
69+
result: AndroidAdbExecutorResult;
70+
}> {
71+
const result = await runAndroidAdb(device, [...AIRPLANE_MODE_ARGS], { allowFailure: true });
72+
return {
73+
state: result.exitCode === 0 ? parseAndroidAirplaneMode(result.stdout) : undefined,
74+
result,
75+
};
76+
}
77+
78+
function parseAndroidAirplaneMode(stdout: string): AndroidAirplaneMode | undefined {
79+
const value = stdout.trim().toLowerCase();
80+
if (value === 'enabled') return 'enabled';
81+
if (value === 'disabled') return 'disabled';
82+
return undefined;
83+
}

packages/platform-android/src/settings.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type CommandAttemptFailure,
1010
} from './settings-parsing.ts';
1111
import { runAndroidAdb } from './adb.ts';
12+
import { setAndroidAirplaneMode } from './settings-airplane.ts';
1213
import { androidAdbResultError } from './adb-executor.ts';
1314
import { resolveAndroidApp } from './app-deployment-resolution.ts';
1415
import { setAndroidPermission } from './settings-permission.ts';
@@ -35,21 +36,7 @@ export async function setAndroidSetting(
3536
return;
3637
}
3738
case 'airplane': {
38-
const enabled = parseSettingState(state);
39-
const flag = enabled ? '1' : '0';
40-
const bool = enabled ? 'true' : 'false';
41-
await runAndroidAdb(device, ['shell', 'settings', 'put', 'global', 'airplane_mode_on', flag]);
42-
await runAndroidAdb(device, [
43-
'shell',
44-
'am',
45-
'broadcast',
46-
'-a',
47-
'android.intent.action.AIRPLANE_MODE',
48-
'--ez',
49-
'state',
50-
bool,
51-
]);
52-
return;
39+
return await setAndroidAirplaneMode(device, parseSettingState(state));
5340
}
5441
case 'location': {
5542
if (state.toLowerCase() === 'set') {

scripts/__tests__/eager-closure-budgets.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
310310
'packages/platform-android/src/adb-host.ts': 1,
311311
// The named mechanics facet is intentionally implementation-eager once selected. Its exact
312312
// closure is pinned so a future facade expansion is visible in review.
313-
'packages/platform-android/src/mechanics.ts': 177,
313+
'packages/platform-android/src/mechanics.ts': 178,
314314

315315
// --- @agent-device/platform-apple ---
316316
'packages/platform-apple/src/index.ts': 1,

src/commands/capture/settings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export const settingsCommandFacet = defineCommandFacet({
6060
text: {
6161
summary: 'Change OS settings and app permissions',
6262
cliDetail:
63-
'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.',
63+
'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.',
6464
},
6565
metadata: settingsCommandMetadata,
6666
definition: settingsCommandDefinition,

src/platform-runtime-android-tool-host.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ export function createAndroidToolHost(): AndroidToolHost {
1818
*/
1919
probeClipboardShellSupport: async (device, signal) => {
2020
try {
21-
const { runAndroidAdb, isClipboardShellUnsupported } = await loadAndroidMechanics();
21+
const { runAndroidAdb, isAndroidShellCommandUnsupported } = await loadAndroidMechanics();
2222
const result = await runAndroidAdb(device, ['shell', 'cmd', 'clipboard', 'get', 'text'], {
2323
allowFailure: true,
2424
signal,
2525
});
2626
if (result.exitCode === 0) return 'supported';
27-
return isClipboardShellUnsupported(result.stdout, result.stderr)
27+
return isAndroidShellCommandUnsupported(result.stdout, result.stderr)
2828
? 'unsupported'
2929
: 'probe-failed';
3030
} catch {

0 commit comments

Comments
 (0)