@react-native-harness/platform-android 1.1.0 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -429,6 +429,60 @@ describe('Android platform instance', () => {
429
429
  fs.rmSync(appPath, { force: true });
430
430
  });
431
431
 
432
+ it('reinstalls the app from HARNESS_APP_PATH when already installed', async () => {
433
+ const appPath = path.join(os.tmpdir(), 'HarnessPlayground-installed.apk');
434
+ fs.writeFileSync(appPath, 'apk');
435
+ vi.stubEnv('HARNESS_APP_PATH', appPath);
436
+ vi.spyOn(
437
+ await import('../environment.js'),
438
+ 'ensureAndroidEmulatorEnvironment',
439
+ ).mockResolvedValue('/tmp/android-sdk');
440
+ vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']);
441
+ vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35');
442
+ vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554');
443
+ vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true);
444
+ const uninstallApp = vi
445
+ .spyOn(adb, 'uninstallApp')
446
+ .mockResolvedValue(undefined);
447
+ const installApp = vi.spyOn(adb, 'installApp').mockResolvedValue(undefined);
448
+ vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined);
449
+ vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined);
450
+ vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234);
451
+ vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue(
452
+ undefined,
453
+ );
454
+
455
+ await expect(
456
+ getAndroidEmulatorPlatformInstance(
457
+ {
458
+ name: 'android',
459
+ device: {
460
+ type: 'emulator',
461
+ name: 'Pixel_8_API_35',
462
+ avd: {
463
+ apiLevel: 35,
464
+ profile: 'pixel_8',
465
+ diskSize: '1G',
466
+ heapSize: '1G',
467
+ },
468
+ },
469
+ bundleId: 'com.harnessplayground',
470
+ activityName: '.MainActivity',
471
+ },
472
+ harnessConfig,
473
+ init,
474
+ ),
475
+ ).resolves.toBeDefined();
476
+
477
+ expect(uninstallApp).toHaveBeenCalledWith(
478
+ 'emulator-5554',
479
+ 'com.harnessplayground',
480
+ );
481
+ expect(installApp).toHaveBeenCalledWith('emulator-5554', appPath);
482
+
483
+ fs.rmSync(appPath, { force: true });
484
+ });
485
+
432
486
  it('throws a HarnessAppPathError when HARNESS_APP_PATH is missing', async () => {
433
487
  vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']);
434
488
  vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35');
@@ -607,4 +661,138 @@ describe('Android platform instance', () => {
607
661
  expect(appMonitor.addListener(listener)).toBeUndefined();
608
662
  expect(appMonitor.removeListener(listener)).toBeUndefined();
609
663
  });
664
+
665
+ it('grants permissions when permissions are enabled for emulator', async () => {
666
+ vi.spyOn(
667
+ await import('../environment.js'),
668
+ 'ensureAndroidEmulatorEnvironment',
669
+ ).mockResolvedValue('/tmp/android-sdk');
670
+ vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']);
671
+ vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35');
672
+ vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554');
673
+ vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true);
674
+ vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined);
675
+ vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined);
676
+ vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234);
677
+ vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue(
678
+ undefined,
679
+ );
680
+ const grantPermissions = vi
681
+ .spyOn(adb, 'grantPermissions')
682
+ .mockResolvedValue(undefined);
683
+
684
+ const harnessConfigWithPermissions = {
685
+ ...harnessConfig,
686
+ permissions: true,
687
+ } as HarnessConfig;
688
+
689
+ await getAndroidEmulatorPlatformInstance(
690
+ {
691
+ name: 'android',
692
+ device: {
693
+ type: 'emulator',
694
+ name: 'Pixel_8_API_35',
695
+ avd: {
696
+ apiLevel: 35,
697
+ profile: 'pixel_8',
698
+ diskSize: '1G',
699
+ heapSize: '1G',
700
+ },
701
+ },
702
+ bundleId: 'com.harnessplayground',
703
+ activityName: '.MainActivity',
704
+ },
705
+ harnessConfigWithPermissions,
706
+ init,
707
+ );
708
+
709
+ expect(grantPermissions).toHaveBeenCalledWith(
710
+ 'emulator-5554',
711
+ 'com.harnessplayground',
712
+ );
713
+ });
714
+
715
+ it('does not grant permissions when permissions are disabled for emulator', async () => {
716
+ vi.spyOn(
717
+ await import('../environment.js'),
718
+ 'ensureAndroidEmulatorEnvironment',
719
+ ).mockResolvedValue('/tmp/android-sdk');
720
+ vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']);
721
+ vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35');
722
+ vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554');
723
+ vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true);
724
+ vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined);
725
+ vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined);
726
+ vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234);
727
+ vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue(
728
+ undefined,
729
+ );
730
+ const grantPermissions = vi
731
+ .spyOn(adb, 'grantPermissions')
732
+ .mockResolvedValue(undefined);
733
+
734
+ await getAndroidEmulatorPlatformInstance(
735
+ {
736
+ name: 'android',
737
+ device: {
738
+ type: 'emulator',
739
+ name: 'Pixel_8_API_35',
740
+ avd: {
741
+ apiLevel: 35,
742
+ profile: 'pixel_8',
743
+ diskSize: '1G',
744
+ heapSize: '1G',
745
+ },
746
+ },
747
+ bundleId: 'com.harnessplayground',
748
+ activityName: '.MainActivity',
749
+ },
750
+ harnessConfig,
751
+ init,
752
+ );
753
+
754
+ expect(grantPermissions).not.toHaveBeenCalled();
755
+ });
756
+
757
+ it('grants permissions when permissions are enabled for physical device', async () => {
758
+ vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['012345']);
759
+ vi.spyOn(adb, 'getDeviceInfo').mockResolvedValue({
760
+ manufacturer: 'motorola',
761
+ model: 'moto g72',
762
+ });
763
+ vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true);
764
+ vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined);
765
+ vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined);
766
+ vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234);
767
+ vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue(
768
+ undefined,
769
+ );
770
+ const grantPermissions = vi
771
+ .spyOn(adb, 'grantPermissions')
772
+ .mockResolvedValue(undefined);
773
+
774
+ const harnessConfigWithPermissions = {
775
+ ...harnessConfig,
776
+ permissions: true,
777
+ } as HarnessConfig;
778
+
779
+ await getAndroidPhysicalDevicePlatformInstance(
780
+ {
781
+ name: 'android-device',
782
+ device: {
783
+ type: 'physical',
784
+ manufacturer: 'motorola',
785
+ model: 'moto g72',
786
+ },
787
+ bundleId: 'com.harnessplayground',
788
+ activityName: '.MainActivity',
789
+ },
790
+ harnessConfigWithPermissions,
791
+ );
792
+
793
+ expect(grantPermissions).toHaveBeenCalledWith(
794
+ '012345',
795
+ 'com.harnessplayground',
796
+ );
797
+ });
610
798
  });
@@ -0,0 +1,47 @@
1
+ export class AdbError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = 'AdbError';
5
+ }
6
+ }
7
+
8
+ export class AdbDeviceNotFoundError extends AdbError {
9
+ constructor(adbId: string) {
10
+ super(
11
+ `Android device "${adbId}" not found or not connected. ` +
12
+ `Run "adb devices" to see available devices.`
13
+ );
14
+ this.name = 'AdbDeviceNotFoundError';
15
+ }
16
+ }
17
+
18
+ export class AdbAppNotInstalledError extends AdbError {
19
+ constructor(bundleId: string, adbId: string) {
20
+ super(
21
+ `App "${bundleId}" is not installed on device "${adbId}". ` +
22
+ `Install the app before running tests.`
23
+ );
24
+ this.name = 'AdbAppNotInstalledError';
25
+ }
26
+ }
27
+
28
+ export class AdbPermissionGrantError extends AdbError {
29
+ constructor(bundleId: string, permissions: string[], adbId: string) {
30
+ const permissionList = permissions.join(', ');
31
+ super(
32
+ `Failed to grant permissions [${permissionList}] to "${bundleId}" on device "${adbId}". ` +
33
+ `Verify the app is installed and the device supports these permissions.`
34
+ );
35
+ this.name = 'AdbPermissionGrantError';
36
+ }
37
+ }
38
+
39
+ export class AdbBinaryNotFoundError extends AdbError {
40
+ constructor() {
41
+ super(
42
+ `adb binary not found or not accessible. ` +
43
+ `Ensure Android SDK is properly installed and ANDROID_HOME is set.`
44
+ );
45
+ this.name = 'AdbBinaryNotFoundError';
46
+ }
47
+ }
package/src/adb-id.ts CHANGED
@@ -16,6 +16,10 @@ export const getAdbId = async (
16
16
 
17
17
  for (const adbId of adbIds) {
18
18
  if (isAndroidDeviceEmulator(device)) {
19
+ if (!isAdbIdEmulator(adbId)) {
20
+ continue;
21
+ }
22
+
19
23
  const emulatorName = await adb.getEmulatorName(adbId);
20
24
 
21
25
  if (emulatorName === device.name) {
package/src/adb.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms';
2
- import { spawn, SubprocessError } from '@react-native-harness/tools';
2
+ import { logger, spawn, SubprocessError } from '@react-native-harness/tools';
3
3
  import { spawn as nodeSpawn } from 'node:child_process';
4
4
  import type { ChildProcessByStdio } from 'node:child_process';
5
5
  import { access, rm } from 'node:fs/promises';
@@ -23,6 +23,10 @@ import {
23
23
  getEmulatorStartupArgs,
24
24
  type EmulatorBootMode,
25
25
  } from './emulator-startup.js';
26
+ import {
27
+ AdbAppNotInstalledError,
28
+ AdbPermissionGrantError,
29
+ } from './adb-errors.js';
26
30
 
27
31
  const wait = async (ms: number): Promise<void> => {
28
32
  await new Promise((resolve) => {
@@ -59,6 +63,7 @@ const waitWithSignal = async (
59
63
 
60
64
  const EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS = 5000;
61
65
  const EMULATOR_OUTPUT_BUFFER_LIMIT = 16 * 1024;
66
+ const androidAdbLogger = logger.child('android-adb');
62
67
 
63
68
  export const emulatorProcess = {
64
69
  startDetachedProcess: (
@@ -374,6 +379,79 @@ export const getDeviceInfo = async (
374
379
  return { manufacturer, model };
375
380
  };
376
381
 
382
+ const getRequestedPermissions = async (
383
+ adbId: string,
384
+ bundleId: string,
385
+ ): Promise<string[]> => {
386
+ const { stdout } = await spawn(getAdbBinaryPath(), [
387
+ '-s',
388
+ adbId,
389
+ 'shell',
390
+ 'dumpsys',
391
+ 'package',
392
+ bundleId,
393
+ ]);
394
+
395
+ const requestedPermissions = new Set<string>();
396
+ const lines = stdout.split('\n');
397
+ let inRequestedPermissionsSection = false;
398
+
399
+ for (const line of lines) {
400
+ const trimmedLine = line.trim();
401
+
402
+ if (trimmedLine === 'requested permissions:') {
403
+ inRequestedPermissionsSection = true;
404
+ continue;
405
+ }
406
+
407
+ if (!inRequestedPermissionsSection) {
408
+ continue;
409
+ }
410
+
411
+ if (trimmedLine === '') {
412
+ continue;
413
+ }
414
+
415
+ if (trimmedLine.endsWith(':')) {
416
+ break;
417
+ }
418
+
419
+ if (/^[a-zA-Z0-9_.]+$/.test(trimmedLine)) {
420
+ requestedPermissions.add(trimmedLine);
421
+ continue;
422
+ }
423
+
424
+ break;
425
+ }
426
+
427
+ return [...requestedPermissions];
428
+ };
429
+
430
+ const getDangerousPermissions = async (adbId: string): Promise<Set<string>> => {
431
+ const { stdout } = await spawn(getAdbBinaryPath(), [
432
+ '-s',
433
+ adbId,
434
+ 'shell',
435
+ 'pm',
436
+ 'list',
437
+ 'permissions',
438
+ '-g',
439
+ '-d',
440
+ ]);
441
+
442
+ const dangerousPermissions = new Set<string>();
443
+
444
+ for (const match of stdout.matchAll(/permission:([a-zA-Z0-9_.]+)/g)) {
445
+ const permission = match[1]?.trim();
446
+
447
+ if (permission) {
448
+ dangerousPermissions.add(permission);
449
+ }
450
+ }
451
+
452
+ return dangerousPermissions;
453
+ };
454
+
377
455
  export const isBootCompleted = async (adbId: string): Promise<boolean> => {
378
456
  try {
379
457
  const bootCompleted = await getShellProperty(adbId, 'sys.boot_completed');
@@ -398,6 +476,13 @@ export const installApp = async (
398
476
  await spawn(getAdbBinaryPath(), ['-s', adbId, 'install', '-r', appPath]);
399
477
  };
400
478
 
479
+ export const uninstallApp = async (
480
+ adbId: string,
481
+ bundleId: string,
482
+ ): Promise<void> => {
483
+ await spawn(getAdbBinaryPath(), ['-s', adbId, 'uninstall', bundleId]);
484
+ };
485
+
401
486
  export const hasAvd = async (name: string): Promise<boolean> => {
402
487
  const avds = await getAvds();
403
488
  return avds.includes(name);
@@ -730,3 +815,77 @@ export const getConnectedDevices = async (): Promise<AdbDevice[]> => {
730
815
 
731
816
  return devices;
732
817
  };
818
+
819
+ export const grantPermissions = async (
820
+ adbId: string,
821
+ bundleId: string,
822
+ ): Promise<void> => {
823
+ androidAdbLogger.debug('grantPermissions:start %o', {
824
+ adbId,
825
+ bundleId,
826
+ });
827
+
828
+ const isInstalled = await isAppInstalled(adbId, bundleId);
829
+ if (!isInstalled) {
830
+ throw new AdbAppNotInstalledError(bundleId, adbId);
831
+ }
832
+
833
+ const [requestedPermissions, dangerousPermissions] = await Promise.all([
834
+ getRequestedPermissions(adbId, bundleId),
835
+ getDangerousPermissions(adbId),
836
+ ]);
837
+ const permissions = requestedPermissions.filter((permission) =>
838
+ dangerousPermissions.has(permission),
839
+ );
840
+
841
+ androidAdbLogger.debug('grantPermissions:resolved %o', {
842
+ adbId,
843
+ bundleId,
844
+ requestedPermissions,
845
+ permissions,
846
+ });
847
+
848
+ if (permissions.length === 0) {
849
+ androidAdbLogger.debug('grantPermissions:skip %o', {
850
+ adbId,
851
+ bundleId,
852
+ });
853
+ return;
854
+ }
855
+
856
+ const grantCommands = permissions.map((permission) => [
857
+ '-s',
858
+ adbId,
859
+ 'shell',
860
+ 'pm',
861
+ 'grant',
862
+ bundleId,
863
+ permission,
864
+ ]);
865
+
866
+ try {
867
+ androidAdbLogger.debug('grantPermissions:commands %o', {
868
+ adbId,
869
+ bundleId,
870
+ grantCommands,
871
+ });
872
+
873
+ await Promise.all(
874
+ grantCommands.map((args) => spawn(getAdbBinaryPath(), args as string[])),
875
+ );
876
+
877
+ androidAdbLogger.debug('grantPermissions:success %o', {
878
+ adbId,
879
+ bundleId,
880
+ permissions,
881
+ });
882
+ } catch (error) {
883
+ androidAdbLogger.debug('grantPermissions:error %o', {
884
+ adbId,
885
+ bundleId,
886
+ permissions,
887
+ error,
888
+ });
889
+ throw new AdbPermissionGrantError(bundleId, permissions, adbId);
890
+ }
891
+ };
@@ -9,8 +9,6 @@ const COMMON_EMULATOR_ARGS = [
9
9
  'swiftshader_indirect',
10
10
  '-noaudio',
11
11
  '-no-boot-anim',
12
- '-camera-back',
13
- 'none',
14
12
  ] as const;
15
13
 
16
14
  export const getEmulatorStartupArgs = (
package/src/instance.ts CHANGED
@@ -59,6 +59,20 @@ const getHarnessAppPath = (): string => {
59
59
  return appPath;
60
60
  };
61
61
 
62
+ const getOptionalHarnessAppPath = (): string | undefined => {
63
+ const appPath = process.env.HARNESS_APP_PATH;
64
+
65
+ if (!appPath) {
66
+ return undefined;
67
+ }
68
+
69
+ if (!fs.existsSync(appPath)) {
70
+ throw new HarnessAppPathError('invalid', appPath);
71
+ }
72
+
73
+ return appPath;
74
+ };
75
+
62
76
  const configureAndroidRuntime = async (
63
77
  adbId: string,
64
78
  config: AndroidPlatformConfig,
@@ -180,6 +194,7 @@ export const getAndroidEmulatorPlatformInstance = async (
180
194
  ): Promise<HarnessPlatformRunner> => {
181
195
  assertAndroidDeviceEmulator(config.device);
182
196
  const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true;
197
+ const permissionsEnabled = harnessConfig.permissions ?? false;
183
198
  const emulatorConfig = config.device;
184
199
  const emulatorName = emulatorConfig.name;
185
200
  const avdConfig = emulatorConfig.avd;
@@ -250,14 +265,22 @@ export const getAndroidEmulatorPlatformInstance = async (
250
265
  );
251
266
 
252
267
  const isInstalled = await adb.isAppInstalled(adbId, config.bundleId);
268
+ const appPath = getOptionalHarnessAppPath();
253
269
 
254
- if (!isInstalled) {
255
- const appPath = getHarnessAppPath();
270
+ if (isInstalled && appPath) {
271
+ await adb.uninstallApp(adbId, config.bundleId);
256
272
  await adb.installApp(adbId, appPath);
273
+ } else if (!isInstalled) {
274
+ const installPath = appPath ?? getHarnessAppPath();
275
+ await adb.installApp(adbId, installPath);
257
276
  }
258
277
 
259
278
  const appUid = await configureAndroidRuntime(adbId, config, harnessConfig);
260
279
 
280
+ if (permissionsEnabled) {
281
+ await adb.grantPermissions(adbId, config.bundleId);
282
+ }
283
+
261
284
  return {
262
285
  startApp: async (options) => {
263
286
  await adb.startApp(
@@ -315,6 +338,7 @@ export const getAndroidPhysicalDevicePlatformInstance = async (
315
338
  ): Promise<HarnessPlatformRunner> => {
316
339
  assertAndroidDevicePhysical(config.device);
317
340
  const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true;
341
+ const permissionsEnabled = harnessConfig.permissions ?? false;
318
342
 
319
343
  const adbId = await getAdbId(config.device);
320
344
 
@@ -333,6 +357,10 @@ export const getAndroidPhysicalDevicePlatformInstance = async (
333
357
 
334
358
  const appUid = await configureAndroidRuntime(adbId, config, harnessConfig);
335
359
 
360
+ if (permissionsEnabled) {
361
+ await adb.grantPermissions(adbId, config.bundleId);
362
+ }
363
+
336
364
  return {
337
365
  startApp: async (options) => {
338
366
  await adb.startApp(