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

Java tutorial

Introduction

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

Source

/*
 * Copyright 2012-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 static com.facebook.buck.util.concurrent.MostExecutors.newMultiThreadExecutor;
import static com.google.common.util.concurrent.MoreExecutors.listeningDecorator;

import com.android.ddmlib.AdbCommandRejectedException;
import com.android.ddmlib.AndroidDebugBridge;
import com.android.ddmlib.CollectingOutputReceiver;
import com.android.ddmlib.DdmPreferences;
import com.android.ddmlib.IDevice;
import com.android.ddmlib.InstallException;
import com.android.ddmlib.MultiLineReceiver;
import com.android.ddmlib.NullOutputReceiver;
import com.android.ddmlib.ShellCommandUnresponsiveException;
import com.android.ddmlib.TimeoutException;
import com.facebook.buck.annotations.SuppressForbidden;
import com.facebook.buck.event.BuckEventBus;
import com.facebook.buck.event.ConsoleEvent;
import com.facebook.buck.event.InstallEvent;
import com.facebook.buck.event.SimplePerfEvent;
import com.facebook.buck.event.StartActivityEvent;
import com.facebook.buck.event.UninstallEvent;
import com.facebook.buck.log.CommandThreadFactory;
import com.facebook.buck.rules.ExopackageInfo;
import com.facebook.buck.rules.InstallableApk;
import com.facebook.buck.step.AdbOptions;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.step.TargetDeviceOptions;
import com.facebook.buck.util.Console;
import com.facebook.buck.util.HumanReadableException;
import com.facebook.buck.util.InterruptionFailedException;
import com.facebook.buck.util.concurrent.MostExecutors;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;

import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

import javax.annotation.Nullable;

/**
 * Helper for executing commands over ADB, especially for multiple devices.
 */
public class AdbHelper {

    private static final long ADB_CONNECT_TIMEOUT_MS = 5000;
    private static final long ADB_CONNECT_TIME_STEP_MS = ADB_CONNECT_TIMEOUT_MS / 10;

    /**
     * Pattern that matches safe package names.  (Must be a full string match).
     */
    public static final Pattern PACKAGE_NAME_PATTERN = Pattern.compile("[\\w.-]+");

    /**
     * Pattern that matches Genymotion serial numbers.
     */
    private static final Pattern RE_LOCAL_TRANSPORT_SERIAL = Pattern.compile("\\d+\\.\\d+\\.\\d+\\.\\d+:\\d+");

    /**
     * If this environment variable is set, the device with the specified serial
     * number is targeted. The -s option overrides this.
     */
    static final String SERIAL_NUMBER_ENV = "ANDROID_SERIAL";

    // Taken from ddms source code.
    public static final long INSTALL_TIMEOUT = 2 * 60 * 1000; // 2 min
    public static final long GETPROP_TIMEOUT = 2 * 1000; // 2 seconds

    public static final String ECHO_COMMAND_SUFFIX = " ; echo -n :$?";

    private final AdbOptions options;
    private final TargetDeviceOptions deviceOptions;
    private final ExecutionContext context;
    private final Console console;
    private final BuckEventBus buckEventBus;
    private final boolean restartAdbOnFailure;

    public AdbHelper(AdbOptions adbOptions, TargetDeviceOptions deviceOptions, ExecutionContext context,
            Console console, BuckEventBus buckEventBus, boolean restartAdbOnFailure) {
        this.options = adbOptions;
        this.deviceOptions = deviceOptions;
        this.context = context;
        this.console = console;
        this.buckEventBus = buckEventBus;
        this.restartAdbOnFailure = restartAdbOnFailure;
    }

    public static AdbHelper get(ExecutionContext context, boolean restartOnFailure) {
        Preconditions.checkArgument(context.getAdbOptions().isPresent());
        Preconditions.checkArgument(context.getTargetDeviceOptions().isPresent());
        return new AdbHelper(context.getAdbOptions().get(), context.getTargetDeviceOptions().get(), context,
                context.getConsole(), context.getBuckEventBus(), restartOnFailure);
    }

    private BuckEventBus getBuckEventBus() {
        return buckEventBus;
    }

    /**
     * Returns list of devices that pass the filter. If there is an invalid combination or no
     * devices are left after filtering this function prints an error and returns null.
     */
    @Nullable
    @VisibleForTesting
    @SuppressForbidden
    List<IDevice> filterDevices(IDevice[] allDevices) {
        if (allDevices.length == 0) {
            console.printBuildFailure("No devices are found.");
            return null;
        }

        List<IDevice> devices = Lists.newArrayList();
        Optional<Boolean> emulatorsOnly = Optional.empty();
        if (deviceOptions.isEmulatorsOnlyModeEnabled() && options.isMultiInstallModeEnabled()) {
            emulatorsOnly = Optional.empty();
        } else if (deviceOptions.isEmulatorsOnlyModeEnabled()) {
            emulatorsOnly = Optional.of(true);
        } else if (deviceOptions.isRealDevicesOnlyModeEnabled()) {
            emulatorsOnly = Optional.of(false);
        }

        int onlineDevices = 0;
        for (IDevice device : allDevices) {
            boolean passed = false;
            if (device.isOnline()) {
                onlineDevices++;

                boolean serialMatches = true;
                if (deviceOptions.hasSerialNumber()) {
                    serialMatches = device.getSerialNumber().equals(deviceOptions.getSerialNumber());
                } else if (context.getEnvironment().containsKey(SERIAL_NUMBER_ENV)) {
                    serialMatches = device.getSerialNumber()
                            .equals(context.getEnvironment().get(SERIAL_NUMBER_ENV));
                }

                boolean deviceTypeMatches;
                if (emulatorsOnly.isPresent()) {
                    // Only devices of specific type are accepted:
                    // either real devices only or emulators only.
                    deviceTypeMatches = (emulatorsOnly.get() == isEmulator(device));
                } else {
                    // All online devices match.
                    deviceTypeMatches = true;
                }
                passed = serialMatches && deviceTypeMatches;
            }

            if (passed) {
                devices.add(device);
            }
        }

        // Filtered out all devices.
        if (onlineDevices == 0) {
            console.printBuildFailure("No devices are found.");
            return null;
        }

        if (devices.isEmpty()) {
            console.printBuildFailure(String.format(
                    "Found %d connected device(s), but none of them matches specified filter.", onlineDevices));
            return null;
        }

        return devices;
    }

    private boolean isEmulator(IDevice device) {
        return isLocalTransport(device) || device.isEmulator();
    }

    /**
     * To be consistent with adb, we treat all local transports (as opposed
     * to USB transports) as emulators instead of devices.
     */
    private boolean isLocalTransport(IDevice device) {
        return RE_LOCAL_TRANSPORT_SERIAL.matcher(device.getSerialNumber()).find();
    }

    private boolean isAdbInitialized(AndroidDebugBridge adb) {
        return adb.isConnected() && adb.hasInitialDeviceList();
    }

    /**
     * Creates connection to adb and waits for this connection to be initialized
     * and receive initial list of devices.
     */
    @Nullable
    @SuppressWarnings("PMD.EmptyCatchBlock")
    private AndroidDebugBridge createAdb(ExecutionContext context) throws InterruptedException {
        DdmPreferences.setTimeOut(60000);

        try {
            AndroidDebugBridge.init(/* clientSupport */ false);
        } catch (IllegalStateException ex) {
            // ADB was already initialized, we're fine, so just ignore.
        }

        AndroidDebugBridge adb = AndroidDebugBridge.createBridge(context.getPathToAdbExecutable(), false);
        if (adb == null) {
            console.printBuildFailure("Failed to connect to adb. Make sure adb server is running.");
            return null;
        }

        long start = System.currentTimeMillis();
        while (!isAdbInitialized(adb)) {
            long timeLeft = start + ADB_CONNECT_TIMEOUT_MS - System.currentTimeMillis();
            if (timeLeft <= 0) {
                break;
            }
            Thread.sleep(ADB_CONNECT_TIME_STEP_MS);
        }
        return isAdbInitialized(adb) ? adb : null;
    }

    @SuppressForbidden
    public List<IDevice> getDevices(boolean quiet) throws InterruptedException {
        // Initialize adb connection.
        AndroidDebugBridge adb = createAdb(context);
        if (adb == null) {
            console.printBuildFailure("Failed to create adb connection.");
            return Lists.newArrayList();
        }

        // Build list of matching devices.
        List<IDevice> devices = filterDevices(adb.getDevices());
        if (devices != null && devices.size() > 1) {
            // Found multiple devices but multi-install mode is not enabled.
            if (!options.isMultiInstallModeEnabled()) {
                console.printBuildFailure(String.format(
                        "%d device(s) matches specified device filter (1 expected).\n"
                                + "Either disconnect other devices or enable multi-install mode (%s).",
                        devices.size(), AdbOptions.MULTI_INSTALL_MODE_SHORT_ARG));
                return Lists.newArrayList();
            }
            if (!quiet) {
                // Report if multiple devices are matching the filter.
                console.getStdOut().printf("Found " + devices.size() + " matching devices.\n");
            }
        }

        if (devices == null && restartAdbOnFailure) {
            console.printErrorText("No devices found with adb, restarting adb-server.");
            adb.restart();
            devices = filterDevices(adb.getDevices());
        }
        if (devices == null) {
            return Lists.newArrayList();
        }
        return devices;
    }

    public IDevice getSingleDevice() throws InterruptedException {
        List<IDevice> devices = getDevices(true);
        if (devices.isEmpty()) {
            throw new HumanReadableException("Expecting one android device/emulator to be attached.");
        }
        return devices.get(0);
    }

    public List<String> getDeviceCPUAbis() throws InterruptedException {
        class GetCpuAbiCallable extends AdbHelper.AdbCallable {
            public List<String> results = Lists.newArrayList();

            @Override
            public boolean call(IDevice device) throws Exception {
                String arch = device.getProperty("ro.product.cpu.abi");
                if (arch == null) {
                    console.printBuildFailure(String.format("Failed to get property \"ro.prouct.cpu.abi\" from %s",
                            device.getSerialNumber()));
                    return false;
                }
                synchronized (results) {
                    results.add(arch);
                }
                return true;
            }

            @Override
            public String toString() {
                return "get device cpu abi";
            }
        }
        GetCpuAbiCallable callable = new GetCpuAbiCallable();
        adbCall(callable, true);
        return callable.results;
    }

    /**
     * Execute an {@link AdbCallable} for all matching devices. This functions performs device
     * filtering based on three possible arguments:
     *
     *  -e (emulator-only) - only emulators are passing the filter
     *  -d (device-only) - only real devices are passing the filter
     *  -s (serial) - only device/emulator with specific serial number are passing the filter
     *
     *  If more than one device matches the filter this function will fail unless multi-install
     *  mode is enabled (-x). This flag is used as a marker that user understands that multiple
     *  devices will be used to install the apk if needed.
     */
    @SuppressWarnings("PMD.EmptyCatchBlock")
    @SuppressForbidden
    public boolean adbCall(AdbCallable adbCallable, boolean quiet) throws InterruptedException {
        List<IDevice> devices;

        try (SimplePerfEvent.Scope ignored = SimplePerfEvent.scope(buckEventBus, "set_up_adb_call")) {
            devices = getDevices(quiet);
            if (devices.size() == 0) {
                return false;
            }
        }

        int adbThreadCount = options.getAdbThreadCount();
        if (adbThreadCount <= 0) {
            adbThreadCount = devices.size();
        }

        // Start executions on all matching devices.
        List<ListenableFuture<Boolean>> futures = Lists.newArrayList();
        ListeningExecutorService executorService = listeningDecorator(
                newMultiThreadExecutor(new CommandThreadFactory(getClass().getSimpleName()), adbThreadCount));

        for (final IDevice device : devices) {
            futures.add(executorService.submit(adbCallable.forDevice(device)));
        }

        // Wait for all executions to complete or fail.
        List<Boolean> results = null;
        try {
            results = Futures.allAsList(futures).get();
        } catch (ExecutionException ex) {
            console.printBuildFailure("Failed: " + adbCallable);
            ex.printStackTrace(console.getStdErr());
            return false;
        } catch (InterruptedException e) {
            try {
                Futures.allAsList(futures).cancel(true);
            } catch (CancellationException ignored) {
                // Rethrow original InterruptedException instead.
            }
            Thread.currentThread().interrupt();
            throw e;
        } finally {
            MostExecutors.shutdownOrThrow(executorService, 10, TimeUnit.MINUTES,
                    new InterruptionFailedException("Failed to shutdown ExecutorService."));
        }

        int successCount = 0;
        for (Boolean result : results) {
            if (result) {
                successCount++;
            }
        }
        int failureCount = results.size() - successCount;

        // Report results.
        if (successCount > 0 && !quiet) {
            console.printSuccess(String.format("Successfully ran %s on %d device(s)", adbCallable, successCount));
        }
        if (failureCount > 0) {
            console.printBuildFailure(String.format("Failed to %s on %d device(s).", adbCallable, failureCount));
        }

        return failureCount == 0;
    }

    /**
     * Base class for commands to be run against an {@link com.android.ddmlib.IDevice IDevice}.
     */
    public abstract static class AdbCallable {

        /**
         * Perform the actions specified by this {@code AdbCallable} and return true on success.
         * @param device the {@link com.android.ddmlib.IDevice IDevice} to run against
         * @return {@code true} if the command succeeded.
         */
        public abstract boolean call(IDevice device) throws Exception;

        /**
         * Wraps this as a {@link java.util.concurrent.Callable Callable&lt;Boolean&gt;} whose
         * {@link Callable#call() call()} method calls
         * {@link AdbHelper.AdbCallable#call(IDevice) call(IDevice)} against the specified
         * device.
         * @param device the {@link com.android.ddmlib.IDevice IDevice} to run against.
         * @return a {@code Callable}
         */
        public Callable<Boolean> forDevice(final IDevice device) {
            return new Callable<Boolean>() {
                @Override
                public Boolean call() throws Exception {
                    return AdbCallable.this.call(device);
                }

                @Override
                public String toString() {
                    return AdbCallable.this.toString();
                }
            };
        }
    }

    /**
     * Implementation of {@link com.android.ddmlib.IShellOutputReceiver} with helper functions to
     * parse output lines and figure out if a call to
     * {@link com.android.ddmlib.IDevice#executeShellCommand(String,
     * com.android.ddmlib.IShellOutputReceiver)} succeeded.
     */
    private abstract static class ErrorParsingReceiver extends MultiLineReceiver {

        @Nullable
        private String errorMessage = null;

        /**
         * Look for an error message in {@code line}.
         * @param line
         * @return an error message if {@code line} is indicative of an error, {@code null} otherwise.
         */
        @Nullable
        protected abstract String matchForError(String line);

        @Override
        public void processNewLines(String[] lines) {
            for (String line : lines) {
                if (line.length() > 0) {
                    String err = matchForError(line);
                    if (err != null) {
                        errorMessage = err;
                    }
                }
            }
        }

        @Override
        public boolean isCancelled() {
            return false;
        }

        @Nullable
        public String getErrorMessage() {
            return errorMessage;
        }
    }

    /**
     * An exception that indicates that an executed command returned an unsuccessful exit code.
     */
    @SuppressWarnings("serial")
    public static class CommandFailedException extends IOException {
        public final String command;
        public final int exitCode;
        public final String output;

        public CommandFailedException(String command, int exitCode, String output) {
            super("Command '" + command + "' failed with code " + exitCode + ".  Output:\n" + output);
            this.command = command;
            this.exitCode = exitCode;
            this.output = output;
        }
    }

    /**
     * Runs a command on a device and throws an exception if it fails.
     *
     * <p>This will not work if your command contains "exit" or "trap" statements.
     *
     * @param device Device to run the command on.
     * @param command Shell command to execute.  Must not use "exit" or "trap".
     * @return The full text output of the command.
     * @throws CommandFailedException if the command fails.
     */
    public static String executeCommandWithErrorChecking(IDevice device, String command)
            throws TimeoutException, AdbCommandRejectedException, ShellCommandUnresponsiveException, IOException {
        CollectingOutputReceiver receiver = new CollectingOutputReceiver();
        device.executeShellCommand(command + ECHO_COMMAND_SUFFIX, receiver);
        return checkReceiverOutput(command, receiver);
    }

    /**
     * This was made public for one specific call site in ExopackageInstaller.
     * If you're reading this, you probably shouldn't call it.  Pretend this method is private.
     */
    public static String checkReceiverOutput(String command, CollectingOutputReceiver receiver)
            throws CommandFailedException {
        String fullOutput = receiver.getOutput();
        int colon = fullOutput.lastIndexOf(':');
        String realOutput = fullOutput.substring(0, colon);
        String exitCodeStr = fullOutput.substring(colon + 1);
        int exitCode = Integer.parseInt(exitCodeStr);
        if (exitCode != 0) {
            throw new CommandFailedException(command, exitCode, realOutput);
        }
        return realOutput;
    }

    /**
     * Install apk on all matching devices. This functions performs device
     * filtering based on three possible arguments:
     *
     *  -e (emulator-only) - only emulators are passing the filter
     *  -d (device-only) - only real devices are passing the filter
     *  -s (serial) - only device/emulator with specific serial number are passing the filter
     *
     *  If more than one device matches the filter this function will fail unless multi-install
     *  mode is enabled (-x). This flag is used as a marker that user understands that multiple
     *  devices will be used to install the apk if needed.
     */
    @SuppressForbidden
    public boolean installApk(final InstallableApk installableApk, final boolean installViaSd, final boolean quiet)
            throws InterruptedException {
        Optional<ExopackageInfo> exopackageInfo = installableApk.getExopackageInfo();
        if (exopackageInfo.isPresent()) {
            return new ExopackageInstaller(context, this, installableApk).install(quiet);
        }
        InstallEvent.Started started = InstallEvent.started(installableApk.getBuildTarget());
        if (!quiet) {
            getBuckEventBus().post(started);
        }

        final File apk = installableApk.getProjectFilesystem().resolve(installableApk.getApkPath()).toFile();
        boolean success = adbCall(new AdbHelper.AdbCallable() {
            @Override
            public boolean call(IDevice device) throws Exception {
                return installApkOnDevice(device, apk, installViaSd, quiet);
            }

            @Override
            @SuppressForbidden
            public String toString() {
                return String.format("install apk %s", installableApk.getBuildTarget().toString());
            }
        }, quiet);
        if (!quiet) {
            AdbHelper.tryToExtractPackageNameFromManifest(installableApk);
            getBuckEventBus().post(InstallEvent.finished(started, success, Optional.empty(),
                    Optional.of(AdbHelper.tryToExtractPackageNameFromManifest(installableApk))));
        }

        return success;
    }

    /**
     * Installs apk on specific device. Reports success or failure to console.
     */
    @SuppressWarnings("PMD.PrematureDeclaration")
    @SuppressForbidden
    public boolean installApkOnDevice(IDevice device, File apk, boolean installViaSd, boolean quiet) {
        String name;
        if (device.isEmulator()) {
            name = device.getSerialNumber() + " (" + device.getAvdName() + ")";
        } else {
            name = device.getSerialNumber();
            String model = device.getProperty("ro.product.model");
            if (model != null) {
                name += " (" + model + ")";
            }
        }

        if (!isDeviceTempWritable(device, name)) {
            return false;
        }

        if (!quiet) {
            getBuckEventBus().post(ConsoleEvent.info("Installing apk on %s.", name));
        }
        try {
            String reason = null;
            if (installViaSd) {
                reason = deviceInstallPackageViaSd(device, apk.getAbsolutePath());
            } else {
                device.installPackage(apk.getAbsolutePath(), true);
            }
            if (reason != null) {
                console.printBuildFailure(String.format("Failed to install apk on %s: %s.", name, reason));
                return false;
            }
            return true;
        } catch (InstallException ex) {
            console.printBuildFailure(String.format("Failed to install apk on %s.", name));
            ex.printStackTrace(console.getStdErr());
            return false;
        }
    }

    @VisibleForTesting
    @SuppressForbidden
    protected boolean isDeviceTempWritable(IDevice device, String name) {
        StringBuilder loggingInfo = new StringBuilder();
        try {
            String output;

            try {
                output = executeCommandWithErrorChecking(device, "ls -l -d /data/local/tmp");
                if (!(
                // Pattern for Android's "toolbox" version of ls
                output.matches("\\Adrwx....-x +shell +shell.* tmp[\\r\\n]*\\z") ||
                // Pattern for CyanogenMod's busybox version of ls
                        output.matches("\\Adrwx....-x +[0-9]+ +shell +shell.* /data/local/tmp[\\r\\n]*\\z"))) {
                    loggingInfo.append(
                            String.format(Locale.ENGLISH, "Bad ls output for /data/local/tmp: '%s'\n", output));
                }

                executeCommandWithErrorChecking(device, "echo exo > /data/local/tmp/buck-experiment");
                output = executeCommandWithErrorChecking(device, "cat /data/local/tmp/buck-experiment");
                if (!output.matches("\\Aexo[\\r\\n]*\\z")) {
                    loggingInfo.append(String.format(Locale.ENGLISH,
                            "Bad echo/cat output for /data/local/tmp: '%s'\n", output));
                }
                executeCommandWithErrorChecking(device, "rm /data/local/tmp/buck-experiment");

            } catch (CommandFailedException e) {
                loggingInfo.append(
                        String.format(Locale.ENGLISH, "Failed (%d) '%s':\n%s\n", e.exitCode, e.command, e.output));
            }

            if (!loggingInfo.toString().isEmpty()) {
                CollectingOutputReceiver receiver = new CollectingOutputReceiver();
                device.executeShellCommand("getprop", receiver);
                for (String line : com.google.common.base.Splitter.on('\n').split(receiver.getOutput())) {
                    if (line.contains("ro.product.model") || line.contains("ro.build.description")) {
                        loggingInfo.append(line).append('\n');
                    }
                }
            }

        } catch (AdbCommandRejectedException | ShellCommandUnresponsiveException | TimeoutException
                | IOException e) {
            console.printBuildFailure(String.format("Failed to test /data/local/tmp on %s.", name));
            e.printStackTrace(console.getStdErr());
            return false;
        }
        String logMessage = loggingInfo.toString();
        if (!logMessage.isEmpty()) {
            StringBuilder fullMessage = new StringBuilder();
            fullMessage.append("============================================================\n");
            fullMessage.append('\n');
            fullMessage.append("HEY! LISTEN!\n");
            fullMessage.append('\n');
            fullMessage.append("The /data/local/tmp directory on your device isn't fully-functional.\n");
            fullMessage.append("Here's some extra info:\n");
            fullMessage.append(logMessage);
            fullMessage.append("============================================================\n");
            console.getStdErr().println(fullMessage.toString());
        }

        return true;
    }

    /**
     * Installs apk on device, copying apk to external storage first.
     */
    @SuppressForbidden
    @Nullable
    private String deviceInstallPackageViaSd(IDevice device, String apk) {
        try {
            // Figure out where the SD card is mounted.
            String externalStorage = deviceGetExternalStorage(device);
            if (externalStorage == null) {
                return "Cannot get external storage location.";
            }
            String remotePackage = String.format("%s/%s.apk", externalStorage, UUID.randomUUID());
            // Copy APK to device
            device.pushFile(apk, remotePackage);
            // Install
            device.installRemotePackage(remotePackage, true);
            // Delete temporary file
            device.removeRemotePackage(remotePackage);
            return null;
        } catch (Throwable t) {
            return String.valueOf(t.getMessage());
        }
    }

    /**
     * Retrieves external storage location (SD card) from device.
     */
    @Nullable
    private String deviceGetExternalStorage(IDevice device)
            throws TimeoutException, AdbCommandRejectedException, ShellCommandUnresponsiveException, IOException {
        CollectingOutputReceiver receiver = new CollectingOutputReceiver();
        device.executeShellCommand("echo $EXTERNAL_STORAGE", receiver, AdbHelper.GETPROP_TIMEOUT,
                TimeUnit.MILLISECONDS);
        String value = receiver.getOutput().trim();
        if (value.isEmpty()) {
            return null;
        }
        return value;
    }

    @SuppressForbidden
    public int startActivity(InstallableApk installableApk, @Nullable String activity, boolean waitForDebugger)
            throws IOException, InterruptedException {

        // Might need the package name and activities from the AndroidManifest.
        Path pathToManifest = installableApk.getProjectFilesystem().resolve(installableApk.getManifestPath());
        AndroidManifestReader reader = DefaultAndroidManifestReader.forPath(pathToManifest,
                installableApk.getProjectFilesystem());

        if (activity == null) {
            // Get list of activities that show up in the launcher.
            List<String> launcherActivities = reader.getLauncherActivities();

            // Sanity check.
            if (launcherActivities.isEmpty()) {
                console.printBuildFailure("No launchable activities found.");
                return 1;
            } else if (launcherActivities.size() > 1) {
                console.printBuildFailure("Default activity is ambiguous.");
                return 1;
            }

            // Construct a component for the '-n' argument of 'adb shell am start'.
            activity = reader.getPackage() + "/" + launcherActivities.get(0);
        } else if (!activity.contains("/")) {
            // If no package name was provided, assume the one in the manifest.
            activity = reader.getPackage() + "/" + activity;
        }

        final String activityToRun = activity;

        PrintStream stdOut = console.getStdOut();
        stdOut.println(String.format("Starting activity %s...", activityToRun));

        StartActivityEvent.Started started = StartActivityEvent.started(installableApk.getBuildTarget(),
                activityToRun);
        getBuckEventBus().post(started);
        boolean success = adbCall(new AdbHelper.AdbCallable() {
            @Override
            public boolean call(IDevice device) throws Exception {
                String err = deviceStartActivity(device, activityToRun, waitForDebugger);
                if (err != null) {
                    console.printBuildFailure(err);
                    return false;
                } else {
                    return true;
                }
            }

            @Override
            public String toString() {
                return "start activity";
            }
        }, false);
        getBuckEventBus().post(StartActivityEvent.finished(started, success));

        return success ? 0 : 1;

    }

    @VisibleForTesting
    @Nullable
    @SuppressForbidden
    String deviceStartActivity(IDevice device, String activityToRun, boolean waitForDebugger) {
        try {
            AdbHelper.ErrorParsingReceiver receiver = new AdbHelper.ErrorParsingReceiver() {
                @Override
                @Nullable
                protected String matchForError(String line) {
                    // Parses output from shell am to determine if activity was started correctly.
                    return (Pattern.matches("^([\\w_$.])*(Exception|Error|error).*$", line)
                            || line.contains("am: not found")) ? line : null;
                }
            };
            final String waitForDebuggerFlag = waitForDebugger ? "-D" : "";
            device.executeShellCommand(
                    //  0x10200000 is FLAG_ACTIVITY_RESET_TASK_IF_NEEDED | FLAG_ACTIVITY_NEW_TASK; the
                    // constant values are public ABI.  This way of invoking "am start" makes buck install -r
                    // act just like the launcher, avoiding activity duplication on subsequent
                    // launcher starts.
                    String.format(
                            "am start -f 0x10200000 -a android.intent.action.MAIN "
                                    + "-c android.intent.category.LAUNCHER -n %s %s",
                            activityToRun, waitForDebuggerFlag),
                    receiver, AdbHelper.INSTALL_TIMEOUT, TimeUnit.MILLISECONDS);
            return receiver.getErrorMessage();
        } catch (Exception e) {
            return e.toString();
        }
    }

    /**
     * Uninstall apk from all matching devices.
     *
     * @see #installApk(InstallableApk, boolean, boolean)
     */
    public boolean uninstallApp(final String packageName, final boolean shouldKeepUserData)
            throws InterruptedException {
        Preconditions.checkArgument(AdbHelper.PACKAGE_NAME_PATTERN.matcher(packageName).matches());

        UninstallEvent.Started started = UninstallEvent.started(packageName);
        getBuckEventBus().post(started);
        boolean success = adbCall(new AdbHelper.AdbCallable() {
            @Override
            public boolean call(IDevice device) throws Exception {
                // Remove any exopackage data as well.  GB doesn't support "rm -f", so just ignore output.
                device.executeShellCommand("rm -r /data/local/tmp/exopackage/" + packageName,
                        NullOutputReceiver.getReceiver());
                return uninstallApkFromDevice(device, packageName, shouldKeepUserData);
            }

            @Override
            public String toString() {
                return "uninstall apk";
            }
        }, false);
        getBuckEventBus().post(UninstallEvent.finished(started, success));
        return success;
    }

    /**
     * Uninstalls apk from specific device. Reports success or failure to console.
     * It's currently here because it's used both by {@link com.facebook.buck.cli.InstallCommand} and
     * {@link com.facebook.buck.cli.UninstallCommand}.
     */
    @SuppressWarnings("PMD.PrematureDeclaration")
    @SuppressForbidden
    private boolean uninstallApkFromDevice(IDevice device, String packageName, boolean keepData) {
        String name;
        if (device.isEmulator()) {
            name = device.getSerialNumber() + " (" + device.getAvdName() + ")";
        } else {
            name = device.getSerialNumber();
            String model = device.getProperty("ro.product.model");
            if (model != null) {
                name += " (" + model + ")";
            }
        }

        PrintStream stdOut = console.getStdOut();
        stdOut.printf("Removing apk from %s.\n", name);
        try {
            long start = System.currentTimeMillis();
            String reason = deviceUninstallPackage(device, packageName, keepData);
            long end = System.currentTimeMillis();

            if (reason != null) {
                console.printBuildFailure(String.format("Failed to uninstall apk from %s: %s.", name, reason));
                return false;
            }

            long delta = end - start;
            stdOut.printf("Uninstalled apk from %s in %d.%03ds.\n", name, delta / 1000, delta % 1000);
            return true;

        } catch (InstallException ex) {
            console.printBuildFailure(String.format("Failed to uninstall apk from %s.", name));
            ex.printStackTrace(console.getStdErr());
            return false;
        }
    }

    /**
     * Modified version of <a href="http://fburl.com/8840769">Device.uninstallPackage()</a>.
     *
     * @param device an {@link IDevice}
     * @param packageName application package name
     * @param keepData  true if user data is to be kept
     * @return error message or null if successful
     * @throws InstallException
     */
    @Nullable
    private String deviceUninstallPackage(IDevice device, String packageName, boolean keepData)
            throws InstallException {
        try {
            AdbHelper.ErrorParsingReceiver receiver = new AdbHelper.ErrorParsingReceiver() {
                @Override
                @Nullable
                protected String matchForError(String line) {
                    return line.toLowerCase(Locale.US).contains("failure") ? line : null;
                }
            };
            device.executeShellCommand("pm uninstall " + (keepData ? "-k " : "") + packageName, receiver,
                    AdbHelper.INSTALL_TIMEOUT, TimeUnit.MILLISECONDS);
            return receiver.getErrorMessage();
        } catch (AdbCommandRejectedException | IOException | ShellCommandUnresponsiveException
                | TimeoutException e) {
            throw new InstallException(e);
        }
    }

    public static String tryToExtractPackageNameFromManifest(InstallableApk androidBinaryRule) {
        Path pathToManifest = androidBinaryRule.getProjectFilesystem().resolve(androidBinaryRule.getManifestPath());

        // Note that the file may not exist if AndroidManifest.xml is a generated file
        // and the rule has not been built yet.
        if (!Files.isRegularFile(pathToManifest)) {
            throw new HumanReadableException("Manifest file %s does not exist, so could not extract package name.",
                    pathToManifest);
        }

        try {
            return DefaultAndroidManifestReader.forPath(pathToManifest, androidBinaryRule.getProjectFilesystem())
                    .getPackage();
        } catch (IOException e) {
            throw new HumanReadableException("Could not extract package name from %s", pathToManifest);
        }
    }

    public static String tryToExtractInstrumentationTestRunnerFromManifest(InstallableApk androidBinaryRule) {
        Path pathToManifest = androidBinaryRule.getProjectFilesystem().resolve(androidBinaryRule.getManifestPath());

        if (!Files.isRegularFile(pathToManifest)) {
            throw new HumanReadableException("Manifest file %s does not exist, so could not extract package name.",
                    pathToManifest);
        }

        try {
            return DefaultAndroidManifestReader.forPath(pathToManifest, androidBinaryRule.getProjectFilesystem())
                    .getInstrumentationTestRunner();
        } catch (IOException e) {
            throw new HumanReadableException("Could not extract package name from %s", pathToManifest);
        }
    }
}