blob: 50d855954d00a74e342c8a504e6da07acfb632bc [file] [log] [blame]
#!/usr/bin/env python
# Copyright 2017 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.
"""A script to replace a system app while running a command."""
import argparse
import contextlib
import logging
import os
import posixpath
import re
import sys
if __name__ == '__main__':
sys.path.append(
os.path.abspath(
os.path.join(os.path.dirname(__file__), '..', '..', '..')))
from devil.android import apk_helper
from devil.android import decorators
from devil.android import device_errors
from devil.android import device_temp_file
from devil.android.sdk import version_codes
from devil.android.sdk import adb_wrapper
from devil.android.tools import script_common
from devil.utils import cmd_helper
from devil.utils import parallelizer
from devil.utils import run_tests_helper
logger = logging.getLogger(__name__)
# Some system apps aren't actually installed in the /system/ directory, so
# special case them here with the correct install location.
SPECIAL_SYSTEM_APP_LOCATIONS = {
# Older versions of ArCore were installed in /data/app/ regardless of
# whether they were system apps or not. Newer versions install in /system/
# if they are system apps, and in /data/app/ if they aren't. Some newer
# devices/OSes install in /product/app/ for system apps, as well.
'com.google.ar.core': ['/data/app/', '/system/', '/product/app/'],
# On older versions of VrCore, the system app version is installed in
# /system/ like normal. However, at some point, this moved to /data/.
# So, we have to handle both cases. Like ArCore, this means we'll end up
# removing even non-system versions due to this, but it doesn't cause any
# issues.
'com.google.vr.core': ['/data/app/', '/system/'],
}
# Gets app path and package name pm list packages -f output.
_PM_LIST_PACKAGE_PATH_RE = re.compile(r'^\s*package:(\S+)=(\S+)\s*$')
def RemoveSystemApps(device, package_names):
"""Removes the given system apps.
Args:
device: (device_utils.DeviceUtils) the device for which the given
system app should be removed.
package_name: (iterable of strs) the names of the packages to remove.
"""
system_package_paths = _FindSystemPackagePaths(device, package_names)
if system_package_paths:
with EnableSystemAppModification(device):
device.RemovePath(system_package_paths, force=True, recursive=True)
@contextlib.contextmanager
def ReplaceSystemApp(device,
package_name,
replacement_apk,
install_timeout=None):
"""A context manager that replaces the given system app while in scope.
Args:
device: (device_utils.DeviceUtils) the device for which the given
system app should be replaced.
package_name: (str) the name of the package to replace.
replacement_apk: (str) the path to the APK to use as a replacement.
"""
storage_dir = device_temp_file.NamedDeviceTemporaryDirectory(device.adb)
relocate_app = _RelocateApp(device, package_name, storage_dir.name)
install_app = _TemporarilyInstallApp(device, replacement_apk, install_timeout)
with storage_dir, relocate_app, install_app:
yield
def _FindSystemPackagePaths(device, system_package_list):
"""Finds all system paths for the given packages."""
found_paths = []
for system_package in system_package_list:
paths = _GetApplicationPaths(device, system_package)
p = _GetSystemPath(system_package, paths)
if p:
found_paths.append(p)
return found_paths
# Find all application paths, even those flagged as uninstalled, as these
# would still block another package with the same name from installation
# if they differ in signing keys.
# TODO(aluo): Move this into device_utils.py
def _GetApplicationPaths(device, package):
paths = []
lines = device.RunShellCommand(
['pm', 'list', 'packages', '-f', '-u', package], check_return=True)
for line in lines:
match = re.match(_PM_LIST_PACKAGE_PATH_RE, line)
if match:
path = match.group(1)
package_name = match.group(2)
if package_name == package:
paths.append(path)
return paths
def _GetSystemPath(package, paths):
for p in paths:
app_locations = SPECIAL_SYSTEM_APP_LOCATIONS.get(package,
['/system/', '/product/'])
for location in app_locations:
if p.startswith(location):
return p
return None
_MODIFICATION_TIMEOUT = 300
_MODIFICATION_RETRIES = 2
_ENABLE_MODIFICATION_PROP = 'devil.modify_sys_apps'
def _ShouldRetryModification(exc):
try:
if isinstance(exc, device_errors.CommandTimeoutError):
logger.info('Restarting the adb server')
adb_wrapper.RestartServer()
return True
except Exception: # pylint: disable=broad-except
logger.exception(('Caught an exception when deciding'
' to retry system modification'))
return False
# timeout and retries are both required by the decorator, but neither
# are used within the body of the function.
# pylint: disable=unused-argument
@decorators.WithTimeoutAndConditionalRetries(_ShouldRetryModification)
def _SetUpSystemAppModification(device, timeout=None, retries=None):
# Ensure that the device is online & available before proceeding to
# handle the case where something fails in the middle of set up and
# triggers a retry.
device.WaitUntilFullyBooted()
# All calls that could potentially need root should run with as_root=True, but
# it looks like some parts of Telemetry work as-is by implicitly assuming that
# root is already granted if it's necessary. The reboot can mess with this, so
# as a workaround, check whether we're starting with root already, and if so,
# restore the device to that state at the end.
should_restore_root = device.HasRoot()
device.EnableRoot()
if not device.HasRoot():
raise device_errors.CommandFailedError(
'Failed to enable modification of system apps on non-rooted device',
str(device))
try:
# Disable Marshmallow's Verity security feature
if device.build_version_sdk >= version_codes.MARSHMALLOW:
logger.info('Disabling Verity on %s', device.serial)
device.adb.DisableVerity()
device.Reboot()
device.WaitUntilFullyBooted()
device.EnableRoot()
device.adb.Remount()
device.RunShellCommand(['stop'], check_return=True)
device.SetProp(_ENABLE_MODIFICATION_PROP, '1')
except device_errors.CommandFailedError:
if device.adb.is_emulator:
# Point the user to documentation, since there's a good chance they can
# workaround this on an emulator.
docs_url = ('https://chromium.googlesource.com/chromium/src/+/'
'HEAD/docs/android_emulator.md#writable-system-partition')
logger.error(
'Did you start the emulator with "-writable-system?"\n'
'See %s\n', docs_url)
raise
return should_restore_root
@decorators.WithTimeoutAndConditionalRetries(_ShouldRetryModification)
def _TearDownSystemAppModification(device,
should_restore_root,
timeout=None,
retries=None):
try:
# The function may be re-entered after the the device loses root
# privilege. For instance if the adb server is restarted before
# re-entering the function then the device may lose root privilege.
# Therefore we need to do a sanity check for root privilege
# on the device and then re-enable root privilege if the device
# does not have it.
if not device.HasRoot():
logger.warning('Need to re-enable root.')
device.EnableRoot()
if not device.HasRoot():
raise device_errors.CommandFailedError(
('Failed to tear down modification of '
'system apps on non-rooted device.'),
str(device))
device.SetProp(_ENABLE_MODIFICATION_PROP, '0')
device.Reboot()
device.WaitUntilFullyBooted()
if should_restore_root:
device.EnableRoot()
except device_errors.CommandTimeoutError:
logger.error('Timed out while tearing down system app modification.')
logger.error(' device state: %s', device.adb.GetState())
raise
# pylint: enable=unused-argument
@contextlib.contextmanager
def EnableSystemAppModification(device):
"""A context manager that allows system apps to be modified while in scope.
Args:
device: (device_utils.DeviceUtils) the device
"""
if device.GetProp(_ENABLE_MODIFICATION_PROP) == '1':
yield
return
should_restore_root = _SetUpSystemAppModification(
device, timeout=_MODIFICATION_TIMEOUT, retries=_MODIFICATION_RETRIES)
try:
yield
finally:
_TearDownSystemAppModification(
device,
should_restore_root,
timeout=_MODIFICATION_TIMEOUT,
retries=_MODIFICATION_RETRIES)
@contextlib.contextmanager
def _RelocateApp(device, package_name, relocate_to):
"""A context manager that relocates an app while in scope."""
relocation_map = {}
system_package_paths = _FindSystemPackagePaths(device, [package_name])
if system_package_paths:
relocation_map = {
p: posixpath.join(relocate_to, posixpath.relpath(p, '/'))
for p in system_package_paths
}
relocation_dirs = [
posixpath.dirname(d) for _, d in relocation_map.iteritems()
]
device.RunShellCommand(['mkdir', '-p'] + relocation_dirs, check_return=True)
_MoveApp(device, relocation_map)
else:
logger.info('No system package "%s"', package_name)
try:
yield
finally:
_MoveApp(device, {v: k for k, v in relocation_map.iteritems()})
@contextlib.contextmanager
def _TemporarilyInstallApp(device, apk, install_timeout=None):
"""A context manager that installs an app while in scope."""
if install_timeout is None:
device.Install(apk, reinstall=True)
else:
device.Install(apk, reinstall=True, timeout=install_timeout)
try:
yield
finally:
device.Uninstall(apk_helper.GetPackageName(apk))
def _MoveApp(device, relocation_map):
"""Moves an app according to the provided relocation map.
Args:
device: (device_utils.DeviceUtils)
relocation_map: (dict) A dict that maps src to dest
"""
movements = ['mv %s %s' % (k, v) for k, v in relocation_map.iteritems()]
cmd = ' && '.join(movements)
with EnableSystemAppModification(device):
device.RunShellCommand(cmd, as_root=True, check_return=True, shell=True)
def main(raw_args):
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
def add_common_arguments(p):
script_common.AddDeviceArguments(p)
script_common.AddEnvironmentArguments(p)
p.add_argument(
'-v',
'--verbose',
action='count',
default=0,
help='Print more information.')
p.add_argument('command', nargs='*')
@contextlib.contextmanager
def remove_system_app(device, args):
RemoveSystemApps(device, args.packages)
yield
remove_parser = subparsers.add_parser('remove')
remove_parser.add_argument(
'--package',
dest='packages',
nargs='*',
required=True,
help='The system package(s) to remove.')
add_common_arguments(remove_parser)
remove_parser.set_defaults(func=remove_system_app)
@contextlib.contextmanager
def replace_system_app(device, args):
with ReplaceSystemApp(device, args.package, args.replace_with):
yield
replace_parser = subparsers.add_parser('replace')
replace_parser.add_argument(
'--package', required=True, help='The system package to replace.')
replace_parser.add_argument(
'--replace-with',
metavar='APK',
required=True,
help='The APK with which the existing system app should be replaced.')
add_common_arguments(replace_parser)
replace_parser.set_defaults(func=replace_system_app)
args = parser.parse_args(raw_args)
run_tests_helper.SetLogLevel(args.verbose)
script_common.InitializeEnvironment(args)
devices = script_common.GetDevices(args.devices, args.denylist_file)
parallel_devices = parallelizer.SyncParallelizer(
[args.func(d, args) for d in devices])
with parallel_devices:
if args.command:
return cmd_helper.Call(args.command)
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))