hudson.plugins.android_emulator.SdkInstaller.java Source code

Java tutorial

Introduction

Here is the source code for hudson.plugins.android_emulator.SdkInstaller.java

Source

package hudson.plugins.android_emulator;

import hudson.EnvVars;
import hudson.FilePath;
import hudson.FilePath.FileCallable;
import hudson.Launcher;
import hudson.Launcher.ProcStarter;
import hudson.Proc;
import hudson.model.BuildListener;
import hudson.model.Computer;
import hudson.model.Node;
import hudson.plugins.android_emulator.SdkInstaller.AndroidInstaller.SdkUnavailableException;
import hudson.plugins.android_emulator.sdk.AndroidSdk;
import hudson.plugins.android_emulator.sdk.Tool;
import hudson.plugins.android_emulator.util.Utils;
import hudson.plugins.android_emulator.util.ValidationResult;
import hudson.remoting.Callable;
import hudson.remoting.VirtualChannel;
import hudson.util.ArgumentListBuilder;
import org.apache.commons.lang.StringUtils;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileFilter;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.WeakHashMap;
import java.util.concurrent.Semaphore;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static hudson.plugins.android_emulator.AndroidEmulator.log;

public class SdkInstaller {

    /** Recent version of the Android SDK that will be installed. */
    private static final String SDK_VERSION = "24.0.2";

    /** Filename to write some metadata to about our automated installation. */
    private static final String SDK_INFO_FILENAME = ".jenkins-install-info";

    /** Map of nodes to locks, to ensure only one executor attempts SDK installation at once. */
    private static final Map<Node, Semaphore> mutexByNode = new WeakHashMap<Node, Semaphore>();

    /**
     * Downloads and installs the Android SDK on the machine we're executing on.
     *
     * @return An {@code AndroidSdk} object for the newly-installed SDK.
     */
    public static AndroidSdk install(Launcher launcher, BuildListener listener, String androidSdkHome)
            throws SdkInstallationException, IOException, InterruptedException {
        Semaphore semaphore = acquireLock();
        try {
            return doInstall(launcher, listener, androidSdkHome);
        } finally {
            semaphore.release();
        }
    }

    private static AndroidSdk doInstall(Launcher launcher, BuildListener listener, String androidSdkHome)
            throws SdkInstallationException, IOException, InterruptedException {
        // We should install the SDK on the current build machine
        Node node = Computer.currentComputer().getNode();

        // Install the SDK if required
        String androidHome;
        try {
            androidHome = installBasicSdk(listener, node).getRemote();
        } catch (IOException e) {
            throw new SdkInstallationException(Messages.SDK_DOWNLOAD_FAILED(), e);
        } catch (SdkUnavailableException e) {
            throw new SdkInstallationException(Messages.SDK_DOWNLOAD_FAILED(), e);
        }

        // Check whether we need to install the SDK components
        if (!isSdkInstallComplete(node, androidHome)) {
            PrintStream logger = listener.getLogger();
            log(logger, Messages.INSTALLING_REQUIRED_COMPONENTS());
            AndroidSdk sdk = getAndroidSdkForNode(node, androidHome, androidSdkHome);

            // Get the latest platform-tools
            installComponent(logger, launcher, sdk, "platform-tool");

            // Upgrade the tools if necessary and add the latest build-tools component
            List<String> components = new ArrayList<String>(4);
            components.add("tool");
            String buildTools = getBuildToolsPackageName(logger, launcher, sdk);
            if (buildTools != null) {
                components.add(buildTools);
            }

            // Add the local maven repos for Gradle
            components.add("extra-android-m2repository");
            components.add("extra-google-m2repository");

            // Install the lot
            installComponent(logger, launcher, sdk, components.toArray(new String[0]));

            // If we made it this far, confirm completion by writing our our metadata file
            getInstallationInfoFilename(node).write(SDK_VERSION, "UTF-8");

            // As this SDK will not be used manually, opt out of the stats gathering;
            // this also prevents the opt-in dialog from popping up during execution
            optOutOfSdkStatistics(launcher, listener, androidSdkHome);
        }

        // Create an SDK object now that all the components exist
        return Utils.getAndroidSdk(launcher, androidHome, androidSdkHome);
    }

    @SuppressWarnings("serial")
    private static AndroidSdk getAndroidSdkForNode(Node node, final String androidHome, final String androidSdkHome)
            throws IOException, InterruptedException {
        return node.getChannel().call(new Callable<AndroidSdk, IOException>() {
            public AndroidSdk call() throws IOException {
                return new AndroidSdk(androidHome, androidSdkHome);
            }
        });
    }

    private static String getBuildToolsPackageName(PrintStream logger, Launcher launcher, AndroidSdk sdk)
            throws IOException, InterruptedException {
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        Utils.runAndroidTool(launcher, output, logger, sdk, Tool.ANDROID, "list sdk --extended", null);
        Matcher m = Pattern.compile("\"(build-tools-.*?)\"").matcher(output.toString());
        if (!m.find()) {
            return null;
        }
        return m.group(0);
    }

    /**
     * Downloads and extracts the basic Android SDK on a given Node, if it hasn't already been done.
     *
     *
     * @param node Node to install the SDK on.
     * @return Path where the SDK is installed, regardless of whether it was installed right now.
     * @throws SdkUnavailableException If the Android SDK is not available on this platform.
     */
    private static FilePath installBasicSdk(final BuildListener listener, Node node)
            throws SdkUnavailableException, IOException, InterruptedException {
        // Locate where the SDK should be installed to on this node
        final FilePath installDir = Utils.getSdkInstallDirectory(node);

        // Get the OS-specific download URL for the SDK
        AndroidInstaller installer = AndroidInstaller.fromNode(node);
        final URL downloadUrl = installer.getUrl(SDK_VERSION);

        // Download the SDK, if required
        boolean wasNowInstalled = installDir.act(new FileCallable<Boolean>() {
            public Boolean invoke(File f, VirtualChannel channel) throws InterruptedException, IOException {
                String msg = Messages.DOWNLOADING_SDK_FROM(downloadUrl);
                return installDir.installIfNecessaryFrom(downloadUrl, listener, msg);
            }

            private static final long serialVersionUID = 1L;
        });

        if (wasNowInstalled) {
            // If the SDK was required, pull files up from the intermediate directory
            installDir.listDirectories().get(0).moveAllChildrenTo(installDir);

            // Java's ZipEntry doesn't preserve the executable bit...
            if (installer == AndroidInstaller.MAC_OS_X) {
                setPermissions(installDir.child("tools"));
            }

            // Success!
            log(listener.getLogger(), Messages.BASE_SDK_INSTALLED());
        }

        return installDir;
    }

    /**
     * Installs the given SDK component(s) into the given installation.
     *
     * @param logger Logs things.
     * @param launcher Used to launch tasks on the remote node.
     * @param sdk Root of the SDK installation to install components for.
     * @param components Name of the component(s) to install.
     */
    private static void installComponent(PrintStream logger, Launcher launcher, AndroidSdk sdk,
            String... components) throws IOException, InterruptedException {
        String proxySettings = getProxySettings();

        // Build the command to install the given component(s)
        String list = StringUtils.join(components, ',');
        log(logger, Messages.INSTALLING_SDK_COMPONENTS(list));
        String all = sdk.getSdkToolsMajorVersion() < 17 ? "-o" : "-a";
        String upgradeArgs = String.format("update sdk -u %s %s -t %s", all, proxySettings, list);
        ArgumentListBuilder cmd = Utils.getToolCommand(sdk, launcher.isUnix(), Tool.ANDROID, upgradeArgs);
        ProcStarter procStarter = launcher.launch().stderr(logger).readStdout().writeStdin().cmds(cmd);
        if (sdk.hasKnownHome()) {
            EnvVars env = new EnvVars();
            env.put("ANDROID_SDK_HOME", sdk.getSdkHome());
            procStarter = procStarter.envs(env);
        }

        // Run the command and accept any licence requests during installation
        Proc proc = procStarter.start();
        BufferedReader r = new BufferedReader(new InputStreamReader(proc.getStdout()));
        String line;
        while (proc.isAlive() && (line = r.readLine()) != null) {
            logger.println(line);
            if (line.toLowerCase(Locale.ENGLISH).startsWith("license id: ")) {
                proc.getStdin().write("y\r\n".getBytes());
                proc.getStdin().flush();
            }
        }
    }

    /**
     * Installs the platform for an emulator config into the given SDK installation, if necessary.
     *
     * @param logger Logs things.
     * @param launcher Used to launch tasks on the remote node.
     * @param sdk SDK installation to install components for.
     * @param emuConfig Specifies the platform to be installed.
     */
    static void installDependencies(PrintStream logger, Launcher launcher, AndroidSdk sdk, EmulatorConfig emuConfig)
            throws IOException, InterruptedException {
        // Get AVD platform from emulator config
        String platform = getPlatformForEmulator(launcher, emuConfig);

        // Install platform and any dependencies it may have
        boolean requiresAbi = !emuConfig.isNamedEmulator() && emuConfig.getOsVersion().requiresAbi();
        String abi = requiresAbi ? emuConfig.getTargetAbi() : null;
        installPlatform(logger, launcher, sdk, platform, abi);
    }

    /**
     * Installs the given platform and its dependencies into the given installation, if necessary.
     *
     * @param logger Logs things.
     * @param launcher Used to launch tasks on the remote node.
     * @param sdk SDK installation to install components for.
     * @param platform Specifies the platform to be installed.
     * @param abi Specifies the ABI to be installed; may be {@code null}.
     */
    public static void installPlatform(PrintStream logger, Launcher launcher, AndroidSdk sdk, String platform,
            String abi) throws IOException, InterruptedException {
        // Check whether this platform is already installed
        if (isPlatformInstalled(logger, launcher, sdk, platform, abi)) {
            return;
        }

        // Check whether we are capable of installing individual components
        log(logger, Messages.PLATFORM_INSTALL_REQUIRED(platform));
        if (!launcher.isUnix() && platform.contains(":") && sdk.getSdkToolsMajorVersion() < 16) {
            // SDK add-ons can't be installed on Windows until r16 due to http://b.android.com/18868
            log(logger, Messages.SDK_ADDON_INSTALLATION_UNSUPPORTED());
            return;
        }
        if (!sdk.supportsComponentInstallation()) {
            log(logger, Messages.SDK_COMPONENT_INSTALLATION_UNSUPPORTED());
            return;
        }

        // Automated installation of ABIs (required for android-14+) is not possible until r17, so
        // we should warn the user that we can't automatically set up an AVD with older SDK Tools.
        // See http://b.android.com/21880
        if ((platform.endsWith("14") || platform.endsWith("15")) && !sdk.supportsSystemImageInstallation()) {
            log(logger, Messages.ABI_INSTALLATION_UNSUPPORTED(), true);
        }

        // Determine which individual component(s) need to be installed for this platform
        List<String> components = getSdkComponentsForPlatform(logger, sdk, platform, abi);
        if (components == null || components.size() == 0) {
            return;
        }

        // If a platform expanded to multiple dependencies (e.g. "GoogleMaps:7" -> android-7 + Maps)
        // then check whether we really need to install android-7, as it may already be installed
        if (components.size() > 1) {
            for (Iterator<String> it = components.iterator(); it.hasNext();) {
                String component = it.next();
                if (isPlatformInstalled(logger, launcher, sdk, component, null)) {
                    it.remove();
                }
            }
        }

        // Grab the lock and attempt installation
        Semaphore semaphore = acquireLock();
        try {
            installComponent(logger, launcher, sdk, components.toArray(new String[0]));
        } finally {
            semaphore.release();
        }
    }

    private static boolean isPlatformInstalled(PrintStream logger, Launcher launcher, AndroidSdk sdk,
            String platform, String abi) throws IOException, InterruptedException {
        ByteArrayOutputStream targetList = new ByteArrayOutputStream();
        // Preferably we'd use the "--compact" flag here, but it wasn't added until r12,
        // nor does it give any information about which system images are installed...
        Utils.runAndroidTool(launcher, targetList, logger, sdk, Tool.ANDROID, "list target", null);
        boolean platformInstalled = targetList.toString().contains('"' + platform + '"');
        if (!platformInstalled) {
            return false;
        }

        if (abi != null) {
            // Check whether the desired ABI is included in the output
            Pattern regex = Pattern.compile(String.format("\"%s\".+?%s", platform, abi), Pattern.DOTALL);
            Matcher matcher = regex.matcher(targetList.toString());
            if (!matcher.find() || matcher.group(0).contains("---")) {
                // We did not find the desired ABI within the section for the given platform
                return false;
            }
        }

        // Everything we wanted is installed
        return true;
    }

    private static List<String> getSdkComponentsForPlatform(PrintStream logger, AndroidSdk sdk, String platform,
            String abi) {
        // Gather list of required components
        List<String> components = new ArrayList<String>();

        // Add dependent platform
        int dependentPlatform = Utils.getApiLevelFromPlatform(platform);
        if (dependentPlatform > 0) {
            components.add(String.format("android-%s", dependentPlatform));
        }

        // Add system image, if required
        // Even if a system image doesn't exist for this platform, the installer silently ignores it
        if (dependentPlatform >= 10 && abi != null) {
            if (sdk.supportsSystemImageNewFormat()) {
                String tag = "android";
                int slash = abi.indexOf('/');
                if (slash > 0 && slash < abi.length() - 1) {
                    tag = abi.substring(0, slash);
                    abi = abi.substring(slash + 1);
                }
                components.add(String.format("sys-img-%s-%s-%d", abi, tag, dependentPlatform));
            } else {
                components.add(String.format("sysimg-%d", dependentPlatform));
            }
        }

        // If it's a straightforward case like "android-10", we're done
        if (!platform.contains(":")) {
            return components;
        }

        // As of SDK r17-ish, we can't always map addon names directly to installable components.
        // But replacing display name "Google Inc." with vendor ID "google" should cover most cases
        platform = platform.replace("Google Inc.", "google");

        String parts[] = platform.toLowerCase().split(":");
        if (parts.length != 3) {
            log(logger, Messages.SDK_ADDON_FORMAT_UNRECOGNISED(platform));
            return null;
        }

        // Determine addon name
        String vendor = parts[0].replaceAll("[^a-z0-9_-]+", "_").replaceAll("_+", "_").replace("_$", "");
        String addon = parts[1].replaceAll("[^a-z0-9_-]+", "_").replaceAll("_+", "_").replace("_$", "");
        String component = String.format("addon-%s-%s-%s", addon, vendor, parts[2]);
        components.add(component);

        return components;
    }

    /**
     * Determines the Android platform for the given emulator configuration.<br>
     * This is a string like "android-10" or "Google Inc.:Google APIs:4".
     *
     * @param launcher Used to launch tasks on the remote node.
     * @param emuConfig The emulator whose target platform we want to determine.
     * @return The platform, or {@code null} if it could not be determined.
     */
    private static String getPlatformForEmulator(Launcher launcher, final EmulatorConfig emuConfig)
            throws IOException, InterruptedException {
        // For existing, named emulators, get the target from the metadata file
        if (emuConfig.isNamedEmulator()) {
            return getPlatformFromExistingEmulator(launcher, emuConfig);
        }

        // Otherwise, use the configured platform
        return emuConfig.getOsVersion().getTargetName();
    }

    /**
     * Determines the Android platform for an existing emulator, via its metadata config file.
     *
     * @param launcher Used to access files on the remote node.
     * @param emuConfig The emulator whose target platform we want to determine.
     * @return The platform identifier.
     */
    private static String getPlatformFromExistingEmulator(Launcher launcher, final EmulatorConfig emuConfig)
            throws IOException, InterruptedException {
        return launcher.getChannel().call(new Callable<String, IOException>() {
            public String call() throws IOException {
                File metadataFile = emuConfig.getAvdMetadataFile();
                Map<String, String> metadata = Utils.parseConfigFile(metadataFile);
                return metadata.get("target");
            }

            private static final long serialVersionUID = 1L;
        });
    }

    /**
     * Writes the configuration file required to opt out of SDK usage statistics gathering.
     *
     * @param launcher Used for running tasks on the remote node.
     * @param listener Used to access logger.
     */
    public static void optOutOfSdkStatistics(Launcher launcher, BuildListener listener, String androidSdkHome) {
        Callable<Void, Exception> optOutTask = new StatsOptOutTask(androidSdkHome, listener);
        try {
            launcher.getChannel().call(optOutTask);
        } catch (Exception e) {
            log(listener.getLogger(), "SDK statistics opt-out failed.", e);
        }
    }

    /**
     * Acquires an exclusive lock for the machine we're executing on.
     * <p>
     * The lock only has one permit, meaning that other executors on the same node which want to
     * install SDK components will block here until the lock is released by another executor.
     *
     * @return The semaphore for the current machine, which must be released once finished with.
     */
    private static Semaphore acquireLock() throws InterruptedException {
        // Retrieve the lock for this node
        Semaphore semaphore;
        final Node node = Computer.currentComputer().getNode();
        synchronized (node) {
            semaphore = mutexByNode.get(node);
            if (semaphore == null) {
                semaphore = new Semaphore(1);
                mutexByNode.put(node, semaphore);
            }
        }

        // Block until the lock is available
        semaphore.acquire();
        return semaphore;
    }

    private static String getProxySettings() {
        // TODO: This needs to run on the remote node and fetch System.getprop("http[s].proxyHost")
        // TODO: Or can/should we integrate with the built-in proxy support (if it's available)
        return "";
    }

    /**
     * Determines whether the Android SDK installation on the given node is complete.
     *
     * @param node The node to check.
     * @param sdkRoot Root directory of the SDK installation to check.
     * @return {@code true} if the basic SDK <b>and</b> all required SDK components are installed.
     */
    private static boolean isSdkInstallComplete(Node node, final String sdkRoot)
            throws IOException, InterruptedException {
        // Validation needs to run on the remote node
        ValidationResult result = node.getChannel().call(new Callable<ValidationResult, InterruptedException>() {
            public ValidationResult call() throws InterruptedException {
                return Utils.validateAndroidHome(new File(sdkRoot), false);
            }

            private static final long serialVersionUID = 1L;
        });

        if (result.isFatal()) {
            // No, we're missing some tools
            return false;
        }

        // SDK is complete if we got as far as writing the metadata file
        return getInstallationInfoFilename(node).exists();
    }

    /** Gets the path of our installation metadata file for the given node. */
    private static final FilePath getInstallationInfoFilename(Node node) {
        return Utils.getSdkInstallDirectory(node).child(SDK_INFO_FILENAME);
    }

    /**
     * Recursively flags anything that looks like an Android tools executable, as executable.
     *
     * @param toolsDir The top level Android SDK tools directory.
     */
    private static final void setPermissions(FilePath toolsDir) throws IOException, InterruptedException {
        for (FilePath dir : toolsDir.listDirectories()) {
            setPermissions(dir);
        }
        for (FilePath f : toolsDir.list(new ToolFileFilter())) {
            f.chmod(0755);
        }
    }

    /** Serializable FileFilter that searches for Android SDK tool executables. */
    private static final class ToolFileFilter implements FileFilter, Serializable {
        public boolean accept(File f) {
            // Executables are files, which have no file extension, except for shell scripts
            return f.isFile() && (!f.getName().contains(".") || f.getName().endsWith(".sh"));
        }

        private static final long serialVersionUID = 1L;
    }

    /** Helper to run SDK statistics opt-out task on a remote node. */
    private static final class StatsOptOutTask implements Callable<Void, Exception> {

        private static final long serialVersionUID = 1L;
        private final String androidSdkHome;

        private final BuildListener listener;
        private transient PrintStream logger;

        public StatsOptOutTask(String androidSdkHome, BuildListener listener) {
            this.androidSdkHome = androidSdkHome;
            this.listener = listener;
        }

        public Void call() throws Exception {
            if (logger == null) {
                logger = listener.getLogger();
            }

            final File homeDir = Utils.getHomeDirectory(androidSdkHome);
            final File androidDir = new File(homeDir, ".android");
            androidDir.mkdirs();

            File configFile = new File(androidDir, "ddms.cfg");
            PrintWriter out;
            try {
                out = new PrintWriter(configFile);
                out.println("pingOptIn=false");
                out.println("pingId=0");
                out.flush();
                out.close();
            } catch (FileNotFoundException e) {
                log(logger, "Failed to automatically opt out of SDK statistics gathering.", e);
            }

            return null;
        }
    }

    /** Helper for getting platform-specific SDK installation information. */
    enum AndroidInstaller {

        LINUX("linux", "tgz"), MAC_OS_X("macosx", "zip"), WINDOWS("windows", "zip");

        private static final String PATTERN = "http://dl.google.com/android/android-sdk_r%s-%s.%s";
        private final String platform;
        private final String extension;

        private AndroidInstaller(String platform, String extension) {
            this.platform = platform;
            this.extension = extension;
        }

        URL getUrl(String version) {
            try {
                return new URL(String.format(PATTERN, version, platform, extension));
            } catch (MalformedURLException e) {
            }
            return null;
        }

        static AndroidInstaller fromNode(Node node)
                throws SdkUnavailableException, IOException, InterruptedException {
            return node.getChannel().call(new Callable<AndroidInstaller, SdkUnavailableException>() {
                public AndroidInstaller call() throws SdkUnavailableException {
                    return get();
                }

                private static final long serialVersionUID = 1L;
            });
        }

        private static AndroidInstaller get() throws SdkUnavailableException {
            String prop = System.getProperty("os.name");
            String os = prop.toLowerCase(Locale.ENGLISH);
            if (os.contains("linux")) {
                return LINUX;
            }
            if (os.contains("mac")) {
                return MAC_OS_X;
            }
            if (os.contains("windows")) {
                return WINDOWS;
            }
            throw new SdkUnavailableException(Messages.SDK_UNAVAILABLE(prop));
        }

        static final class SdkUnavailableException extends Exception {
            private SdkUnavailableException(String message) {
                super(message);
            }

            private static final long serialVersionUID = 1L;
        }
    }

}