com.facebook.buck.android.ExopackageInstaller.java Source code

Java tutorial

Introduction

Here is the source code for com.facebook.buck.android.ExopackageInstaller.java

Source

/*
 * Copyright 2014-present Facebook, Inc.
 *
 * Licensed 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.
 */

package com.facebook.buck.android;

import com.android.ddmlib.AdbCommandRejectedException;
import com.android.ddmlib.CollectingOutputReceiver;
import com.android.ddmlib.IDevice;
import com.android.ddmlib.InstallException;
import com.facebook.buck.android.agent.util.AgentUtil;
import com.facebook.buck.event.BuckEventBus;
import com.facebook.buck.event.ConsoleEvent;
import com.facebook.buck.event.InstallEvent;
import com.facebook.buck.event.PerfEventId;
import com.facebook.buck.event.SimplePerfEvent;
import com.facebook.buck.io.ProjectFilesystem;
import com.facebook.buck.log.Logger;
import com.facebook.buck.rules.ExopackageInfo;
import com.facebook.buck.rules.InstallableApk;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.util.NamedTemporaryFile;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Charsets;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicates;
import com.google.common.base.Splitter;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.annotation.Nullable;

/**
 * ExopackageInstaller manages the installation of apps with the "exopackage" flag set to true.
 */
public class ExopackageInstaller {

    private static final Logger LOG = Logger.get(ExopackageInstaller.class);

    /**
     * Prefix of the path to the agent apk on the device.
     */
    private static final String AGENT_DEVICE_PATH = "/data/app/" + AgentUtil.AGENT_PACKAGE_NAME;

    /**
     * Command line to invoke the agent on the device.
     */
    private static final String JAVA_AGENT_COMMAND = "dalvikvm -classpath " + AGENT_DEVICE_PATH + "-1.apk:"
            + AGENT_DEVICE_PATH + "-2.apk:" + AGENT_DEVICE_PATH + "-1/base.apk:" + AGENT_DEVICE_PATH
            + "-2/base.apk " + "com.facebook.buck.android.agent.AgentMain ";

    /**
     * Maximum length of commands that can be passed to "adb shell".
     */
    private static final int MAX_ADB_COMMAND_SIZE = 1019;

    private static final Path SECONDARY_DEX_DIR = Paths.get("secondary-dex");

    private static final Path NATIVE_LIBS_DIR = Paths.get("native-libs");

    @VisibleForTesting
    static final Pattern DEX_FILE_PATTERN = Pattern.compile("secondary-([0-9a-f]+)\\.[\\w.-]*");

    @VisibleForTesting
    static final Pattern NATIVE_LIB_PATTERN = Pattern.compile("native-([0-9a-f]+)\\.so");

    private static final Pattern LINE_ENDING = Pattern.compile("\r?\n");

    private final ProjectFilesystem projectFilesystem;
    private final BuckEventBus eventBus;
    private final AdbHelper adbHelper;
    private final InstallableApk apkRule;
    private final String packageName;
    private final Path dataRoot;

    private final ExopackageInfo exopackageInfo;

    /**
     * The next port number to use for communicating with the agent on a device.
     * This resets for every instance of ExopackageInstaller,
     * but is incremented for every device we are installing on when using "-x".
     */
    private final AtomicInteger nextAgentPort = new AtomicInteger(2828);

    @VisibleForTesting
    static class PackageInfo {
        final String apkPath;
        final String nativeLibPath;
        final String versionCode;

        private PackageInfo(String apkPath, String nativeLibPath, String versionCode) {
            this.nativeLibPath = nativeLibPath;
            this.apkPath = apkPath;
            this.versionCode = versionCode;
        }
    }

    public ExopackageInstaller(ExecutionContext context, AdbHelper adbHelper, InstallableApk apkRule) {
        this.adbHelper = adbHelper;
        this.projectFilesystem = apkRule.getProjectFilesystem();
        this.eventBus = context.getBuckEventBus();
        this.apkRule = apkRule;
        this.packageName = AdbHelper.tryToExtractPackageNameFromManifest(apkRule);
        this.dataRoot = Paths.get("/data/local/tmp/exopackage/").resolve(packageName);

        Preconditions.checkArgument(AdbHelper.PACKAGE_NAME_PATTERN.matcher(packageName).matches());

        Optional<ExopackageInfo> exopackageInfo = apkRule.getExopackageInfo();
        Preconditions.checkArgument(exopackageInfo.isPresent());
        this.exopackageInfo = exopackageInfo.get();
    }

    /**
     * Installs the app specified in the constructor.  This object should be discarded afterward.
     */
    public synchronized boolean install(boolean quiet) throws InterruptedException {
        InstallEvent.Started started = InstallEvent.started(apkRule.getBuildTarget());
        eventBus.post(started);

        boolean success = adbHelper.adbCall(new AdbHelper.AdbCallable() {
            @Override
            public boolean call(IDevice device) throws Exception {
                try {
                    return new SingleDeviceInstaller(device, nextAgentPort.getAndIncrement()).doInstall();
                } catch (Exception e) {
                    throw new RuntimeException("Failed to install exopackage on " + device, e);
                }
            }

            @Override
            public String toString() {
                return "install exopackage";
            }
        }, quiet);

        eventBus.post(InstallEvent.finished(started, success, Optional.empty(),
                Optional.of(AdbHelper.tryToExtractPackageNameFromManifest(apkRule))));
        return success;
    }

    /**
     * Helper class to manage the state required to install on a single device.
     */
    private class SingleDeviceInstaller {

        /**
         * Device that we are installing onto.
         */
        private final IDevice device;

        /**
         * Port to use for sending files to the agent.
         */
        private final int agentPort;

        /**
         * True iff we should use the native agent.
         */
        private boolean useNativeAgent = true;

        /**
         * Set after the agent is installed.
         */
        @Nullable
        private String nativeAgentPath;

        private SingleDeviceInstaller(IDevice device, int agentPort) {
            this.device = device;
            this.agentPort = agentPort;
        }

        boolean doInstall() throws Exception {
            Optional<PackageInfo> agentInfo = installAgentIfNecessary();
            if (!agentInfo.isPresent()) {
                return false;
            }

            nativeAgentPath = agentInfo.get().nativeLibPath;
            determineBestAgent();

            final File apk = apkRule.getProjectFilesystem().resolve(apkRule.getApkPath()).toFile();
            // TODO(dreiss): Support SD installation.
            final boolean installViaSd = false;

            if (shouldAppBeInstalled()) {
                try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(eventBus, "install_exo_apk")) {
                    boolean success = adbHelper.installApkOnDevice(device, apk, installViaSd, false);
                    if (!success) {
                        return false;
                    }
                }
            }

            if (exopackageInfo.getDexInfo().isPresent()) {
                installSecondaryDexFiles();
            }

            if (exopackageInfo.getNativeLibsInfo().isPresent()) {
                installNativeLibraryFiles();
            }

            // TODO(dreiss): Make this work on Gingerbread.
            try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(eventBus, "kill_app")) {
                AdbHelper.executeCommandWithErrorChecking(device, "am force-stop " + packageName);
            }

            return true;
        }

        private void installSecondaryDexFiles() throws Exception {
            final ImmutableMap<String, Path> hashToSources = getRequiredDexFiles();
            final ImmutableSet<String> requiredHashes = hashToSources.keySet();
            final ImmutableSet<String> presentHashes = prepareSecondaryDexDir(requiredHashes);
            final Set<String> hashesToInstall = Sets.difference(requiredHashes, presentHashes);

            Map<String, Path> filesToInstallByHash = Maps.filterKeys(hashToSources, hashesToInstall::contains);

            // This is a bit gross.  It was a late addition.  Ideally, we could eliminate this, but
            // it wouldn't be terrible if we don't.  We store the dexed jars on the device
            // with the full SHA-1 hashes in their names.  This is the format that the loader uses
            // internally, so ideally we would just load them in place.  However, the code currently
            // expects to be able to copy the jars from a directory that matches the name in the
            // metadata file, like "secondary-1.dex.jar".  We don't want to give up putting the
            // hashes in the file names (because we use that to skip re-uploads), so just hack
            // the metadata file to have hash-like names.
            String metadataContents = com.google.common.io.Files
                    .toString(projectFilesystem.resolve(exopackageInfo.getDexInfo().get().getMetadata()).toFile(),
                            Charsets.UTF_8)
                    .replaceAll("secondary-(\\d+)\\.dex\\.jar (\\p{XDigit}{40}) ", "secondary-$2.dex.jar $2 ");

            installFiles("secondary_dex", ImmutableMap.copyOf(filesToInstallByHash), metadataContents,
                    "secondary-%s.dex.jar", SECONDARY_DEX_DIR);
        }

        private ImmutableList<String> getDeviceAbis() throws Exception {
            ImmutableList.Builder<String> abis = ImmutableList.builder();
            // Rare special indigenous to Lollipop devices
            String abiListProperty = getProperty("ro.product.cpu.abilist");
            if (!abiListProperty.isEmpty()) {
                abis.addAll(Splitter.on(',').splitToList(abiListProperty));
            } else {
                String abi1 = getProperty("ro.product.cpu.abi");
                if (abi1.isEmpty()) {
                    throw new RuntimeException("adb returned empty result for ro.product.cpu.abi property.");
                }

                abis.add(abi1);
                String abi2 = getProperty("ro.product.cpu.abi2");
                if (!abi2.isEmpty()) {
                    abis.add(abi2);
                }
            }

            return abis.build();
        }

        private void installNativeLibraryFiles() throws Exception {
            ImmutableMultimap<String, Path> allLibraries = getAllLibraries();
            ImmutableSet.Builder<String> providedLibraries = ImmutableSet.builder();
            for (String abi : getDeviceAbis()) {
                ImmutableMap<String, Path> libraries = getRequiredLibrariesForAbi(allLibraries, abi,
                        providedLibraries.build());

                installNativeLibrariesForAbi(abi, libraries);
                providedLibraries.addAll(libraries.keySet());
            }
        }

        private void installNativeLibrariesForAbi(String abi, ImmutableMap<String, Path> libraries)
                throws Exception {
            if (libraries.isEmpty()) {
                return;
            }

            String metadataContents = Joiner.on('\n')
                    .join(FluentIterable.from(libraries.entrySet()).transform(input -> {
                        String hash = input.getKey();
                        String filename = input.getValue().getFileName().toString();
                        int index = filename.indexOf('.');
                        String libname = index == -1 ? filename : filename.substring(0, index);
                        return String.format("%s native-%s.so", libname, hash);
                    }));

            ImmutableSet<String> requiredHashes = libraries.keySet();
            ImmutableSet<String> presentHashes = prepareNativeLibsDir(abi, requiredHashes);

            Map<String, Path> filesToInstallByHash = Maps.filterKeys(libraries,
                    Predicates.not(presentHashes::contains));

            installFiles("native_library", ImmutableMap.copyOf(filesToInstallByHash), metadataContents,
                    "native-%s.so", NATIVE_LIBS_DIR.resolve(abi));
        }

        /**
         * Sets {@link #useNativeAgent} to true on pre-L devices, because our native agent is built
         * without -fPIC.  The java agent works fine on L as long as we don't use it for mkdir.
         */
        private void determineBestAgent() throws Exception {
            String value = getProperty("ro.build.version.sdk");
            try {
                if (Integer.valueOf(value.trim()) > 19) {
                    useNativeAgent = false;
                }
            } catch (NumberFormatException exn) {
                useNativeAgent = false;
            }
        }

        private String getAgentCommand() {
            if (useNativeAgent) {
                return nativeAgentPath + "/libagent.so ";
            } else {
                return JAVA_AGENT_COMMAND;
            }
        }

        private Optional<PackageInfo> getPackageInfo(final String packageName) throws Exception {
            try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(eventBus, PerfEventId.of("get_package_info"),
                    "package", packageName)) {

                /* "dumpsys package <package>" produces output that looks like
                    
                  Package [com.facebook.katana] (4229ce68):
                    userId=10145 gids=[1028, 1015, 3003]
                    pkg=Package{42690b80 com.facebook.katana}
                    codePath=/data/app/com.facebook.katana-1.apk
                    resourcePath=/data/app/com.facebook.katana-1.apk
                    nativeLibraryPath=/data/app-lib/com.facebook.katana-1
                    versionCode=1640376 targetSdk=14
                    versionName=8.0.0.0.23
                    
                    ...
                    
                 */
                // We call "pm path" because "dumpsys package" returns valid output if an app has been
                // uninstalled using the "--keepdata" option. "pm path", on the other hand, returns an empty
                // output in that case.
                String lines = AdbHelper.executeCommandWithErrorChecking(device,
                        String.format("pm path %s; dumpsys package %s", packageName, packageName));

                return parsePathAndPackageInfo(packageName, lines);
            }
        }

        /**
         * @return PackageInfo for the agent, or absent if installation failed.
         */
        private Optional<PackageInfo> installAgentIfNecessary() throws Exception {
            Optional<PackageInfo> agentInfo = getPackageInfo(AgentUtil.AGENT_PACKAGE_NAME);
            if (!agentInfo.isPresent()) {
                LOG.debug("Agent not installed.  Installing.");
                return installAgentApk();
            }
            LOG.debug("Agent version: %s", agentInfo.get().versionCode);
            if (!agentInfo.get().versionCode.equals(AgentUtil.AGENT_VERSION_CODE)) {
                // Always uninstall before installing.  We might be downgrading, which requires
                // an uninstall, or we might just want a clean installation.
                uninstallAgent();
                return installAgentApk();
            }
            return agentInfo;
        }

        private void uninstallAgent() throws InstallException {
            try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(eventBus, "uninstall_old_agent")) {
                device.uninstallPackage(AgentUtil.AGENT_PACKAGE_NAME);
            }
        }

        private Optional<PackageInfo> installAgentApk() throws Exception {
            try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(eventBus, "install_agent_apk")) {
                String apkFileName = System.getProperty("buck.android_agent_path");
                if (apkFileName == null) {
                    throw new RuntimeException("Android agent apk path not specified in properties");
                }
                File apkPath = new File(apkFileName);
                boolean success = adbHelper.installApkOnDevice(device, apkPath, /* installViaSd */ false,
                        /* quiet */ false);
                if (!success) {
                    return Optional.empty();
                }
                return getPackageInfo(AgentUtil.AGENT_PACKAGE_NAME);
            }
        }

        private boolean shouldAppBeInstalled() throws Exception {
            Optional<PackageInfo> appPackageInfo = getPackageInfo(packageName);
            if (!appPackageInfo.isPresent()) {
                eventBus.post(ConsoleEvent.info("App not installed.  Installing now."));
                return true;
            }

            LOG.debug("App path: %s", appPackageInfo.get().apkPath);
            String installedAppSignature = getInstalledAppSignature(appPackageInfo.get().apkPath);
            String localAppSignature = AgentUtil
                    .getJarSignature(apkRule.getProjectFilesystem().resolve(apkRule.getApkPath()).toString());
            LOG.debug("Local app signature: %s", localAppSignature);
            LOG.debug("Remote app signature: %s", installedAppSignature);

            if (!installedAppSignature.equals(localAppSignature)) {
                LOG.debug("App signatures do not match.  Must re-install.");
                return true;
            }

            LOG.debug("App signatures match.  No need to install.");
            return false;
        }

        private String getInstalledAppSignature(final String packagePath) throws Exception {
            try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(eventBus, "get_app_signature")) {
                String command = getAgentCommand() + "get-signature " + packagePath;
                LOG.debug("Executing %s", command);
                String output = AdbHelper.executeCommandWithErrorChecking(device, command);

                String result = output.trim();
                if (result.contains("\n") || result.contains("\r")) {
                    throw new IllegalStateException("Unexpected return from get-signature:\n" + output);
                }

                return result;
            }
        }

        private ImmutableMap<String, Path> getRequiredDexFiles() throws IOException {
            ExopackageInfo.DexInfo dexInfo = exopackageInfo.getDexInfo().get();
            ImmutableMultimap<String, Path> multimap = parseExopackageInfoMetadata(dexInfo.getMetadata(),
                    dexInfo.getDirectory(), projectFilesystem);
            // Convert multimap to a map, because every key should have only one value.
            ImmutableMap.Builder<String, Path> builder = ImmutableMap.builder();
            for (Map.Entry<String, Path> entry : multimap.entries()) {
                builder.put(entry);
            }
            return builder.build();
        }

        private ImmutableSet<String> prepareSecondaryDexDir(ImmutableSet<String> requiredHashes) throws Exception {
            return prepareDirectory("secondary-dex", DEX_FILE_PATTERN, requiredHashes);
        }

        private ImmutableSet<String> prepareNativeLibsDir(String abi, ImmutableSet<String> requiredHashes)
                throws Exception {
            return prepareDirectory("native-libs/" + abi, NATIVE_LIB_PATTERN, requiredHashes);
        }

        private ImmutableSet<String> prepareDirectory(String dirname, Pattern filePattern,
                ImmutableSet<String> requiredHashes) throws Exception {
            try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(eventBus, "prepare_" + dirname)) {
                String dirPath = dataRoot.resolve(dirname).toString();
                mkDirP(dirPath);

                String output = AdbHelper.executeCommandWithErrorChecking(device, "ls " + dirPath);

                ImmutableSet.Builder<String> foundHashes = ImmutableSet.builder();
                ImmutableSet.Builder<String> filesToDelete = ImmutableSet.builder();

                processLsOutput(output, filePattern, requiredHashes, foundHashes, filesToDelete);

                String commandPrefix = "cd " + dirPath + " && rm ";
                // Add a fudge factor for separators and error checking.
                final int overhead = commandPrefix.length() + 100;
                for (List<String> rmArgs : chunkArgs(filesToDelete.build(), MAX_ADB_COMMAND_SIZE - overhead)) {
                    String command = commandPrefix + Joiner.on(' ').join(rmArgs);
                    LOG.debug("Executing %s", command);
                    AdbHelper.executeCommandWithErrorChecking(device, command);
                }

                return foundHashes.build();
            }
        }

        private void installFiles(String filesType, ImmutableMap<String, Path> filesToInstallByHash,
                String metadataFileContents, String filenameFormat, Path destinationDirRelativeToDataRoot)
                throws Exception {
            try (SimplePerfEvent.Scope ignored1 = SimplePerfEvent.scope(eventBus, "multi_install_" + filesType)) {
                device.createForward(agentPort, agentPort);
                try {
                    for (Map.Entry<String, Path> entry : filesToInstallByHash.entrySet()) {
                        Path destination = destinationDirRelativeToDataRoot
                                .resolve(String.format(filenameFormat, entry.getKey()));
                        Path source = entry.getValue();

                        try (SimplePerfEvent.Scope ignored2 = SimplePerfEvent.scope(eventBus,
                                "install_" + filesType)) {
                            installFile(device, agentPort, destination, source);
                        }
                    }
                    try (SimplePerfEvent.Scope ignored3 = SimplePerfEvent.scope(eventBus,
                            "install_" + filesType + "_metadata")) {
                        try (NamedTemporaryFile temp = new NamedTemporaryFile("metadata", "tmp")) {
                            com.google.common.io.Files.write(metadataFileContents.getBytes(Charsets.UTF_8),
                                    temp.get().toFile());
                            installFile(device, agentPort, destinationDirRelativeToDataRoot.resolve("metadata.txt"),
                                    temp.get());
                        }
                    }
                } finally {
                    try {
                        device.removeForward(agentPort, agentPort);
                    } catch (AdbCommandRejectedException e) {
                        LOG.warn(e, "Failed to remove adb forward on port %d for device %s", agentPort, device);
                        eventBus.post(ConsoleEvent
                                .warning("Failed to remove adb forward %d. This is not necessarily a problem\n"
                                        + "because it will be recreated during the next exopackage installation.\n"
                                        + "See the log for the full exception.", agentPort));
                    }
                }
            }
        }

        private void installFile(IDevice device, final int port, Path pathRelativeToDataRoot,
                final Path relativeSource) throws Exception {
            final Path source = projectFilesystem.resolve(relativeSource);
            CollectingOutputReceiver receiver = new CollectingOutputReceiver() {

                private boolean sentPayload = false;

                @Override
                public void addOutput(byte[] data, int offset, int length) {
                    super.addOutput(data, offset, length);
                    if (!sentPayload && getOutput().length() >= AgentUtil.TEXT_SECRET_KEY_SIZE) {
                        LOG.verbose("Got key: %s", getOutput().trim());

                        sentPayload = true;
                        try (Socket clientSocket = new Socket("localhost", port)) {
                            LOG.verbose("Connected");
                            OutputStream outToDevice = clientSocket.getOutputStream();
                            outToDevice.write(getOutput().substring(0, AgentUtil.TEXT_SECRET_KEY_SIZE).getBytes());
                            LOG.verbose("Wrote key");
                            com.google.common.io.Files.asByteSource(source.toFile()).copyTo(outToDevice);
                            LOG.verbose("Wrote file");
                        } catch (IOException e) {
                            throw new RuntimeException(e);
                        }
                    }
                }
            };

            String targetFileName = projectFilesystem.resolve(dataRoot.resolve(pathRelativeToDataRoot)).toString();
            String command = "umask 022 && " + getAgentCommand() + "receive-file " + port + " " + Files.size(source)
                    + " " + targetFileName + " ; echo -n :$?";
            LOG.debug("Executing %s", command);

            // If we fail to execute the command, stash the exception.  My experience during development
            // has been that the exception from checkReceiverOutput is more actionable.
            Exception shellException = null;
            try {
                device.executeShellCommand(command, receiver);
            } catch (Exception e) {
                shellException = e;
            }

            try {
                AdbHelper.checkReceiverOutput(command, receiver);
            } catch (Exception e) {
                if (shellException != null) {
                    e.addSuppressed(shellException);
                }
                throw e;
            }

            if (shellException != null) {
                throw shellException;
            }

            // The standard Java libraries on Android always create new files un-readable by other users.
            // We use the shell user or root to create these files, so we need to explicitly set the mode
            // to allow the app to read them.  Ideally, the agent would do this automatically, but
            // there's no easy way to do this in Java.  We can drop this if we drop support for the
            // Java agent.
            AdbHelper.executeCommandWithErrorChecking(device, "chmod 644 " + targetFileName);
        }

        private String getProperty(String property) throws Exception {
            return AdbHelper.executeCommandWithErrorChecking(device, "getprop " + property).trim();
        }

        private void mkDirP(String dirpath) throws Exception {
            // Kind of a hack here.  The java agent can't force the proper permissions on the
            // directories it creates, so we use the command-line "mkdir -p" instead of the java agent.
            // Fortunately, "mkdir -p" seems to work on all devices where we use use the java agent.
            String mkdirP = useNativeAgent ? getAgentCommand() + "mkdir-p" : "mkdir -p";

            AdbHelper.executeCommandWithErrorChecking(device, "umask 022 && " + mkdirP + " " + dirpath);
        }
    }

    private ImmutableMultimap<String, Path> getAllLibraries() throws IOException {
        ExopackageInfo.NativeLibsInfo nativeLibsInfo = exopackageInfo.getNativeLibsInfo().get();
        return parseExopackageInfoMetadata(nativeLibsInfo.getMetadata(), nativeLibsInfo.getDirectory(),
                projectFilesystem);
    }

    private ImmutableMap<String, Path> getRequiredLibrariesForAbi(ImmutableMultimap<String, Path> allLibraries,
            String abi, ImmutableSet<String> ignoreLibraries) {
        return filterLibrariesForAbi(exopackageInfo.getNativeLibsInfo().get().getDirectory(), allLibraries, abi,
                ignoreLibraries);
    }

    @VisibleForTesting
    static ImmutableMap<String, Path> filterLibrariesForAbi(Path nativeLibsDir,
            ImmutableMultimap<String, Path> allLibraries, String abi, ImmutableSet<String> ignoreLibraries) {
        ImmutableMap.Builder<String, Path> filteredLibraries = ImmutableMap.builder();
        for (Map.Entry<String, Path> entry : allLibraries.entries()) {
            Path relativePath = nativeLibsDir.relativize(entry.getValue());
            // relativePath is of the form libs/x86/foo.so, or assetLibs/x86/foo.so etc.
            Preconditions.checkState(relativePath.getNameCount() == 3);
            Preconditions.checkState(relativePath.getName(0).toString().equals("libs")
                    || relativePath.getName(0).toString().equals("assetLibs"));
            String libAbi = relativePath.getParent().getFileName().toString();
            String libName = relativePath.getFileName().toString();
            if (libAbi.equals(abi) && !ignoreLibraries.contains(libName)) {
                filteredLibraries.put(entry);
            }
        }
        return filteredLibraries.build();
    }

    /**
     * Parses a text file which is supposed to be in the following format:
     * "file_path_without_spaces file_hash ...." i.e. it parses the first two columns of each line
     * and ignores the rest of it.
     *
     * @return  A multi map from the file hash to its path, which equals the raw path resolved against
     *     {@code resolvePathAgainst}.
     */
    @VisibleForTesting
    static ImmutableMultimap<String, Path> parseExopackageInfoMetadata(Path metadataTxt, Path resolvePathAgainst,
            ProjectFilesystem filesystem) throws IOException {
        ImmutableMultimap.Builder<String, Path> builder = ImmutableMultimap.builder();
        for (String line : filesystem.readLines(metadataTxt)) {
            // ignore lines that start with '.'
            if (line.startsWith(".")) {
                continue;
            }
            List<String> parts = Splitter.on(' ').splitToList(line);
            if (parts.size() < 2) {
                throw new RuntimeException("Illegal line in metadata file: " + line);
            }
            builder.put(parts.get(1), resolvePathAgainst.resolve(parts.get(0)));
        }
        return builder.build();
    }

    @VisibleForTesting
    static Optional<PackageInfo> parsePathAndPackageInfo(String packageName, String rawOutput) {
        Iterable<String> lines = Splitter.on(LINE_ENDING).omitEmptyStrings().split(rawOutput);
        String pmPathPrefix = "package:";

        String pmPath = null;
        for (String line : lines) {
            // Ignore silly linker warnings about non-PIC code on emulators
            if (!line.startsWith("WARNING: linker: ")) {
                pmPath = line;
                break;
            }
        }

        if (pmPath == null || !pmPath.startsWith(pmPathPrefix)) {
            LOG.warn("unable to locate package path for [" + packageName + "]");
            return Optional.empty();
        }

        final String packagePrefix = "  Package [" + packageName + "] (";
        final String otherPrefix = "  Package [";
        boolean sawPackageLine = false;
        final Splitter splitter = Splitter.on('=').limit(2);

        String codePath = null;
        String resourcePath = null;
        String nativeLibPath = null;
        String versionCode = null;

        for (String line : lines) {
            // Just ignore everything until we see the line that says we are in the right package.
            if (line.startsWith(packagePrefix)) {
                sawPackageLine = true;
                continue;
            }
            // This should never happen, but if we do see a different package, stop parsing.
            if (line.startsWith(otherPrefix)) {
                break;
            }
            // Ignore lines before our package.
            if (!sawPackageLine) {
                continue;
            }
            // Parse key-value pairs.
            List<String> parts = splitter.splitToList(line.trim());
            if (parts.size() != 2) {
                continue;
            }
            switch (parts.get(0)) {
            case "codePath":
                codePath = parts.get(1);
                break;
            case "resourcePath":
                resourcePath = parts.get(1);
                break;
            case "nativeLibraryPath":
                nativeLibPath = parts.get(1);
                break;
            // Lollipop uses this name.  Not sure what's "legacy" about it yet.
            // Maybe something to do with 64-bit?
            // Might need to update if people report failures.
            case "legacyNativeLibraryDir":
                nativeLibPath = parts.get(1);
                break;
            case "versionCode":
                // Extra split to get rid of the SDK thing.
                versionCode = parts.get(1).split(" ", 2)[0];
                break;
            default:
                break;
            }
        }

        if (!sawPackageLine) {
            return Optional.empty();
        }

        Preconditions.checkNotNull(codePath, "Could not find codePath");
        Preconditions.checkNotNull(resourcePath, "Could not find resourcePath");
        Preconditions.checkNotNull(nativeLibPath, "Could not find nativeLibraryPath");
        Preconditions.checkNotNull(versionCode, "Could not find versionCode");
        if (!codePath.equals(resourcePath)) {
            throw new IllegalStateException("Code and resource path do not match");
        }

        // Lollipop doesn't give the full path to the apk anymore.  Not sure why it's "base.apk".
        if (!codePath.endsWith(".apk")) {
            codePath += "/base.apk";
        }

        return Optional.of(new PackageInfo(codePath, nativeLibPath, versionCode));
    }

    /**
     * @param output  Output of "ls" command.
     * @param filePattern  A {@link Pattern} that is used to check if a file is valid, and if it
     *     matches, {@code filePattern.group(1)} should return the hash in the file name.
     * @param requiredHashes  Hashes of dex files required for this apk.
     * @param foundHashes  Builder to receive hashes that we need and were found.
     * @param toDelete  Builder to receive files that we need to delete.
     */
    @VisibleForTesting
    static void processLsOutput(String output, Pattern filePattern, ImmutableSet<String> requiredHashes,
            ImmutableSet.Builder<String> foundHashes, ImmutableSet.Builder<String> toDelete) {
        for (String line : Splitter.on(LINE_ENDING).omitEmptyStrings().split(output)) {
            if (line.equals("lock")) {
                continue;
            }

            Matcher m = filePattern.matcher(line);
            if (m.matches()) {
                if (requiredHashes.contains(m.group(1))) {
                    foundHashes.add(m.group(1));
                } else {
                    toDelete.add(line);
                }
            } else {
                toDelete.add(line);
            }
        }
    }

    /**
     * Breaks a list of strings into groups whose total size is within some limit.
     * Kind of like the xargs command that groups arguments to avoid maximum argument length limits.
     * Except that the limit in adb is about 1k instead of 512k or 2M on Linux.
     */
    @VisibleForTesting
    static ImmutableList<ImmutableList<String>> chunkArgs(Iterable<String> args, int sizeLimit) {
        ImmutableList.Builder<ImmutableList<String>> topLevelBuilder = ImmutableList.builder();
        ImmutableList.Builder<String> chunkBuilder = ImmutableList.builder();
        int chunkSize = 0;
        for (String arg : args) {
            if (chunkSize + arg.length() > sizeLimit) {
                topLevelBuilder.add(chunkBuilder.build());
                chunkBuilder = ImmutableList.builder();
                chunkSize = 0;
            }
            // We don't check for an individual arg greater than the limit.
            // We just put it in its own chunk and hope for the best.
            chunkBuilder.add(arg);
            chunkSize += arg.length();
        }
        ImmutableList<String> tail = chunkBuilder.build();
        if (!tail.isEmpty()) {
            topLevelBuilder.add(tail);
        }
        return topLevelBuilder.build();
    }
}