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()