372 lines
14 KiB
Python
Executable file
372 lines
14 KiB
Python
Executable file
#!/usr/bin/env vpython3
|
|
#
|
|
# 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.items():
|
|
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, str):
|
|
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())
|