Skip to content

Commit 2fcfa0c

Browse files
committed
feat(platform-android): implement permission automation via adb
Add permission granting functionality for Android that uses adb to automatically grant a comprehensive set of common dangerous permissions when the permissions config flag is enabled. Works on both emulators and physical devices. - Create android/permissions.ts with default permission list - Add adb.grantPermissions() function to grant permissions via 'pm grant' - Integrate permission granting in emulator and physical device instance setup - Add comprehensive tests for permission granting scenarios - Update permissions documentation to describe Android implementation
1 parent f658867 commit 2fcfa0c

6 files changed

Lines changed: 275 additions & 3 deletions

File tree

packages/platform-android/src/__tests__/adb.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,4 +595,46 @@ describe('getStartAppArgs', () => {
595595
await expect(waitPromise).resolves.toBeUndefined();
596596
expect(spawnSpy).toHaveBeenCalledTimes(2);
597597
});
598+
599+
it('grants permissions to an app', async () => {
600+
const { grantPermissions } = await import('../adb.js');
601+
const spawnSpy = vi.spyOn(tools, 'spawn');
602+
spawnSpy.mockResolvedValue({
603+
stdout: '',
604+
} as Awaited<ReturnType<typeof tools.spawn>>);
605+
606+
await grantPermissions('emulator-5554', 'com.example.app', [
607+
'android.permission.CAMERA',
608+
'android.permission.RECORD_AUDIO',
609+
]);
610+
611+
expect(spawnSpy).toHaveBeenCalledTimes(2);
612+
expect(spawnSpy).toHaveBeenNthCalledWith(1, expect.any(String), [
613+
'-s',
614+
'emulator-5554',
615+
'shell',
616+
'pm',
617+
'grant',
618+
'com.example.app',
619+
'android.permission.CAMERA',
620+
]);
621+
expect(spawnSpy).toHaveBeenNthCalledWith(2, expect.any(String), [
622+
'-s',
623+
'emulator-5554',
624+
'shell',
625+
'pm',
626+
'grant',
627+
'com.example.app',
628+
'android.permission.RECORD_AUDIO',
629+
]);
630+
});
631+
632+
it('handles empty permission list when granting permissions', async () => {
633+
const { grantPermissions } = await import('../adb.js');
634+
const spawnSpy = vi.spyOn(tools, 'spawn');
635+
636+
await grantPermissions('emulator-5554', 'com.example.app', []);
637+
638+
expect(spawnSpy).not.toHaveBeenCalled();
639+
});
598640
});

packages/platform-android/src/__tests__/instance.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,4 +607,148 @@ describe('Android platform instance', () => {
607607
expect(appMonitor.addListener(listener)).toBeUndefined();
608608
expect(appMonitor.removeListener(listener)).toBeUndefined();
609609
});
610+
611+
it('grants permissions when permissions are enabled for emulator', async () => {
612+
vi.spyOn(
613+
await import('../environment.js'),
614+
'ensureAndroidEmulatorEnvironment',
615+
).mockResolvedValue('/tmp/android-sdk');
616+
vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']);
617+
vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35');
618+
vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554');
619+
vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true);
620+
vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined);
621+
vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined);
622+
vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234);
623+
vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue(
624+
undefined,
625+
);
626+
const grantPermissions = vi
627+
.spyOn(adb, 'grantPermissions')
628+
.mockResolvedValue(undefined);
629+
630+
const harnessConfigWithPermissions = {
631+
...harnessConfig,
632+
permissions: true,
633+
} as HarnessConfig;
634+
635+
await getAndroidEmulatorPlatformInstance(
636+
{
637+
name: 'android',
638+
device: {
639+
type: 'emulator',
640+
name: 'Pixel_8_API_35',
641+
avd: {
642+
apiLevel: 35,
643+
profile: 'pixel_8',
644+
diskSize: '1G',
645+
heapSize: '1G',
646+
},
647+
},
648+
bundleId: 'com.harnessplayground',
649+
activityName: '.MainActivity',
650+
},
651+
harnessConfigWithPermissions,
652+
init,
653+
);
654+
655+
expect(grantPermissions).toHaveBeenCalledWith(
656+
'emulator-5554',
657+
'com.harnessplayground',
658+
expect.arrayContaining([
659+
'android.permission.CAMERA',
660+
'android.permission.RECORD_AUDIO',
661+
'android.permission.ACCESS_FINE_LOCATION',
662+
]),
663+
);
664+
});
665+
666+
it('does not grant permissions when permissions are disabled for emulator', async () => {
667+
vi.spyOn(
668+
await import('../environment.js'),
669+
'ensureAndroidEmulatorEnvironment',
670+
).mockResolvedValue('/tmp/android-sdk');
671+
vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']);
672+
vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35');
673+
vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554');
674+
vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true);
675+
vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined);
676+
vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined);
677+
vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234);
678+
vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue(
679+
undefined,
680+
);
681+
const grantPermissions = vi
682+
.spyOn(adb, 'grantPermissions')
683+
.mockResolvedValue(undefined);
684+
685+
await getAndroidEmulatorPlatformInstance(
686+
{
687+
name: 'android',
688+
device: {
689+
type: 'emulator',
690+
name: 'Pixel_8_API_35',
691+
avd: {
692+
apiLevel: 35,
693+
profile: 'pixel_8',
694+
diskSize: '1G',
695+
heapSize: '1G',
696+
},
697+
},
698+
bundleId: 'com.harnessplayground',
699+
activityName: '.MainActivity',
700+
},
701+
harnessConfig,
702+
init,
703+
);
704+
705+
expect(grantPermissions).not.toHaveBeenCalled();
706+
});
707+
708+
it('grants permissions when permissions are enabled for physical device', async () => {
709+
vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['012345']);
710+
vi.spyOn(adb, 'getDeviceInfo').mockResolvedValue({
711+
manufacturer: 'motorola',
712+
model: 'moto g72',
713+
});
714+
vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true);
715+
vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined);
716+
vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined);
717+
vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234);
718+
vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue(
719+
undefined,
720+
);
721+
const grantPermissions = vi
722+
.spyOn(adb, 'grantPermissions')
723+
.mockResolvedValue(undefined);
724+
725+
const harnessConfigWithPermissions = {
726+
...harnessConfig,
727+
permissions: true,
728+
} as HarnessConfig;
729+
730+
await getAndroidPhysicalDevicePlatformInstance(
731+
{
732+
name: 'android-device',
733+
device: {
734+
type: 'physical',
735+
manufacturer: 'motorola',
736+
model: 'moto g72',
737+
},
738+
bundleId: 'com.harnessplayground',
739+
activityName: '.MainActivity',
740+
},
741+
harnessConfigWithPermissions,
742+
);
743+
744+
expect(grantPermissions).toHaveBeenCalledWith(
745+
'012345',
746+
'com.harnessplayground',
747+
expect.arrayContaining([
748+
'android.permission.CAMERA',
749+
'android.permission.RECORD_AUDIO',
750+
'android.permission.ACCESS_FINE_LOCATION',
751+
]),
752+
);
753+
});
610754
});

packages/platform-android/src/adb.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,3 +730,27 @@ export const getConnectedDevices = async (): Promise<AdbDevice[]> => {
730730

731731
return devices;
732732
};
733+
734+
export const grantPermissions = async (
735+
adbId: string,
736+
bundleId: string,
737+
permissions: string[],
738+
): Promise<void> => {
739+
if (permissions.length === 0) {
740+
return;
741+
}
742+
743+
const grantCommands = permissions.map((permission) => [
744+
'-s',
745+
adbId,
746+
'shell',
747+
'pm',
748+
'grant',
749+
bundleId,
750+
permission,
751+
]);
752+
753+
await Promise.all(
754+
grantCommands.map((args) => spawn(getAdbBinaryPath(), args as string[])),
755+
);
756+
};

packages/platform-android/src/instance.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import { isInteractive } from '@react-native-harness/tools';
3535
import fs from 'node:fs';
3636
import type { AppMonitor } from '@react-native-harness/platforms';
37+
import { getDefaultAndroidPermissions } from './permissions.js';
3738

3839
const androidInstanceLogger = logger.child('android-instance');
3940

@@ -180,6 +181,7 @@ export const getAndroidEmulatorPlatformInstance = async (
180181
): Promise<HarnessPlatformRunner> => {
181182
assertAndroidDeviceEmulator(config.device);
182183
const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true;
184+
const permissionsEnabled = harnessConfig.permissions ?? false;
183185
const emulatorConfig = config.device;
184186
const emulatorName = emulatorConfig.name;
185187
const avdConfig = emulatorConfig.avd;
@@ -258,6 +260,11 @@ export const getAndroidEmulatorPlatformInstance = async (
258260

259261
const appUid = await configureAndroidRuntime(adbId, config, harnessConfig);
260262

263+
if (permissionsEnabled) {
264+
const defaultPermissions = getDefaultAndroidPermissions();
265+
await adb.grantPermissions(adbId, config.bundleId, defaultPermissions);
266+
}
267+
261268
return {
262269
startApp: async (options) => {
263270
await adb.startApp(
@@ -315,6 +322,7 @@ export const getAndroidPhysicalDevicePlatformInstance = async (
315322
): Promise<HarnessPlatformRunner> => {
316323
assertAndroidDevicePhysical(config.device);
317324
const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true;
325+
const permissionsEnabled = harnessConfig.permissions ?? false;
318326

319327
const adbId = await getAdbId(config.device);
320328

@@ -333,6 +341,11 @@ export const getAndroidPhysicalDevicePlatformInstance = async (
333341

334342
const appUid = await configureAndroidRuntime(adbId, config, harnessConfig);
335343

344+
if (permissionsEnabled) {
345+
const defaultPermissions = getDefaultAndroidPermissions();
346+
await adb.grantPermissions(adbId, config.bundleId, defaultPermissions);
347+
}
348+
336349
return {
337350
startApp: async (options) => {
338351
await adb.startApp(
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const DEFAULT_PERMISSIONS = [
2+
'android.permission.ACCESS_FINE_LOCATION',
3+
'android.permission.ACCESS_COARSE_LOCATION',
4+
'android.permission.CAMERA',
5+
'android.permission.RECORD_AUDIO',
6+
'android.permission.READ_CALENDAR',
7+
'android.permission.WRITE_CALENDAR',
8+
'android.permission.READ_CONTACTS',
9+
'android.permission.WRITE_CONTACTS',
10+
'android.permission.READ_CALL_LOG',
11+
'android.permission.WRITE_CALL_LOG',
12+
'android.permission.READ_EXTERNAL_STORAGE',
13+
'android.permission.WRITE_EXTERNAL_STORAGE',
14+
'android.permission.READ_PHONE_STATE',
15+
'android.permission.CALL_PHONE',
16+
'android.permission.READ_SMS',
17+
'android.permission.SEND_SMS',
18+
'android.permission.RECEIVE_SMS',
19+
'android.permission.READ_CELL_BROADCASTS',
20+
'android.permission.BODY_SENSORS',
21+
'android.permission.INTERNET',
22+
'android.permission.ACCESS_NETWORK_STATE',
23+
];
24+
25+
export const getDefaultAndroidPermissions = (): string[] => {
26+
return DEFAULT_PERMISSIONS;
27+
};

website/src/docs/guides/permissions.mdx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ When `permissions` is `true`:
2525

2626
- On iOS simulators, Harness starts the XCTest agent and enables best-effort permission prompt auto-accept.
2727
- On iOS physical devices, Harness also starts the XCTest agent when the runner is configured with `device.codeSign`.
28-
- On Android, the shared config flag exists, but there is currently no dedicated Android permission automation implementation in the codebase.
28+
- On Android emulators and physical devices, Harness uses `adb shell pm grant` to automatically grant a set of common permissions.
2929

30-
## What iOS Actually Automates
30+
## Platform-Specific Implementation Details
3131

32-
The current iOS implementation does **not** pre-approve every permission up front.
32+
### iOS
33+
34+
The iOS implementation does **not** pre-approve every permission up front.
3335
Instead, it runs a best-effort watchdog that taps known positive system-prompt buttons such as:
3436

3537
- `Allow`
@@ -41,6 +43,26 @@ Instead, it runs a best-effort watchdog that taps known positive system-prompt b
4143

4244
Because this is button-based automation, it should be treated as a practical helper for common prompts rather than a guarantee that every permission dialog will be handled.
4345

46+
### Android
47+
48+
On Android, Harness pre-grants a comprehensive set of common dangerous permissions using `adb shell pm grant`. This approach:
49+
50+
- Grants permissions **proactively** before the app runs, avoiding permission dialogs during testing
51+
- Works on both emulators and physical devices
52+
- Includes location, camera, microphone, contacts, calendar, storage, SMS, and other common test permissions
53+
- Uses the `pm grant` command which requires API 23+ or emulated environment support
54+
55+
The default set of granted permissions includes:
56+
- Location (fine and coarse)
57+
- Camera and microphone
58+
- Contacts and calendar (read/write)
59+
- Call log (read/write)
60+
- Storage (read/write)
61+
- Phone state and calling
62+
- SMS (read/send/receive)
63+
- Body sensors
64+
- Network state and internet
65+
4466
## Performance Impact
4567

4668
Enabling `permissions` can make Harness heavier on iOS because it starts an additional XCTest agent session alongside the normal run.

0 commit comments

Comments
 (0)