diff --git a/CHANGELOG.md b/CHANGELOG.md index e87f570c69..b9a77e05e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Add `uninstall ` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs. - 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. +- Add `settings bluetooth on|off` for adb-backed Android targets. It turns the Bluetooth adapter on or off with `svc bluetooth`, from Android 8.1, and reports the resulting `bluetooth` state. When the device refuses the change it fails with `COMMAND_FAILED` and `error.details.reason` `bluetooth-not-applied`, or `bluetooth-unreadable` if `bluetooth_on` holds a value it does not recognize. A device without a Bluetooth adapter is refused with `UNSUPPORTED_OPERATION` and `error.details.reason: android_bluetooth_adapter_missing` before anything changes, and a device with an adapter on a build older than Android 8.1 with `android_bluetooth_command_unavailable`. `--app` is refused because the switch is device-wide. - On iOS, a `fill` that runs under a degraded XCTest snapshot channel now aims at a point, including ref, selector and replay fills. When the runner identifies no text input under that point, it refuses with `NO_TEXT_INPUT_AT_POINT` (`error.details.reason: no_text_input_at_point`, `dispatched: no`) before dispatching its focus tap, and no field changes; when the input lookup itself cannot answer, it refuses with `TEXT_INPUT_PROBE_UNAVAILABLE` (`error.details.reason: text_input_probe_unavailable`). Previously such a fill cleared and typed through the app-level first responder, so it could rewrite a field the caller never named, or report a clear failure after emptying it. A ref or selector fill whose rect centre misses the real field (an outer container, a non-input node) now needs a point inside the input's own rect. - 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`. diff --git a/packages/contracts/src/client-settings.test.ts b/packages/contracts/src/client-settings.test.ts index c40a9fcca5..faacb34c2b 100644 --- a/packages/contracts/src/client-settings.test.ts +++ b/packages/contracts/src/client-settings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, expectTypeOf, test, vi } from 'vitest'; import type { SettingsUpdateOptions } from './client-settings.ts'; +import type { SettingName } from './settings-catalog.ts'; type Permission = Extract; @@ -43,6 +44,23 @@ describe('public client permission vocabulary', () => { }); }); +describe('public client settings cover the catalog', () => { + test('every catalog setting has an update arm', () => { + type SettingWithoutArm = { + [Name in SettingName]: [Extract] extends [never] + ? Name + : never; + }[SettingName]; + expectTypeOf().toEqualTypeOf(); + }); + + test('the device-wide toggles take on|off and an app to refuse', () => { + type Toggle = Extract; + expectTypeOf().toEqualTypeOf<'on' | 'off'>(); + expectTypeOf().toEqualTypeOf(); + }); +}); + /** * The client-facing vocabulary is a type surface. Deriving it from `settings.ts` must not put that * module, and with it `AppError`, on the client's runtime path — a value import here would load it. diff --git a/packages/contracts/src/client-settings.ts b/packages/contracts/src/client-settings.ts index 8489df6ed6..870ffd1a13 100644 --- a/packages/contracts/src/client-settings.ts +++ b/packages/contracts/src/client-settings.ts @@ -36,19 +36,32 @@ type StateOnlySettingOptions = { : { setting: Name; state: SettingState }); }[StateOnlySetting]; +/** Switches that change the whole Android device; naming an app is refused with `setting_app_not_consumed`. */ +type DeviceLevelToggleSetting = { + [Name in SettingName]: (typeof SETTINGS)[Name] extends { + app: 'input'; + appScope: { mobile: 'device-level' }; + } + ? Name + : never; +}[SettingName]; + +type DeviceLevelToggleOptions = { + [Name in DeviceLevelToggleSetting]: DeviceCommandBaseOptions & { + setting: Name; + state: SettingState; + app?: string; + }; +}[DeviceLevelToggleSetting]; + export type SettingsUpdateOptions = | StateOnlySettingOptions + | DeviceLevelToggleOptions | (DeviceCommandBaseOptions & { setting: 'clear-app-state'; 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 58c6be1570..8506fc8817 100644 --- a/packages/contracts/src/settings-catalog.ts +++ b/packages/contracts/src/settings-catalog.ts @@ -101,6 +101,12 @@ export const SETTINGS = { forms: [{ kind: 'on-off' }], help: 'Android switches Wi-Fi with svc wifi; an Apple simulator overrides only its status bar.', }, + bluetooth: { + forms: [{ kind: 'on-off' }], + app: 'input', + appScope: { mobile: 'device-level' }, + help: 'Android over adb turns its Bluetooth adapter on or off with svc bluetooth, from Android 8.1. It reports the bluetooth state the device holds afterwards, or fails with bluetooth-not-applied or bluetooth-unreadable. A device without an adapter refuses with android_bluetooth_adapter_missing, and an adapter on a build older than 8.1 with android_bluetooth_command_unavailable, before anything changes. --app is refused.', + }, airplane: { 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.', diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 209b698966..6ab1889f73 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 $/, ); }); @@ -70,6 +70,10 @@ describe('parseSettingsMutation', () => { expect(parseSettingsMutation(['WiFi', 'ON'])).toEqual({ setting: 'wifi', state: 'on' }); expect(parseSettingsMutation(['airplane', '0'])).toEqual({ setting: 'airplane', state: 'off' }); expect(parseSettingsMutation(['wifi', ' true '])).toEqual({ setting: 'wifi', state: 'on' }); + expect(parseSettingsMutation(['Bluetooth', 'off'])).toEqual({ + setting: 'bluetooth', + state: 'off', + }); }); test('refuses a state outside a choice with the accepted states', () => { @@ -84,10 +88,7 @@ describe('parseSettingsMutation', () => { }); test('refuses an unknown setting and extra positionals with the usage', () => { - expectInvalidArgs( - () => parseSettingsMutation(['bluetooth', 'on']), - SETTINGS_INVALID_ARGS_MESSAGE, - ); + expectInvalidArgs(() => parseSettingsMutation(['nfc', 'on']), SETTINGS_INVALID_ARGS_MESSAGE); expectInvalidArgs( () => parseSettingsMutation(['reset-keychain', 'clear', 'extra']), SETTINGS_INVALID_ARGS_MESSAGE, @@ -238,14 +239,17 @@ 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('settingsAppScope device-wide radios', () => { + test.each(['mobile-data', 'bluetooth'])( + '%s switches the whole device wherever it is served', + (setting) => { + expect(settingsAppScope('mobile', setting, 'on')).toBe('device-level'); + expect(settingsAppScope('mobile', setting, 'off')).toBe('device-level'); + // No Apple target serves it; its refusal belongs to the leaf, not to the app. + expect(settingsAppScope('apple', setting, 'on')).toBe('unknown'); + expect(settingsAppScope('macos-host', setting, 'on')).toBe('unknown'); + }, + ); }); describe('settingsAppNotConsumedRefusal', () => { diff --git a/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts b/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts index 1b47864824..c878501fb0 100644 --- a/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts +++ b/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts @@ -179,6 +179,7 @@ function settingsFacts(device: DeviceInfo) { unsupported, write: { wifi: available, + bluetooth: available, airplane: available, 'mobile-data': available, location: 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 af78e832b2..7a5f9cb30a 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 @@ -227,6 +227,29 @@ test('settings text-size reads the category the owner holds without mutating any expect(fixtureSettingsMutations).toHaveLength(0); }); +test('settings bluetooth on an iOS simulator is refused at admission, before dispatch', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-bluetooth'; + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); + + const response = await handleSnapshotCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: snapshotRequest(sessionName, 'settings', { positionals: ['bluetooth', 'on'] }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.message).toBe('settings bluetooth is not supported on this device'); + expect(response.error.details?.reason).toBe('unsupported-platform-leaf'); + } + expect(fixtureSettingsMutations).toHaveLength(0); +}); + test('settings on the macOS host refuses a setting it does not serve, read and write alike', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-settings'; @@ -655,31 +678,34 @@ test('settings mobile-data reaches the Android owner as an on/off mutation', asy 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)); +test.each(['mobile-data', 'bluetooth'])( + 'settings Android %s refuses a named app before reaching the owner', + async (setting) => { + const sessionStore = makeSessionStore(); + const sessionName = `android-${setting}-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, - }); + const response = await handleSnapshotCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: snapshotRequest(sessionName, 'settings', { + positionals: [setting, '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'); - } -}); + 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(); diff --git a/packages/platform-android/src/__tests__/alert.test.ts b/packages/platform-android/src/__tests__/alert.test.ts index eb6a5aa95c..5cb2e3374f 100644 --- a/packages/platform-android/src/__tests__/alert.test.ts +++ b/packages/platform-android/src/__tests__/alert.test.ts @@ -1,5 +1,6 @@ import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; +import { underFakeTimers } from './test-utils/fake-timers.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { deviceShellArgv, type ShellWord } from '@agent-device/kernel/device-shell'; import { button, node, text } from './alert-fixtures.ts'; @@ -23,19 +24,6 @@ const dialog = [ button(2, 'Cancel', 'android:id/button2', { x: 210, y: 612 }), ]; -/** Runs an alert action whose polls sleep between captures, without waiting the sleeps out. */ -async function underFakeTimers(run: () => Promise): Promise { - vi.useFakeTimers(); - try { - const outcome = run(); - outcome.catch(() => {}); - await vi.runAllTimersAsync(); - return await outcome; - } finally { - vi.useRealTimers(); - } -} - /** The dialog is in the tree until the button press lands, then gone — the real timeline. */ function dialogUntilPressed(nodes = dialog) { return async () => (runAndroidAdb.mock.calls.length === 0 ? nodes : []); diff --git a/packages/platform-android/src/__tests__/device-sdk-int.test.ts b/packages/platform-android/src/__tests__/device-sdk-int.test.ts new file mode 100644 index 0000000000..de3a92c527 --- /dev/null +++ b/packages/platform-android/src/__tests__/device-sdk-int.test.ts @@ -0,0 +1,24 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { readAndroidSdkInt } from '../device-sdk-int.ts'; +import { withFakeAdb, type FakeAdbResponse } from './test-utils/fake-adb.ts'; + +test.each([ + { response: '34\n', expected: 34 }, + { response: '', expected: null }, + { response: 'unknown', expected: null }, + { response: '0', expected: null }, + { response: '27garbage', expected: null }, + { response: { stderr: 'error: closed', exitCode: 1 }, expected: null }, +] satisfies { response: FakeAdbResponse; expected: number | null }[])( + 'readAndroidSdkInt reads $response as $expected', + async ({ response, expected }) => { + await withFakeAdb( + () => response, + async ({ calls, device }) => { + assert.equal((await readAndroidSdkInt(device)).sdkInt, expected); + assert.deepEqual(calls, [['shell', 'getprop', 'ro.build.version.sdk']]); + }, + ); + }, +); diff --git a/packages/platform-android/src/__tests__/fill-verification.test.ts b/packages/platform-android/src/__tests__/fill-verification.test.ts index 69725ead74..b699f01f09 100644 --- a/packages/platform-android/src/__tests__/fill-verification.test.ts +++ b/packages/platform-android/src/__tests__/fill-verification.test.ts @@ -8,9 +8,10 @@ // whether any of a commit reached the field, which both the soft-success guard and the IME rebind // share. -import { afterEach, beforeEach, test, vi } from 'vitest'; +import { afterEach, beforeEach, test } from 'vitest'; import './test-utils/android-host-test-setup.ts'; import assert from 'node:assert/strict'; +import { underFakeTimers } from './test-utils/fake-timers.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { withAndroidAdbProvider, type AndroidAdbProvider } from '../adb-executor.ts'; import type { AndroidFillVerificationNode } from '../fill-diagnostics.ts'; @@ -145,25 +146,6 @@ test('a value that never lands is a mismatch read from the last sample', async ( ); }); -/** - * Runs the sampler with its sleeps advanced instead of waited out. The clock moves in slices - * shorter than a sample interval so the helper session's own timers never fire ahead of its replies. - */ -async function underFakeTimers(run: () => Promise): Promise { - vi.useFakeTimers(); - try { - let done = false; - const outcome = run().finally(() => { - done = true; - }); - outcome.catch(() => {}); - while (!done) await vi.advanceTimersByTimeAsync(10); - return await outcome; - } finally { - vi.useRealTimers(); - } -} - test('the pre-action target read shares the daemon-session helper with the samples', async () => { const session = createFillHelperSession(); diff --git a/packages/platform-android/src/__tests__/settings-bluetooth.test.ts b/packages/platform-android/src/__tests__/settings-bluetooth.test.ts new file mode 100644 index 0000000000..da74a61e01 --- /dev/null +++ b/packages/platform-android/src/__tests__/settings-bluetooth.test.ts @@ -0,0 +1,254 @@ +import { expect, test } from 'vitest'; +import assert from 'node:assert/strict'; +import { setAndroidSetting } from '../settings.ts'; +import { assertRejectsAppError } from './test-utils/app-error.ts'; +import { withFakeAdb, type FakeAdbResponse } from './test-utils/fake-adb.ts'; +import { underFakeTimers } from './test-utils/fake-timers.ts'; + +const FEATURES = 'shell pm list features'; +const SDK = 'shell getprop ro.build.version.sdk'; +const STATE = 'shell settings get global bluetooth_on'; +const ENABLE = 'shell svc bluetooth enable'; +const WITH_ADAPTER = [ + 'feature:android.hardware.bluetooth', + 'feature:android.hardware.bluetooth_le', + 'feature:android.hardware.wifi', +].join('\n'); + +type BluetoothManagerOptions = { + write?: FakeAdbResponse; + stateRead?: FakeAdbResponse; + initialState?: string; + stateAfterWrite?: (action: string) => string; + staleReads?: number; + honorWrites?: boolean; +}; + +/** + * A Bluetooth manager that persists `bluetooth_on` after a write lands. `staleReads` is how many + * reads still see the old value, `stateAfterWrite` what it persists, and `honorWrites: false` a + * write the adapter refuses while the command still exits 0. + */ +function bluetoothManager(options: BluetoothManagerOptions) { + let state = options.initialState ?? '0'; + let pending: string | undefined; + let staleReads = 0; + return { + read(): FakeAdbResponse { + if (options.stateRead) return options.stateRead; + if (pending !== undefined && staleReads-- <= 0) { + state = pending; + pending = undefined; + } + return state; + }, + write(action: string): FakeAdbResponse { + if (options.honorWrites !== false) { + pending = options.stateAfterWrite?.(action) ?? (action === 'enable' ? '1' : '0'); + staleReads = options.staleReads ?? 0; + } + return options.write ?? ''; + }, + }; +} + +function bluetoothDevice( + sdk: FakeAdbResponse, + options: BluetoothManagerOptions & { features?: FakeAdbResponse } = {}, +) { + const manager = bluetoothManager(options); + const probes = new Map([ + [FEATURES, options.features ?? WITH_ADAPTER], + [SDK, sdk], + ]); + return (args: string[]): FakeAdbResponse => { + const flat = args.join(' '); + const probe = probes.get(flat); + if (probe !== undefined) return probe; + if (flat === STATE) return manager.read(); + const write = /^shell svc bluetooth (enable|disable)$/.exec(flat); + if (write) return manager.write(write[1]!); + return { stderr: `unexpected args: ${flat}`, exitCode: 1 }; + }; +} + +test.each([ + { sdk: '27', state: 'on', write: ENABLE, bluetooth: 'enabled' }, + { sdk: '36', state: 'off', write: 'shell svc bluetooth disable', bluetooth: 'disabled' }, +])( + 'setAndroidSetting bluetooth $state on API $sdk runs $write and reports $bluetooth', + async ({ sdk, state, write, bluetooth }) => { + const initialState = state === 'on' ? '0' : '1'; + await withFakeAdb(bluetoothDevice(sdk, { initialState }), async ({ calls, device }) => { + assert.deepEqual(await setAndroidSetting(device, 'bluetooth', state), { bluetooth }); + assert.deepEqual( + calls.map((args) => args.join(' ')), + [FEATURES, SDK, write, STATE], + ); + }); + }, +); + +test('setAndroidSetting bluetooth waits for the Bluetooth manager to persist the change', async () => { + await withFakeAdb(bluetoothDevice('34', { staleReads: 2 }), async ({ calls, device }) => { + const result = await underFakeTimers(() => setAndroidSetting(device, 'bluetooth', 'on')); + assert.deepEqual(result, { bluetooth: 'enabled' }); + assert.deepEqual( + calls.slice(3).map((args) => args.join(' ')), + [STATE, STATE, STATE], + ); + }); +}); + +test('setAndroidSetting bluetooth off accepts the state airplane mode leaves behind', async () => { + await withFakeAdb( + bluetoothDevice('30', { initialState: '1', stateAfterWrite: () => '2' }), + async ({ device }) => { + assert.deepEqual(await setAndroidSetting(device, 'bluetooth', 'off'), { + bluetooth: 'disabled', + }); + }, + ); +}); + +test.each([ + { + name: 'a refused write', + options: { honorWrites: false }, + message: /reads disabled after requesting enabled/, + reason: 'bluetooth-not-applied', + storedValue: '0', + }, + { + name: 'an unreadable state', + options: { stateAfterWrite: () => 'null' }, + message: /unusable bluetooth_on value after requesting Bluetooth enabled: null/, + reason: 'bluetooth-unreadable', + storedValue: 'null', + }, +])( + 'setAndroidSetting bluetooth fails with $reason on $name that exits 0', + async ({ options, message, reason, storedValue }) => { + await withFakeAdb(bluetoothDevice('30', options), async ({ calls, device }) => { + await expect( + underFakeTimers(() => setAndroidSetting(device, 'bluetooth', 'on')), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: expect.stringMatching(message), + details: { + reason, + storedValue, + dispatched: 'unknown', + hint: expect.stringMatching(/settings get global bluetooth_on/), + }, + }); + assert.ok(calls.filter((args) => args.join(' ') === STATE).length > 1); + }); + }, +); + +const offline = { stderr: 'error: device offline', exitCode: 1 }; + +test.each([ + { + name: 'an unreadable feature list', + device: bluetoothDevice('34', { features: offline }), + error: { + code: 'COMMAND_FAILED', + message: /Failed to read Android device features/, + details: { dispatched: 'no' }, + }, + calls: [FEATURES], + }, + { + // A Bluetooth LE feature alone is not the adapter feature the Bluetooth service needs. + name: 'a device without an adapter', + device: bluetoothDevice('34', { features: 'feature:android.hardware.bluetooth_le' }), + error: { + code: 'UNSUPPORTED_OPERATION', + message: /no Bluetooth adapter/, + details: { + reason: 'android_bluetooth_adapter_missing', + dispatched: 'no', + hint: expect.stringMatching(/android\.hardware\.bluetooth/), + }, + }, + calls: [FEATURES], + }, + { + name: 'an unreadable API level', + device: bluetoothDevice({ stderr: 'getprop: closed', exitCode: 1 }), + error: { + code: 'COMMAND_FAILED', + message: /Failed to read the Android API level/, + details: { stderr: 'getprop: closed', exitCode: 1, dispatched: 'no' }, + }, + calls: [FEATURES, SDK], + }, + { + name: 'a build older than Android 8.1', + device: bluetoothDevice('26'), + error: { + code: 'UNSUPPORTED_OPERATION', + message: /no shell command for Bluetooth/, + details: { + reason: 'android_bluetooth_command_unavailable', + dispatched: 'no', + hint: expect.stringMatching(/Android 8\.1 \(API 27\)/), + }, + }, + calls: [FEATURES, SDK], + }, + { + name: 'a failed write', + device: bluetoothDevice('34', { write: { stderr: 'svc: closed', exitCode: 255 } }), + error: { + code: 'COMMAND_FAILED', + message: /Failed to turn Android Bluetooth on/, + details: { dispatched: 'unknown' }, + }, + calls: [FEATURES, SDK, ENABLE], + }, + { + name: 'a failed state read', + device: bluetoothDevice('34', { stateRead: offline }), + error: { + code: 'COMMAND_FAILED', + message: /Failed to read Android Bluetooth state/, + details: { dispatched: 'unknown' }, + }, + calls: [FEATURES, SDK, ENABLE, STATE], + }, +])('setAndroidSetting bluetooth on stops at $name', async ({ device, error, calls }) => { + await withFakeAdb(device, async ({ calls: made, device: target }) => { + await expect(setAndroidSetting(target, 'bluetooth', 'on')).rejects.toMatchObject({ + ...error, + message: expect.stringMatching(error.message), + }); + assert.deepEqual( + made.map((args) => args.join(' ')), + calls, + ); + }); +}); + +test('setAndroidSetting bluetooth writes nothing once the request is cancelled during the checks', async () => { + const controller = new AbortController(); + const script = bluetoothDevice('34'); + await withFakeAdb( + (args) => { + if (args.join(' ') === SDK) controller.abort(); + return script(args); + }, + async ({ calls, device }) => { + await assertRejectsAppError( + () => setAndroidSetting(device, 'bluetooth', 'on', undefined, undefined, controller.signal), + { code: 'COMMAND_FAILED', dispatched: 'no' }, + ); + assert.deepEqual( + calls.map((args) => args.join(' ')), + [FEATURES, SDK], + ); + }, + ); +}); diff --git a/packages/platform-android/src/__tests__/settings-global-toggle.test.ts b/packages/platform-android/src/__tests__/settings-global-toggle.test.ts new file mode 100644 index 0000000000..421c443bc2 --- /dev/null +++ b/packages/platform-android/src/__tests__/settings-global-toggle.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from 'vitest'; +import assert from 'node:assert/strict'; +import { setAndroidGlobalToggle, type AndroidGlobalToggle } from '../settings-global-toggle.ts'; +import { withFakeAdb } from './test-utils/fake-adb.ts'; +import { underFakeTimers } from './test-utils/fake-timers.ts'; + +const READ = 'shell settings get global example_on'; + +const TOGGLE: AndroidGlobalToggle<'example'> = { + service: 'example', + row: 'example_on', + label: 'example', + stateKey: 'example', + parseRow: (storedValue) => + storedValue === '1' ? 'enabled' : storedValue === '0' ? 'disabled' : undefined, + notAppliedReason: 'example-not-applied', + unreadableReason: 'example-unreadable', + sampleIntervalMs: 300, + hint: 'Read example_on.', +}; + +test('the sample taken after the settle window runs out still answers with the typed reason', async () => { + const readTimeouts: (number | undefined)[] = []; + await withFakeAdb( + (args, options) => { + if (args.join(' ') === READ) readTimeouts.push(options?.timeoutMs); + return args.join(' ') === READ ? '0' : ''; + }, + async ({ device }) => { + await expect( + underFakeTimers(() => setAndroidGlobalToggle(device, TOGGLE, true, undefined)), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'example-not-applied', + example: 'disabled', + storedValue: '0', + dispatched: 'unknown', + }, + }); + }, + ); + assert.ok(readTimeouts.length > 1); + for (const timeoutMs of readTimeouts) assert.ok(timeoutMs !== undefined && timeoutMs >= 1_000); +}); diff --git a/packages/platform-android/src/__tests__/test-utils/fake-timers.ts b/packages/platform-android/src/__tests__/test-utils/fake-timers.ts new file mode 100644 index 0000000000..cbb60f6584 --- /dev/null +++ b/packages/platform-android/src/__tests__/test-utils/fake-timers.ts @@ -0,0 +1,20 @@ +import { vi } from 'vitest'; + +/** + * Runs `run` with its sleeps advanced instead of waited out. The clock moves in slices shorter than + * a poll interval, so a helper session's own timers never fire ahead of its replies. + */ +export async function underFakeTimers(run: () => Promise): Promise { + vi.useFakeTimers(); + try { + let done = false; + const outcome = run().finally(() => { + done = true; + }); + outcome.catch(() => {}); + while (!done) await vi.advanceTimersByTimeAsync(10); + return await outcome; + } finally { + vi.useRealTimers(); + } +} diff --git a/packages/platform-android/src/device-sdk-int.ts b/packages/platform-android/src/device-sdk-int.ts new file mode 100644 index 0000000000..1dc6c5ab6b --- /dev/null +++ b/packages/platform-android/src/device-sdk-int.ts @@ -0,0 +1,20 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { runAndroidShell } from './adb.ts'; +import type { AndroidAdbExecutorResult } from './adb-executor.ts'; + +/** + * The device's `ro.build.version.sdk`; `sdkInt` is `null` when the property cannot be read as an + * API level, and `result` keeps the `getprop` output for the caller's error. + */ +export async function readAndroidSdkInt( + device: DeviceInfo, + signal?: AbortSignal, +): Promise<{ sdkInt: number | null; result: AndroidAdbExecutorResult }> { + const result = await runAndroidShell(device, ['getprop', 'ro.build.version.sdk'], { + allowFailure: true, + signal, + }); + if (result.exitCode !== 0) return { sdkInt: null, result }; + const value = result.stdout.trim(); + return { sdkInt: /^[1-9]\d{0,3}$/.test(value) ? Number(value) : null, result }; +} diff --git a/packages/platform-android/src/inventory-parsers.test.ts b/packages/platform-android/src/inventory-parsers.test.ts index 5f8f383c42..3eb44f5ba3 100644 --- a/packages/platform-android/src/inventory-parsers.test.ts +++ b/packages/platform-android/src/inventory-parsers.test.ts @@ -7,7 +7,7 @@ import { parseAndroidDeviceDescription, parseAndroidDeviceEntries, parseAndroidEmulatorAvdNameOutput, - parseAndroidFeatureListForTv, + androidFeatureListDeclares, parseAndroidTargetFromCharacteristics, } from './inventory-parsers.ts'; @@ -32,7 +32,14 @@ test('Android inventory parsers preserve device, AVD-name, and TV detection sema assert.equal(parseAndroidEmulatorAvdNameOutput('\r\nOK\r\n'), undefined); assert.equal(parseAndroidTargetFromCharacteristics('tv,nosdcard'), 'tv'); assert.equal(parseAndroidTargetFromCharacteristics('phone,tablet'), null); - assert.equal(parseAndroidFeatureListForTv('feature:android.hardware.type.television'), true); + const features = + 'feature:android.hardware.bluetooth_le\r\nfeature:android.hardware.type.television=1\n'; + assert.equal(androidFeatureListDeclares(features, 'android.hardware.type.television'), true); + assert.equal(androidFeatureListDeclares(features, 'android.hardware.bluetooth'), false); + assert.equal( + androidFeatureListDeclares('feature:Android.Software.LEANBACK', 'android.software.leanback'), + true, + ); }); test('Android description parser reads the model and OS version lines and skips unset props', () => { diff --git a/packages/platform-android/src/inventory-parsers.ts b/packages/platform-android/src/inventory-parsers.ts index cf65fae510..6d2a07e7f2 100644 --- a/packages/platform-android/src/inventory-parsers.ts +++ b/packages/platform-android/src/inventory-parsers.ts @@ -60,10 +60,12 @@ export function parseAndroidTargetFromCharacteristics(rawOutput: string): 'tv' | return normalized.includes('tv') || normalized.includes('leanback') ? 'tv' : null; } -export function parseAndroidFeatureListForTv(rawOutput: string): boolean { - return /feature:android\.(software\.leanback(_only)?|hardware\.type\.television)\b/i.test( - rawOutput, - ); +/** `pm list features` prints one `feature:` or `feature:=` line per feature. */ +export function androidFeatureListDeclares(rawOutput: string, feature: string): boolean { + const declared = `feature:${feature}`.toLowerCase(); + return rawOutput + .split(/\r?\n/) + .some((line) => line.trim().split('=')[0]?.toLowerCase() === declared); } export function inferAndroidAvdTarget(avdName: string): 'mobile' | 'tv' { diff --git a/packages/platform-android/src/inventory.test.ts b/packages/platform-android/src/inventory.test.ts index 2c3f4f50f6..83fcf072fa 100644 --- a/packages/platform-android/src/inventory.test.ts +++ b/packages/platform-android/src/inventory.test.ts @@ -111,6 +111,32 @@ test.each([ }, ); +test.each([ + ['feature:android.software.leanback_only\nfeature:android.hardware.wifi\n', 'tv'], + ['feature:android.hardware.type.television=1\n', 'tv'], + ['feature:android.software.leanback_extra\nfeature:android.hardware.wifi\n', 'mobile'], +] as const)( + 'Android inventory reads the feature list %j as a %s target', + async (featureList, target) => { + const host = createHost(async (request) => { + const key = request.args.join('\0'); + if (key === 'devices\0-l') + return result('List of devices attached\nR5CT1 device model:Box\n'); + if (key.includes('pm\0list\0features')) return result(featureList); + const probe = RUNNING_EMULATOR_PROBES.find(([fragment]) => key.includes(fragment)); + if (probe) return result(probe[1]); + throw new Error(`Unexpected command: ${request.executable} ${request.args.join(' ')}`); + }); + + const [device] = await createAndroidInventory(host).discover( + { androidAvdSelection: 'running-only' }, + scope, + ); + + assert.equal(device?.target, target); + }, +); + test('Android inventory fails closed when adb is unavailable', async () => { const host = createHost(async () => result(''), { adb: undefined }); await assert.rejects( diff --git a/packages/platform-android/src/inventory.ts b/packages/platform-android/src/inventory.ts index 9d8de97079..6066de5d96 100644 --- a/packages/platform-android/src/inventory.ts +++ b/packages/platform-android/src/inventory.ts @@ -19,7 +19,7 @@ import { parseAndroidDeviceDescription, parseAndroidDeviceEntries, parseAndroidEmulatorAvdNameOutput, - parseAndroidFeatureListForTv, + androidFeatureListDeclares, parseAndroidTargetFromCharacteristics, type AndroidDeviceEntry, } from './inventory-parsers.ts'; @@ -229,7 +229,10 @@ async function resolveTarget( return 'tv'; } const featureList = await runAdbShell(context, serial, ['pm', 'list', 'features']); - return parseAndroidFeatureListForTv(commandOutput(featureList)) ? 'tv' : 'mobile'; + const features = commandOutput(featureList); + return TV_FEATURES.some((feature) => androidFeatureListDeclares(features, feature)) + ? 'tv' + : 'mobile'; } async function listStoppedAvds( diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index a5267bcabb..e6b97c5cb5 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -356,6 +356,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor unsupported: androidSettingUnavailable, write: { wifi: available, + bluetooth: available, airplane: available, 'mobile-data': available, location: available, diff --git a/packages/platform-android/src/settings-bluetooth.ts b/packages/platform-android/src/settings-bluetooth.ts new file mode 100644 index 0000000000..5a37b04243 --- /dev/null +++ b/packages/platform-android/src/settings-bluetooth.ts @@ -0,0 +1,103 @@ +import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { runAndroidShell } from './adb.ts'; +import { androidAdbResultError } from './adb-failure.ts'; +import { readAndroidSdkInt } from './device-sdk-int.ts'; +import { androidFeatureListDeclares } from './inventory-parsers.ts'; +import { + setAndroidGlobalToggle, + type AndroidGlobalToggle, + type AndroidToggleState, +} from './settings-global-toggle.ts'; + +const NO_BLUETOOTH_ADAPTER_REASON = 'android_bluetooth_adapter_missing'; +const BLUETOOTH_COMMAND_UNAVAILABLE_REASON = 'android_bluetooth_command_unavailable'; + +/** Without this feature no Bluetooth service starts, and `svc bluetooth` exits 0 having done nothing. */ +const BLUETOOTH_FEATURE = 'android.hardware.bluetooth'; + +/** + * Android 8.1. Older `svc` prints its help for an unknown command and exits 0. From Android 13 + * `svc bluetooth` forwards to `cmd bluetooth_manager`, so one command serves every level. + */ +const SVC_BLUETOOTH_MIN_SDK = 27; + +/** + * The Bluetooth manager persists `bluetooth_on`: 1 while the adapter is on, and 0, or 2 when + * airplane mode turned it off. `svc bluetooth` exits 0 when the adapter refuses the change. + */ +const BLUETOOTH_TOGGLE: AndroidGlobalToggle<'bluetooth'> = { + service: 'bluetooth', + row: 'bluetooth_on', + label: 'Bluetooth', + stateKey: 'bluetooth', + parseRow: (storedValue) => { + if (storedValue === '1') return 'enabled'; + if (storedValue === '0' || storedValue === '2') return 'disabled'; + return undefined; + }, + notAppliedReason: 'bluetooth-not-applied', + unreadableReason: 'bluetooth-unreadable', + sampleIntervalMs: 200, + hint: 'A device policy or user restriction can block Bluetooth changes. Run `adb shell settings get global bluetooth_on` to see what the device holds.', +}; + +/** The adapter and the API level are proven before the write. */ +export async function setAndroidBluetooth( + device: DeviceInfo, + enabled: boolean, + signal: AbortSignal | undefined, +): Promise<{ bluetooth: AndroidToggleState }> { + try { + await requireAndroidSvcBluetooth(device, signal); + } catch (error) { + throw error instanceof AppError ? discloseDispatch(error, 'no') : error; + } + return await setAndroidGlobalToggle(device, BLUETOOTH_TOGGLE, enabled, signal); +} + +async function requireAndroidSvcBluetooth( + device: DeviceInfo, + signal: AbortSignal | undefined, +): Promise { + const features = await runAndroidShell(device, ['pm', 'list', 'features'], { + allowFailure: true, + signal, + }); + if (features.exitCode !== 0) { + throw androidAdbResultError('Failed to read Android device features', features, { + deviceId: device.id, + }); + } + if (!androidFeatureListDeclares(features.stdout, BLUETOOTH_FEATURE)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'This Android device has no Bluetooth adapter, so nothing was changed.', + { + reason: NO_BLUETOOTH_ADAPTER_REASON, + deviceId: device.id, + hint: `Some emulator images ship without Bluetooth. Use a device or emulator image whose pm list features includes ${BLUETOOTH_FEATURE}.`, + }, + ); + } + const { sdkInt, result: sdkResult } = await readAndroidSdkInt(device, signal); + if (sdkInt === null) { + throw androidAdbResultError( + 'Failed to read the Android API level, so nothing was changed.', + sdkResult, + { deviceId: device.id }, + ); + } + if (sdkInt < SVC_BLUETOOTH_MIN_SDK) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'This Android build has no shell command for Bluetooth, so nothing was changed.', + { + reason: BLUETOOTH_COMMAND_UNAVAILABLE_REASON, + deviceId: device.id, + sdkInt, + hint: 'settings bluetooth needs svc bluetooth, which requires Android 8.1 (API 27) or newer. Use a newer device or emulator image.', + }, + ); + } +} diff --git a/packages/platform-android/src/settings-global-toggle.ts b/packages/platform-android/src/settings-global-toggle.ts new file mode 100644 index 0000000000..3780bbd849 --- /dev/null +++ b/packages/platform-android/src/settings-global-toggle.ts @@ -0,0 +1,117 @@ +import { Deadline, pollUntilSettled } from '@agent-device/host-kit/retry'; +import { + AppError, + createRequestCanceledError, + discloseDispatch, +} from '@agent-device/kernel/errors'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { runAndroidShell } from './adb.ts'; +import { androidAdbResultError } from './adb-failure.ts'; + +export type AndroidToggleState = 'enabled' | 'disabled'; + +/** + * A device-wide switch that `svc` flips and a system service then persists in a `settings global` + * row. `svc` can exit 0 before the row changes, or without changing it, so the write is confirmed + * by polling the row until it holds the requested state. + */ +export type AndroidGlobalToggle = Readonly<{ + /** The `svc` service name: `svc enable|disable`. */ + service: string; + /** The `settings global` row the system service persists the switch in. */ + row: string; + /** How the toggle is named in failure messages. */ + label: string; + /** The key the toggle's state is reported under, on success and in failure details. */ + stateKey: Key; + parseRow: (storedValue: string) => AndroidToggleState | undefined; + notAppliedReason: string; + unreadableReason: string; + sampleIntervalMs: number; + hint: string; +}>; + +const GLOBAL_TOGGLE_SETTLE_MS = 2_000; +/** Each read has its own budget, so the sample taken once the settle window has run out can still answer. */ +const GLOBAL_TOGGLE_READ_TIMEOUT_MS = 1_000; + +/** Failures before `svc` runs disclose `dispatched: no`; every failure after it `unknown`. */ +export async function setAndroidGlobalToggle( + device: DeviceInfo, + toggle: AndroidGlobalToggle, + enabled: boolean, + signal: AbortSignal | undefined, +): Promise> { + if (signal?.aborted) throw discloseDispatch(createRequestCanceledError(), 'no'); + const requested: AndroidToggleState = enabled ? 'enabled' : 'disabled'; + try { + const result = await runAndroidShell( + device, + ['svc', toggle.service, enabled ? 'enable' : 'disable'], + { allowFailure: true, signal }, + ); + if (result.exitCode !== 0) { + throw androidAdbResultError( + `Failed to turn Android ${toggle.label} ${enabled ? 'on' : 'off'}`, + result, + { deviceId: device.id }, + ); + } + const state = await awaitAndroidGlobalToggle(device, toggle, requested, signal); + return { [toggle.stateKey]: state } as Record; + } catch (error) { + throw error instanceof AppError ? discloseDispatch(error, 'unknown') : error; + } +} + +async function awaitAndroidGlobalToggle( + device: DeviceInfo, + toggle: AndroidGlobalToggle, + requested: AndroidToggleState, + signal: AbortSignal | undefined, +): Promise { + const deadline = Deadline.fromTimeoutMs(GLOBAL_TOGGLE_SETTLE_MS); + const { settled, value } = await pollUntilSettled({ + probe: async () => { + const result = await runAndroidShell(device, ['settings', 'get', 'global', toggle.row], { + allowFailure: true, + signal, + timeoutMs: GLOBAL_TOGGLE_READ_TIMEOUT_MS, + }); + if (result.exitCode !== 0) { + throw androidAdbResultError(`Failed to read Android ${toggle.label} state`, result, { + deviceId: device.id, + }); + } + const storedValue = result.stdout.trim(); + return { storedValue, state: toggle.parseRow(storedValue) }; + }, + settled: (read) => read.state === requested, + deadline, + intervalMs: toggle.sampleIntervalMs, + signal, + }); + if (settled) return requested; + throw androidGlobalToggleSettleError(device, toggle, requested, value.state, value.storedValue); +} + +function androidGlobalToggleSettleError( + device: DeviceInfo, + toggle: AndroidGlobalToggle, + requested: AndroidToggleState, + state: AndroidToggleState | undefined, + storedValue: string, +): AppError { + const message = + state === undefined + ? `Android reported an unusable ${toggle.row} value after requesting ${toggle.label} ${requested}: ${storedValue || '(empty)'}` + : `Android ${toggle.label} reads ${state} after requesting ${requested}.`; + return new AppError('COMMAND_FAILED', message, { + deviceId: device.id, + reason: state === undefined ? toggle.unreadableReason : toggle.notAppliedReason, + requested, + ...(state === undefined ? {} : { [toggle.stateKey]: state }), + storedValue, + hint: toggle.hint, + }); +} diff --git a/packages/platform-android/src/settings-permission.ts b/packages/platform-android/src/settings-permission.ts index 8a6e7748c0..0f3bcc958b 100644 --- a/packages/platform-android/src/settings-permission.ts +++ b/packages/platform-android/src/settings-permission.ts @@ -11,6 +11,7 @@ import { } from '@agent-device/contracts/settings-catalog'; import { runAndroidShell } from './adb.ts'; import { androidAdbResultError } from './adb-failure.ts'; +import { readAndroidSdkInt } from './device-sdk-int.ts'; import { readAndroidCurrentUserId, readAndroidPackagePermissions, @@ -662,7 +663,7 @@ async function setAndroidPhotoPermission( pmAction: 'grant' | 'revoke', userArgs: AndroidUserArgs, ): Promise { - const sdkInt = await getAndroidSdkInt(device); + const { sdkInt } = await readAndroidSdkInt(device); const candidates = sdkInt !== null && sdkInt >= 33 ? ['android.permission.READ_MEDIA_IMAGES', 'android.permission.READ_EXTERNAL_STORAGE'] @@ -734,13 +735,3 @@ async function clearAndroidPermissionFlags( ); } } - -async function getAndroidSdkInt(device: DeviceInfo): Promise { - const result = await runAndroidShell(device, ['getprop', 'ro.build.version.sdk'], { - allowFailure: true, - }); - if (result.exitCode !== 0) return null; - const value = Number.parseInt(result.stdout.trim(), 10); - if (!Number.isFinite(value) || value <= 0) return null; - return value; -} diff --git a/packages/platform-android/src/settings.ts b/packages/platform-android/src/settings.ts index f31ad5cfb3..cc69686d4a 100644 --- a/packages/platform-android/src/settings.ts +++ b/packages/platform-android/src/settings.ts @@ -1,7 +1,5 @@ import { AppError, - createRequestCanceledError, - discloseDispatch, sessionAppRequiredDetails, summarizeCommandAttemptFailures, type CommandAttemptFailure, @@ -26,8 +24,8 @@ 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 { setAndroidGlobalToggle, type AndroidGlobalToggle } from './settings-global-toggle.ts'; import { androidAdbResultError } from './adb-executor.ts'; import { resolveAndroidApp } from './app-deployment-resolution.ts'; import { @@ -39,6 +37,7 @@ import { /** The settings {@link setAndroidSetting} applies over adb, wherever an owner runs it. */ export const ANDROID_SETTINGS = [ 'wifi', + 'bluetooth', 'airplane', 'mobile-data', 'location', @@ -100,11 +99,20 @@ export async function setAndroidSetting( await runAndroidShell(device, ['svc', 'wifi', enabled ? 'enable' : 'disable']); return; } + case 'bluetooth': { + const { setAndroidBluetooth } = await import('./settings-bluetooth.ts'); + return await setAndroidBluetooth(device, parseSettingState(state), signal); + } case 'airplane': { return await setAndroidAirplaneMode(device, parseSettingState(state)); } case 'mobile-data': { - return await setAndroidMobileData(device, parseSettingState(state), signal); + return await setAndroidGlobalToggle( + device, + MOBILE_DATA_TOGGLE, + parseSettingState(state), + signal, + ); } case 'location': { if (state.toLowerCase() === 'set') { @@ -293,85 +301,26 @@ 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.', - }); -} +const MOBILE_DATA_TOGGLE: AndroidGlobalToggle<'mobileData'> = { + service: 'data', + row: 'mobile_data', + label: 'mobile data', + stateKey: 'mobileData', + parseRow: (storedValue) => { + if (storedValue === '1') return 'enabled'; + if (storedValue === '0') return 'disabled'; + return undefined; + }, + notAppliedReason: 'mobile-data-not-applied', + unreadableReason: 'mobile-data-unreadable', + sampleIntervalMs: 100, + 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'; diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 6410609599..9b987ffc23 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -358,6 +358,7 @@ export function limrunSettingsOperationFacts( unsupported: settingUnavailableAndroid, write: { wifi: available, + bluetooth: available, airplane: available, 'mobile-data': available, location: available, diff --git a/src/commands/capture/settings.test.ts b/src/commands/capture/settings.test.ts index f5ab1c5f3a..3f9c160faf 100644 --- a/src/commands/capture/settings.test.ts +++ b/src/commands/capture/settings.test.ts @@ -106,15 +106,18 @@ describe('settings CLI permission', () => { } }); - 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.each(['mobile-data', 'bluetooth'])( + 'forwards --app on %s so the daemon refuses it instead of dropping it', + (setting) => { + const input = settingsCliReader([setting, 'off'], { + targetApp: 'com.example.app', + } as CliFlags); + expect(settingsDaemonWriter(input)).toMatchObject({ + positionals: [setting, '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'], { @@ -177,10 +180,10 @@ describe('settings CLI permission', () => { test('passes a setting outside the catalog to the daemon, which refuses it', () => { expect( - settingsDaemonWriter({ setting: 'bluetooth', state: 'on' } as unknown as Parameters< + settingsDaemonWriter({ setting: 'nfc', state: 'on' } as unknown as Parameters< typeof settingsDaemonWriter >[0]), - ).toMatchObject({ positionals: ['bluetooth', 'on'] }); + ).toMatchObject({ positionals: ['nfc', 'on'] }); }); test('omits the request input when no app is named', () => { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b154a7e8c1..321840f9f0 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -633,6 +633,8 @@ agent-device trigger-app-event screenshot_taken '{"source":"qa"}' ```bash agent-device settings wifi on agent-device settings wifi off +agent-device settings bluetooth on +agent-device settings bluetooth off agent-device settings airplane on agent-device settings airplane off agent-device settings mobile-data off @@ -682,10 +684,11 @@ Platform support: 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 bluetooth on|off` (Android over adb) turns the Bluetooth adapter on or off with `svc bluetooth`, from Android 8.1. A device or emulator image without a Bluetooth adapter fails with `UNSUPPORTED_OPERATION` and `error.details.reason: android_bluetooth_adapter_missing` without changing anything. A device that has an adapter but runs a build older than Android 8.1 fails the same way with `android_bluetooth_command_unavailable`. It waits up to 2 seconds for the device to hold the requested state and reports it as `bluetooth`; a change the device refuses, such as one a device policy blocks, fails with `COMMAND_FAILED` and `error.details.reason: bluetooth-not-applied`, or `bluetooth-unreadable` if the final read is not a known state. The app may take longer to react, so poll it rather than asserting immediately. - `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. +- Mobile data and Bluetooth are 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. @@ -710,7 +713,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, macOS permissions, and `mobile-data`. + - `setting_app_not_consumed`: the change is device-wide and cannot take an app: `location set`, the Android `location on|off` toggle, macOS permissions, `mobile-data`, and `bluetooth`. - 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.