Repository: ReactiveCircus/android-emulator-runner
Pull request #383 (2024-03-08 12:13:54)

Add port parameter
## The problem
Currently, this action uses ADB commands without specifying device/emulator to use.
This is fine for GitHub-hosted machines, as they are isolated from each other, but becomes a problem when using this action on self-hosted machine, as ADB commands will fail if there are multiple devices present, and it wasn't specified which one to use.

## The fix
Adding `emulator-port` input, that allows a job to specify port to start emulator on.
This way, one machine could have up to 16 emulators running at the same time (due to constraints in port range) without interfering with each other.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b049c0a..27cd232 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Change Log
 
+## v2.31.0
+
+* Support setting `VanillaIceCream` as `api-level`. - [#378](https://github.com/ReactiveCircus/android-emulator-runner/pull/378)
+* Install `platforms` for the specified `api-level`. - [#384](https://github.com/ReactiveCircus/android-emulator-runner/pull/384)
+
 ## v2.30.1
 
 * Run action on Node 20. - [#371](https://github.com/ReactiveCircus/android-emulator-runner/pull/371)
diff --git a/README.md b/README.md
index 15a44b9..f245ff3 100644
--- a/README.md
+++ b/README.md
@@ -191,6 +191,7 @@ jobs:
 | `avd-name` | Optional | `test` | Custom AVD name used for creating the Android Virtual Device. |
 | `force-avd-creation` | Optional | `true` | Whether to force create the AVD by overwriting an existing AVD with the same name as `avd-name` - `true` or `false`. |
 | `emulator-boot-timeout` | Optional | `600` | Emulator boot timeout in seconds. If it takes longer to boot, the action would fail - e.g. `300` for 5 minutes. |
+| `emulator-port` | Optional | `5554` | Emulator port to use. Allows to run this action on multiple workers on a single machine at the same time. This input is available for the script as `EMULATOR_PORT` enviromental variable. This port is automatically used by android device related tasks in gradle |
 | `emulator-options` | Optional | See below | Command-line options used when launching the emulator (replacing all default options) - e.g. `-no-window -no-snapshot -camera-back emulated`. |
 | `disable-animations` | Optional | `true` | Whether to disable animations - `true` or `false`. |
 | `disable-spellchecker` | Optional | `false` | Whether to disable spellchecker - `true` or `false`. |
diff --git a/action-types.yml b/action-types.yml
index 49cbb73..b77d4b1 100644
--- a/action-types.yml
+++ b/action-types.yml
@@ -37,6 +37,8 @@ inputs:
    type: boolean
   emulator-boot-timeout:
    type: integer
+  emulator-port:
+    type: integer
   emulator-options:
    type: string
   disable-animations:
diff --git a/action.yml b/action.yml
index fe609fd..fa60d74 100644
--- a/action.yml
+++ b/action.yml
@@ -36,6 +36,9 @@ inputs:
   emulator-boot-timeout:
     description: 'Emulator boot timeout in seconds. If it takes longer to boot, the action would fail - e.g. `300` for 5 minutes'
     default: '600'
+  emulator-port:
+    description: 'Port to run emulator on, allows to run multiple emulators on the same physical machine'
+    default: '5554'
   emulator-options:
     description: 'command-line options used when launching the emulator - e.g. `-no-window -no-snapshot -camera-back emulated`'
     default: '-no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim'
diff --git a/lib/emulator-manager.js b/lib/emulator-manager.js
index 5225a18..a86015d 100644
--- a/lib/emulator-manager.js
+++ b/lib/emulator-manager.js
@@ -38,7 +38,7 @@ const fs = __importStar(require("fs"));
 /**
  * Creates and launches a new AVD instance with the specified configurations.
  */
-function launchEmulator(apiLevel, target, arch, profile, cores, ramSize, heapSize, sdcardPathOrSize, diskSize, avdName, forceAvdCreation, emulatorBootTimeout, emulatorOptions, disableAnimations, disableSpellChecker, disableLinuxHardwareAcceleration, enableHardwareKeyboard) {
+function launchEmulator(apiLevel, target, arch, profile, cores, ramSize, heapSize, sdcardPathOrSize, diskSize, avdName, forceAvdCreation, emulatorBootTimeout, port, emulatorOptions, disableAnimations, disableSpellChecker, disableLinuxHardwareAcceleration, enableHardwareKeyboard) {
     return __awaiter(this, void 0, void 0, function* () {
         try {
             console.log(`::group::Launch Emulator`);
@@ -82,19 +82,19 @@ function launchEmulator(apiLevel, target, arch, profile, cores, ramSize, heapSiz
                 },
             });
             // wait for emulator to complete booting
-            yield waitForDevice(emulatorBootTimeout);
-            yield exec.exec(`adb shell input keyevent 82`);
+            yield waitForDevice(port, emulatorBootTimeout);
+            yield adb(port, `shell input keyevent 82`);
             if (disableAnimations) {
                 console.log('Disabling animations.');
-                yield exec.exec(`adb shell settings put global window_animation_scale 0.0`);
-                yield exec.exec(`adb shell settings put global transition_animation_scale 0.0`);
-                yield exec.exec(`adb shell settings put global animator_duration_scale 0.0`);
+                yield adb(port, `shell settings put global window_animation_scale 0.0`);
+                yield adb(port, `shell settings put global transition_animation_scale 0.0`);
+                yield adb(port, `shell settings put global animator_duration_scale 0.0`);
             }
             if (disableSpellChecker) {
-                yield exec.exec(`adb shell settings put secure spell_checker_enabled 0`);
+                yield adb(port, `shell settings put secure spell_checker_enabled 0`);
             }
             if (enableHardwareKeyboard) {
-                yield exec.exec(`adb shell settings put secure show_ime_with_hard_keyboard 0`);
+                yield adb(port, `shell settings put secure show_ime_with_hard_keyboard 0`);
             }
         }
         finally {
@@ -106,11 +106,11 @@ exports.launchEmulator = launchEmulator;
 /**
  * Kills the running emulator on the default port.
  */
-function killEmulator() {
+function killEmulator(port) {
     return __awaiter(this, void 0, void 0, function* () {
         try {
             console.log(`::group::Terminate Emulator`);
-            yield exec.exec(`adb -s emulator-5554 emu kill`);
+            yield adb(port, `emu kill`);
         }
         catch (error) {
             console.log(error instanceof Error ? error.message : error);
@@ -121,10 +121,15 @@ function killEmulator() {
     });
 }
 exports.killEmulator = killEmulator;
+function adb(port, command) {
+    return __awaiter(this, void 0, void 0, function* () {
+        return yield exec.exec(`adb -s emulator-${port} ${command}`);
+    });
+}
 /**
  * Wait for emulator to boot.
  */
-function waitForDevice(emulatorBootTimeout) {
+function waitForDevice(port, emulatorBootTimeout) {
     return __awaiter(this, void 0, void 0, function* () {
         let booted = false;
         let attempts = 0;
@@ -133,7 +138,7 @@ function waitForDevice(emulatorBootTimeout) {
         while (!booted) {
             try {
                 let result = '';
-                yield exec.exec(`adb shell getprop sys.boot_completed`, [], {
+                yield exec.exec(`adb -s emulator-${port} shell getprop sys.boot_completed`, [], {
                     listeners: {
                         stdout: (data) => {
                             result += data.toString();
diff --git a/lib/input-validator.js b/lib/input-validator.js
index 9a26a40..c27593b 100644
--- a/lib/input-validator.js
+++ b/lib/input-validator.js
@@ -1,10 +1,12 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.checkDiskSize = exports.checkEmulatorBuild = exports.checkEnableHardwareKeyboard = exports.checkDisableLinuxHardwareAcceleration = exports.checkDisableSpellchecker = exports.checkDisableAnimations = exports.checkForceAvdCreation = exports.checkChannel = exports.checkArch = exports.checkTarget = exports.checkApiLevel = exports.PREVIEW_API_LEVELS = exports.VALID_CHANNELS = exports.VALID_ARCHS = exports.VALID_TARGETS = exports.MIN_API_LEVEL = void 0;
+exports.checkDiskSize = exports.checkEmulatorBuild = exports.checkEnableHardwareKeyboard = exports.checkDisableLinuxHardwareAcceleration = exports.checkDisableSpellchecker = exports.checkDisableAnimations = exports.checkPort = exports.checkForceAvdCreation = exports.checkChannel = exports.checkArch = exports.checkTarget = exports.checkApiLevel = exports.PREVIEW_API_LEVELS = exports.MAX_PORT = exports.MIN_PORT = exports.VALID_CHANNELS = exports.VALID_ARCHS = exports.VALID_TARGETS = exports.MIN_API_LEVEL = void 0;
 exports.MIN_API_LEVEL = 15;
 exports.VALID_TARGETS = ['default', 'google_apis', 'aosp_atd', 'google_atd', 'google_apis_playstore', 'android-wear', 'android-wear-cn', 'android-tv', 'google-tv'];
 exports.VALID_ARCHS = ['x86', 'x86_64', 'arm64-v8a'];
 exports.VALID_CHANNELS = ['stable', 'beta', 'dev', 'canary'];
+exports.MIN_PORT = 5554;
+exports.MAX_PORT = 5584;
 exports.PREVIEW_API_LEVELS = ['Tiramisu', 'UpsideDownCake', 'VanillaIceCream'];
 function checkApiLevel(apiLevel) {
     if (exports.PREVIEW_API_LEVELS.some((previewLevel) => apiLevel.startsWith(previewLevel)))
@@ -41,6 +43,15 @@ function checkForceAvdCreation(forceAvdCreation) {
     }
 }
 exports.checkForceAvdCreation = checkForceAvdCreation;
+function checkPort(port) {
+    if (port < exports.MIN_PORT || port > exports.MAX_PORT) {
+        throw new Error(`Emulator port is outside of the supported port range [${exports.MIN_PORT}, ${exports.MAX_PORT}], was ${port}`);
+    }
+    if (port % 2 == 1) {
+        throw new Error(`Emulator port has to be even, was ${port}`);
+    }
+}
+exports.checkPort = checkPort;
 function checkDisableAnimations(disableAnimations) {
     if (!isValidBoolean(disableAnimations)) {
         throw new Error(`Input for input.disable-animations should be either 'true' or 'false'.`);
diff --git a/lib/main.js b/lib/main.js
index 1bbc7d0..8b7761d 100644
--- a/lib/main.js
+++ b/lib/main.js
@@ -102,6 +102,10 @@ function run() {
             // Emulator boot timeout seconds
             const emulatorBootTimeout = parseInt(core.getInput('emulator-boot-timeout'), 10);
             console.log(`Emulator boot timeout: ${emulatorBootTimeout}`);
+            // Emulator port to use
+            const port = parseInt(core.getInput('emulator-port'), 10);
+            (0, input_validator_1.checkPort)(port);
+            console.log(`emulator port: ${port}`);
             // emulator options
             const emulatorOptions = core.getInput('emulator-options').trim();
             console.log(`emulator options: ${emulatorOptions}`);
@@ -193,7 +197,7 @@ function run() {
                 console.log(`::endgroup::`);
             }
             // launch an emulator
-            yield (0, emulator_manager_1.launchEmulator)(apiLevel, target, arch, profile, cores, ramSize, heapSize, sdcardPathOrSize, diskSize, avdName, forceAvdCreation, emulatorBootTimeout, emulatorOptions, disableAnimations, disableSpellchecker, disableLinuxHardwareAcceleration, enableHardwareKeyboard);
+            yield (0, emulator_manager_1.launchEmulator)(apiLevel, target, arch, profile, cores, ramSize, heapSize, sdcardPathOrSize, diskSize, avdName, forceAvdCreation, emulatorBootTimeout, port, emulatorOptions, disableAnimations, disableSpellchecker, disableLinuxHardwareAcceleration, enableHardwareKeyboard);
             // execute the custom script
             try {
                 // move to custom working directory if set
@@ -203,18 +207,20 @@ function run() {
                 for (const script of scripts) {
                     // use array form to avoid various quote escaping problems
                     // caused by exec(`sh -c "${script}"`)
-                    yield exec.exec('sh', ['-c', script]);
+                    yield exec.exec('sh', ['-c', script], {
+                        env: Object.assign(Object.assign({}, process.env), { EMULATOR_PORT: `${port}`, ANDROID_SERIAL: `emulator-${port}` }),
+                    });
                 }
             }
             catch (error) {
                 core.setFailed(error instanceof Error ? error.message : error);
             }
             // finally kill the emulator
-            yield (0, emulator_manager_1.killEmulator)();
+            yield (0, emulator_manager_1.killEmulator)(port);
         }
         catch (error) {
             // kill the emulator so the action can exit
-            yield (0, emulator_manager_1.killEmulator)();
+            yield (0, emulator_manager_1.killEmulator)(input_validator_1.MIN_PORT);
             core.setFailed(error instanceof Error ? error.message : error);
         }
     });
diff --git a/lib/sdk-installer.js b/lib/sdk-installer.js
index e6fbf75..c57dea9 100644
--- a/lib/sdk-installer.js
+++ b/lib/sdk-installer.js
@@ -70,7 +70,7 @@ function installAndroidSdk(apiLevel, target, arch, channelId, emulatorBuild, ndk
             // accept all Android SDK licenses
             yield exec.exec(`sh -c \\"yes | sdkmanager --licenses > /dev/null"`);
             console.log('Installing latest build tools, platform tools, and platform.');
-            yield exec.exec(`sh -c \\"sdkmanager --install 'build-tools;${BUILD_TOOLS_VERSION}' platform-tools > /dev/null"`);
+            yield exec.exec(`sh -c \\"sdkmanager --install 'build-tools;${BUILD_TOOLS_VERSION}' platform-tools 'platforms;android-${apiLevel}'> /dev/null"`);
             console.log('Installing latest emulator.');
             yield exec.exec(`sh -c \\"sdkmanager --install emulator --channel=${channelId} > /dev/null"`);
             if (emulatorBuild) {
diff --git a/src/emulator-manager.ts b/src/emulator-manager.ts
index d1fb364..aa2a67b 100644
--- a/src/emulator-manager.ts
+++ b/src/emulator-manager.ts
@@ -17,6 +17,7 @@ export async function launchEmulator(
   avdName: string,
   forceAvdCreation: boolean,
   emulatorBootTimeout: number,
+  port: number,
   emulatorOptions: string,
   disableAnimations: boolean,
   disableSpellChecker: boolean,
@@ -65,7 +66,7 @@ export async function launchEmulator(
     // start emulator
     console.log('Starting emulator.');
 
-    await exec.exec(`sh -c \\"${process.env.ANDROID_HOME}/emulator/emulator -avd "${avdName}" ${emulatorOptions} &"`, [], {
+    await exec.exec(`sh -c \\"${process.env.ANDROID_HOME}/emulator/emulator -port ${port} -avd "${avdName}" ${emulatorOptions} &"`, [], {
       listeners: {
         stderr: (data: Buffer) => {
           if (data.toString().includes('invalid command-line parameter')) {
@@ -76,20 +77,20 @@ export async function launchEmulator(
     });
 
     // wait for emulator to complete booting
-    await waitForDevice(emulatorBootTimeout);
-    await exec.exec(`adb shell input keyevent 82`);
+    await waitForDevice(port, emulatorBootTimeout);
+    await adb(port, `shell input keyevent 82`);
 
     if (disableAnimations) {
       console.log('Disabling animations.');
-      await exec.exec(`adb shell settings put global window_animation_scale 0.0`);
-      await exec.exec(`adb shell settings put global transition_animation_scale 0.0`);
-      await exec.exec(`adb shell settings put global animator_duration_scale 0.0`);
+      await adb(port, `shell settings put global window_animation_scale 0.0`);
+      await adb(port, `shell settings put global transition_animation_scale 0.0`);
+      await adb(port, `shell settings put global animator_duration_scale 0.0`);
     }
     if (disableSpellChecker) {
-      await exec.exec(`adb shell settings put secure spell_checker_enabled 0`);
+      await adb(port, `shell settings put secure spell_checker_enabled 0`);
     }
     if (enableHardwareKeyboard) {
-      await exec.exec(`adb shell settings put secure show_ime_with_hard_keyboard 0`);
+      await adb(port, `shell settings put secure show_ime_with_hard_keyboard 0`);
     }
   } finally {
     console.log(`::endgroup::`);
@@ -99,10 +100,10 @@ export async function launchEmulator(
 /**
  * Kills the running emulator on the default port.
  */
-export async function killEmulator(): Promise<void> {
+export async function killEmulator(port: number): Promise<void> {
   try {
     console.log(`::group::Terminate Emulator`);
-    await exec.exec(`adb -s emulator-5554 emu kill`);
+    await adb(port, `emu kill`);
   } catch (error) {
     console.log(error instanceof Error ? error.message : error);
   } finally {
@@ -110,10 +111,14 @@ export async function killEmulator(): Promise<void> {
   }
 }
 
+async function adb(port: number, command: string): Promise<number> {
+  return await exec.exec(`adb -s emulator-${port} ${command}`);
+}
+
 /**
  * Wait for emulator to boot.
  */
-async function waitForDevice(emulatorBootTimeout: number): Promise<void> {
+async function waitForDevice(port: number, emulatorBootTimeout: number): Promise<void> {
   let booted = false;
   let attempts = 0;
   const retryInterval = 2; // retry every 2 seconds
@@ -121,7 +126,7 @@ async function waitForDevice(emulatorBootTimeout: number): Promise<void> {
   while (!booted) {
     try {
       let result = '';
-      await exec.exec(`adb shell getprop sys.boot_completed`, [], {
+      await exec.exec(`adb -s emulator-${port} shell getprop sys.boot_completed`, [], {
         listeners: {
           stdout: (data: Buffer) => {
             result += data.toString();
diff --git a/src/input-validator.ts b/src/input-validator.ts
index 1a3bb21..8804685 100644
--- a/src/input-validator.ts
+++ b/src/input-validator.ts
@@ -2,6 +2,8 @@ export const MIN_API_LEVEL = 15;
 export const VALID_TARGETS: Array<string> = ['default', 'google_apis', 'aosp_atd', 'google_atd', 'google_apis_playstore', 'android-wear', 'android-wear-cn', 'android-tv', 'google-tv'];
 export const VALID_ARCHS: Array<string> = ['x86', 'x86_64', 'arm64-v8a'];
 export const VALID_CHANNELS: Array<string> = ['stable', 'beta', 'dev', 'canary'];
+export const MIN_PORT = 5554;
+export const MAX_PORT = 5584;
 export const PREVIEW_API_LEVELS: Array<string> = ['Tiramisu', 'UpsideDownCake', 'VanillaIceCream'];
 
 export function checkApiLevel(apiLevel: string): void {
@@ -38,6 +40,15 @@ export function checkForceAvdCreation(forceAvdCreation: string): void {
   }
 }
 
+export function checkPort(port: number): void {
+  if (port < MIN_PORT || port > MAX_PORT) {
+    throw new Error(`Emulator port is outside of the supported port range [${MIN_PORT}, ${MAX_PORT}], was ${port}`);
+  }
+  if (port % 2 == 1) {
+    throw new Error(`Emulator port has to be even, was ${port}`);
+  }
+}
+
 export function checkDisableAnimations(disableAnimations: string): void {
   if (!isValidBoolean(disableAnimations)) {
     throw new Error(`Input for input.disable-animations should be either 'true' or 'false'.`);
diff --git a/src/main.ts b/src/main.ts
index 0f21a20..a958824 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -12,6 +12,8 @@ import {
   checkChannel,
   checkEnableHardwareKeyboard,
   checkDiskSize,
+  checkPort,
+  MIN_PORT,
 } from './input-validator';
 import { launchEmulator, killEmulator } from './emulator-manager';
 import * as exec from '@actions/exec';
@@ -20,6 +22,7 @@ import { getChannelId } from './channel-id-mapper';
 import { accessSync, constants } from 'fs';
 
 async function run() {
+  let port: number = MIN_PORT;
   try {
     console.log(`::group::Configure emulator`);
     let linuxSupportKVM = false;
@@ -93,6 +96,11 @@ async function run() {
     const emulatorBootTimeout = parseInt(core.getInput('emulator-boot-timeout'), 10);
     console.log(`Emulator boot timeout: ${emulatorBootTimeout}`);
 
+    // Emulator port to use
+    port = parseInt(core.getInput('emulator-port'), 10);
+    checkPort(port);
+    console.log(`emulator port: ${port}`);
+
     // emulator options
     const emulatorOptions = core.getInput('emulator-options').trim();
     console.log(`emulator options: ${emulatorOptions}`);
@@ -210,6 +218,7 @@ async function run() {
       avdName,
       forceAvdCreation,
       emulatorBootTimeout,
+      port,
       emulatorOptions,
       disableAnimations,
       disableSpellchecker,
@@ -226,17 +235,19 @@ async function run() {
       for (const script of scripts) {
         // use array form to avoid various quote escaping problems
         // caused by exec(`sh -c "${script}"`)
-        await exec.exec('sh', ['-c', script]);
+        await exec.exec('sh', ['-c', script], {
+          env: { ...process.env, EMULATOR_PORT: `${port}`, ANDROID_SERIAL: `emulator-${port}` },
+        });
       }
     } catch (error) {
       core.setFailed(error instanceof Error ? error.message : (error as string));
     }
 
     // finally kill the emulator
-    await killEmulator();
+    await killEmulator(port);
   } catch (error) {
     // kill the emulator so the action can exit
-    await killEmulator();
+    await killEmulator(port);
     core.setFailed(error instanceof Error ? error.message : (error as string));
   }
 }
diff --git a/src/sdk-installer.ts b/src/sdk-installer.ts
index 0fd55c8..164fb39 100644
--- a/src/sdk-installer.ts
+++ b/src/sdk-installer.ts
@@ -43,7 +43,7 @@ export async function installAndroidSdk(apiLevel: string, target: string, arch:
 
     console.log('Installing latest build tools, platform tools, and platform.');
 
-    await exec.exec(`sh -c \\"sdkmanager --install 'build-tools;${BUILD_TOOLS_VERSION}' platform-tools > /dev/null"`);
+    await exec.exec(`sh -c \\"sdkmanager --install 'build-tools;${BUILD_TOOLS_VERSION}' platform-tools 'platforms;android-${apiLevel}'> /dev/null"`);
 
     console.log('Installing latest emulator.');
     await exec.exec(`sh -c \\"sdkmanager --install emulator --channel=${channelId} > /dev/null"`);


Test patch:
diff --git a/__tests__/input-validator.test.ts b/__tests__/input-validator.test.ts
index 59201f4..b08d5f5 100644
--- a/__tests__/input-validator.test.ts
+++ b/__tests__/input-validator.test.ts
@@ -1,4 +1,5 @@
 import * as validator from '../src/input-validator';
+import { MAX_PORT, MIN_PORT } from '../src/input-validator';
 
 describe('api-level validator tests', () => {
   it('Throws if api-level is not a number', () => {
@@ -172,6 +173,33 @@ describe('force-avd-creation validator tests', () => {
   });
 });
 
+describe('emulator-port validator tests', () => {
+  it('Validates if emulator-port is even and in range', () => {
+    const func = () => {
+      validator.checkPort(5554);
+    };
+    expect(func).not.toThrow();
+  });
+  it('Throws if emulator-port is lower than MIN_PORT', () => {
+    const func = () => {
+      validator.checkPort(MIN_PORT - 2);
+    };
+    expect(func).toThrow();
+  });
+  it('Throws if emulator-port is higher than MAX_PORT', () => {
+    const func = () => {
+      validator.checkPort(MAX_PORT + 2);
+    };
+    expect(func).toThrow();
+  });
+  it('Throws if emulator-port is odd', () => {
+    const func = () => {
+      validator.checkPort(5555);
+    };
+    expect(func).toThrow();
+  });
+});
+
 describe('disable-animations validator tests', () => {
   it('Throws if disable-animations is not a boolean', () => {
     const func = () => {