Repository: ReactiveCircus/android-emulator-runner (js)

Issue:
Support more SDK configurations
- [ ] `channel` from which to download / update SDK - 0 (Stable), 1 (Beta), 2 (Dev), and 3 (Canary)

Pull request:
Support choosing channel to download SDK components from.
Fixes #1.

Support setting the `channel` to download SDK components from: `stable` (default), `beta`, `dev`, `canary`.

Patch:
diff --git a/.github/workflows/workflow.yml b/.github/workflows/main.yml
similarity index 100%
rename from .github/workflows/workflow.yml
rename to .github/workflows/main.yml
diff --git a/.github/workflows/manually.yml b/.github/workflows/manually.yml
new file mode 100644
index 0000000..0f2ecae
--- /dev/null
+++ b/.github/workflows/manually.yml
@@ -0,0 +1,77 @@
+name: Manually triggered workflow
+on:
+  workflow_dispatch:
+    inputs:
+      os:
+        description: 'OS'
+        required: true
+        default: 'macos-latest'
+      api-level:
+        description: 'API level of the platform and system image'
+        required: true
+        default: '30'
+      target:
+        description: 'CPU architecture of the system image - x86, x86_64 or arm64-v8a'
+        required: true
+        default: 'default'
+      os:
+        description: 'OS'
+        required: true
+        default: 'macos-latest'
+      emulator-options:
+        description: 'command-line options used when launching the emulator'
+        default: '-no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim'
+      emulator-build:
+        description: 'build number of a specific version of the emulator binary to use'
+      channel:
+        description: 'Channel to download the SDK components from - `stable`, `beta`, `dev`, `canary`'
+        default: 'stable'
+      script:
+        description: 'custom script to run - e.g. `./gradlew connectedCheck`'
+        required: true
+        default: './gradlew connectedDebugAndroidTest'
+
+jobs:
+  test:
+    runs-on: ${{ github.event.inputs.os }}
+    env:
+      JAVA_TOOL_OPTIONS: -Xmx4g
+    timeout-minutes: 15
+    
+    steps:
+    - name: checkout
+      uses: actions/checkout@v2
+
+    - name: validate gradle wrapper
+      uses: gradle/wrapper-validation-action@v1
+
+    - name: build, test and lint
+      run: |
+        npm install
+        npm run build
+        npm run lint
+        npm test
+
+    - name: Java 15
+      uses: actions/setup-java@v1
+      with:
+        java-version: 15
+    - uses: actions/cache@v2
+      with:
+        path: |
+          ~/.gradle/caches
+          ~/.gradle/wrapper
+        key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
+
+    - name: run action
+      uses: ./
+      with:
+        api-level: ${{ github.event.inputs.api-level }}
+        target: ${{ github.event.inputs.target }}
+        arch: ${{ github.event.inputs.arch }}
+        profile: Galaxy Nexus
+        emulator-options: ${{ github.event.inputs.emulator-options }}
+        emulator-build: ${{ github.event.inputs.emulator-build }}
+        channel: ${{ github.event.inputs.channel }}
+        working-directory: ./test-fixture/
+        script: ${{ github.event.inputs.script }}
diff --git a/README.md b/README.md
index f1aacd1..30c2e45 100644
--- a/README.md
+++ b/README.md
@@ -143,7 +143,7 @@ jobs:
 | **Input** | **Required** | **Default** | **Description** |
 |-|-|-|-|
 | `api-level` | Required | N/A | API level of the platform system image - e.g. 23 for Android Marshmallow, 29 for Android 10. **Minimum API level supported is 15**. |
-| `target` | Optional | `default` | Target of the system image - `default`, `google_apis`, `google_apis_playstore`, `android-wear`, `android-wear-cn`, `android-tv` or `google-tv`. |
+| `target` | Optional | `default` | Target of the system image - `default`, `google_apis` or `playstore`. |
 | `arch` | Optional | `x86` | CPU architecture of the system image - `x86`, `x86_64` or `arm64-v8a`. Note that `x86_64` image is only available for API 21+. `arm64-v8a` images require Android 4.2+ and are limited to fewer API levels (e.g. 30). |
 | `profile` | Optional | N/A | Hardware profile used for creating the AVD - e.g. `Nexus 6`. For a list of all profiles available, run `avdmanager list` and refer to the results under "Available Android Virtual Devices". |
 | `cores` | Optional | 2 | Number of cores to use for the emulator (`hw.cpu.ncore` in config.ini). |
@@ -159,6 +159,7 @@ jobs:
 | `working-directory` | Optional | `./` | A custom working directory - e.g. `./android` if your root Gradle project is under the `./android` sub-directory within your repository. |
 | `ndk` | Optional | N/A | Version of NDK to install - e.g. `21.0.6113669` |
 | `cmake` | Optional | N/A | Version of CMake to install - e.g. `3.10.2.4988404` |
+| `channel` | Optional | stable | Channel to download the SDK components from - `stable`, `beta`, `dev`, `canary` |
 | `script` | Required | N/A | Custom script to run - e.g. to run Android instrumented tests on the emulator: `./gradlew connectedCheck` |
 
 Default `emulator-options`: `-no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim`.
@@ -201,7 +202,5 @@ These are some of the open-source projects using (or used) **Android Emulator Ru
 - [google/android-fhir](https://github.com/google/android-fhir/tree/master/.github/workflows)
 - [google/accompanist](https://github.com/google/accompanist/blob/main/.github/workflows)
 - [dotanuki-labs/norris](https://github.com/dotanuki-labs/norris/blob/master/.github/workflows/main.yml)
-- [TiagoMSSantos/MobileRT](https://github.com/TiagoMSSantos/MobileRT/blob/master/.github/workflows/android.yml)
-- [realm/realm-js](https://github.com/realm/realm-js/blob/master/.github/workflows/integration-tests.yml)
 
 If you are using **Android Emulator Runner** and want your project included in the list, please feel free to create an issue or open a pull request.
diff --git a/action.yml b/action.yml
index ffe6e01..1ffadf8 100644
--- a/action.yml
+++ b/action.yml
@@ -49,6 +49,9 @@ inputs:
     description: 'version of NDK to install - e.g. 21.0.6113669'
   cmake:
     description: 'version of CMake to install - e.g. 3.10.2.4988404'
+  channel:
+    description: 'Channel to download the SDK components from - `stable`, `beta`, `dev`, `canary`'
+    default: 'stable'
   script:
     description: 'custom script to run - e.g. `./gradlew connectedCheck`'
     required: true
diff --git a/lib/channel-id-mapper.js b/lib/channel-id-mapper.js
new file mode 100644
index 0000000..befb5cf
--- /dev/null
+++ b/lib/channel-id-mapper.js
@@ -0,0 +1,21 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.getChannelId = void 0;
+function getChannelId(channelName) {
+    if (channelName === 'stable') {
+        return 0;
+    }
+    else if (channelName === 'beta') {
+        return 1;
+    }
+    else if (channelName === 'dev') {
+        return 2;
+    }
+    else if (channelName === 'canary') {
+        return 3;
+    }
+    else {
+        throw new Error(`Unexpected channel name: '${channelName}'.`);
+    }
+}
+exports.getChannelId = getChannelId;
diff --git a/lib/input-validator.js b/lib/input-validator.js
index 206738f..b069cc9 100644
--- a/lib/input-validator.js
+++ b/lib/input-validator.js
@@ -1,9 +1,10 @@
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
-exports.checkEmulatorBuild = exports.checkDisableLinuxHardwareAcceleration = exports.checkDisableSpellchecker = exports.checkDisableAnimations = exports.checkForceAvdCreation = exports.checkArch = exports.checkTarget = exports.checkApiLevel = exports.VALID_ARCHS = exports.VALID_TARGETS = exports.MIN_API_LEVEL = void 0;
+exports.checkEmulatorBuild = exports.checkDisableLinuxHardwareAcceleration = exports.checkDisableSpellchecker = exports.checkDisableAnimations = exports.checkForceAvdCreation = exports.checkChannel = exports.checkArch = exports.checkTarget = exports.checkApiLevel = 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', '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'];
 function checkApiLevel(apiLevel) {
     if (isNaN(Number(apiLevel)) || !Number.isInteger(Number(apiLevel))) {
         throw new Error(`Unexpected API level: '${apiLevel}'.`);
@@ -25,6 +26,12 @@ function checkArch(arch) {
     }
 }
 exports.checkArch = checkArch;
+function checkChannel(channel) {
+    if (!exports.VALID_CHANNELS.includes(channel)) {
+        throw new Error(`Value for input.channel '${channel}' is unknown. Supported options: ${exports.VALID_CHANNELS}.`);
+    }
+}
+exports.checkChannel = checkChannel;
 function checkForceAvdCreation(forceAvdCreation) {
     if (!isValidBoolean(forceAvdCreation)) {
         throw new Error(`Input for input.force-avd-creation should be either 'true' or 'false'.`);
diff --git a/lib/main.js b/lib/main.js
index 83e37b6..b74dcc3 100644
--- a/lib/main.js
+++ b/lib/main.js
@@ -34,6 +34,7 @@ const input_validator_1 = require("./input-validator");
 const emulator_manager_1 = require("./emulator-manager");
 const exec = __importStar(require("@actions/exec"));
 const script_parser_1 = require("./script-parser");
+const channel_id_mapper_1 = require("./channel-id-mapper");
 function run() {
     return __awaiter(this, void 0, void 0, function* () {
         try {
@@ -123,6 +124,11 @@ function run() {
                 console.log(`version of CMake to install: ${cmakeInput}`);
             }
             const cmakeVersion = !cmakeInput ? undefined : cmakeInput;
+            // channelId (up to and including) to download the SDK packages from
+            const channelName = core.getInput('channel');
+            input_validator_1.checkChannel(channelName);
+            const channelId = channel_id_mapper_1.getChannelId(channelName);
+            console.log(`Channel: ${channelId} (${channelName})`);
             // custom script to run
             const scriptInput = core.getInput('script', { required: true });
             const scripts = script_parser_1.parseScript(scriptInput);
@@ -131,7 +137,7 @@ function run() {
                 console.log(`${script}`);
             }));
             // install SDK
-            yield sdk_installer_1.installAndroidSdk(apiLevel, target, arch, emulatorBuild, ndkVersion, cmakeVersion);
+            yield sdk_installer_1.installAndroidSdk(apiLevel, target, arch, channelId, emulatorBuild, ndkVersion, cmakeVersion);
             // launch an emulator
             yield emulator_manager_1.launchEmulator(apiLevel, target, arch, profile, cores, ramSize, sdcardPathOrSize, avdName, forceAvdCreation, emulatorOptions, disableAnimations, disableSpellchecker, disableLinuxHardwareAcceleration);
             // execute the custom script
diff --git a/lib/sdk-installer.js b/lib/sdk-installer.js
index 80c02a4..348ac87 100644
--- a/lib/sdk-installer.js
+++ b/lib/sdk-installer.js
@@ -41,7 +41,7 @@ const CMDLINE_TOOLS_URL_LINUX = 'https://dl.google.com/android/repository/comman
  * Installs & updates the Android SDK for the macOS platform, including SDK platform for the chosen API level, latest build tools, platform tools, Android Emulator,
  * and the system image for the chosen API level, CPU arch, and target.
  */
-function installAndroidSdk(apiLevel, target, arch, emulatorBuild, ndkVersion, cmakeVersion) {
+function installAndroidSdk(apiLevel, target, arch, channelId, emulatorBuild, ndkVersion, cmakeVersion) {
     return __awaiter(this, void 0, void 0, function* () {
         const isOnMac = process.platform === 'darwin';
         if (!isOnMac) {
@@ -64,7 +64,7 @@ function installAndroidSdk(apiLevel, target, arch, emulatorBuild, ndkVersion, cm
         console.log('Installing latest build tools, platform tools, and platform.');
         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 > /dev/null"`);
+        yield exec.exec(`sh -c \\"sdkmanager --install emulator --channel=${channelId} > /dev/null"`);
         if (emulatorBuild) {
             console.log(`Installing emulator build ${emulatorBuild}.`);
             // TODO find out the correct download URLs for all build ids
@@ -74,14 +74,14 @@ function installAndroidSdk(apiLevel, target, arch, emulatorBuild, ndkVersion, cm
             yield io.rmRF('emulator.zip');
         }
         console.log('Installing system images.');
-        yield exec.exec(`sh -c \\"sdkmanager --install 'system-images;android-${apiLevel};${target};${arch}' > /dev/null"`);
+        yield exec.exec(`sh -c \\"sdkmanager --install 'system-images;android-${apiLevel};${target};${arch}' --channel=${channelId} > /dev/null"`);
         if (ndkVersion) {
             console.log(`Installing NDK ${ndkVersion}.`);
-            yield exec.exec(`sh -c \\"sdkmanager --install 'ndk;${ndkVersion}' > /dev/null"`);
+            yield exec.exec(`sh -c \\"sdkmanager --install 'ndk;${ndkVersion}' --channel=${channelId} > /dev/null"`);
         }
         if (cmakeVersion) {
             console.log(`Installing CMake ${cmakeVersion}.`);
-            yield exec.exec(`sh -c \\"sdkmanager --install 'cmake;${cmakeVersion}' > /dev/null"`);
+            yield exec.exec(`sh -c \\"sdkmanager --install 'cmake;${cmakeVersion}' --channel=${channelId} > /dev/null"`);
         }
     });
 }
diff --git a/src/channel-id-mapper.ts b/src/channel-id-mapper.ts
new file mode 100644
index 0000000..fbc0ca3
--- /dev/null
+++ b/src/channel-id-mapper.ts
@@ -0,0 +1,13 @@
+export function getChannelId(channelName: string): number {
+  if (channelName === 'stable') {
+    return 0;
+  } else if (channelName === 'beta') {
+    return 1;
+  } else if (channelName === 'dev') {
+    return 2;
+  } else if (channelName === 'canary') {
+    return 3;
+  } else {
+    throw new Error(`Unexpected channel name: '${channelName}'.`);
+  }
+}
diff --git a/src/input-validator.ts b/src/input-validator.ts
index ee8d26d..fabf61b 100644
--- a/src/input-validator.ts
+++ b/src/input-validator.ts
@@ -1,6 +1,7 @@
 export const MIN_API_LEVEL = 15;
 export const VALID_TARGETS: Array<string> = ['default', 'google_apis', '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 function checkApiLevel(apiLevel: string): void {
   if (isNaN(Number(apiLevel)) || !Number.isInteger(Number(apiLevel))) {
@@ -23,6 +24,12 @@ export function checkArch(arch: string): void {
   }
 }
 
+export function checkChannel(channel: string): void {
+  if (!VALID_CHANNELS.includes(channel)) {
+    throw new Error(`Value for input.channel '${channel}' is unknown. Supported options: ${VALID_CHANNELS}.`);
+  }
+}
+
 export function checkForceAvdCreation(forceAvdCreation: string): void {
   if (!isValidBoolean(forceAvdCreation)) {
     throw new Error(`Input for input.force-avd-creation should be either 'true' or 'false'.`);
diff --git a/src/main.ts b/src/main.ts
index 80e1cee..98df433 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -8,11 +8,13 @@ import {
   checkEmulatorBuild,
   checkDisableSpellchecker,
   checkDisableLinuxHardwareAcceleration,
-  checkForceAvdCreation
+  checkForceAvdCreation,
+  checkChannel
 } from './input-validator';
 import { launchEmulator, killEmulator } from './emulator-manager';
 import * as exec from '@actions/exec';
 import { parseScript } from './script-parser';
+import { getChannelId } from './channel-id-mapper';
 
 async function run() {
   try {
@@ -121,6 +123,12 @@ async function run() {
     }
     const cmakeVersion = !cmakeInput ? undefined : cmakeInput;
 
+    // channelId (up to and including) to download the SDK packages from
+    const channelName = core.getInput('channel');
+    checkChannel(channelName);
+    const channelId = getChannelId(channelName);
+    console.log(`Channel: ${channelId} (${channelName})`);
+
     // custom script to run
     const scriptInput = core.getInput('script', { required: true });
     const scripts = parseScript(scriptInput);
@@ -130,7 +138,7 @@ async function run() {
     });
 
     // install SDK
-    await installAndroidSdk(apiLevel, target, arch, emulatorBuild, ndkVersion, cmakeVersion);
+    await installAndroidSdk(apiLevel, target, arch, channelId, emulatorBuild, ndkVersion, cmakeVersion);
 
     // launch an emulator
     await launchEmulator(
diff --git a/src/sdk-installer.ts b/src/sdk-installer.ts
index 4797162..57de8e8 100644
--- a/src/sdk-installer.ts
+++ b/src/sdk-installer.ts
@@ -12,7 +12,7 @@ const CMDLINE_TOOLS_URL_LINUX = 'https://dl.google.com/android/repository/comman
  * Installs & updates the Android SDK for the macOS platform, including SDK platform for the chosen API level, latest build tools, platform tools, Android Emulator,
  * and the system image for the chosen API level, CPU arch, and target.
  */
-export async function installAndroidSdk(apiLevel: number, target: string, arch: string, emulatorBuild?: string, ndkVersion?: string, cmakeVersion?: string): Promise<void> {
+export async function installAndroidSdk(apiLevel: number, target: string, arch: string, channelId: number, emulatorBuild?: string, ndkVersion?: string, cmakeVersion?: string): Promise<void> {
   const isOnMac = process.platform === 'darwin';
 
   if (!isOnMac) {
@@ -42,7 +42,7 @@ export async function installAndroidSdk(apiLevel: number, target: string, arch:
   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 > /dev/null"`);
+  await exec.exec(`sh -c \\"sdkmanager --install emulator --channel=${channelId} > /dev/null"`);
 
   if (emulatorBuild) {
     console.log(`Installing emulator build ${emulatorBuild}.`);
@@ -53,14 +53,14 @@ export async function installAndroidSdk(apiLevel: number, target: string, arch:
     await io.rmRF('emulator.zip');
   }
   console.log('Installing system images.');
-  await exec.exec(`sh -c \\"sdkmanager --install 'system-images;android-${apiLevel};${target};${arch}' > /dev/null"`);
+  await exec.exec(`sh -c \\"sdkmanager --install 'system-images;android-${apiLevel};${target};${arch}' --channel=${channelId} > /dev/null"`);
 
   if (ndkVersion) {
     console.log(`Installing NDK ${ndkVersion}.`);
-    await exec.exec(`sh -c \\"sdkmanager --install 'ndk;${ndkVersion}' > /dev/null"`);
+    await exec.exec(`sh -c \\"sdkmanager --install 'ndk;${ndkVersion}' --channel=${channelId} > /dev/null"`);
   }
   if (cmakeVersion) {
     console.log(`Installing CMake ${cmakeVersion}.`);
-    await exec.exec(`sh -c \\"sdkmanager --install 'cmake;${cmakeVersion}' > /dev/null"`);
+    await exec.exec(`sh -c \\"sdkmanager --install 'cmake;${cmakeVersion}' --channel=${channelId} > /dev/null"`);
   }
 }


Test patch:
diff --git a/__tests__/channel-id-mapper.test.ts b/__tests__/channel-id-mapper.test.ts
new file mode 100644
index 0000000..bbdf727
--- /dev/null
+++ b/__tests__/channel-id-mapper.test.ts
@@ -0,0 +1,17 @@
+import * as mapper from '../src/channel-id-mapper';
+
+describe('channel id mapper tests', () => {
+  it('Throws if channelName is unknown', () => {
+    const func = () => {
+      mapper.getChannelId('unknown-channel');
+    };
+    expect(func).toThrowError(`Unexpected channel name: 'unknown-channel'.`);
+  });
+
+  it('Returns expected channelId from channelName', () => {
+    expect(mapper.getChannelId('stable')).toBe(0);
+    expect(mapper.getChannelId('beta')).toBe(1);
+    expect(mapper.getChannelId('dev')).toBe(2);
+    expect(mapper.getChannelId('canary')).toBe(3);
+  });
+});
diff --git a/__tests__/input-validator.test.ts b/__tests__/input-validator.test.ts
index 7d761c5..a5a2a22 100644
--- a/__tests__/input-validator.test.ts
+++ b/__tests__/input-validator.test.ts
@@ -102,6 +102,37 @@ describe('arch validator tests', () => {
   });
 });
 
+describe('channel validator tests', () => {
+  it('Throws if channel is unknown', () => {
+    const func = () => {
+      validator.checkChannel('some-channel');
+    };
+    expect(func).toThrowError(`Value for input.channel 'some-channel' is unknown. Supported options: ${validator.VALID_CHANNELS}`);
+  });
+
+  it('Validates successfully with valid channel', () => {
+    const func1 = () => {
+      validator.checkChannel('stable');
+    };
+    expect(func1).not.toThrow();
+
+    const func2 = () => {
+      validator.checkChannel('beta');
+    };
+    expect(func2).not.toThrow();
+
+    const func3 = () => {
+      validator.checkChannel('dev');
+    };
+    expect(func3).not.toThrow();
+
+    const func4 = () => {
+      validator.checkChannel('canary');
+    };
+    expect(func4).not.toThrow();
+  });
+});
+
 describe('force-avd-creation validator tests', () => {
   it('Throws if force-avd-creation is not a boolean', () => {
     const func = () => {