diff --git a/lib/check_reqs.js b/lib/check_reqs.js index 6e1bfd4a..217f5dd6 100644 --- a/lib/check_reqs.js +++ b/lib/check_reqs.js @@ -24,6 +24,7 @@ const java = require('./env/java'); const { CordovaError, ConfigParser, events } = require('cordova-common'); const android_sdk = require('./android_sdk'); const { SDK_VERSION } = require('./gradle-config-defaults'); +const AndroidCommandLineTools = require('./env/AndroidCommandLineTools'); // Re-exporting these for backwards compatibility and for unit testing. // TODO: Remove uses and use the ./utils module directly. @@ -216,14 +217,14 @@ module.exports.check_android = function () { } } if (avdmanagerInPath) { - parentDir = path.dirname(avdmanagerInPath); - grandParentDir = path.dirname(parentDir); - if (path.basename(parentDir) === 'bin' && path.basename(grandParentDir) === 'tools') { - maybeSetAndroidHome(path.dirname(grandParentDir)); + let sdkPath = null; + if (/cmdline-tools/.test(avdmanagerInPath)) { + sdkPath = path.resolve(avdmanagerInPath, '../../../..'); + maybeSetAndroidHome(sdkPath); } else { throw new CordovaError('Failed to find \'ANDROID_HOME\' environment variable. Try setting it manually.\n' + - 'Detected \'avdmanager\' command at ' + parentDir + ' but no \'tools' + path.sep + 'bin\' directory found near.\n' + - 'Try reinstall Android SDK or update your PATH to include valid path to SDK' + path.sep + 'tools' + path.sep + 'bin directory.'); + 'Detected \'avdmanager\' command at ' + parentDir + ' but does not appear to be within an Android SDK installation.\n' + + 'Try reinstall Android SDK or update your PATH to include valid path to SDK'); } } } @@ -240,7 +241,10 @@ module.exports.check_android = function () { process.env.PATH += path.delimiter + path.join(process.env.ANDROID_HOME, 'platform-tools'); } if (hasAndroidHome && !avdmanagerInPath) { - process.env.PATH += path.delimiter + path.join(process.env.ANDROID_HOME, 'tools', 'bin'); + const cmdLineToolsBin = AndroidCommandLineTools.getBinPath(); + if (cmdLineToolsBin) { + process.env.PATH += path.delimiter + cmdLineToolsBin; + } } return hasAndroidHome; }); diff --git a/lib/env/AndroidCommandLineTools.js b/lib/env/AndroidCommandLineTools.js new file mode 100644 index 00000000..e99ccc3c --- /dev/null +++ b/lib/env/AndroidCommandLineTools.js @@ -0,0 +1,102 @@ +/** + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +const { events } = require('cordova-common'); +const fs = require('node:fs'); +const path = require('node:path'); +const semver = require('semver'); + +/** + * Utility collection for resolving the Android SDK command line tools installed + * on the workstation. + */ +const AndroidCommandLineTools = { + /** + * Gets a sorted list of available versions found on the system. + * + * If the command line tools is not resolvable, then an empty array will be returned. + * + * This function depends on ANDROID_HOME environment variable. + * + * @returns {String[]} + */ + getAvailableVersions: () => { + const androidHome = path.resolve(AndroidCommandLineTools.__getAndroidHome()); + + if (!fs.existsSync(androidHome)) { + events.emit('warn', 'ANDROID_HOME is not resolvable.'); + return []; + } + + const cmdLineToolsContainer = path.join(androidHome, 'cmdline-tools'); + if (!fs.existsSync(cmdLineToolsContainer)) { + events.emit('warn', 'Android SDK is missing cmdline-tools directory.'); + return []; + } + + const cmdLineVersions = fs.readdirSync(cmdLineToolsContainer) + .filter((value) => { + // expected directory paths are semver-like version strings or literally "latest" + return value === 'latest' || semver.coerce(value) !== null; + }) + .sort((a, b) => { + // "latest" directory always comes first + if (a === 'latest') return -1; + if (b === 'latest') return 1; + + const av = semver.coerce(a, { + includePrerelease: true + }); + const bv = semver.coerce(b, { + includePrerelease: true + }); + + // Descending (highest version first) + return semver.rcompare(av, bv); + }); + + return cmdLineVersions; + }, + + /** + * Gets the bin path of the cmd line tools using the latest available that + * is installed on the workstation. + * + * Returns null if there are no versions fond + * + * @returns {String | null} + */ + getBinPath: () => { + const versions = AndroidCommandLineTools.getAvailableVersions(); + + if (versions.length === 0) { + return null; + } + + const version = versions[0]; + return path.resolve(AndroidCommandLineTools.__getAndroidHome(), 'cmdline-tools', version, 'bin'); + }, + + /** + * @internal + */ + __getAndroidHome: () => process.env.ANDROID_HOME +}; + +module.exports = AndroidCommandLineTools; diff --git a/spec/unit/AndroidCommandLineTools.spec.js b/spec/unit/AndroidCommandLineTools.spec.js new file mode 100644 index 00000000..dc7f1fab --- /dev/null +++ b/spec/unit/AndroidCommandLineTools.spec.js @@ -0,0 +1,105 @@ +/** + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +*/ + +const fs = require('node:fs'); +const path = require('node:path'); +const AndroidCommandLineTools = require('../../lib/env/AndroidCommandLineTools'); + +describe('AndroidCommandLineTools', () => { + beforeAll(() => { + // For the purposes of these test, we will assume ANDROID_HOME is proper. + spyOn(fs, 'existsSync').and.returnValue(true); + }); + + describe('getAvailableVersions', () => { + describe('should return a list of command line versions', () => { + it('in descending order', () => { + spyOn(fs, 'readdirSync').and.returnValue([ + '10.0', + '15.0', + '13' + ]); + + expect(AndroidCommandLineTools.getAvailableVersions()).toEqual([ + '15.0', + '13', + '10.0' + ]); + }); + + it('stable releases appear before prereleases', () => { + spyOn(fs, 'readdirSync').and.returnValue([ + '15.0-rc01', + '15.0-alpha01', + '15.0' + ]); + + expect(AndroidCommandLineTools.getAvailableVersions()).toEqual([ + '15.0', + '15.0-rc01', + '15.0-alpha01' + ]); + }); + + it('"latest" should take all precedence', () => { + spyOn(fs, 'readdirSync').and.returnValue([ + '15.0-rc01', + '15.0-alpha01', + '15.0', + 'latest' + ]); + + expect(AndroidCommandLineTools.getAvailableVersions()).toEqual([ + 'latest', + '15.0', + '15.0-rc01', + '15.0-alpha01' + ]); + }); + + it('invalid versions are ignored', () => { + spyOn(fs, 'readdirSync').and.returnValue([ + '15.0-rc01', + 'xyz', + '15.0' + ]); + + expect(AndroidCommandLineTools.getAvailableVersions()).toEqual([ + '15.0', + '15.0-rc01' + ]); + }); + }); + }); + + describe('getBinPath', () => { + beforeEach(() => { + spyOn(AndroidCommandLineTools, '__getAndroidHome').and.returnValue('/Android/Sdk'); + }); + + it('should return the bin path of the latest version', () => { + spyOn(AndroidCommandLineTools, 'getAvailableVersions').and.returnValue([ + '19.0', + '18.0' + ]); + + expect(AndroidCommandLineTools.getBinPath()).toBe(path.resolve('/Android/Sdk/cmdline-tools/19.0/bin')); + }); + }); +}); diff --git a/spec/unit/check_reqs.spec.js b/spec/unit/check_reqs.spec.js index 54df1cf7..4628f41a 100644 --- a/spec/unit/check_reqs.spec.js +++ b/spec/unit/check_reqs.spec.js @@ -27,6 +27,7 @@ const which = require('which'); const { SDK_VERSION: DEFAULT_TARGET_API } = require('../../lib/gradle-config-defaults'); +const AndroidCommandLineTools = require('../../lib/env/AndroidCommandLineTools'); describe('check_reqs', function () { let check_reqs; @@ -58,6 +59,10 @@ describe('check_reqs', function () { }); describe('check_android', function () { + beforeAll(() => { + spyOn(AndroidCommandLineTools, 'getAvailableVersions').and.returnValue(['latest']); + }); + describe('find and set ANDROID_HOME when neither ANDROID_HOME nor ANDROID_SDK_ROOT is set', function () { beforeEach(function () { delete process.env.ANDROID_HOME; @@ -123,13 +128,13 @@ describe('check_reqs', function () { spyOn(fs, 'existsSync').and.returnValue(true); spyOn(which, 'sync').and.callFake(function (cmd) { if (cmd === 'avdmanager') { - return path.normalize('/android/sdk/tools/bin/avdmanager'); + return path.resolve('/android/sdk/cmdline-tools/latest/bin/avdmanager'); } else { return null; } }); return check_reqs.check_android().then(function () { - expect(process.env.ANDROID_HOME).toEqual(path.normalize('/android/sdk')); + expect(process.env.ANDROID_HOME).toEqual(path.resolve('/android/sdk')); }); }); it('should error out if `avdmanager` command exists in a non-SDK-like directory structure', () => { @@ -208,9 +213,8 @@ describe('check_reqs', function () { }); it('should add tools/bin,tools,platform-tools to PATH if `avdmanager`,`android`,`adb` is not found', () => { return check_reqs.check_android().then(function () { - expect(process.env.PATH).toContain('let the children play' + path.sep + 'tools'); expect(process.env.PATH).toContain('let the children play' + path.sep + 'platform-tools'); - expect(process.env.PATH).toContain('let the children play' + path.sep + 'tools' + path.sep + 'bin'); + expect(process.env.PATH).toContain('let the children play' + path.sep + 'cmdline-tools' + path.sep + 'latest' + path.sep + 'bin'); }); }); });