1#!/usr/bin/env python
2# Copyright 2017 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5"""A script to replace a system app while running a command."""
6
7import argparse
8import contextlib
9import logging
10import os
11import posixpath
12import re
13import sys
14
15if __name__ == '__main__':
16  sys.path.append(
17      os.path.abspath(
18          os.path.join(os.path.dirname(__file__), '..', '..', '..')))
19
20from devil.android import apk_helper
21from devil.android import decorators
22from devil.android import device_errors
23from devil.android import device_temp_file
24from devil.android.sdk import version_codes
25from devil.android.sdk import adb_wrapper
26from devil.android.tools import script_common
27from devil.utils import cmd_helper
28from devil.utils import parallelizer
29from devil.utils import run_tests_helper
30
31logger = logging.getLogger(__name__)
32
33# Some system apps aren't actually installed in the /system/ directory, so
34# special case them here with the correct install location.
35SPECIAL_SYSTEM_APP_LOCATIONS = {
36    # Older versions of ArCore were installed in /data/app/ regardless of
37    # whether they were system apps or not. Newer versions install in /system/
38    # if they are system apps, and in /data/app/ if they aren't. Some newer
39    # devices/OSes install in /product/app/ for system apps, as well.
40    'com.google.ar.core': ['/data/app/', '/system/', '/product/app/'],
41    # On older versions of VrCore, the system app version is installed in
42    # /system/ like normal. However, at some point, this moved to /data/.
43    # So, we have to handle both cases. Like ArCore, this means we'll end up
44    # removing even non-system versions due to this, but it doesn't cause any
45    # issues.
46    'com.google.vr.core': ['/data/app/', '/system/'],
47}
48
49# Gets app path and package name pm list packages -f output.
50_PM_LIST_PACKAGE_PATH_RE = re.compile(r'^\s*package:(\S+)=(\S+)\s*$')
51
52
53def RemoveSystemApps(device, package_names):
54  """Removes the given system apps.
55
56  Args:
57    device: (device_utils.DeviceUtils) the device for which the given
58      system app should be removed.
59    package_name: (iterable of strs) the names of the packages to remove.
60  """
61  system_package_paths = _FindSystemPackagePaths(device, package_names)
62  if system_package_paths:
63    with EnableSystemAppModification(device):
64      device.RemovePath(system_package_paths, force=True, recursive=True)
65
66
67@contextlib.contextmanager
68def ReplaceSystemApp(device,
69                     package_name,
70                     replacement_apk,
71                     install_timeout=None):
72  """A context manager that replaces the given system app while in scope.
73
74  Args:
75    device: (device_utils.DeviceUtils) the device for which the given
76      system app should be replaced.
77    package_name: (str) the name of the package to replace.
78    replacement_apk: (str) the path to the APK to use as a replacement.
79  """
80  storage_dir = device_temp_file.NamedDeviceTemporaryDirectory(device.adb)
81  relocate_app = _RelocateApp(device, package_name, storage_dir.name)
82  install_app = _TemporarilyInstallApp(device, replacement_apk, install_timeout)
83  with storage_dir, relocate_app, install_app:
84    yield
85
86
87def _FindSystemPackagePaths(device, system_package_list):
88  """Finds all system paths for the given packages."""
89  found_paths = []
90  for system_package in system_package_list:
91    paths = _GetApplicationPaths(device, system_package)
92    p = _GetSystemPath(system_package, paths)
93    if p:
94      found_paths.append(p)
95  return found_paths
96
97
98# Find all application paths, even those flagged as uninstalled, as these
99# would still block another package with the same name from installation
100# if they differ in signing keys.
101# TODO(aluo): Move this into device_utils.py
102def _GetApplicationPaths(device, package):
103  paths = []
104  lines = device.RunShellCommand(
105      ['pm', 'list', 'packages', '-f', '-u', package], check_return=True)
106  for line in lines:
107    match = re.match(_PM_LIST_PACKAGE_PATH_RE, line)
108    if match:
109      path = match.group(1)
110      package_name = match.group(2)
111      if package_name == package:
112        paths.append(path)
113  return paths
114
115
116def _GetSystemPath(package, paths):
117  for p in paths:
118    app_locations = SPECIAL_SYSTEM_APP_LOCATIONS.get(package,
119                                                     ['/system/', '/product/'])
120    for location in app_locations:
121      if p.startswith(location):
122        return p
123  return None
124
125
126_MODIFICATION_TIMEOUT = 300
127_MODIFICATION_RETRIES = 2
128_ENABLE_MODIFICATION_PROP = 'devil.modify_sys_apps'
129
130
131def _ShouldRetryModification(exc):
132  try:
133    if isinstance(exc, device_errors.CommandTimeoutError):
134      logger.info('Restarting the adb server')
135      adb_wrapper.RestartServer()
136    return True
137  except Exception: # pylint: disable=broad-except
138    logger.exception(('Caught an exception when deciding'
139                      ' to retry system modification'))
140    return False
141
142
143# timeout and retries are both required by the decorator, but neither
144# are used within the body of the function.
145# pylint: disable=unused-argument
146
147
148@decorators.WithTimeoutAndConditionalRetries(_ShouldRetryModification)
149def _SetUpSystemAppModification(device, timeout=None, retries=None):
150  # Ensure that the device is online & available before proceeding to
151  # handle the case where something fails in the middle of set up and
152  # triggers a retry.
153  device.WaitUntilFullyBooted()
154
155  # All calls that could potentially need root should run with as_root=True, but
156  # it looks like some parts of Telemetry work as-is by implicitly assuming that
157  # root is already granted if it's necessary. The reboot can mess with this, so
158  # as a workaround, check whether we're starting with root already, and if so,
159  # restore the device to that state at the end.
160  should_restore_root = device.HasRoot()
161  device.EnableRoot()
162  if not device.HasRoot():
163    raise device_errors.CommandFailedError(
164        'Failed to enable modification of system apps on non-rooted device',
165        str(device))
166
167  try:
168    # Disable Marshmallow's Verity security feature
169    if device.build_version_sdk >= version_codes.MARSHMALLOW:
170      logger.info('Disabling Verity on %s', device.serial)
171      device.adb.DisableVerity()
172      device.Reboot()
173      device.WaitUntilFullyBooted()
174      device.EnableRoot()
175
176    device.adb.Remount()
177    device.RunShellCommand(['stop'], check_return=True)
178    device.SetProp(_ENABLE_MODIFICATION_PROP, '1')
179  except device_errors.CommandFailedError:
180    if device.adb.is_emulator:
181      # Point the user to documentation, since there's a good chance they can
182      # workaround this on an emulator.
183      docs_url = ('https://chromium.googlesource.com/chromium/src/+/'
184                  'HEAD/docs/android_emulator.md#writable-system-partition')
185      logger.error(
186          'Did you start the emulator with "-writable-system?"\n'
187          'See %s\n', docs_url)
188    raise
189
190  return should_restore_root
191
192
193@decorators.WithTimeoutAndConditionalRetries(_ShouldRetryModification)
194def _TearDownSystemAppModification(device,
195                                   should_restore_root,
196                                   timeout=None,
197                                   retries=None):
198  try:
199    # The function may be re-entered after the the device loses root
200    # privilege. For instance if the adb server is restarted before
201    # re-entering the function then the device may lose root privilege.
202    # Therefore we need to do a sanity check for root privilege
203    # on the device and then re-enable root privilege if the device
204    # does not have it.
205    if not device.HasRoot():
206      logger.warning('Need to re-enable root.')
207      device.EnableRoot()
208
209      if not device.HasRoot():
210        raise device_errors.CommandFailedError(
211          ('Failed to tear down modification of '
212           'system apps on non-rooted device.'),
213          str(device))
214
215    device.SetProp(_ENABLE_MODIFICATION_PROP, '0')
216    device.Reboot()
217    device.WaitUntilFullyBooted()
218    if should_restore_root:
219      device.EnableRoot()
220  except device_errors.CommandTimeoutError:
221    logger.error('Timed out while tearing down system app modification.')
222    logger.error('  device state: %s', device.adb.GetState())
223    raise
224
225
226# pylint: enable=unused-argument
227
228
229@contextlib.contextmanager
230def EnableSystemAppModification(device):
231  """A context manager that allows system apps to be modified while in scope.
232
233  Args:
234    device: (device_utils.DeviceUtils) the device
235  """
236  if device.GetProp(_ENABLE_MODIFICATION_PROP) == '1':
237    yield
238    return
239
240  should_restore_root = _SetUpSystemAppModification(
241      device, timeout=_MODIFICATION_TIMEOUT, retries=_MODIFICATION_RETRIES)
242  try:
243    yield
244  finally:
245    _TearDownSystemAppModification(
246        device,
247        should_restore_root,
248        timeout=_MODIFICATION_TIMEOUT,
249        retries=_MODIFICATION_RETRIES)
250
251
252@contextlib.contextmanager
253def _RelocateApp(device, package_name, relocate_to):
254  """A context manager that relocates an app while in scope."""
255  relocation_map = {}
256  system_package_paths = _FindSystemPackagePaths(device, [package_name])
257  if system_package_paths:
258    relocation_map = {
259        p: posixpath.join(relocate_to, posixpath.relpath(p, '/'))
260        for p in system_package_paths
261    }
262    relocation_dirs = [
263        posixpath.dirname(d) for _, d in relocation_map.iteritems()
264    ]
265    device.RunShellCommand(['mkdir', '-p'] + relocation_dirs, check_return=True)
266    _MoveApp(device, relocation_map)
267  else:
268    logger.info('No system package "%s"', package_name)
269
270  try:
271    yield
272  finally:
273    _MoveApp(device, {v: k for k, v in relocation_map.iteritems()})
274
275
276@contextlib.contextmanager
277def _TemporarilyInstallApp(device, apk, install_timeout=None):
278  """A context manager that installs an app while in scope."""
279  if install_timeout is None:
280    device.Install(apk, reinstall=True)
281  else:
282    device.Install(apk, reinstall=True, timeout=install_timeout)
283
284  try:
285    yield
286  finally:
287    device.Uninstall(apk_helper.GetPackageName(apk))
288
289
290def _MoveApp(device, relocation_map):
291  """Moves an app according to the provided relocation map.
292
293  Args:
294    device: (device_utils.DeviceUtils)
295    relocation_map: (dict) A dict that maps src to dest
296  """
297  movements = ['mv %s %s' % (k, v) for k, v in relocation_map.iteritems()]
298  cmd = ' && '.join(movements)
299  with EnableSystemAppModification(device):
300    device.RunShellCommand(cmd, as_root=True, check_return=True, shell=True)
301
302
303def main(raw_args):
304  parser = argparse.ArgumentParser()
305  subparsers = parser.add_subparsers()
306
307  def add_common_arguments(p):
308    script_common.AddDeviceArguments(p)
309    script_common.AddEnvironmentArguments(p)
310    p.add_argument(
311        '-v',
312        '--verbose',
313        action='count',
314        default=0,
315        help='Print more information.')
316    p.add_argument('command', nargs='*')
317
318  @contextlib.contextmanager
319  def remove_system_app(device, args):
320    RemoveSystemApps(device, args.packages)
321    yield
322
323  remove_parser = subparsers.add_parser('remove')
324  remove_parser.add_argument(
325      '--package',
326      dest='packages',
327      nargs='*',
328      required=True,
329      help='The system package(s) to remove.')
330  add_common_arguments(remove_parser)
331  remove_parser.set_defaults(func=remove_system_app)
332
333  @contextlib.contextmanager
334  def replace_system_app(device, args):
335    with ReplaceSystemApp(device, args.package, args.replace_with):
336      yield
337
338  replace_parser = subparsers.add_parser('replace')
339  replace_parser.add_argument(
340      '--package', required=True, help='The system package to replace.')
341  replace_parser.add_argument(
342      '--replace-with',
343      metavar='APK',
344      required=True,
345      help='The APK with which the existing system app should be replaced.')
346  add_common_arguments(replace_parser)
347  replace_parser.set_defaults(func=replace_system_app)
348
349  args = parser.parse_args(raw_args)
350
351  run_tests_helper.SetLogLevel(args.verbose)
352  script_common.InitializeEnvironment(args)
353
354  devices = script_common.GetDevices(args.devices, args.denylist_file)
355  parallel_devices = parallelizer.SyncParallelizer(
356      [args.func(d, args) for d in devices])
357  with parallel_devices:
358    if args.command:
359      return cmd_helper.Call(args.command)
360    return 0
361
362
363if __name__ == '__main__':
364  sys.exit(main(sys.argv[1:]))
365