Adds tools for installing Android apps.

--
MOS_MIGRATED_REVID=94198797
diff --git a/tools/android/BUILD b/tools/android/BUILD
new file mode 100644
index 0000000..bc8e154
--- /dev/null
+++ b/tools/android/BUILD
@@ -0,0 +1,68 @@
+py_binary(
+    name = "build_incremental_dexmanifest",
+    srcs = [":build_incremental_dexmanifest.py"],
+    visibility = ["//visibility:public"],
+    deps = [],
+)
+
+py_binary(
+    name = "build_split_manifest",
+    srcs = ["build_split_manifest.py"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//third_party/py/gflags",
+    ],
+)
+
+py_test(
+    name = "build_split_manifest_test",
+    srcs = ["build_split_manifest_test.py"],
+    deps = [
+        ":build_split_manifest",
+    ],
+)
+
+py_binary(
+    name = "incremental_install",
+    srcs = ["incremental_install.py"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//third_party/py/concurrent:futures",
+        "//third_party/py/gflags",
+    ],
+)
+
+py_test(
+    name = "incremental_install_test",
+    srcs = ["incremental_install_test.py"],
+    deps = [
+        ":incremental_install",
+        "//third_party/py/mock",
+    ],
+)
+
+py_binary(
+    name = "strip_resources",
+    srcs = ["strip_resources.py"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//third_party/py/gflags",
+    ],
+)
+
+py_binary(
+    name = "stubify_manifest",
+    srcs = ["stubify_manifest.py"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//third_party/py/gflags",
+    ],
+)
+
+py_test(
+    name = "stubify_manifest_test",
+    srcs = ["stubify_manifest_test.py"],
+    deps = [
+        ":stubify_manifest",
+    ],
+)
diff --git a/tools/android/build_incremental_dexmanifest.py b/tools/android/build_incremental_dexmanifest.py
new file mode 100644
index 0000000..2574165
--- /dev/null
+++ b/tools/android/build_incremental_dexmanifest.py
@@ -0,0 +1,133 @@
+# 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.
+
+"""Construct a dex manifest from a set of input .dex.zip files.
+
+Usage: %s <output manifest> <input zip file>*
+       %s @<params file>
+
+Input files must be either .zip files containing one or more .dex files or
+.dex files.
+
+A manifest file is written that contains one line for each input dex in the
+following form:
+
+<input zip> <path in input zip> <path in output zip> <MD5 checksum>
+
+or
+
+<input dex> - <path in output zip> <SHA-256 checksum>
+"""
+
+import hashlib
+import os
+import shutil
+import sys
+import tempfile
+import zipfile
+
+
+class DexmanifestBuilder(object):
+  """Implementation of the dex manifest builder."""
+
+  def __init__(self):
+    self.manifest_lines = []
+    self.dir_counter = 1
+    self.output_dex_counter = 1
+    self.checksums = set()
+    self.tmpdir = None
+
+  def __enter__(self):
+    self.tmpdir = tempfile.mkdtemp()
+    return self
+
+  def __exit__(self, unused_type, unused_value, unused_traceback):
+    shutil.rmtree(self.tmpdir, True)
+
+  def Checksum(self, 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 AddDex(self, input_dex_or_zip, zippath, dex):
+    """Adds a dex file to the output.
+
+    Args:
+      input_dex_or_zip: the input file written to the manifest
+      zippath: the zip path written to the manifest or None if the input file
+          is not a .zip .
+      dex: the dex file to be added
+
+    Returns:
+      None.
+    """
+
+    fs_checksum = self.Checksum(dex)
+    if fs_checksum in self.checksums:
+      return
+
+    self.checksums.add(fs_checksum)
+    zip_dex = "incremental_classes%d.dex" % self.output_dex_counter
+    self.output_dex_counter += 1
+    self.manifest_lines.append("%s %s %s %s" %(
+        input_dex_or_zip, zippath if zippath else "-", zip_dex, fs_checksum))
+
+  def Run(self, argv):
+    """Creates a dex manifest."""
+    if len(argv) < 1:
+      raise Exception("At least one argument expected")
+
+    if argv[0][0] == "@":
+      if len(argv) != 1:
+        raise IOError("A parameter file should be the only argument")
+      with file(argv[0][1:]) as param_file:
+        argv = [a.strip() for a in param_file.readlines()]
+
+    for input_filename in argv[1:]:
+      input_filename = input_filename.strip()
+      if input_filename.endswith(".zip"):
+        with zipfile.ZipFile(input_filename, "r") as input_dex_zip:
+          input_dex_dir = os.path.join(self.tmpdir, str(self.dir_counter))
+          os.makedirs(input_dex_dir)
+          self.dir_counter += 1
+
+          for input_dex_dex in input_dex_zip.namelist():
+            if not input_dex_dex.endswith(".dex"):
+              continue
+
+            input_dex_zip.extract(input_dex_dex, input_dex_dir)
+            fs_dex = input_dex_dir + "/" + input_dex_dex
+            self.AddDex(input_filename, input_dex_dex, fs_dex)
+      elif input_filename.endswith(".dex"):
+        self.AddDex(input_filename, None, input_filename)
+
+    with file(argv[0], "w") as manifest:
+      manifest.write("\n".join(self.manifest_lines))
+
+
+def main(argv):
+  with DexmanifestBuilder() as b:
+    b.Run(argv[1:])
+
+
+if __name__ == "__main__":
+  main(sys.argv)
diff --git a/tools/android/build_split_manifest.py b/tools/android/build_split_manifest.py
new file mode 100644
index 0000000..9cd0c55
--- /dev/null
+++ b/tools/android/build_split_manifest.py
@@ -0,0 +1,104 @@
+# 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.
+
+"""Stubifies an AndroidManifest.xml.
+
+Does the following things:
+  - Replaces the Application class in an Android manifest with a stub one
+  - Resolve string and integer resources to their default values
+
+usage: %s [input manifest] [output manifest] [file for old application class]
+
+Writes the old application class into the file designated by the third argument.
+"""
+
+import sys
+from xml.etree import ElementTree
+
+from third_party.py import gflags
+
+
+gflags.DEFINE_string("main_manifest", None, "The main manifest of the app")
+gflags.DEFINE_string("split_manifest", None, "The output manifest")
+gflags.DEFINE_string("override_package", None,
+                     "The Android package. Override the one specified in the "
+                     "input manifest")
+gflags.DEFINE_string("split", None, "The name of the split")
+gflags.DEFINE_boolean("hascode", False, "Whether this split .apk has dexes")
+
+FLAGS = gflags.FLAGS
+
+MANIFEST_TEMPLATE = """<?xml version="1.0" encoding="utf-8"?>
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:versionCode="%(version_code)s"
+    android:versionName="%(version_name)s"
+    package="%(package)s"
+    split="%(split)s">
+  <application android:hasCode="%(hascode)s">
+  </application>
+</manifest>
+"""
+
+
+def BuildSplitManifest(main_manifest, override_package, split, hascode):
+  """Builds a split manifest based on the manifest of the main APK.
+
+  Args:
+    main_manifest: the XML manifest of the main APK as a string
+    override_package: if not None, override the package in the main manifest
+    split: the name of the split as a string
+    hascode: if this split APK will contain .dex files
+
+  Returns:
+    The XML split manifest as a string
+
+  Raises:
+    Exception if something goes wrong.
+  """
+
+  manifest = ElementTree.fromstring(main_manifest)
+  android_namespace_prefix = "{http://schemas.android.com/apk/res/android}"
+
+  if override_package:
+    package = override_package
+  else:
+    package = manifest.get("package")
+
+  version_code = manifest.get(android_namespace_prefix + "versionCode")
+  version_name = manifest.get(android_namespace_prefix + "versionName")
+
+  return MANIFEST_TEMPLATE % {
+      "version_code": version_code,
+      "version_name": version_name,
+      "package": package,
+      "split": split,
+      "hascode": str(hascode).lower()
+  }
+
+
+def main():
+  split_manifest = BuildSplitManifest(
+      file(FLAGS.main_manifest).read(),
+      FLAGS.override_package,
+      FLAGS.split,
+      FLAGS.hascode)
+
+  with file(FLAGS.split_manifest, "w") as output_xml:
+    output_xml.write(split_manifest)
+
+
+if __name__ == "__main__":
+  FLAGS(sys.argv)
+  main()
diff --git a/tools/android/build_split_manifest_test.py b/tools/android/build_split_manifest_test.py
new file mode 100644
index 0000000..fbecf60
--- /dev/null
+++ b/tools/android/build_split_manifest_test.py
@@ -0,0 +1,54 @@
+# 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.
+
+"""Unit tests for stubify_application_manifest."""
+
+import unittest
+from xml.etree import ElementTree
+
+from tools.android.build_split_manifest import BuildSplitManifest
+
+
+MAIN_MANIFEST = """
+<manifest
+  xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.package"
+    android:versionCode="1"
+    android:versionName="1.0">
+</manifest>
+"""
+
+
+class BuildSplitManifestTest(unittest.TestCase):
+
+  def testNoPackageOveride(self):
+    split = BuildSplitManifest(MAIN_MANIFEST, None, "split", False)
+    manifest = ElementTree.fromstring(split)
+    self.assertEqual("com.google.package",
+                     manifest.get("package"))
+
+  def testPackageOveride(self):
+    split = BuildSplitManifest(MAIN_MANIFEST, "package.other", "split", False)
+    manifest = ElementTree.fromstring(split)
+    self.assertEqual("package.other",
+                     manifest.get("package"))
+
+  def testSplitName(self):
+    split = BuildSplitManifest(MAIN_MANIFEST, None, "my.little.splony", False)
+    manifest = ElementTree.fromstring(split)
+    self.assertEqual("my.little.splony", manifest.get("split"))
+
+
+if __name__ == "__main__":
+  unittest.main()
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()
diff --git a/tools/android/incremental_install_test.py b/tools/android/incremental_install_test.py
new file mode 100644
index 0000000..b8ea64f
--- /dev/null
+++ b/tools/android/incremental_install_test.py
@@ -0,0 +1,433 @@
+# 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.
+
+"""Unit tests for stubify_incremental_install."""
+
+import os
+import unittest
+import zipfile
+
+from tools.android import incremental_install
+from third_party.py import mock
+
+
+class MockAdb(object):
+  """Mocks the Android ADB binary."""
+
+  def __init__(self):
+    # Map of file name -> contents.
+    self.files = {}
+    self._error = None
+    self.package_timestamp = None
+    self._last_package_timestamp = 0
+    self.shell_cmdlns = []
+
+  def Exec(self, args):
+    if self._error:
+      error_info, arg = self._error  # pylint: disable=unpacking-non-sequence
+      if not arg or arg in args:
+        return self._CreatePopenMock(*error_info)
+
+    returncode = 0
+    stdout = ""
+    stderr = ""
+    cmd = args[1]
+    if cmd == "push":
+      # "/test/adb push local remote"
+      with open(args[2]) as f:
+        content = f.read()
+      self.files[args[3]] = content
+    elif cmd == "pull":
+      # "/test/adb pull remote local"
+      remote = args[2]
+      local = args[3]
+      content = self.files.get(remote)
+      if content is not None:
+        with open(local, "w") as f:
+          f.write(content)
+      else:
+        returncode = 1
+        stderr = "remote object '%s' does not exist\n" % remote
+    elif cmd == "install":
+      self.package_timestamp = self._last_package_timestamp
+      self._last_package_timestamp += 1
+      return self._CreatePopenMock(0, "Success", "")
+    elif cmd == "shell":
+      # "/test/adb shell ..."
+      # mkdir, rm, am (application manager), or monkey
+      shell_cmdln = args[2]
+      self.shell_cmdlns.append(shell_cmdln)
+      if shell_cmdln.startswith(("mkdir", "am", "monkey")):
+        pass
+      elif shell_cmdln.startswith("dumpsys package "):
+        return self._CreatePopenMock(
+            0,
+            "lastUpdateTime=%s" % self.package_timestamp,
+            "")
+      elif shell_cmdln.startswith("rm"):
+        file_path = shell_cmdln.split()[2]
+        self.files.pop(file_path, None)
+      else:
+        raise Exception("Unknown shell command line: %s" % shell_cmdln)
+    # Return a mock subprocess.Popen object
+    return self._CreatePopenMock(returncode, stdout, stderr)
+
+  def _CreatePopenMock(self, returncode, stdout, stderr):
+    return mock.Mock(
+        returncode=returncode, communicate=lambda: (stdout, stderr))
+
+  def SetError(self, returncode, stdout, stderr, for_arg=None):
+    self._error = ((returncode, stdout, stderr), for_arg)
+
+
+class IncrementalInstallTest(unittest.TestCase):
+  """Unit tests for incremental install."""
+
+  _DEXMANIFEST = "dexmanifest.txt"
+  _ADB_PATH = "/test/adb"
+  _OUTPUT_MARKER = "full_deploy_marker"
+  _APK = "myapp_incremental.apk"
+  _RESOURCE_APK = "incremental.ap_"
+  _STUB_DATAFILE = "stub_application_data.txt"
+  _OLD_APP_PACKGE = "old.app.package"
+  _APP_PACKAGE = "new.app.package"
+  _EXEC_ROOT = "."
+
+  def setUp(self):
+    os.chdir(os.environ["TEST_TMPDIR"])
+
+    self._mock_adb = MockAdb()
+
+    # Write the stub datafile which contains the package name of the app.
+    with open(self._STUB_DATAFILE, "w") as f:
+      f.write("\n".join([self._OLD_APP_PACKGE, self._APP_PACKAGE]))
+
+    # Write the local resource apk file.
+    with open(self._RESOURCE_APK, "w") as f:
+      f.write("resource apk")
+
+    # Mock out subprocess.Popen to use our mock adb.
+    self._popen_patch = mock.patch.object(incremental_install, "subprocess")
+    self._popen = self._popen_patch.start().Popen
+    self._popen.side_effect = lambda args, **kwargs: self._mock_adb.Exec(args)
+
+  def tearDown(self):
+    self._popen_patch.stop()
+
+  def _CreateZip(self, name="zip1", *files):
+    if not files:
+      files = [("zp1", "content1"), ("zp2", "content2")]
+    with zipfile.ZipFile(name, "w") as z:
+      for f, content in files:
+        z.writestr(f, content)
+
+  def _CreateLocalManifest(self, *lines):
+    content = "\n".join(lines)
+    with open(self._DEXMANIFEST, "w") as f:
+      f.write(content)
+    return content
+
+  def _CreateRemoteManifest(self, *lines):
+    self._PutDeviceFile("dex/manifest", "\n".join(lines))
+
+  def _GetDeviceAppPath(self, f):
+    return os.path.join(
+        incremental_install.DEVICE_DIRECTORY, self._APP_PACKAGE, f)
+
+  def _GetDeviceFile(self, f):
+    return self._mock_adb.files[self._GetDeviceAppPath(f)]
+
+  def _PutDeviceFile(self, f, content):
+    self._mock_adb.files[self._GetDeviceAppPath(f)] = content
+
+  def _CallIncrementalInstall(self, incremental, start_app=False):
+    if incremental:
+      apk = None
+    else:
+      apk = self._APK
+    incremental_install.IncrementalInstall(
+        adb_path=self._ADB_PATH,
+        execroot=self._EXEC_ROOT,
+        stub_datafile=self._STUB_DATAFILE,
+        dexmanifest=self._DEXMANIFEST,
+        apk=apk,
+        resource_apk=self._RESOURCE_APK,
+        output_marker=self._OUTPUT_MARKER,
+        adb_jobs=1,
+        start_app=start_app,
+        user_home_dir="/home/root")
+
+  def testUploadToPristineDevice(self):
+
+    self._CreateZip()
+
+    with open("dex1", "w") as f:
+      f.write("content3")
+
+    manifest = self._CreateLocalManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zp2 ip2 0",
+        "dex1 - ip3 0")
+
+    self._CallIncrementalInstall(incremental=False)
+
+    resources_checksum_path = self._GetDeviceAppPath("resources_checksum")
+    self.assertTrue(resources_checksum_path in self._mock_adb.files)
+    self.assertEquals(manifest, self._GetDeviceFile("dex/manifest"))
+    self.assertEquals("content1", self._GetDeviceFile("dex/ip1"))
+    self.assertEquals("content2", self._GetDeviceFile("dex/ip2"))
+    self.assertEquals("content3", self._GetDeviceFile("dex/ip3"))
+    self.assertEquals("resource apk", self._GetDeviceFile("resources.ap_"))
+
+  def testUploadWithOneChangedFile(self):
+
+    # Existing manifest from a previous install.
+    self._CreateRemoteManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zp2 ip2 1")
+
+    # Existing files from a previous install.
+    self._PutDeviceFile("dex/ip1", "old content1")
+    self._PutDeviceFile("dex/ip2", "old content2")
+    self._PutDeviceFile("install_timestamp", "0")
+    self._mock_adb.package_timestamp = "0"
+
+    self._CreateZip()
+
+    # Updated dex manifest.
+    self._CreateLocalManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zp2 ip2 2")
+
+    self._CallIncrementalInstall(incremental=True)
+
+    # This is a bit of a dishonest test: the local content for "ip1" is
+    # "content1" and the remote content for it is "old content1", but
+    # the checksums for that file are the same in the local and remote manifest.
+    # We just want to make sure that only one file was updated, so to
+    # distinguish that we force the local and remote content to be different but
+    # keep the checksum the same.
+    self.assertEquals("old content1", self._GetDeviceFile("dex/ip1"))
+    self.assertEquals("content2", self._GetDeviceFile("dex/ip2"))
+
+  def testFullUploadWithOneChangedFile(self):
+
+    # Existing manifest from a previous install.
+    self._CreateRemoteManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zp2 ip2 1")
+
+    self._PutDeviceFile("dex/ip1", "old content1")
+    self._PutDeviceFile("dex/ip2", "old content2")
+    self._PutDeviceFile("install_timestamp", "0")
+    self._mock_adb.package_timestamp = "0"
+
+    self._CreateZip()
+
+    self._CreateLocalManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zp2 ip2 2")
+
+    self._CallIncrementalInstall(incremental=False)
+
+    # Even though the checksums for ip1 were the same, the file still got
+    # updated. This is a bit of a dishonest test because the local and remote
+    # content for ip1 were different, but their checksums were the same.
+    self.assertEquals("content1", self._GetDeviceFile("dex/ip1"))
+    self.assertEquals("content2", self._GetDeviceFile("dex/ip2"))
+
+  def testUploadWithNewFile(self):
+
+    self._CreateRemoteManifest("zip1 zp1 ip1 0")
+    self._PutDeviceFile("dex/ip1", "content1")
+    self._PutDeviceFile("install_timestamp", "0")
+    self._mock_adb.package_timestamp = "0"
+
+    self._CreateLocalManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zp2 ip2 1")
+
+    self._CreateZip()
+
+    self._CallIncrementalInstall(incremental=True)
+
+    self.assertEquals("content1", self._GetDeviceFile("dex/ip1"))
+    self.assertEquals("content2", self._GetDeviceFile("dex/ip2"))
+
+  def testDeletesFile(self):
+
+    self._CreateRemoteManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zip2 ip2 1")
+    self._PutDeviceFile("dex/ip1", "content1")
+    self._PutDeviceFile("dex/ip2", "content2")
+    self._PutDeviceFile("install_timestamp", "0")
+    self._mock_adb.package_timestamp = "0"
+
+    self._CreateZip("zip1", ("zp1", "content1"))
+    self._CreateLocalManifest("zip1 zp1 ip1 0")
+
+    self.assertTrue(self._GetDeviceAppPath("dex/ip2") in self._mock_adb.files)
+    self._CallIncrementalInstall(incremental=True)
+    self.assertFalse(self._GetDeviceAppPath("dex/ip2") in self._mock_adb.files)
+
+  def testNothingToUpdate(self):
+    self._CreateRemoteManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zip2 ip2 1",
+        "dex1 - ip3 0")
+    self._PutDeviceFile("dex/ip1", "content1")
+    self._PutDeviceFile("dex/ip2", "content2")
+    self._PutDeviceFile("dex/ip3", "content3")
+    self._PutDeviceFile("install_timestamp", "0")
+    self._mock_adb.package_timestamp = "0"
+
+    self._CreateZip()
+    self._CreateLocalManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zip2 ip2 1",
+        "dex1 - ip3 0")
+
+    self._CallIncrementalInstall(incremental=True)
+    self.assertEquals("content1", self._GetDeviceFile("dex/ip1"))
+    self.assertEquals("content2", self._GetDeviceFile("dex/ip2"))
+    self.assertEquals("content3", self._GetDeviceFile("dex/ip3"))
+
+  def testNoResourcesToUpdate(self):
+    self._CreateRemoteManifest("zip1 zp1 ip1 0")
+    self._PutDeviceFile("dex/ip1", "content1")
+    # The local file is actually "resource apk", but the checksum on the device
+    # for the resources file is set to be the same as the checksum for the
+    # local file so that we can confirm that it was not updated.
+    self._PutDeviceFile("resources.ap_", "resources")
+    self._PutDeviceFile("resources_checksum",
+                        incremental_install.Checksum(self._RESOURCE_APK))
+    self._PutDeviceFile("install_timestamp", "0")
+    self._mock_adb.package_timestamp = "0"
+
+    self._CreateZip()
+    self._CreateLocalManifest("zip1 zp1 ip1 0")
+
+    self._CallIncrementalInstall(incremental=True)
+    self.assertEquals("resources", self._GetDeviceFile("resources.ap_"))
+
+  def testUpdateResources(self):
+    self._CreateRemoteManifest("zip1 zp1 ip1 0")
+    self._PutDeviceFile("dex/ip1", "content1")
+    self._PutDeviceFile("resources.ap_", "resources")
+    self._PutDeviceFile("resources_checksum", "checksum")
+    self._PutDeviceFile("install_timestamp", "0")
+    self._mock_adb.package_timestamp = "0"
+
+    self._CreateZip()
+    self._CreateLocalManifest("zip1 zp1 ip1 0")
+
+    self._CallIncrementalInstall(incremental=True)
+    self.assertEquals("resource apk", self._GetDeviceFile("resources.ap_"))
+
+  def testNoDevice(self):
+    self._mock_adb.SetError(1, "", "device not found")
+    try:
+      self._CallIncrementalInstall(incremental=True)
+      self.fail("Should have quit if there is no device")
+    except SystemExit:
+      pass
+
+  def testUnauthorizedDevice(self):
+    self._mock_adb.SetError(1, "", "device unauthorized. Please check the "
+                            "confirmation dialog on your device")
+    try:
+      self._CallIncrementalInstall(incremental=True)
+      self.fail("Should have quit if the device is unauthorized.")
+    except SystemExit:
+      pass
+
+  def testInstallFailure(self):
+    self._mock_adb.SetError(0, "Failure", "", for_arg="install")
+    self._CreateZip()
+    self._CreateLocalManifest("zip1 zp1 ip1 0")
+    try:
+      self._CallIncrementalInstall(incremental=False)
+      self.fail("Should have quit if the install failed.")
+    except SystemExit:
+      pass
+
+  def testStartApp(self):
+    # Based on testUploadToPristineDevice
+    self._CreateZip()
+
+    with open("dex1", "w") as f:
+      f.write("content3")
+
+    self._CreateLocalManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zp2 ip2 0",
+        "dex1 - ip3 0")
+
+    self._CallIncrementalInstall(incremental=False, start_app=True)
+
+    self.assertTrue(("monkey -p %s -c android.intent.category.LAUNCHER 1" %
+                     self._APP_PACKAGE) in self._mock_adb.shell_cmdlns)
+
+  def testMultipleDevicesError(self):
+    errors = [
+        "more than one device and emulator",
+        "more than one device",
+        "more than one emulator",
+    ]
+    for error in errors:
+      self._mock_adb.SetError(255, "", error)
+      try:
+        self._CallIncrementalInstall(incremental=True)
+        self.fail("Should have quit if there were multiple devices.")
+      except SystemExit:
+        pass
+
+  def testIncrementalInstallOnPristineDevice(self):
+    self._CreateZip()
+    self._CreateLocalManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zip2 ip2 1",
+        "dex1 - ip3 0")
+
+    try:
+      self._CallIncrementalInstall(incremental=True)
+      self.fail("Should have quit for incremental install on pristine device")
+    except SystemExit:
+      pass
+
+  def testIncrementalInstallWithWrongInstallTimestamp(self):
+    self._CreateRemoteManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zip2 ip2 1",
+        "dex1 - ip3 0")
+    self._PutDeviceFile("dex/ip1", "content1")
+    self._PutDeviceFile("dex/ip2", "content2")
+    self._PutDeviceFile("dex/ip3", "content3")
+    self._mock_adb.package_timestamp = "WRONG"
+
+    self._CreateZip()
+    self._CreateLocalManifest(
+        "zip1 zp1 ip1 0",
+        "zip1 zip2 ip2 1",
+        "dex1 - ip3 0")
+
+    try:
+      self._CallIncrementalInstall(incremental=True)
+      self.fail("Should have quit if install timestamp is wrong")
+    except SystemExit:
+      pass
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/tools/android/strip_resources.py b/tools/android/strip_resources.py
new file mode 100644
index 0000000..f807781
--- /dev/null
+++ b/tools/android/strip_resources.py
@@ -0,0 +1,52 @@
+# 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.
+
+"""Removes the resources from a resource APK for incremental deployment.
+
+The reason this utility exists is that the only way we can build a binary
+AndroidManifest.xml is by invoking aapt, which builds a whole resource .apk.
+
+Thus, in order to build the AndroidManifest.xml for an incremental .apk, we
+invoke aapt, then extract AndroidManifest.xml from its output.
+"""
+
+import sys
+import zipfile
+
+from third_party.py import gflags
+
+
+gflags.DEFINE_string("input_resource_apk", None, "The input resource .apk")
+gflags.DEFINE_string("output_resource_apk", None, "The output resource .apk")
+
+FLAGS = gflags.FLAGS
+HERMETIC_TIMESTAMP = (2001, 1, 1, 0, 0, 0)
+
+
+def main():
+  with zipfile.ZipFile(FLAGS.input_resource_apk) as input_zip:
+    with input_zip.open("AndroidManifest.xml") as android_manifest_entry:
+      android_manifest = android_manifest_entry.read()
+
+  with zipfile.ZipFile(FLAGS.output_resource_apk, "w") as output_zip:
+    # Timestamp is explicitly set so that the resulting zip file is hermetic
+    zipinfo = zipfile.ZipInfo(
+        filename="AndroidManifest.xml",
+        date_time=HERMETIC_TIMESTAMP)
+    output_zip.writestr(zipinfo, android_manifest)
+
+
+if __name__ == "__main__":
+  FLAGS(sys.argv)
+  main()
diff --git a/tools/android/stubify_manifest.py b/tools/android/stubify_manifest.py
new file mode 100644
index 0000000..7c677bf
--- /dev/null
+++ b/tools/android/stubify_manifest.py
@@ -0,0 +1,117 @@
+# 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.
+
+"""Stubifies an AndroidManifest.xml.
+
+Does the following things:
+  - Replaces the Application class in an Android manifest with a stub one
+  - Resolve string and integer resources to their default values
+
+usage: %s [input manifest] [output manifest] [file for old application class]
+
+Writes the old application class into the file designated by the third argument.
+"""
+
+import sys
+from xml.etree import ElementTree
+
+from third_party.py import gflags
+
+
+gflags.DEFINE_string("input_manifest", None, "The input manifest")
+gflags.DEFINE_string("output_manifest", None, "The output manifest")
+gflags.DEFINE_string("output_datafile", None, "The output data file that will "
+                     "be embedded in the final APK")
+gflags.DEFINE_string("override_package", None,
+                     "The Android package. Override the one specified in the "
+                     "input manifest")
+
+FLAGS = gflags.FLAGS
+
+ANDROID = "http://schemas.android.com/apk/res/android"
+READ_EXTERNAL_STORAGE = "android.permission.READ_EXTERNAL_STORAGE"
+STUB_APPLICATION = (
+    "com.google.devtools.build.android.incrementaldeployment.StubApplication")
+
+# This is global state, but apparently that's the best one can to with
+# ElementTree :(
+ElementTree.register_namespace("android", ANDROID)
+
+
+class BadManifestException(Exception):
+  pass
+
+
+def Stubify(manifest_string):
+  """Does the stubification on an XML string.
+
+  Args:
+    manifest_string: the input manifest as a string.
+  Returns:
+    A tuple of (output manifest, old application class, app package)
+  Raises:
+    Exception: if something goes wrong
+  """
+  manifest = ElementTree.fromstring(manifest_string)
+  if manifest.tag != "manifest":
+    raise BadManifestException("invalid input manifest")
+
+  app_list = manifest.findall("application")
+  if len(app_list) == 1:
+    # <application> element is present
+    application = app_list[0]
+  elif len(app_list) == 0:  # pylint: disable=g-explicit-length-test
+    # <application> element is not present
+    application = ElementTree.Element("application")
+    manifest.insert(0, application)
+  else:
+    raise BadManifestException("multiple <application> elements present")
+
+  old_application = application.get("{%s}name" % ANDROID)
+  if old_application is None:
+    old_application = "android.app.Application"
+
+  application.set("{%s}name" % ANDROID, STUB_APPLICATION)
+
+  read_permission = manifest.findall(
+      './uses-permission[@android:name="%s"]' % READ_EXTERNAL_STORAGE,
+      namespaces={"android": ANDROID})
+
+  if not read_permission:
+    read_permission = ElementTree.Element("uses-permission")
+    read_permission.set("{%s}name" % ANDROID, READ_EXTERNAL_STORAGE)
+    manifest.insert(0, read_permission)
+
+  new_manifest = ElementTree.tostring(manifest)
+  app_package = manifest.get("package")
+  return (new_manifest, old_application, app_package)
+
+
+def main():
+  with file(FLAGS.input_manifest) as input_manifest:
+    new_manifest, old_application, app_package = Stubify(input_manifest.read())
+
+  if FLAGS.override_package:
+    app_package = FLAGS.override_package
+
+  with file(FLAGS.output_manifest, "w") as output_xml:
+    output_xml.write(new_manifest)
+
+  with file(FLAGS.output_datafile, "w") as output_file:
+    output_file.write("\n".join([old_application, app_package]))
+
+
+if __name__ == "__main__":
+  FLAGS(sys.argv)
+  main()
diff --git a/tools/android/stubify_manifest_test.py b/tools/android/stubify_manifest_test.py
new file mode 100644
index 0000000..296fc05
--- /dev/null
+++ b/tools/android/stubify_manifest_test.py
@@ -0,0 +1,112 @@
+# 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.
+
+"""Unit tests for stubify_application_manifest."""
+
+import unittest
+from xml.etree import ElementTree
+
+from tools.android.stubify_manifest import ANDROID
+from tools.android.stubify_manifest import BadManifestException
+from tools.android.stubify_manifest import READ_EXTERNAL_STORAGE
+from tools.android.stubify_manifest import STUB_APPLICATION
+from tools.android.stubify_manifest import Stubify
+
+
+MANIFEST_WITH_APPLICATION = """
+<manifest
+  xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.package">
+  <application android:name="old.application">
+  </application>
+</manifest>
+"""
+
+MANIFEST_WITHOUT_APPLICATION = """
+<manifest
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  package="com.google.package">
+</manifest>
+"""
+
+MANIFEST_WITH_PERMISSION = """
+<manifest
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  package="com.google.package">
+  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
+</manifest>
+"""
+
+BAD_MANIFEST = """
+<b>Hello World!</b>
+"""
+
+MULTIPLE_APPLICATIONS = """
+<manifest
+  xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.package">
+  <application android:name="old.application">
+  </application>
+  <application android:name="old.application">
+  </application>
+</manifest>
+"""
+
+
+class StubifyTest(unittest.TestCase):
+
+  def GetApplication(self, manifest_string):
+    manifest = ElementTree.fromstring(manifest_string)
+    application = manifest.find("application")
+    return application.get("{%s}name" % ANDROID)
+
+  def testReplacesOldApplication(self):
+    new_manifest, old_application, app_pkg = Stubify(MANIFEST_WITH_APPLICATION)
+    self.assertEqual("com.google.package", app_pkg)
+    self.assertEqual("old.application", old_application)
+    self.assertEqual(STUB_APPLICATION, self.GetApplication(new_manifest))
+
+  def testAddsNewAplication(self):
+    new_manifest, old_application, app_pkg = (
+        Stubify(MANIFEST_WITHOUT_APPLICATION))
+    self.assertEqual("com.google.package", app_pkg)
+    self.assertEqual("android.app.Application", old_application)
+    self.assertEqual(STUB_APPLICATION, self.GetApplication(new_manifest))
+
+  def assertHasPermission(self, manifest_string, permission):
+    manifest = ElementTree.fromstring(manifest_string)
+    nodes = manifest.findall(
+        'uses-permission[@android:name="%s"]' % permission,
+        namespaces={"android": ANDROID})
+    self.assertEqual(1, len(nodes))
+
+  def testAddsPermission(self):
+    self.assertHasPermission(
+        Stubify(MANIFEST_WITH_APPLICATION)[0], READ_EXTERNAL_STORAGE)
+
+  def testDoesNotDuplicatePermission(self):
+    self.assertHasPermission(
+        Stubify(MANIFEST_WITH_PERMISSION)[0], READ_EXTERNAL_STORAGE)
+
+  def testBadManifest(self):
+    with self.assertRaises(BadManifestException):
+      Stubify(BAD_MANIFEST)
+
+  def testTooManyApplications(self):
+    with self.assertRaises(BadManifestException):
+      Stubify(MULTIPLE_APPLICATIONS)
+
+
+if __name__ == "__main__":
+  unittest.main()