diff --git a/src/__tests__/test-file-size-ratchet.test.ts b/src/__tests__/test-file-size-ratchet.test.ts index 92b77c979..54a3a6b88 100644 --- a/src/__tests__/test-file-size-ratchet.test.ts +++ b/src/__tests__/test-file-size-ratchet.test.ts @@ -43,7 +43,7 @@ const PINNED_TEST_FILE_LINES: Readonly> = Object.freeze({ 'src/platforms/android/__tests__/snapshot.test.ts': 1660, 'src/platforms/apple/core/__tests__/runner-client.test.ts': 1615, 'src/__tests__/client.test.ts': 1598, - 'test/integration/provider-scenarios/android-lifecycle.test.ts': 1597, + 'test/integration/provider-scenarios/android-lifecycle.test.ts': 1559, 'src/utils/__tests__/daemon-client-lifecycle.test.ts': 1414, 'src/platforms/apple/core/__tests__/runner-command-retry.test.ts': 1327, 'src/__tests__/cli-client-commands.test.ts': 1317, diff --git a/src/commands/capture/index.test.ts b/src/commands/capture/index.test.ts index d7f9af193..b188c2d80 100644 --- a/src/commands/capture/index.test.ts +++ b/src/commands/capture/index.test.ts @@ -4,7 +4,7 @@ import { alertCliReader, alertDaemonWriter } from './alert.ts'; import { diffCliReader } from './diff.ts'; import { snapshotCliOutput } from './output.ts'; import { screenshotCliReader, screenshotDaemonWriter } from './screenshot.ts'; -import { settingsCliReader, settingsDaemonWriter } from './settings.ts'; +import { settingsCliReader, settingsCommandFacet, settingsDaemonWriter } from './settings.ts'; import { snapshotCliReader } from './snapshot.ts'; import { waitCliReader, waitDaemonWriter } from './wait.ts'; @@ -187,4 +187,26 @@ describe('capture command interface', () => { positionals: ['permission', 'grant', 'camera', 'limited'], }); }); + + // #1796: the Android revoke warning rides `warnings`; the human CLI line must show it. + test('settings CLI output renders response warnings after the message', () => { + const warning = 'android.permission.CAMERA was granted before this revoke, and Android …'; + const output = settingsCommandFacet.cliOutputFormatter!({ + input: {}, + result: { setting: 'permission', state: 'reset', message: 'Updated setting: permission' }, + }); + expect(output.text).toBe('Updated setting: permission'); + + const warned = settingsCommandFacet.cliOutputFormatter!({ + input: {}, + result: { + setting: 'permission', + state: 'reset', + message: 'Updated setting: permission', + warnings: [warning], + }, + }); + expect(warned.text).toBe(`Updated setting: permission\nWarning: ${warning}`); + expect(warned.data).toMatchObject({ warnings: [warning] }); + }); }); diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 0c47ddfeb..6d7b077b1 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -17,6 +17,7 @@ import { import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; import { defineCommandFacet } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { messageWithWarningsOutput } from '../output-common.ts'; const SETTINGS_COMMAND_NAME = 'settings'; const settingsCommandDescription = @@ -59,13 +60,15 @@ export const settingsCommandFacet = defineCommandFacet({ text: { summary: 'Change OS settings and app permissions', cliDetail: - 'macOS supports only settings appearance and settings permission ; wifi|airplane|location|animations remain unsupported on macOS. Mobile permission actions use the active session app.', + 'macOS supports only settings appearance and settings permission ; 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 --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one.', }, metadata: settingsCommandMetadata, definition: settingsCommandDefinition, cliSchema: settingsCliSchema, cliReader: settingsCliReader, daemonWriter: settingsDaemonWriter, + // Android permission revokes append a relaunch warning (#1796); render it for humans too. + cliOutputFormatter: messageWithWarningsOutput, }); // fallow-ignore-next-line complexity diff --git a/src/commands/output-common.ts b/src/commands/output-common.ts index 8597302ba..53125ae60 100644 --- a/src/commands/output-common.ts +++ b/src/commands/output-common.ts @@ -18,6 +18,23 @@ export function messageCliOutput(result: Record): CliOutput { return { data: result, text: readCommandMessage(result) }; } +/** + * `messageCliOutput` plus one `Warning:` line per entry of the response's `warnings` + * array — the composable warnings channel (`open`, `debug`, snapshot capture use it too), + * so a warning the daemon appended reaches the human CLI reader, not only `--json`. + */ +export const messageWithWarningsOutput = resultOutput( + (result: Record): CliOutput => { + const output = messageCliOutput(result); + const warnings = Array.isArray(result.warnings) + ? result.warnings.filter((warning): warning is string => typeof warning === 'string') + : []; + if (warnings.length === 0) return output; + const lines = [output.text, ...warnings.map((warning) => `Warning: ${warning}`)]; + return { data: output.data, text: lines.filter(Boolean).join('\n') }; + }, +); + /** * ADR 0014: a reusable ref in a PARTIAL result renders in ready-to-copy * `@eN~s` form so a human CLI caller can paste it into the next diff --git a/src/platforms/android/__tests__/permission-grant-state.test.ts b/src/platforms/android/__tests__/permission-grant-state.test.ts new file mode 100644 index 000000000..41fcb9c1c --- /dev/null +++ b/src/platforms/android/__tests__/permission-grant-state.test.ts @@ -0,0 +1,93 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { parseAndroidRuntimePermissionGrants } from '../permission-grant-state.ts'; + +// Captured from `adb shell dumpsys package com.callstack.agentdevicelab` on a Pixel 7 / API 36 +// emulator, trimmed to the sections that decide the answer. The indentation is load-bearing: +// `install permissions:` and each `User :` sit at the same depth, and the runtime grants +// hang under the user block — which is the only reason the two can be told apart. +const DUMPSYS = [ + 'Packages:', + ' Package [com.example.app] (5f3a1c2):', + ' install permissions:', + ' android.permission.MODIFY_AUDIO_SETTINGS: granted=true', + ' android.permission.RECORD_AUDIO: granted=true', + ' User 0: ceDataInode=1032405 installed=true hidden=false stopped=false', + ' gids=[3003]', + ' runtime permissions:', + ' android.permission.RECORD_AUDIO: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED]', + ' android.permission.CAMERA: granted=true, flags=[ USER_SET]', + ' User 10: ceDataInode=99 installed=true hidden=false stopped=false', + ' runtime permissions:', + ' android.permission.RECORD_AUDIO: granted=true, flags=[ USER_SET]', + '', + 'Queries:', + ' queryable via interaction:', + ' User 0:', + '', + 'Dexopt state:', + ' [com.example.app]', + ' path: /data/app/~~abc==/com.example.app-def==', +].join('\n'); + +// `undefined` is the answer for anything the dump does not place inside the requested user's +// runtime block: an install-permission grant (`pm revoke` cannot touch it), another profile's +// grant, or a permission that user never declared. The caller reports that as `unknown`. +test.each([ + [0, 'android.permission.RECORD_AUDIO', 'not_granted', 'acting user overrides the install grant'], + [0, 'android.permission.CAMERA', 'granted', 'acting user'], + [10, 'android.permission.RECORD_AUDIO', 'granted', 'other profile, only when asked for'], + [10, 'android.permission.CAMERA', undefined, 'absent for that user'], + [0, 'android.permission.MODIFY_AUDIO_SETTINGS', undefined, 'install-only'], + [0, 'android.permission.INTERNET', undefined, 'never mentioned'], +] as const)('user %s reads %s as %s (%s)', (userId, permission, expected, _why) => { + assert.equal(parseAndroidRuntimePermissionGrants(DUMPSYS, userId)?.get(permission), expected); +}); + +// A missing block is not an empty one. Every input here means "the device did not tell us", +// which must stay distinguishable from "the app holds nothing". +test.each([ + ['no runtime block for that user', DUMPSYS, 11], + ['empty output', '', 0], + ['no Packages section', 'Activity Resolver Table:\n User 0:\n runtime permissions:', 0], + ['a package section without the user', 'Packages:\n Package [com.example.app] (abc):', 0], +] as const)('%s reads as unknown', (_label, output, userId) => { + assert.equal(parseAndroidRuntimePermissionGrants(output, userId), undefined); +}); + +test('an empty runtime block answers not_granted for everything it could have listed', () => { + // Distinct from a missing block: the device reported the list and it was empty, so the app + // holds nothing — a claim the parser is allowed to make. + const grants = parseAndroidRuntimePermissionGrants( + [ + 'Packages:', + ' Package [com.example.app] (5f3a1c2):', + ' User 0: installed=true', + ' runtime permissions:', + ' User 10: installed=true', + ].join('\n'), + 0, + ); + assert.notEqual(grants, undefined); + assert.equal(grants?.size, 0); +}); + +test('sections after Packages: cannot reopen the scan', () => { + // `Queries:` repeats `User 0:` with no grants, and `Shared users:` repeats runtime grant lines + // for the shared uid. Merging either would flip RECORD_AUDIO to granted for a user whose own + // block says otherwise — the same wrong-answer class as reading the install section. + const grants = parseAndroidRuntimePermissionGrants( + [ + DUMPSYS, + 'Shared users:', + ' SharedUser [android.uid.shared] (a1b2):', + ' User 0: ceDataInode=0 installed=true', + ' runtime permissions:', + ' android.permission.RECORD_AUDIO: granted=true, flags=[ USER_SET]', + ].join('\n'), + 0, + ); + + assert.equal(grants?.get('android.permission.RECORD_AUDIO'), 'not_granted'); + assert.equal(grants?.get('android.permission.CAMERA'), 'granted'); +}); diff --git a/src/platforms/android/__tests__/settings-permission.test.ts b/src/platforms/android/__tests__/settings-permission.test.ts new file mode 100644 index 000000000..dda4ba199 --- /dev/null +++ b/src/platforms/android/__tests__/settings-permission.test.ts @@ -0,0 +1,325 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { setAndroidSetting } from '../settings.ts'; +import { androidRevokedPermissionWarning } from '../settings-permission.ts'; +import { + ANDROID_EMULATOR, + assertRejectsAppError, + withFakeAdb, +} from '../../../__tests__/test-utils/index.ts'; + +// #1796. Two invariants decide every case here: +// * `pm` defaults grant/revoke and the permission-flag operations to UserHandle.USER_SYSTEM, +// so a bare mutation edits user 0 rather than the foreground user the session app runs as. +// Proven on a Pixel 7 / API 36 emulator with the foreground user switched to 10: a bare +// `pm revoke` flipped User 0 to granted=false while User 10 stayed granted=true. +// * The prior state is read for that same user, and a state we could not read is `unknown` — +// never `not_granted`, which would claim the app was left alone. +// The fake adb provider installs through the production withAndroidAdbProvider scope, so +// `calls` records device-scoped args without a leading `-s `. +const CURRENT_USER = 'shell am get-current-user'; +const DUMPSYS = 'shell dumpsys package com.example.app'; +const MICROPHONE = 'android.permission.RECORD_AUDIO'; + +/** A dump shaped like the real one: an install-permission section, then per-user blocks. */ +function dumpsys( + users: ReadonlyArray<{ id: number; runtime?: ReadonlyArray<[string, boolean]> }>, +): string { + return [ + 'Packages:', + ' Package [com.example.app] (abc):', + ' install permissions:', + // Install permissions are granted for the package, not per user, and `pm revoke` cannot + // touch them — a scan that reads `granted=true` anywhere reports these as runtime grants. + ' android.permission.INTERNET: granted=true', + ` ${MICROPHONE}: granted=true`, + ...users.flatMap(({ id, runtime }) => [ + ` User ${id}: ceDataInode=0 installed=true`, + ...(runtime + ? [ + ' runtime permissions:', + ...runtime.map(([permission, granted]) => ` ${permission}: granted=${granted}`), + ] + : []), + ]), + // A later top-level section repeats `User :` without any runtime block. + 'Queries:', + ' queryable via interaction:', + ' User 0:', + ].join('\n'); +} + +type FakeAdbReply = string | undefined | { stderr: string; exitCode: number }; + +function fakeAdb(script: (flat: string) => FakeAdbReply) { + return (args: string[]) => script(args.join(' ')); +} + +/** Foreground user `userId`, holding exactly `granted` of the microphone permission. */ +function foregroundUser(userId: string, granted: boolean) { + return fakeAdb((flat) => { + if (flat === CURRENT_USER) return userId; + if (flat === DUMPSYS) + return dumpsys([{ id: Number(userId), runtime: [[MICROPHONE, granted]] }]); + return undefined; + }); +} + +// Exact argv and order, on a device whose foreground user is NOT 0: a response-shape assertion +// passes whether or not the mutation named a user, so these pin the wire instead. +test.each([ + [ + 'deny microphone', + 'deny' as const, + { permissionTarget: 'microphone' } as const, + [ + ['shell', 'am', 'get-current-user'], + ['shell', 'dumpsys', 'package', 'com.example.app'], + ['shell', 'pm', 'revoke', '--user', '10', 'com.example.app', MICROPHONE], + ], + ], + [ + 'reset camera', + 'reset' as const, + { permissionTarget: 'camera' } as const, + [ + ['shell', 'am', 'get-current-user'], + ['shell', 'dumpsys', 'package', 'com.example.app'], + ['shell', 'pm', 'revoke', '--user', '10', 'com.example.app', 'android.permission.CAMERA'], + // prettier-ignore + ['shell', 'pm', 'clear-permission-flags', '--user', '10', 'com.example.app', 'android.permission.CAMERA', 'user-set'], + // prettier-ignore + ['shell', 'pm', 'clear-permission-flags', '--user', '10', 'com.example.app', 'android.permission.CAMERA', 'user-fixed'], + ], + ], + [ + 'reset notifications', + 'reset' as const, + { permissionTarget: 'notifications' } as const, + [ + ['shell', 'am', 'get-current-user'], + ['shell', 'dumpsys', 'package', 'com.example.app'], + // prettier-ignore + ['shell', 'pm', 'revoke', '--user', '10', 'com.example.app', 'android.permission.POST_NOTIFICATIONS'], + // prettier-ignore + ['shell', 'pm', 'clear-permission-flags', '--user', '10', 'com.example.app', 'android.permission.POST_NOTIFICATIONS', 'user-set'], + // prettier-ignore + ['shell', 'pm', 'clear-permission-flags', '--user', '10', 'com.example.app', 'android.permission.POST_NOTIFICATIONS', 'user-fixed'], + // prettier-ignore + ['shell', 'appops', 'set', '--user', '10', 'com.example.app', 'POST_NOTIFICATION', 'default'], + ], + ], + [ + 'deny notifications', + 'deny' as const, + { permissionTarget: 'notifications' } as const, + [ + ['shell', 'am', 'get-current-user'], + ['shell', 'dumpsys', 'package', 'com.example.app'], + // prettier-ignore + ['shell', 'pm', 'revoke', '--user', '10', 'com.example.app', 'android.permission.POST_NOTIFICATIONS'], + ['shell', 'appops', 'set', '--user', '10', 'com.example.app', 'POST_NOTIFICATION', 'deny'], + ], + ], + [ + 'grant microphone', + 'grant' as const, + { permissionTarget: 'microphone' } as const, + // No dumpsys: a grant cannot kill the app, so it reads no state. + [ + ['shell', 'am', 'get-current-user'], + ['shell', 'pm', 'grant', '--user', '10', 'com.example.app', MICROPHONE], + ], + ], +] as const)( + 'setAndroidSetting permission %s addresses the foreground user in every adb call', + async (_label, action, options, expected) => { + await withFakeAdb(foregroundUser('10', false), async ({ calls, device }) => { + await setAndroidSetting(device, 'permission', action, 'com.example.app', options); + assert.deepEqual( + calls, + expected.map((args) => [...args]), + ); + }); + }, +); + +// The tri-state, including which user answers it. `unknown` must never be reported as +// `not_granted`, and must still hand over the relaunch guidance. +test.each([ + ['the acting user holds it', foregroundUser('0', true), 'granted'], + ['the acting user does not', foregroundUser('0', false), 'not_granted'], + [ + 'only another profile holds it', + fakeAdb((flat) => { + if (flat === CURRENT_USER) return '0'; + if (flat === DUMPSYS) { + return dumpsys([ + { id: 0, runtime: [[MICROPHONE, false]] }, + { id: 10, runtime: [[MICROPHONE, true]] }, + ]); + } + return undefined; + }), + 'not_granted', + ], + [ + 'the acting user is the one that holds it', + fakeAdb((flat) => { + if (flat === CURRENT_USER) return '10'; + if (flat === DUMPSYS) { + return dumpsys([ + { id: 0, runtime: [[MICROPHONE, false]] }, + { id: 10, runtime: [[MICROPHONE, true]] }, + ]); + } + return undefined; + }), + 'granted', + ], + [ + 'dumpsys fails', + fakeAdb((flat) => (flat === DUMPSYS ? { stderr: 'error', exitCode: 1 } : '0')), + 'unknown', + ], + [ + 'dumpsys output is unparseable', + fakeAdb((flat) => (flat === DUMPSYS ? 'Packages:' : '0')), + 'unknown', + ], +] as const)( + 'setAndroidSetting permission deny reports %s', + async (_label, script, priorGrantState) => { + await withFakeAdb(script, async ({ device }) => { + const result = await setAndroidSetting(device, 'permission', 'deny', 'com.example.app', { + permissionTarget: 'microphone', + }); + const warning = androidRevokedPermissionWarning( + 'com.example.app', + MICROPHONE, + priorGrantState, + ); + assert.deepEqual(result, { + permission: MICROPHONE, + priorGrantState, + ...(warning ? { warnings: [warning] } : {}), + }); + // not_granted is the only silent state; the other two hand over the same recovery. + assert.equal(warning === undefined, priorGrantState === 'not_granted'); + if (warning) assert.match(warning, /open com\.example\.app --relaunch/); + }); + }, +); + +test('the revoke warning states the platform rule and keeps the consequence conditional', () => { + // The read proves neither that the app was running nor, for `unknown`, what the state was. + assert.match( + androidRevokedPermissionWarning('com.example.app', MICROPHONE, 'granted')!, + /was granted before this revoke.*if com\.example\.app was running it is no longer/s, + ); + assert.match( + androidRevokedPermissionWarning('com.example.app', MICROPHONE, 'unknown')!, + /could not be read.*may no longer be running/s, + ); +}); + +// A mutation that cannot name its user is refused, not issued unscoped: `pm` would apply it to +// user 0 and leave a session running as another user untouched, which is the whole defect. +test.each(['grant', 'deny', 'reset'] as const)( + 'setAndroidSetting permission %s refuses to mutate when the acting user cannot be resolved', + async (action) => { + await withFakeAdb( + fakeAdb((flat) => + flat === CURRENT_USER ? { stderr: 'cmd: not found', exitCode: 1 } : undefined, + ), + async ({ calls, device }) => { + await assertRejectsAppError( + () => + setAndroidSetting(device, 'permission', action, 'com.example.app', { + permissionTarget: 'microphone', + }), + { + code: 'COMMAND_FAILED', + message: /Could not determine which Android user/, + hint: /am get-current-user/, + }, + ); + // The load-bearing assertion: the resolution attempt is the ONLY adb call. No pm, no + // appops, no clear-permission-flags — nothing that could edit user 0's state. + assert.deepEqual(calls, [['shell', 'am', 'get-current-user']]); + }, + ); + }, +); + +// `photos` is the one target whose permission is discovered by probing the device, so its +// SDK-dependent candidate order and the flags that follow the resolved permission are pinned. +test.each([ + ['36', 'reset' as const, 'android.permission.READ_MEDIA_IMAGES'], + ['32', 'grant' as const, 'android.permission.READ_EXTERNAL_STORAGE'], +] as const)( + 'setAndroidSetting permission photos on SDK %s resolves %s to %s', + async (sdk, action, permission) => { + // `reset` maps to `pm revoke`; only `grant` keeps its verb. + const pmAction = action === 'grant' ? 'grant' : 'revoke'; + await withFakeAdb( + fakeAdb((flat) => { + if (flat === 'shell getprop ro.build.version.sdk') return sdk; + if (flat === CURRENT_USER) return '0'; + if (flat === DUMPSYS) return dumpsys([{ id: 0, runtime: [[permission, true]] }]); + if (flat.startsWith(`shell pm ${pmAction} --user 0 com.example.app ${permission}`)) + return ''; + return { stderr: `unexpected args: ${flat}`, exitCode: 1 }; + }), + async ({ calls, device }) => { + await setAndroidSetting(device, 'permission', action, 'com.example.app', { + permissionTarget: 'photos', + }); + const flat = calls.map((args) => args.join(' ')); + assert.ok(flat.includes('shell getprop ro.build.version.sdk'), flat.join('; ')); + assert.ok( + flat.includes(`shell pm ${pmAction} --user 0 com.example.app ${permission}`), + flat.join('; '), + ); + if (action === 'reset') { + for (const flag of ['user-set', 'user-fixed']) { + assert.ok( + flat.includes( + `shell pm clear-permission-flags --user 0 com.example.app ${permission} ${flag}`, + ), + flat.join('; '), + ); + } + } + }, + ); + }, +); + +test.each([ + [ + 'mode outside photos', + { permissionTarget: 'camera', permissionMode: 'limited' }, + /mode is only supported for photos/i, + ], + [ + 'an iOS-only target', + { permissionTarget: 'calendar' }, + /Unsupported permission target on Android/i, + ], +] as const)('setAndroidSetting permission rejects %s', async (_label, options, message) => { + await assertRejectsAppError( + () => setAndroidSetting(ANDROID_EMULATOR, 'permission', 'grant', 'com.example.app', options), + { code: 'INVALID_ARGS', message }, + ); +}); + +test('setAndroidSetting permission requires an app in session', async () => { + await assertRejectsAppError( + () => + setAndroidSetting(ANDROID_EMULATOR, 'permission', 'deny', undefined, { + permissionTarget: 'camera', + }), + { code: 'INVALID_ARGS', message: /requires an active app in session/ }, + ); +}); diff --git a/src/platforms/android/__tests__/settings.test.ts b/src/platforms/android/__tests__/settings.test.ts index 336579af2..4fa0f9f6e 100644 --- a/src/platforms/android/__tests__/settings.test.ts +++ b/src/platforms/android/__tests__/settings.test.ts @@ -124,156 +124,3 @@ test('setAndroidSetting fingerprint does not use adb emu command on physical dev }, ); }); - -test('setAndroidSetting permission deny notifications revokes runtime permission and appops', async () => { - await withFakeAdb( - () => undefined, - async ({ calls, device }) => { - await setAndroidSetting(device, 'permission', 'deny', 'com.example.app', { - permissionTarget: 'notifications', - }); - const flat = calls.map((args) => args.join(' ')); - assert.ok( - flat.includes('shell pm revoke com.example.app android.permission.POST_NOTIFICATIONS'), - flat.join('; '), - ); - assert.ok( - flat.includes('shell appops set com.example.app POST_NOTIFICATION deny'), - flat.join('; '), - ); - }, - ); -}); - -test('setAndroidSetting permission reset notifications clears permission flags for reprompt', async () => { - await withFakeAdb( - () => undefined, - async ({ calls, device }) => { - await setAndroidSetting(device, 'permission', 'reset', 'com.example.app', { - permissionTarget: 'notifications', - }); - const flat = calls.map((args) => args.join(' ')); - assert.ok( - flat.includes('shell pm revoke com.example.app android.permission.POST_NOTIFICATIONS'), - flat.join('; '), - ); - assert.ok( - flat.includes( - 'shell pm clear-permission-flags com.example.app android.permission.POST_NOTIFICATIONS user-set', - ), - flat.join('; '), - ); - assert.ok( - flat.includes( - 'shell pm clear-permission-flags com.example.app android.permission.POST_NOTIFICATIONS user-fixed', - ), - flat.join('; '), - ); - assert.ok( - flat.includes('shell appops set com.example.app POST_NOTIFICATION default'), - flat.join('; '), - ); - }, - ); -}); - -test('setAndroidSetting permission reset camera clears permission flags for reprompt', async () => { - await withFakeAdb( - () => undefined, - async ({ calls, device }) => { - await setAndroidSetting(device, 'permission', 'reset', 'com.example.app', { - permissionTarget: 'camera', - }); - const flat = calls.map((args) => args.join(' ')); - assert.ok( - flat.includes('shell pm revoke com.example.app android.permission.CAMERA'), - flat.join('; '), - ); - assert.ok( - flat.includes( - 'shell pm clear-permission-flags com.example.app android.permission.CAMERA user-set', - ), - flat.join('; '), - ); - assert.ok( - flat.includes( - 'shell pm clear-permission-flags com.example.app android.permission.CAMERA user-fixed', - ), - flat.join('; '), - ); - }, - ); -}); - -test('setAndroidSetting permission reset photos clears flags for the resolved permission', async () => { - await withFakeAdb( - (args) => (args.join(' ') === 'shell getprop ro.build.version.sdk' ? '36' : undefined), - async ({ calls, device }) => { - await setAndroidSetting(device, 'permission', 'reset', 'com.example.app', { - permissionTarget: 'photos', - }); - const flat = calls.map((args) => args.join(' ')); - assert.ok( - flat.includes('shell pm revoke com.example.app android.permission.READ_MEDIA_IMAGES'), - flat.join('; '), - ); - assert.ok( - flat.includes( - 'shell pm clear-permission-flags com.example.app android.permission.READ_MEDIA_IMAGES user-set', - ), - flat.join('; '), - ); - assert.ok( - flat.includes( - 'shell pm clear-permission-flags com.example.app android.permission.READ_MEDIA_IMAGES user-fixed', - ), - flat.join('; '), - ); - }, - ); -}); - -test('setAndroidSetting permission rejects mode argument', async () => { - await assertRejectsAppError( - () => - setAndroidSetting(ANDROID_EMULATOR, 'permission', 'grant', 'com.example.app', { - permissionTarget: 'camera', - permissionMode: 'limited', - }), - { code: 'INVALID_ARGS', message: /mode is only supported for photos/i }, - ); -}); - -test('setAndroidSetting permission rejects iOS-only targets with Android-specific guidance', async () => { - await assertRejectsAppError( - () => - setAndroidSetting(ANDROID_EMULATOR, 'permission', 'grant', 'com.example.app', { - permissionTarget: 'calendar', - }), - { code: 'INVALID_ARGS', message: /Unsupported permission target on Android/i }, - ); -}); - -test('setAndroidSetting permission grant photos falls back to legacy permission on older SDK', async () => { - await withFakeAdb( - (args) => { - const flat = args.join(' '); - if (flat === 'shell getprop ro.build.version.sdk') return '32'; - if (flat === 'shell pm grant com.example.app android.permission.READ_EXTERNAL_STORAGE') { - return ''; - } - return { stderr: `unexpected args: ${flat}`, exitCode: 1 }; - }, - async ({ calls, device }) => { - await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { - permissionTarget: 'photos', - }); - const flat = calls.map((args) => args.join(' ')); - assert.ok(flat.includes('shell getprop ro.build.version.sdk'), flat.join('; ')); - assert.ok( - flat.includes('shell pm grant com.example.app android.permission.READ_EXTERNAL_STORAGE'), - flat.join('; '), - ); - }, - ); -}); diff --git a/src/platforms/android/permission-grant-state.ts b/src/platforms/android/permission-grant-state.ts new file mode 100644 index 000000000..5e0bfd18c --- /dev/null +++ b/src/platforms/android/permission-grant-state.ts @@ -0,0 +1,119 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { runAndroidAdb } from './adb.ts'; + +/** + * Whether the app held a runtime permission immediately before a revoke. + * + * `unknown` is a first-class answer, not a synonym for `not_granted`: the state is read from + * `dumpsys package`, which can fail, be truncated, or not list the permission for the acting + * user at all. Collapsing that into `not_granted` would make the response assert the app was + * untouched exactly when we cannot tell — and the consequence of being wrong is an agent whose + * app Android silently killed (#1796). + */ +export type AndroidPriorGrantState = 'granted' | 'not_granted' | 'unknown'; + +/** + * What the acting user actually holds. `unknown` is never a value here — absence is what carries + * it, so a caller reads the map before the revoke and can still answer for a permission it only + * learns afterwards (`photos` resolves its permission by probing the device). + */ +export type AndroidRuntimePermissionGrants = ReadonlyMap; + +/** + * `userId`'s runtime permissions, or `undefined` when the state could not be read — adb failed, + * or the dump carried no runtime-permission block for that user. + * + * The caller passes the user its mutation will target, so the two halves cannot disagree. + * `dumpsys package` prints an `install permissions:` section and one block per user, all + * carrying `granted=` lines; a scan that ignores that structure reports another profile's + * grant — or an install permission that `pm revoke` cannot touch — as this user's. + */ +export async function readAndroidRuntimePermissionGrants( + device: DeviceInfo, + appPackage: string, + userId: number, +): Promise { + const result = await runAndroidAdb(device, ['shell', 'dumpsys', 'package', appPackage], { + allowFailure: true, + }); + if (result.exitCode !== 0) return undefined; + return parseAndroidRuntimePermissionGrants(result.stdout, userId); +} + +/** + * The foreground user, or `undefined` when it cannot be resolved. + * + * This is the user the session's app runs as, and it is NOT what `pm` defaults to: + * `PackageManagerShellCommand` defaults grant/revoke/permission-flag operations to + * `UserHandle.USER_SYSTEM`, so on a device whose foreground user is nonzero a bare `pm revoke` + * silently edits user 0 and leaves the running app's permission untouched. Every permission + * mutation therefore passes `--user` explicitly (#1796). + */ +export async function readAndroidCurrentUserId(device: DeviceInfo): Promise { + const result = await runAndroidAdb(device, ['shell', 'am', 'get-current-user'], { + allowFailure: true, + }); + if (result.exitCode !== 0) return undefined; + // Device stdout is a trust boundary: `parsed` is whatever the shell printed. + const parsed = Number.parseInt(result.stdout.trim(), 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +const USER_BLOCK = /^\s*User (\d+):/; +const RUNTIME_PERMISSIONS_BLOCK = /^\s*runtime permissions:\s*$/; +const GRANT_LINE = /^\s*([\w.]+): granted=(true|false)\b/; + +/** A non-blank dump line with the indentation that places it in the tree. */ +type DumpLine = { text: string; indent: number }; + +/** + * Runtime permission grants for `userId` only, or `undefined` when that user has no + * runtime-permission block in the dump. + * + * `dumpsys package` is indentation-structured, so the read walks three nested blocks — + * `Packages:` → `User :` → `runtime permissions:` — instead of matching `granted=` + * anywhere. That nesting is what keeps the `install permissions:` section, other users' + * blocks, and the later top-level sections (`Queries:`, `Shared users:`, `Dexopt state:`, + * which repeat `User :` and can repeat grant lines) out of the answer. + */ +export function parseAndroidRuntimePermissionGrants( + dumpsysOutput: string, + userId: number, +): AndroidRuntimePermissionGrants | undefined { + const lines = dumpsysOutput + .split('\n') + .filter((text) => text.trim().length > 0) + .map((text) => ({ text, indent: text.length - text.trimStart().length })); + const packages = nestedBlock( + lines, + (line) => line.indent === 0 && line.text.trim() === 'Packages:', + ); + const user = nestedBlock(packages, (line) => USER_BLOCK.exec(line.text)?.[1] === String(userId)); + const runtime = nestedBlock(user, (line) => RUNTIME_PERMISSIONS_BLOCK.test(line.text)); + // Absent block: the device never reported this user's grants. An empty one is still an + // answer — the app holds no runtime permissions for this user. + if (!runtime) return undefined; + const grants = new Map(); + for (const { text } of runtime) { + const grant = GRANT_LINE.exec(text); + if (grant) grants.set(grant[1]!, grant[2] === 'true' ? 'granted' : 'not_granted'); + } + return grants; +} + +/** + * The lines nested under the first line `isHeader` accepts, or `undefined` if there is none. + * A block ends at the first line indented no deeper than its header, which is also what ends + * the top-level `Packages:` section at the next top-level heading. + */ +function nestedBlock( + lines: readonly DumpLine[] | undefined, + isHeader: (line: DumpLine) => boolean, +): DumpLine[] | undefined { + if (!lines) return undefined; + const start = lines.findIndex((line) => isHeader(line)); + if (start < 0) return undefined; + const rest = lines.slice(start + 1); + const end = rest.findIndex((line) => line.indent <= lines[start]!.indent); + return end < 0 ? rest : rest.slice(0, end); +} diff --git a/src/platforms/android/settings-permission.ts b/src/platforms/android/settings-permission.ts new file mode 100644 index 000000000..3c174d167 --- /dev/null +++ b/src/platforms/android/settings-permission.ts @@ -0,0 +1,259 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { parsePermissionAction, parsePermissionTarget } from '@agent-device/contracts/settings'; +import type { SettingOptions } from '@agent-device/contracts/settings'; +import { runAndroidAdb } from './adb.ts'; +import { + readAndroidCurrentUserId, + readAndroidRuntimePermissionGrants, + type AndroidPriorGrantState, +} from './permission-grant-state.ts'; + +/** + * Android kills the app's process whenever a runtime permission it currently holds is + * revoked (`pm revoke` after a grant, foreground or background), so a `deny`/`reset` that + * follows a grant leaves the session pointing at a dead app and the next selector fails + * against the launcher (#1796). Revoking a permission the app does not hold is harmless. + * + * Process death itself is NOT observed (that would be option (b) in the issue) and the prior + * state cannot prove the app was running, so the consequence stays conditional. When the state + * could not be read, the same guidance is given without claiming what the state was: silence + * there would assert "your app is untouched" on no evidence. + */ +export function androidRevokedPermissionWarning( + appPackage: string, + permission: string, + priorGrantState: AndroidPriorGrantState, +): string | undefined { + if (priorGrantState === 'not_granted') return undefined; + const preamble = + priorGrantState === 'granted' + ? `${permission} was granted before this revoke, and Android kills an app when a granted permission is revoked: if ${appPackage} was running it is no longer.` + : `Whether ${permission} was granted before this revoke could not be read (adb did not report the acting user's runtime permission state), and Android kills an app when a granted permission is revoked: ${appPackage} may no longer be running.`; + return `${preamble} Relaunch it with open ${appPackage} --relaunch before the next interaction.`; +} + +type AndroidPermissionTarget = ReturnType; + +/** + * `--user ` for every permission mutation, resolved once so the state read and the mutation + * cannot address different users. Never empty: a permission mutation that cannot name its user + * is refused rather than issued (see `requireAndroidPermissionUser`). + */ +type AndroidUserArgs = readonly string[]; + +/** + * The user a permission mutation will act on, or a refusal. + * + * `pm` and `appops` default to `UserHandle.USER_SYSTEM`, so an unscoped mutation on a device + * whose foreground user is nonzero edits user 0 and leaves the running app untouched — the + * defect #1796 is about. Issuing the bare command as a fallback would reintroduce it on exactly + * the path where we already know we are guessing, so the command refuses instead: no permission + * state is changed when we cannot name whose state it is. + */ +async function requireAndroidPermissionUser(device: DeviceInfo): Promise { + const userId = await readAndroidCurrentUserId(device); + if (userId !== undefined) return userId; + throw new AppError( + 'COMMAND_FAILED', + 'Could not determine which Android user the session runs as, so no permission was changed.', + { + deviceId: device.id, + hint: `Check adb -s ${device.id} shell am get-current-user — if the device is still booting, retry once it reports a user. agent-device refuses to change permissions it cannot scope, because pm would silently apply them to user 0.`, + }, + ); +} + +export async function setAndroidPermission( + device: DeviceInfo, + appPackage: string, + state: string, + options: SettingOptions | undefined, +): Promise | void> { + const action = parsePermissionAction(state); + const target = parseAndroidPermissionTarget(options?.permissionTarget, options?.permissionMode); + const userId = await requireAndroidPermissionUser(device); + const userArgs: AndroidUserArgs = ['--user', String(userId)]; + if (action === 'grant') { + await grantAndroidPermission(device, appPackage, target, userArgs); + return; + } + // Read before the revoke — afterwards every permission reads as not granted — but resolved + // after it, because `photos` only learns which permission it revoked by probing the device. + const grants = await readAndroidRuntimePermissionGrants(device, appPackage, userId); + const permission = await revokeAndroidPermission(device, appPackage, action, target, userArgs); + const priorGrantState: AndroidPriorGrantState = grants?.get(permission) ?? 'unknown'; + const warning = androidRevokedPermissionWarning(appPackage, permission, priorGrantState); + return { + permission, + priorGrantState, + ...(warning ? { warnings: [warning] } : {}), + }; +} + +async function grantAndroidPermission( + device: DeviceInfo, + appPackage: string, + target: AndroidPermissionTarget, + userArgs: AndroidUserArgs, +): Promise { + if (target.kind === 'notifications') { + await setAndroidNotificationPermission(device, appPackage, 'grant', target, userArgs); + } else if (target.type === 'photos') { + await setAndroidPhotoPermission(device, appPackage, 'grant', userArgs); + } else { + await runAndroidAdb(device, ['shell', 'pm', 'grant', ...userArgs, appPackage, target.value]); + } +} + +/** Revokes (and for `reset`, clears the flags of) the target; returns the permission revoked. */ +async function revokeAndroidPermission( + device: DeviceInfo, + appPackage: string, + action: 'deny' | 'reset', + target: AndroidPermissionTarget, + userArgs: AndroidUserArgs, +): Promise { + if (target.kind === 'notifications') { + await setAndroidNotificationPermission(device, appPackage, action, target, userArgs); + return target.permission; + } + let permission: string; + if (target.type === 'photos') { + permission = await setAndroidPhotoPermission(device, appPackage, 'revoke', userArgs); + } else { + permission = target.value; + await runAndroidAdb(device, ['shell', 'pm', 'revoke', ...userArgs, appPackage, permission]); + } + if (action === 'reset') { + await clearAndroidPermissionFlags(device, appPackage, permission, userArgs); + } + return permission; +} + +function parseAndroidPermissionTarget( + permissionTarget: string | undefined, + permissionMode: string | undefined, +): + | { kind: 'pm'; value: string; type: 'camera' | 'microphone' | 'photos' | 'contacts' } + | { kind: 'notifications'; appOps: string; permission: string } { + const normalized = parsePermissionTarget(permissionTarget); + if (permissionMode?.trim()) { + throw new AppError( + 'INVALID_ARGS', + `Permission mode is only supported for photos. Received: ${permissionMode}.`, + ); + } + if (normalized === 'camera') + return { kind: 'pm', value: 'android.permission.CAMERA', type: 'camera' }; + if (normalized === 'microphone') { + return { kind: 'pm', value: 'android.permission.RECORD_AUDIO', type: 'microphone' }; + } + if (normalized === 'photos') { + return { kind: 'pm', value: 'android.permission.READ_MEDIA_IMAGES', type: 'photos' }; + } + if (normalized === 'contacts') { + return { kind: 'pm', value: 'android.permission.READ_CONTACTS', type: 'contacts' }; + } + if (normalized === 'notifications') { + return { + kind: 'notifications', + appOps: 'POST_NOTIFICATION', + permission: 'android.permission.POST_NOTIFICATIONS', + }; + } + throw new AppError( + 'INVALID_ARGS', + `Unsupported permission target on Android: ${permissionTarget}. Use camera|microphone|photos|contacts|notifications.`, + ); +} + +async function setAndroidPhotoPermission( + device: DeviceInfo, + appPackage: string, + pmAction: 'grant' | 'revoke', + userArgs: AndroidUserArgs, +): Promise { + const sdkInt = await getAndroidSdkInt(device); + const candidates = + sdkInt !== null && sdkInt >= 33 + ? ['android.permission.READ_MEDIA_IMAGES', 'android.permission.READ_EXTERNAL_STORAGE'] + : ['android.permission.READ_EXTERNAL_STORAGE', 'android.permission.READ_MEDIA_IMAGES']; + + const failures: Array<{ permission: string; stderr: string; exitCode: number }> = []; + for (const permission of candidates) { + const result = await runAndroidAdb( + device, + ['shell', 'pm', pmAction, ...userArgs, appPackage, permission], + { allowFailure: true }, + ); + if (result.exitCode === 0) return permission; + failures.push({ permission, stderr: result.stderr, exitCode: result.exitCode }); + } + + throw new AppError('COMMAND_FAILED', `Failed to ${pmAction} Android photos permission`, { + appPackage, + sdkInt, + attempts: failures, + }); +} + +async function setAndroidNotificationPermission( + device: DeviceInfo, + appPackage: string, + action: 'grant' | 'deny' | 'reset', + target: { appOps: string; permission: string }, + userArgs: AndroidUserArgs, +): Promise { + const appOpsMode = action === 'grant' ? 'allow' : action === 'deny' ? 'deny' : 'default'; + if (action === 'grant') { + await runAndroidAdb( + device, + ['shell', 'pm', 'grant', ...userArgs, appPackage, target.permission], + { allowFailure: true }, + ); + } else { + await runAndroidAdb( + device, + ['shell', 'pm', 'revoke', ...userArgs, appPackage, target.permission], + { allowFailure: true }, + ); + if (action === 'reset') { + await clearAndroidPermissionFlags(device, appPackage, target.permission, userArgs); + } + } + await runAndroidAdb(device, [ + 'shell', + 'appops', + 'set', + ...userArgs, + appPackage, + target.appOps, + appOpsMode, + ]); +} + +async function clearAndroidPermissionFlags( + device: DeviceInfo, + appPackage: string, + permission: string, + userArgs: AndroidUserArgs, +): Promise { + for (const flag of ['user-set', 'user-fixed']) { + await runAndroidAdb( + device, + ['shell', 'pm', 'clear-permission-flags', ...userArgs, appPackage, permission, flag], + { allowFailure: true }, + ); + } +} + +async function getAndroidSdkInt(device: DeviceInfo): Promise { + const result = await runAndroidAdb(device, ['shell', '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/src/platforms/android/settings.ts b/src/platforms/android/settings.ts index a7dd34067..b4b7825cc 100644 --- a/src/platforms/android/settings.ts +++ b/src/platforms/android/settings.ts @@ -5,16 +5,13 @@ import { summarizeCommandAttemptFailures, type CommandAttemptFailure, } from '../command-attempts.ts'; -import { - parsePermissionAction, - parsePermissionTarget, - type SettingOptions, -} from '@agent-device/contracts/settings'; +import type { SettingOptions } from '@agent-device/contracts/settings'; import { parseAppearanceAction } from '../appearance.ts'; import { parseSettingState } from '../setting-state.ts'; import { runAndroidAdb } from './adb.ts'; import { androidAdbResultError } from './adb-executor.ts'; import { resolveAndroidApp } from './app-deployment-resolution.ts'; +import { setAndroidPermission } from './settings-permission.ts'; const ANDROID_ANIMATION_SCALE_SETTINGS = [ 'window_animation_scale', @@ -142,28 +139,7 @@ export async function setAndroidSetting( if (!appPackage) { throw new AppError('INVALID_ARGS', 'permission setting requires an active app in session'); } - const action = parsePermissionAction(state); - const target = parseAndroidPermissionTarget( - options?.permissionTarget, - options?.permissionMode, - ); - if (target.kind === 'notifications') { - await setAndroidNotificationPermission(device, appPackage, action, target); - return; - } - const pmAction = action === 'grant' ? 'grant' : 'revoke'; - if (target.type === 'photos') { - const permission = await setAndroidPhotoPermission(device, appPackage, pmAction); - if (action === 'reset') { - await clearAndroidPermissionFlags(device, appPackage, permission); - } - return; - } - await runAndroidAdb(device, ['shell', 'pm', pmAction, appPackage, target.value]); - if (action === 'reset') { - await clearAndroidPermissionFlags(device, appPackage, target.value); - } - return; + return await setAndroidPermission(device, appPackage, state, options); } default: throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`); @@ -287,116 +263,3 @@ function parseAndroidAppearance(stdout: string, stderr: string): 'light' | 'dark if (value === 'auto') return 'auto'; return null; } - -function parseAndroidPermissionTarget( - permissionTarget: string | undefined, - permissionMode: string | undefined, -): - | { kind: 'pm'; value: string; type: 'camera' | 'microphone' | 'photos' | 'contacts' } - | { kind: 'notifications'; appOps: string; permission: string } { - const normalized = parsePermissionTarget(permissionTarget); - if (permissionMode?.trim()) { - throw new AppError( - 'INVALID_ARGS', - `Permission mode is only supported for photos. Received: ${permissionMode}.`, - ); - } - if (normalized === 'camera') - return { kind: 'pm', value: 'android.permission.CAMERA', type: 'camera' }; - if (normalized === 'microphone') { - return { kind: 'pm', value: 'android.permission.RECORD_AUDIO', type: 'microphone' }; - } - if (normalized === 'photos') { - return { kind: 'pm', value: 'android.permission.READ_MEDIA_IMAGES', type: 'photos' }; - } - if (normalized === 'contacts') { - return { kind: 'pm', value: 'android.permission.READ_CONTACTS', type: 'contacts' }; - } - if (normalized === 'notifications') { - return { - kind: 'notifications', - appOps: 'POST_NOTIFICATION', - permission: 'android.permission.POST_NOTIFICATIONS', - }; - } - throw new AppError( - 'INVALID_ARGS', - `Unsupported permission target on Android: ${permissionTarget}. Use camera|microphone|photos|contacts|notifications.`, - ); -} - -async function setAndroidPhotoPermission( - device: DeviceInfo, - appPackage: string, - pmAction: 'grant' | 'revoke', -): Promise { - const sdkInt = await getAndroidSdkInt(device); - const candidates = - sdkInt !== null && sdkInt >= 33 - ? ['android.permission.READ_MEDIA_IMAGES', 'android.permission.READ_EXTERNAL_STORAGE'] - : ['android.permission.READ_EXTERNAL_STORAGE', 'android.permission.READ_MEDIA_IMAGES']; - - const failures: Array<{ permission: string; stderr: string; exitCode: number }> = []; - for (const permission of candidates) { - const result = await runAndroidAdb(device, ['shell', 'pm', pmAction, appPackage, permission], { - allowFailure: true, - }); - if (result.exitCode === 0) return permission; - failures.push({ permission, stderr: result.stderr, exitCode: result.exitCode }); - } - - throw new AppError('COMMAND_FAILED', `Failed to ${pmAction} Android photos permission`, { - appPackage, - sdkInt, - attempts: failures, - }); -} - -async function setAndroidNotificationPermission( - device: DeviceInfo, - appPackage: string, - action: 'grant' | 'deny' | 'reset', - target: { appOps: string; permission: string }, -): Promise { - const appOpsMode = action === 'grant' ? 'allow' : action === 'deny' ? 'deny' : 'default'; - if (action === 'grant') { - await runAndroidAdb(device, ['shell', 'pm', 'grant', appPackage, target.permission], { - allowFailure: true, - }); - } else { - await runAndroidAdb(device, ['shell', 'pm', 'revoke', appPackage, target.permission], { - allowFailure: true, - }); - if (action === 'reset') { - await clearAndroidPermissionFlags(device, appPackage, target.permission); - } - } - await runAndroidAdb(device, ['shell', 'appops', 'set', appPackage, target.appOps, appOpsMode]); -} - -async function clearAndroidPermissionFlags( - device: DeviceInfo, - appPackage: string, - permission: string, -): Promise { - await runAndroidAdb( - device, - ['shell', 'pm', 'clear-permission-flags', appPackage, permission, 'user-set'], - { allowFailure: true }, - ); - await runAndroidAdb( - device, - ['shell', 'pm', 'clear-permission-flags', appPackage, permission, 'user-fixed'], - { allowFailure: true }, - ); -} - -async function getAndroidSdkInt(device: DeviceInfo): Promise { - const result = await runAndroidAdb(device, ['shell', '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/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 2e14f45da..345c6b930 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -11,6 +11,7 @@ import { assertRpcOk, } from './assertions.ts'; import { createAndroidSettingsWorld, waitForFileContent } from './android-world.ts'; +import { assertAndroidSettingsContract } from './android-settings-contract.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; import { createProviderScenarioTempPath, withProviderScenarioResource } from './harness.ts'; import { @@ -1486,45 +1487,6 @@ function assertAndroidObservabilityContract(world: AndroidSettingsWorld): void { ); } -function assertAndroidSettingsContract(world: AndroidSettingsWorld): void { - const { adbCalls } = world; - assertCommandCall(adbCalls, ['shell', 'cmd', 'uimode', 'night', 'yes']); - assertCommandCall(adbCalls, ['emu', 'geo', 'fix', '-122.009', '37.3349']); - assertCommandCall(adbCalls, ['shell', 'cmd', 'fingerprint', 'touch', '1']); - assertCommandCall(adbCalls, [ - 'shell', - 'pm', - 'grant', - 'com.example.demo', - 'android.permission.CAMERA', - ]); - assertCommandCall(adbCalls, [ - 'shell', - 'settings', - 'put', - 'global', - 'window_animation_scale', - '0', - ]); - assertCommandCall(adbCalls, [ - 'shell', - 'settings', - 'put', - 'global', - 'transition_animation_scale', - '0', - ]); - assertCommandCall(adbCalls, [ - 'shell', - 'settings', - 'put', - 'global', - 'animator_duration_scale', - '0', - ]); - assertCommandCall(adbCalls, ['shell', 'echo', 'ok']); -} - function assertAndroidInteractionContract(world: AndroidSettingsWorld): void { const { adbCalls } = world; assert.ok( diff --git a/test/integration/provider-scenarios/android-settings-contract.ts b/test/integration/provider-scenarios/android-settings-contract.ts new file mode 100644 index 000000000..c0f4b2004 --- /dev/null +++ b/test/integration/provider-scenarios/android-settings-contract.ts @@ -0,0 +1,54 @@ +import { assertCommandCall } from './assertions.ts'; +import type { createAndroidSettingsWorld } from './android-world.ts'; + +type AndroidSettingsWorld = Awaited>; + +/** + * The adb evidence the Android settings flow must leave behind: the appearance, location, + * fingerprint, permission, animation-scale, and network mutations each named with the exact + * argv the provider saw. Extracted from `android-lifecycle.test.ts`, which sits over the + * test-file tripwire (`src/__tests__/test-file-size-ratchet.test.ts`). + */ +export function assertAndroidSettingsContract(world: AndroidSettingsWorld): void { + const { adbCalls } = world; + assertCommandCall(adbCalls, ['shell', 'cmd', 'uimode', 'night', 'yes']); + assertCommandCall(adbCalls, ['emu', 'geo', 'fix', '-122.009', '37.3349']); + assertCommandCall(adbCalls, ['shell', 'cmd', 'fingerprint', 'touch', '1']); + // #1796: the grant names the acting user, because `pm` would otherwise default to user 0 + // rather than the foreground user the session's app runs as. + assertCommandCall(adbCalls, ['shell', 'am', 'get-current-user']); + assertCommandCall(adbCalls, [ + 'shell', + 'pm', + 'grant', + '--user', + '0', + 'com.example.demo', + 'android.permission.CAMERA', + ]); + assertCommandCall(adbCalls, [ + 'shell', + 'settings', + 'put', + 'global', + 'window_animation_scale', + '0', + ]); + assertCommandCall(adbCalls, [ + 'shell', + 'settings', + 'put', + 'global', + 'transition_animation_scale', + '0', + ]); + assertCommandCall(adbCalls, [ + 'shell', + 'settings', + 'put', + 'global', + 'animator_duration_scale', + '0', + ]); + assertCommandCall(adbCalls, ['shell', 'echo', 'ok']); +} diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index 5abda4f2b..5502f1d48 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -466,10 +466,18 @@ function androidFingerprintMutationAdbResult(args: string[]): AndroidAdbResult | } function androidPermissionMutationAdbResult(args: string[]): AndroidAdbResult | undefined { + // #1796: permission mutations name the acting user explicitly, because `pm` defaults + // grant/revoke to user 0 rather than the foreground user. The scripted provider answers the + // resolution and accepts the `--user ` form the production path now sends. + if (args.length === 3 && argsStartWith(args, ['shell', 'am', 'get-current-user'])) { + return { stdout: '0\n', stderr: '', exitCode: 0 }; + } + const scoped = args[3] === '--user'; + const verb = args[2]; if ( - args.length === 5 && argsStartWith(args, ['shell', 'pm']) && - (args[2] === 'grant' || args[2] === 'revoke') + (verb === 'grant' || verb === 'revoke') && + args.length === (scoped ? 7 : 5) ) { return { stdout: '', stderr: '', exitCode: 0 }; } diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b4dab1963..cca3d0322 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -673,7 +673,8 @@ agent-device settings permission reset screen-recording --platform macos - macOS permission targets: `accessibility`, `screen-recording`, `input-monitoring`. - On macOS, `settings permission grant ...` checks/request access and opens System Settings guidance when needed; it does not silently grant TCC permissions. - On macOS, `settings permission deny ...` is intentionally unsupported. -- Android uses `pm grant|revoke` for runtime permissions (`reset` maps to revoke) and `appops` for notifications. +- Android uses `pm grant|revoke` for runtime permissions (`reset` maps to revoke) and `appops` for notifications. Every permission mutation names the foreground user explicitly (`--user `, resolved with `am get-current-user`): `pm` defaults these operations to user 0, so on a device whose foreground user is nonzero an unscoped revoke would edit user 0 and leave the running app's permission untouched. Resolving that user is a prerequisite — if `am get-current-user` does not answer, `settings permission` fails with `COMMAND_FAILED` and changes nothing rather than applying the mutation to user 0. +- Android kills a running app whenever a runtime permission it currently holds is revoked, so `settings permission deny|reset` after a grant leaves the session app no longer running. The response reports the prior state of the revoked permission for the acting user as `priorGrantState: granted | not_granted | unknown`, and carries a warning naming `open --relaunch` for both `granted` and `unknown` — `unknown` means the device did not report a readable state, not that the app was left alone. Revoking a permission the app does not hold (`not_granted`) is harmless and warns nothing. - `full|limited` mode is supported only for iOS `photos`; other targets reject mode. - Use `match`/`nonmatch` to simulate valid/invalid Face ID, Touch ID, and Android fingerprint outcomes.