| #!/usr/bin/env vpython |
| # |
| # Copyright 2015 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Install *_incremental.apk targets as well as their dependent files.""" |
| |
| import argparse |
| import collections |
| import functools |
| import glob |
| import json |
| import logging |
| import os |
| import posixpath |
| import shutil |
| import sys |
| |
| sys.path.append( |
| os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))) |
| import devil_chromium |
| from devil.android import apk_helper |
| from devil.android import device_utils |
| from devil.utils import reraiser_thread |
| from devil.utils import run_tests_helper |
| from pylib import constants |
| from pylib.utils import time_profile |
| |
| prev_sys_path = list(sys.path) |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, 'gyp')) |
| import dex |
| from util import build_utils |
| sys.path = prev_sys_path |
| |
| |
| _R8_PATH = os.path.join(build_utils.DIR_SOURCE_ROOT, 'third_party', 'r8', 'lib', |
| 'r8.jar') |
| |
| |
| def _DeviceCachePath(device): |
| file_name = 'device_cache_%s.json' % device.adb.GetDeviceSerial() |
| return os.path.join(constants.GetOutDirectory(), file_name) |
| |
| |
| def _Execute(concurrently, *funcs): |
| """Calls all functions in |funcs| concurrently or in sequence.""" |
| timer = time_profile.TimeProfile() |
| if concurrently: |
| reraiser_thread.RunAsync(funcs) |
| else: |
| for f in funcs: |
| f() |
| timer.Stop(log=False) |
| return timer |
| |
| |
| def _GetDeviceIncrementalDir(package): |
| """Returns the device path to put incremental files for the given package.""" |
| return '/data/local/tmp/incremental-app-%s' % package |
| |
| |
| def _IsStale(src_paths, dest): |
| """Returns if |dest| is older than any of |src_paths|, or missing.""" |
| if not os.path.exists(dest): |
| return True |
| dest_time = os.path.getmtime(dest) |
| for path in src_paths: |
| if os.path.getmtime(path) > dest_time: |
| return True |
| return False |
| |
| |
| def _AllocateDexShards(dex_files): |
| """Divides input dex files into buckets.""" |
| # Goals: |
| # * Make shards small enough that they are fast to merge. |
| # * Minimize the number of shards so they load quickly on device. |
| # * Partition files into shards such that a change in one file results in only |
| # one shard having to be re-created. |
| shards = collections.defaultdict(list) |
| # As of Oct 2019, 10 shards results in a min/max size of 582K/2.6M. |
| NUM_CORE_SHARDS = 10 |
| # As of Oct 2019, 17 dex files are larger than 1M. |
| SHARD_THRESHOLD = 2**20 |
| for src_path in dex_files: |
| if os.path.getsize(src_path) >= SHARD_THRESHOLD: |
| # Use the path as the name rather than an incrementing number to ensure |
| # that it shards to the same name every time. |
| name = os.path.relpath(src_path, constants.GetOutDirectory()).replace( |
| os.sep, '.') |
| shards[name].append(src_path) |
| else: |
| name = 'shard{}.dex.jar'.format(hash(src_path) % NUM_CORE_SHARDS) |
| shards[name].append(src_path) |
| logging.info('Sharding %d dex files into %d buckets', len(dex_files), |
| len(shards)) |
| return shards |
| |
| |
| def _CreateDexFiles(shards, dex_staging_dir, min_api, use_concurrency): |
| """Creates dex files within |dex_staging_dir| defined by |shards|.""" |
| tasks = [] |
| for name, src_paths in shards.iteritems(): |
| dest_path = os.path.join(dex_staging_dir, name) |
| if _IsStale(src_paths, dest_path): |
| tasks.append( |
| functools.partial(dex.MergeDexForIncrementalInstall, _R8_PATH, |
| src_paths, dest_path, min_api)) |
| |
| # TODO(agrieve): It would be more performant to write a custom d8.jar |
| # wrapper in java that would process these in bulk, rather than spinning |
| # up a new process for each one. |
| _Execute(use_concurrency, *tasks) |
| |
| # Remove any stale shards. |
| for name in os.listdir(dex_staging_dir): |
| if name not in shards: |
| os.unlink(os.path.join(dex_staging_dir, name)) |
| |
| |
| def Uninstall(device, package, enable_device_cache=False): |
| """Uninstalls and removes all incremental files for the given package.""" |
| main_timer = time_profile.TimeProfile() |
| device.Uninstall(package) |
| if enable_device_cache: |
| # Uninstall is rare, so just wipe the cache in this case. |
| cache_path = _DeviceCachePath(device) |
| if os.path.exists(cache_path): |
| os.unlink(cache_path) |
| device.RunShellCommand(['rm', '-rf', _GetDeviceIncrementalDir(package)], |
| check_return=True) |
| logging.info('Uninstall took %s seconds.', main_timer.GetDelta()) |
| |
| |
| def Install(device, install_json, apk=None, enable_device_cache=False, |
| use_concurrency=True, permissions=()): |
| """Installs the given incremental apk and all required supporting files. |
| |
| Args: |
| device: A DeviceUtils instance (to install to). |
| install_json: Path to .json file or already parsed .json object. |
| apk: An existing ApkHelper instance for the apk (optional). |
| enable_device_cache: Whether to enable on-device caching of checksums. |
| use_concurrency: Whether to speed things up using multiple threads. |
| permissions: A list of the permissions to grant, or None to grant all |
| non-denylisted permissions in the manifest. |
| """ |
| if isinstance(install_json, basestring): |
| with open(install_json) as f: |
| install_dict = json.load(f) |
| else: |
| install_dict = install_json |
| |
| main_timer = time_profile.TimeProfile() |
| install_timer = time_profile.TimeProfile() |
| push_native_timer = time_profile.TimeProfile() |
| merge_dex_timer = time_profile.TimeProfile() |
| push_dex_timer = time_profile.TimeProfile() |
| |
| def fix_path(p): |
| return os.path.normpath(os.path.join(constants.GetOutDirectory(), p)) |
| |
| if not apk: |
| apk = apk_helper.ToHelper(fix_path(install_dict['apk_path'])) |
| split_globs = [fix_path(p) for p in install_dict['split_globs']] |
| native_libs = [fix_path(p) for p in install_dict['native_libs']] |
| dex_files = [fix_path(p) for p in install_dict['dex_files']] |
| show_proguard_warning = install_dict.get('show_proguard_warning') |
| |
| apk_package = apk.GetPackageName() |
| device_incremental_dir = _GetDeviceIncrementalDir(apk_package) |
| dex_staging_dir = os.path.join(constants.GetOutDirectory(), |
| 'incremental-install', |
| install_dict['apk_path']) |
| device_dex_dir = posixpath.join(device_incremental_dir, 'dex') |
| |
| # Install .apk(s) if any of them have changed. |
| def do_install(): |
| install_timer.Start() |
| if split_globs: |
| splits = [] |
| for split_glob in split_globs: |
| splits.extend((f for f in glob.glob(split_glob))) |
| device.InstallSplitApk( |
| apk, |
| splits, |
| allow_downgrade=True, |
| reinstall=True, |
| allow_cached_props=True, |
| permissions=permissions) |
| else: |
| device.Install( |
| apk, allow_downgrade=True, reinstall=True, permissions=permissions) |
| install_timer.Stop(log=False) |
| |
| # Push .so and .dex files to the device (if they have changed). |
| def do_push_files(): |
| |
| def do_push_native(): |
| push_native_timer.Start() |
| if native_libs: |
| with build_utils.TempDir() as temp_dir: |
| device_lib_dir = posixpath.join(device_incremental_dir, 'lib') |
| for path in native_libs: |
| # Note: Can't use symlinks as they don't work when |
| # "adb push parent_dir" is used (like we do here). |
| shutil.copy(path, os.path.join(temp_dir, os.path.basename(path))) |
| device.PushChangedFiles([(temp_dir, device_lib_dir)], |
| delete_device_stale=True) |
| push_native_timer.Stop(log=False) |
| |
| def do_merge_dex(): |
| merge_dex_timer.Start() |
| shards = _AllocateDexShards(dex_files) |
| build_utils.MakeDirectory(dex_staging_dir) |
| _CreateDexFiles(shards, dex_staging_dir, apk.GetMinSdkVersion(), |
| use_concurrency) |
| merge_dex_timer.Stop(log=False) |
| |
| def do_push_dex(): |
| push_dex_timer.Start() |
| device.PushChangedFiles([(dex_staging_dir, device_dex_dir)], |
| delete_device_stale=True) |
| push_dex_timer.Stop(log=False) |
| |
| _Execute(use_concurrency, do_push_native, do_merge_dex) |
| do_push_dex() |
| |
| def check_device_configured(): |
| target_sdk_version = int(apk.GetTargetSdkVersion()) |
| # Beta Q builds apply allowlist to targetSdk=28 as well. |
| if target_sdk_version >= 28 and device.build_version_sdk >= 28: |
| # In P, there are two settings: |
| # * hidden_api_policy_p_apps |
| # * hidden_api_policy_pre_p_apps |
| # In Q, there is just one: |
| # * hidden_api_policy |
| if device.build_version_sdk == 28: |
| setting_name = 'hidden_api_policy_p_apps' |
| else: |
| setting_name = 'hidden_api_policy' |
| apis_allowed = ''.join( |
| device.RunShellCommand(['settings', 'get', 'global', setting_name], |
| check_return=True)) |
| if apis_allowed.strip() not in '01': |
| msg = """\ |
| Cannot use incremental installs on Android P+ without first enabling access to |
| non-SDK interfaces (https://developer.android.com/preview/non-sdk-q). |
| |
| To enable access: |
| adb -s {0} shell settings put global {1} 0 |
| To restore back to default: |
| adb -s {0} shell settings delete global {1}""" |
| raise Exception(msg.format(device.serial, setting_name)) |
| |
| cache_path = _DeviceCachePath(device) |
| def restore_cache(): |
| if not enable_device_cache: |
| return |
| if os.path.exists(cache_path): |
| logging.info('Using device cache: %s', cache_path) |
| with open(cache_path) as f: |
| device.LoadCacheData(f.read()) |
| # Delete the cached file so that any exceptions cause it to be cleared. |
| os.unlink(cache_path) |
| else: |
| logging.info('No device cache present: %s', cache_path) |
| |
| def save_cache(): |
| if not enable_device_cache: |
| return |
| with open(cache_path, 'w') as f: |
| f.write(device.DumpCacheData()) |
| logging.info('Wrote device cache: %s', cache_path) |
| |
| # Create 2 lock files: |
| # * install.lock tells the app to pause on start-up (until we release it). |
| # * firstrun.lock is used by the app to pause all secondary processes until |
| # the primary process finishes loading the .dex / .so files. |
| def create_lock_files(): |
| # Creates or zeros out lock files. |
| cmd = ('D="%s";' |
| 'mkdir -p $D &&' |
| 'echo -n >$D/install.lock 2>$D/firstrun.lock') |
| device.RunShellCommand( |
| cmd % device_incremental_dir, shell=True, check_return=True) |
| |
| # The firstrun.lock is released by the app itself. |
| def release_installer_lock(): |
| device.RunShellCommand('echo > %s/install.lock' % device_incremental_dir, |
| check_return=True, shell=True) |
| |
| # Concurrency here speeds things up quite a bit, but DeviceUtils hasn't |
| # been designed for multi-threading. Enabling only because this is a |
| # developer-only tool. |
| setup_timer = _Execute(use_concurrency, create_lock_files, restore_cache, |
| check_device_configured) |
| |
| _Execute(use_concurrency, do_install, do_push_files) |
| |
| finalize_timer = _Execute(use_concurrency, release_installer_lock, save_cache) |
| |
| logging.info( |
| 'Install of %s took %s seconds (setup=%s, install=%s, lib_push=%s, ' |
| 'dex_merge=%s dex_push=%s, finalize=%s)', os.path.basename(apk.path), |
| main_timer.GetDelta(), setup_timer.GetDelta(), install_timer.GetDelta(), |
| push_native_timer.GetDelta(), merge_dex_timer.GetDelta(), |
| push_dex_timer.GetDelta(), finalize_timer.GetDelta()) |
| if show_proguard_warning: |
| logging.warning('Target had proguard enabled, but incremental install uses ' |
| 'non-proguarded .dex files. Performance characteristics ' |
| 'may differ.') |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('json_path', |
| help='The path to the generated incremental apk .json.') |
| parser.add_argument('-d', '--device', dest='device', |
| help='Target device for apk to install on.') |
| parser.add_argument('--uninstall', |
| action='store_true', |
| default=False, |
| help='Remove the app and all side-loaded files.') |
| parser.add_argument('--output-directory', |
| help='Path to the root build directory.') |
| parser.add_argument('--no-threading', |
| action='store_false', |
| default=True, |
| dest='threading', |
| help='Do not install and push concurrently') |
| parser.add_argument('--no-cache', |
| action='store_false', |
| default=True, |
| dest='cache', |
| help='Do not use cached information about what files are ' |
| 'currently on the target device.') |
| parser.add_argument('-v', |
| '--verbose', |
| dest='verbose_count', |
| default=0, |
| action='count', |
| help='Verbose level (multiple times for more)') |
| |
| args = parser.parse_args() |
| |
| run_tests_helper.SetLogLevel(args.verbose_count) |
| if args.output_directory: |
| constants.SetOutputDirectory(args.output_directory) |
| |
| devil_chromium.Initialize(output_directory=constants.GetOutDirectory()) |
| |
| # Retries are annoying when commands fail for legitimate reasons. Might want |
| # to enable them if this is ever used on bots though. |
| device = device_utils.DeviceUtils.HealthyDevices( |
| device_arg=args.device, |
| default_retries=0, |
| enable_device_files_cache=True)[0] |
| |
| if args.uninstall: |
| with open(args.json_path) as f: |
| install_dict = json.load(f) |
| apk = apk_helper.ToHelper(install_dict['apk_path']) |
| Uninstall(device, apk.GetPackageName(), enable_device_cache=args.cache) |
| else: |
| Install(device, args.json_path, enable_device_cache=args.cache, |
| use_concurrency=args.threading) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main()) |