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