Adds tools for installing Android apps.

--
MOS_MIGRATED_REVID=94198797
diff --git a/tools/android/incremental_install.py b/tools/android/incremental_install.py
new file mode 100644
index 0000000..aaa0850
--- /dev/null
+++ b/tools/android/incremental_install.py
@@ -0,0 +1,559 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Installs an Android application, possibly in an incremental way."""
+
+import collections
+import hashlib
+import logging
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+import zipfile
+
+from third_party.py import gflags
+from third_party.py.concurrent import futures
+
+
+gflags.DEFINE_string("split_main_apk", None, "The main APK for split install")
+gflags.DEFINE_multistring("split_apk", [], "Split APKs to install")
+gflags.DEFINE_string("dexmanifest", None, "The .dex manifest")
+gflags.DEFINE_string("resource_apk", None, "The resource .apk")
+gflags.DEFINE_string("apk", None, "The app .apk. If not specified, "
+                     "do incremental deployment")
+gflags.DEFINE_string("adb", None, "ADB to use")
+gflags.DEFINE_string("stub_datafile", None, "The stub data file")
+gflags.DEFINE_string("output_marker", None, "The output marker file")
+gflags.DEFINE_multistring("extra_adb_arg", [], "Extra arguments to adb")
+gflags.DEFINE_string("execroot", ".", "The exec root")
+gflags.DEFINE_integer("adb_jobs", 2,
+                      "The number of instances of adb to use in parallel to "
+                      "update files on the device",
+                      lower_bound=1)
+gflags.DEFINE_boolean("start_app", False, "Whether to start the app after "
+                      "installing it.")
+gflags.DEFINE_string("user_home_dir", None, "Path to the user's home directory")
+gflags.DEFINE_string("flagfile", None,
+                     "Path to a file to read additional flags from")
+gflags.DEFINE_string("verbosity", None, "Logging verbosity")
+
+FLAGS = gflags.FLAGS
+
+DEVICE_DIRECTORY = "/data/local/tmp/incrementaldeployment"
+
+
+class AdbError(Exception):
+  """An exception class signaling an error in an adb invocation."""
+
+  def __init__(self, args, returncode, stdout, stderr):
+    self.args = args
+    self.returncode = returncode
+    self.stdout = stdout
+    self.stderr = stderr
+    details = "\n".join([
+        "adb command: %s" % args,
+        "return code: %s" % returncode,
+        "stdout: %s" % stdout,
+        "stderr: %s" % stderr,
+    ])
+    super(AdbError, self).__init__(details)
+
+
+class DeviceNotFoundError(Exception):
+  """Raised when the device could not be found."""
+
+
+class MultipleDevicesError(Exception):
+  """Raised when > 1 device is attached and no device serial was given."""
+
+  @staticmethod
+  def CheckError(s):
+    return re.search("more than one (device and emulator|device|emulator)", s)
+
+
+class DeviceUnauthorizedError(Exception):
+  """Raised when the local machine is not authorized to the device."""
+
+
+class TimestampException(Exception):
+  """Raised when there is a problem with timestamp reading/writing."""
+
+
+class Adb(object):
+  """A class to handle interaction with adb."""
+
+  def __init__(self, adb_path, temp_dir, adb_jobs, user_home_dir):
+    self._adb_path = adb_path
+    self._temp_dir = temp_dir
+    self._user_home_dir = user_home_dir
+    self._file_counter = 1
+    self._executor = futures.ThreadPoolExecutor(max_workers=adb_jobs)
+
+  def _Exec(self, adb_args):
+    """Executes the given adb command + args."""
+    args = [self._adb_path] + FLAGS.extra_adb_arg + adb_args
+    # TODO(ahumesky): Because multiple instances of adb are executed in
+    # parallel, these debug logging lines will get interleaved.
+    logging.debug("Executing: %s", " ".join(args))
+
+    # adb sometimes requires the user's home directory to access things in
+    # $HOME/.android (e.g. keys to authorize with the device). To avoid any
+    # potential problems with python picking up things in the user's home
+    # directory, HOME is not set in the environment around python and is instead
+    # passed explicitly as a flag.
+    env = {}
+    if self._user_home_dir:
+      env["HOME"] = self._user_home_dir
+
+    adb = subprocess.Popen(
+        args,
+        stdin=subprocess.PIPE,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        env=env)
+    stdout, stderr = adb.communicate()
+    stdout = stdout.strip()
+    stderr = stderr.strip()
+    logging.debug("adb ret: %s", adb.returncode)
+    logging.debug("adb out: %s", stdout)
+    logging.debug("adb err: %s", stderr)
+
+    # Check these first so that the more specific error gets raised instead of
+    # the more generic AdbError.
+    if "device not found" in stderr:
+      raise DeviceNotFoundError()
+    elif "device unauthorized" in stderr:
+      raise DeviceUnauthorizedError()
+    elif MultipleDevicesError.CheckError(stderr):
+      # The error messages are from adb's transport.c, but something adds
+      # "error: " to the beginning, so take it off so that we don't end up
+      # printing "Error: error: ..."
+      raise MultipleDevicesError(re.sub("^error: ", "", stderr))
+
+    if adb.returncode != 0:
+      raise AdbError(args, adb.returncode, stdout, stderr)
+
+    return adb.returncode, stdout, stderr, args
+
+  def _ExecParallel(self, adb_args):
+    return self._executor.submit(self._Exec, adb_args)
+
+  def _CreateLocalFile(self):
+    """Returns a path to a temporary local file in the temp directory."""
+    local = os.path.join(self._temp_dir, "adbfile_%d" % self._file_counter)
+    self._file_counter += 1
+    return local
+
+  def GetInstallTime(self, package):
+    """Get the installation time of a package."""
+    _, stdout, _, _ = self._Shell("dumpsys package %s" % package)
+    match = re.search("lastUpdateTime=(.*)$", stdout, re.MULTILINE)
+    if match:
+      return match.group(1)
+    else:
+      raise TimestampException(
+          "Package '%s' is not installed on the device. At least one "
+          "non-incremental 'mobile-install' must precede incremental "
+          "installs." % package)
+
+  def Push(self, local, remote):
+    """Invoke 'adb push' in parallel."""
+    return self._ExecParallel(["push", local, remote])
+
+  def PushString(self, contents, remote):
+    """Push a given string to a given path on the device in parallel."""
+    local = self._CreateLocalFile()
+    with file(local, "w") as f:
+      f.write(contents)
+    return self.Push(local, remote)
+
+  def Pull(self, remote):
+    """Invoke 'adb pull'.
+
+    Args:
+      remote: The path to the remote file to pull.
+
+    Returns:
+      The contents of a file or None if the file didn't exist.
+    """
+    local = self._CreateLocalFile()
+    try:
+      self._Exec(["pull", remote, local])
+      with file(local) as f:
+        return f.read()
+    except (AdbError, IOError):
+      return None
+
+  def InstallMultiple(self, apk, pkg=None):
+    """Invoke 'adb install-multiple'."""
+
+    pkg_args = ["-p", pkg] if pkg else []
+    ret, stdout, stderr, args = self._Exec(
+        ["install-multiple", "-r"] + pkg_args + [apk])
+    if "Success" not in stderr and "Success" not in stdout:
+      raise AdbError(args, ret, stdout, stderr)
+
+  def Install(self, apk):
+    """Invoke 'adb install'."""
+    ret, stdout, stderr, args = self._Exec(["install", "-r", apk])
+
+    # adb install could fail with a message on stdout like this:
+    #
+    #   pkg: /data/local/tmp/Gmail_dev_sharded_incremental.apk
+    #   Failure [INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES]
+    #
+    # and yet it will still have a return code of 0. At least for the install
+    # command, it will print "Success" if it succeeded, so check for that in
+    # standard out instead of relying on the return code.
+    if "Success" not in stderr and "Success" not in stdout:
+      raise AdbError(args, ret, stdout, stderr)
+
+  def Delete(self, remote):
+    """Delete the given file (or directory) on the device."""
+    self.DeleteMultiple([remote])
+
+  def DeleteMultiple(self, remote_files):
+    """Delete the given files (or directories) on the device."""
+    files_str = " ".join(remote_files)
+    if files_str:
+      self._Shell("rm -fr %s" % files_str)
+
+  def Mkdir(self, d):
+    """Invokes mkdir with the specified directory on the device."""
+    self._Shell("mkdir -p %s" % d)
+
+  def StopApp(self, package):
+    """Force stops the app with the given package."""
+    self._Shell("am force-stop %s" % package)
+
+  def StartApp(self, package):
+    """Starts the app with the given package."""
+    self._Shell("monkey -p %s -c android.intent.category.LAUNCHER 1" % package)
+
+  def _Shell(self, cmd):
+    """Invoke 'adb shell'."""
+    return self._Exec(["shell", cmd])
+
+
+ManifestEntry = collections.namedtuple(
+    "ManifestEntry", ["input_file", "zippath", "installpath", "sha256"])
+
+
+def ParseManifest(contents):
+  """Parses a dexmanifest file.
+
+  Args:
+    contents: the contents of the manifest file to be parsed.
+
+  Returns:
+    A dict of install path -> ManifestEntry.
+  """
+  result = {}
+
+  for l in contents.split("\n"):
+    entry = ManifestEntry(*(l.strip().split(" ")))
+    result[entry.installpath] = entry
+
+  return result
+
+
+def GetAppPackage(stub_datafile):
+  """Returns the app package specified in a stub data file."""
+  with file(stub_datafile) as f:
+    return f.readlines()[1].strip()
+
+
+def UploadDexes(adb, execroot, app_dir, temp_dir, dexmanifest, full_install):
+  """Uploads dexes to the device so that the state.
+
+  Does the minimum amount of work necessary to make the state of the device
+  consistent with what was built.
+
+  Args:
+    adb: the Adb instance representing the device to install to
+    execroot: the execroot
+    app_dir: the directory things should be installed under on the device
+    temp_dir: a local temporary directory
+    dexmanifest: contents of the dex manifest
+    full_install: whether to do a full install
+
+  Returns:
+    None.
+  """
+
+  # Fetch the manifest on the device
+  dex_dir = os.path.join(app_dir, "dex")
+  adb.Mkdir(dex_dir)
+
+  old_manifest = None
+
+  if not full_install:
+    logging.info("Fetching dex manifest from device...")
+    old_manifest_contents = adb.Pull("%s/manifest" % dex_dir)
+    if old_manifest_contents:
+      old_manifest = ParseManifest(old_manifest_contents)
+    else:
+      logging.info("Dex manifest not found on device")
+
+  if old_manifest is None:
+    # If the manifest is not found, maybe a previous installation attempt
+    # was interrupted. Wipe the slate clean. Do this also in case we do a full
+    # installation.
+    old_manifest = {}
+    adb.Delete("%s/*" % dex_dir)
+
+  new_manifest = ParseManifest(dexmanifest)
+  dexes_to_delete = set(old_manifest) - set(new_manifest)
+
+  # Figure out which dexes to upload: those that are present in the new manifest
+  # but not in the old one and those whose checksum was changed
+  common_dexes = set(new_manifest).intersection(old_manifest)
+  dexes_to_upload = set(d for d in common_dexes
+                        if new_manifest[d].sha256 != old_manifest[d].sha256)
+  dexes_to_upload.update(set(new_manifest) - set(old_manifest))
+
+  if not dexes_to_delete and not dexes_to_upload:
+    # If we have nothing to do, don't bother removing and rewriting the manifest
+    logging.info("Application dexes up-to-date")
+    return
+
+  # Delete the manifest so that we know how to get back to a consistent state
+  # if we are interrupted.
+  adb.Delete("%s/manifest" % dex_dir)
+
+  # Tuple of (local, remote) files to push to the device.
+  files_to_push = []
+
+  # Sort dexes to be uploaded by the zip file they are in so that we only need
+  # to open each zip only once.
+  dexzips_in_upload = set(new_manifest[d].input_file for d in dexes_to_upload
+                          if new_manifest[d].zippath != "-")
+  for i, dexzip_name in enumerate(dexzips_in_upload):
+    zip_dexes = [
+        d for d in dexes_to_upload if new_manifest[d].input_file == dexzip_name]
+    dexzip_tempdir = os.path.join(temp_dir, "dex", str(i))
+    with zipfile.ZipFile(os.path.join(execroot, dexzip_name)) as dexzip:
+      for dex in zip_dexes:
+        zippath = new_manifest[dex].zippath
+        dexzip.extract(zippath, dexzip_tempdir)
+        files_to_push.append(
+            (os.path.join(dexzip_tempdir, zippath), "%s/%s" % (dex_dir, dex)))
+
+  # Now gather all the dexes that are not within a .zip file.
+  dexes_to_upload = set(
+      d for d in dexes_to_upload if new_manifest[d].zippath == "-")
+  for dex in dexes_to_upload:
+    files_to_push.append(
+        (new_manifest[dex].input_file, "%s/%s" % (dex_dir, dex)))
+
+  num_files = len(dexes_to_delete) + len(files_to_push)
+  logging.info("Updating %d dex%s...", num_files, "es" if num_files > 1 else "")
+
+  # Delete the dexes that are not in the new manifest
+  adb.DeleteMultiple(os.path.join(dex_dir, dex) for dex in dexes_to_delete)
+
+  # Upload all the files.
+  upload_walltime_start = time.time()
+  fs = [adb.Push(local, remote) for local, remote in files_to_push]
+  done, not_done = futures.wait(fs, return_when=futures.FIRST_EXCEPTION)
+  upload_walltime = time.time() - upload_walltime_start
+  logging.debug("Dex upload walltime: %s seconds", upload_walltime)
+
+  # If there is anything in not_done, then some adb call failed and we
+  # can cancel the rest.
+  if not_done:
+    for f in not_done:
+      f.cancel()
+
+  # If any adb call resulted in an exception, re-raise it.
+  for f in done:
+    f.result()
+
+  # If no dex upload failed, upload the manifest. If any upload failed, the
+  # exception should have been re-raised above.
+  # Call result() to raise the exception if there was one.
+  adb.PushString(dexmanifest, "%s/manifest" % dex_dir).result()
+
+
+def Checksum(filename):
+  """Compute the SHA-256 checksum of a file."""
+  h = hashlib.sha256()
+  with file(filename, "r") as f:
+    while True:
+      data = f.read(65536)
+      if not data:
+        break
+
+      h.update(data)
+
+  return h.hexdigest()
+
+
+def UploadResources(adb, resource_apk, app_dir):
+  """Uploads resources to the device.
+
+  Args:
+    adb: The Adb instance representing the device to install to.
+    resource_apk: Path to the resource apk.
+    app_dir: The directory things should be installed under on the device.
+
+  Returns:
+    None.
+  """
+
+  # Compute the checksum of the new resources file
+  new_checksum = Checksum(resource_apk)
+
+  # Fetch the checksum of the resources file on the device, if it exists
+  device_checksum_file = "%s/%s" % (app_dir, "resources_checksum")
+  old_checksum = adb.Pull(device_checksum_file)
+  if old_checksum == new_checksum:
+    logging.info("Application resources up-to-date")
+    return
+  logging.info("Updating application resources...")
+
+  # Remove the checksum file on the device so that if the transfer is
+  # interrupted, we know how to get the device back to a consistent state.
+  adb.Delete(device_checksum_file)
+  adb.Push(resource_apk, "%s/%s" % (app_dir, "resources.ap_")).result()
+
+  # Write the new checksum to the device.
+  adb.PushString(new_checksum, device_checksum_file).result()
+
+
+def VerifyInstallTimestamp(adb, app_package):
+  """Verifies that the app is unchanged since the last mobile-install."""
+  expected_timestamp = adb.Pull("%s/%s/install_timestamp" % (
+      DEVICE_DIRECTORY, app_package))
+  if not expected_timestamp:
+    raise TimestampException(
+        "Cannot verify last mobile install. At least one non-incremental "
+        "'mobile-install' must precede incremental installs")
+
+  actual_timestamp = adb.GetInstallTime(app_package)
+  if actual_timestamp != expected_timestamp:
+    raise TimestampException("Installed app '%s' has an unexpected timestamp. "
+                             "Did you last install the app in a way other than "
+                             "'mobile-install'?" % app_package)
+
+
+def IncrementalInstall(adb_path, execroot, stub_datafile, output_marker,
+                       adb_jobs, start_app, dexmanifest=None, apk=None,
+                       resource_apk=None, split_main_apk=None, split_apks=None,
+                       user_home_dir=None):
+  """Performs an incremental install.
+
+  Args:
+    adb_path: Path to the adb executable.
+    execroot: Exec root.
+    stub_datafile: The stub datafile containing the app's package name.
+    output_marker: Path to the output marker file.
+    adb_jobs: The number of instances of adb to use in parallel.
+    start_app: If True, starts the app after updating.
+    dexmanifest: Path to the .dex manifest file.
+    apk: Path to the .apk file. May be None to perform an incremental install.
+    resource_apk: Path to the apk containing the app's resources.
+    split_main_apk: the split main .apk if split installation is desired.
+    split_apks: the list of split .apks to be installed.
+    user_home_dir: Path to the user's home directory.
+  """
+  temp_dir = tempfile.mkdtemp()
+  try:
+    adb = Adb(adb_path, temp_dir, adb_jobs, user_home_dir)
+    app_package = GetAppPackage(os.path.join(execroot, stub_datafile))
+    app_dir = os.path.join(DEVICE_DIRECTORY, app_package)
+    if split_main_apk:
+      adb.InstallMultiple(os.path.join(execroot, split_main_apk))
+      for split_apk in split_apks:
+        adb.InstallMultiple(os.path.join(execroot, split_apk), app_package)
+    else:
+      if not apk:
+        VerifyInstallTimestamp(adb, app_package)
+
+      with file(os.path.join(execroot, dexmanifest)) as f:
+        dexmanifest = f.read()
+      UploadDexes(adb, execroot, app_dir, temp_dir, dexmanifest, bool(apk))
+      # TODO(ahumesky): UploadDexes waits for all the dexes to be uploaded, and
+      # then UploadResources is called. We could instead enqueue everything
+      # onto the threadpool so that uploading resources happens sooner.
+      UploadResources(adb, os.path.join(execroot, resource_apk), app_dir)
+      if apk:
+        apk_path = os.path.join(execroot, apk)
+        adb.Install(apk_path)
+        future = adb.PushString(
+            adb.GetInstallTime(app_package),
+            "%s/%s/install_timestamp" % (DEVICE_DIRECTORY, app_package))
+        future.result()
+
+      else:
+        adb.StopApp(app_package)
+
+    if start_app:
+      logging.info("Starting application %s", app_package)
+      adb.StartApp(app_package)
+
+    with file(output_marker, "w") as _:
+      pass
+  except DeviceNotFoundError:
+    sys.exit("Error: Device not found")
+  except DeviceUnauthorizedError:
+    sys.exit("Error: Device unauthorized. Please check the confirmation "
+             "dialog on your device.")
+  except MultipleDevicesError as e:
+    sys.exit(
+        "Error: " + e.message + "\nTry specifying a device serial with " +
+        "\"blaze mobile-install --adb_arg=-s --adb_arg=$ANDROID_SERIAL\"")
+  except TimestampException as e:
+    sys.exit("Error:\n%s" % e.message)
+  except AdbError as e:
+    sys.exit("Error:\n%s" % e.message)
+  finally:
+    shutil.rmtree(temp_dir, True)
+
+
+def main():
+  if FLAGS.verbosity == "1":
+    level = logging.DEBUG
+    fmt = "%(levelname)-5s %(asctime)s %(module)s:%(lineno)3d] %(message)s"
+  else:
+    level = logging.INFO
+    fmt = "%(message)s"
+  logging.basicConfig(stream=sys.stdout, level=level, format=fmt)
+
+  IncrementalInstall(
+      adb_path=FLAGS.adb,
+      adb_jobs=FLAGS.adb_jobs,
+      execroot=FLAGS.execroot,
+      stub_datafile=FLAGS.stub_datafile,
+      output_marker=FLAGS.output_marker,
+      start_app=FLAGS.start_app,
+      split_main_apk=FLAGS.split_main_apk,
+      split_apks=FLAGS.split_apk,
+      dexmanifest=FLAGS.dexmanifest,
+      apk=FLAGS.apk,
+      resource_apk=FLAGS.resource_apk,
+      user_home_dir=FLAGS.user_home_dir)
+
+
+if __name__ == "__main__":
+  FLAGS(sys.argv)
+  # process any additional flags in --flagfile
+  if FLAGS.flagfile:
+    with open(FLAGS.flagfile) as flagsfile:
+      FLAGS(sys.argv + [line.strip() for line in flagsfile.readlines()])
+  main()