• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python
2# Copyright 2015 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
6'''
7Script to help uploading and downloading the Google Play services library to
8and from a Google Cloud storage.
9'''
10
11import argparse
12import logging
13import os
14import re
15import shutil
16import sys
17import tempfile
18import zipfile
19
20sys.path.append(os.path.join(os.path.dirname(__file__), os.pardir))
21import devil_chromium
22from devil.utils import cmd_helper
23from play_services import utils
24from pylib import constants
25from pylib.constants import host_paths
26from pylib.utils import logging_utils
27
28sys.path.append(os.path.join(host_paths.DIR_SOURCE_ROOT, 'build'))
29import find_depot_tools  # pylint: disable=import-error,unused-import
30import breakpad
31import download_from_google_storage
32import upload_to_google_storage
33
34
35# Directory where the SHA1 files for the zip and the license are stored
36# It should be managed by git to provided information about new versions.
37SHA1_DIRECTORY = os.path.join(host_paths.DIR_SOURCE_ROOT, 'build', 'android',
38                              'play_services')
39
40# Default bucket used for storing the files.
41GMS_CLOUD_STORAGE = 'chromium-android-tools/play-services'
42
43# Path to the default configuration file. It exposes the currently installed
44# version of the library in a human readable way.
45CONFIG_DEFAULT_PATH = os.path.join(host_paths.DIR_SOURCE_ROOT, 'build',
46                                   'android', 'play_services', 'config.json')
47
48LICENSE_FILE_NAME = 'LICENSE'
49ZIP_FILE_NAME = 'google_play_services_library.zip'
50GMS_PACKAGE_ID = 'extra-google-google_play_services'  # used by sdk manager
51
52LICENSE_PATTERN = re.compile(r'^Pkg\.License=(?P<text>.*)$', re.MULTILINE)
53
54
55def main(raw_args):
56  parser = argparse.ArgumentParser(
57      description=__doc__ + 'Please see the subcommand help for more details.',
58      formatter_class=utils.DefaultsRawHelpFormatter)
59  subparsers = parser.add_subparsers(title='commands')
60
61  # Download arguments
62  parser_download = subparsers.add_parser(
63      'download',
64      help='download the library from the cloud storage',
65      description=Download.__doc__,
66      formatter_class=utils.DefaultsRawHelpFormatter)
67  parser_download.set_defaults(func=Download)
68  AddBasicArguments(parser_download)
69  AddBucketArguments(parser_download)
70
71  # SDK Update arguments
72  parser_sdk = subparsers.add_parser(
73      'sdk',
74      help='get the latest Google Play services SDK using Android SDK Manager',
75      description=UpdateSdk.__doc__,
76      formatter_class=utils.DefaultsRawHelpFormatter)
77  parser_sdk.set_defaults(func=UpdateSdk)
78  AddBasicArguments(parser_sdk)
79
80  # Upload arguments
81  parser_upload = subparsers.add_parser(
82      'upload',
83      help='upload the library to the cloud storage',
84      description=Upload.__doc__,
85      formatter_class=utils.DefaultsRawHelpFormatter)
86
87  parser_upload.add_argument('--skip-git',
88                             action='store_true',
89                             help="don't commit the changes at the end")
90  parser_upload.set_defaults(func=Upload)
91  AddBasicArguments(parser_upload)
92  AddBucketArguments(parser_upload)
93
94  args = parser.parse_args(raw_args)
95  if args.verbose:
96    logging.basicConfig(level=logging.DEBUG)
97  logging_utils.ColorStreamHandler.MakeDefault(not _IsBotEnvironment())
98  devil_chromium.Initialize()
99  return args.func(args)
100
101
102def AddBasicArguments(parser):
103  '''
104  Defines the common arguments on subparser rather than the main one. This
105  allows to put arguments after the command: `foo.py upload --debug --force`
106  instead of `foo.py --debug upload --force`
107  '''
108
109  parser.add_argument('--sdk-root',
110                      help='base path to the Android SDK tools root',
111                      default=constants.ANDROID_SDK_ROOT)
112
113  parser.add_argument('-v', '--verbose',
114                      action='store_true',
115                      help='print debug information')
116
117
118def AddBucketArguments(parser):
119  parser.add_argument('--bucket',
120                      help='name of the bucket where the files are stored',
121                      default=GMS_CLOUD_STORAGE)
122
123  parser.add_argument('--config',
124                      help='JSON Configuration file',
125                      default=CONFIG_DEFAULT_PATH)
126
127  parser.add_argument('--dry-run',
128                      action='store_true',
129                      help=('run the script in dry run mode. Files will be '
130                            'copied to a local directory instead of the '
131                            'cloud storage. The bucket name will be as path '
132                            'to that directory relative to the repository '
133                            'root.'))
134
135  parser.add_argument('-f', '--force',
136                      action='store_true',
137                      help='run even if the library is already up to date')
138
139
140def Download(args):
141  '''
142  Downloads the Google Play services library from a Google Cloud Storage bucket
143  and installs it to
144  //third_party/android_tools/sdk/extras/google/google_play_services.
145
146  A license check will be made, and the user might have to accept the license
147  if that has not been done before.
148  '''
149
150  if not os.path.isdir(args.sdk_root):
151    logging.debug('Did not find the Android SDK root directory at "%s".',
152                  args.sdk_root)
153    if not args.force:
154      logging.info('Skipping, not on an android checkout.')
155      return 0
156
157  config = utils.ConfigParser(args.config)
158  paths = PlayServicesPaths(args.sdk_root, config.version_xml_path)
159
160  if os.path.isdir(paths.package) and not os.access(paths.package, os.W_OK):
161    logging.error('Failed updating the Google Play Services library. '
162                  'The location is not writable. Please remove the '
163                  'directory (%s) and try again.', paths.package)
164    return -2
165
166  new_lib_zip_sha1 = os.path.join(SHA1_DIRECTORY, ZIP_FILE_NAME + '.sha1')
167
168  logging.debug('Comparing zip hashes: %s and %s', new_lib_zip_sha1,
169                paths.lib_zip_sha1)
170  if utils.FileEquals(new_lib_zip_sha1, paths.lib_zip_sha1) and not args.force:
171    logging.info('Skipping, the Google Play services library is up to date.')
172    return 0
173
174  bucket_path = _VerifyBucketPathFormat(args.bucket,
175                                        config.version_number,
176                                        args.dry_run)
177
178  tmp_root = tempfile.mkdtemp()
179  try:
180    # setup the destination directory
181    if not os.path.isdir(paths.package):
182      os.makedirs(paths.package)
183
184    # download license file from bucket/{version_number}/license.sha1
185    new_license = os.path.join(tmp_root, LICENSE_FILE_NAME)
186
187    license_sha1 = os.path.join(SHA1_DIRECTORY, LICENSE_FILE_NAME + '.sha1')
188    _DownloadFromBucket(bucket_path, license_sha1, new_license,
189                        args.verbose, args.dry_run)
190
191    if (not _IsBotEnvironment() and
192        not _CheckLicenseAgreement(new_license, paths.license,
193                                   config.version_number)):
194        logging.warning('Your version of the Google Play services library is '
195                        'not up to date. You might run into issues building '
196                        'or running the app. Please run `%s download` to '
197                        'retry downloading it.', __file__)
198        return 0
199
200    new_lib_zip = os.path.join(tmp_root, ZIP_FILE_NAME)
201    _DownloadFromBucket(bucket_path, new_lib_zip_sha1, new_lib_zip,
202                        args.verbose, args.dry_run)
203
204    try:
205      # We remove the current version of the Google Play services SDK.
206      if os.path.exists(paths.package):
207        shutil.rmtree(paths.package)
208      os.makedirs(paths.package)
209
210      logging.debug('Extracting the library to %s', paths.lib)
211      with zipfile.ZipFile(new_lib_zip, "r") as new_lib_zip_file:
212        new_lib_zip_file.extractall(paths.lib)
213
214      logging.debug('Copying %s to %s', new_license, paths.license)
215      shutil.copy(new_license, paths.license)
216
217      logging.debug('Copying %s to %s', new_lib_zip_sha1, paths.lib_zip_sha1)
218      shutil.copy(new_lib_zip_sha1, paths.lib_zip_sha1)
219
220      logging.info('Update complete.')
221
222    except Exception as e:  # pylint: disable=broad-except
223      logging.error('Failed updating the Google Play Services library. '
224                    'An error occurred while installing the new version in '
225                    'the SDK directory: %s ', e)
226      return -3
227  finally:
228    shutil.rmtree(tmp_root)
229
230  return 0
231
232
233def UpdateSdk(args):
234  '''
235  Uses the Android SDK Manager to download the latest Google Play services SDK
236  locally. Its usual installation path is
237  //third_party/android_tools/sdk/extras/google/google_play_services
238  '''
239
240  # This should function should not run on bots and could fail for many user
241  # and setup related reasons. Also, exceptions here are not caught, so we
242  # disable breakpad to avoid spamming the logs.
243  breakpad.IS_ENABLED = False
244
245  sdk_manager = os.path.join(args.sdk_root, 'tools', 'android')
246  cmd = [sdk_manager, 'update', 'sdk', '--no-ui', '--filter', GMS_PACKAGE_ID]
247  cmd_helper.Call(cmd)
248  # If no update is needed, it still returns successfully so we just do nothing
249
250  return 0
251
252
253def Upload(args):
254  '''
255  Uploads the library from the local Google Play services SDK to a Google Cloud
256  storage bucket.
257
258  By default, a local commit will be made at the end of the operation.
259  '''
260
261  # This should function should not run on bots and could fail for many user
262  # and setup related reasons. Also, exceptions here are not caught, so we
263  # disable breakpad to avoid spamming the logs.
264  breakpad.IS_ENABLED = False
265
266  config = utils.ConfigParser(args.config)
267  paths = PlayServicesPaths(args.sdk_root, config.version_xml_path)
268
269  if not args.skip_git and utils.IsRepoDirty(host_paths.DIR_SOURCE_ROOT):
270    logging.error('The repo is dirty. Please commit or stash your changes.')
271    return -1
272
273  new_version_number = utils.GetVersionNumberFromLibraryResources(
274      paths.version_xml)
275  logging.debug('comparing versions: new=%d, old=%s',
276                new_version_number, config.version_number)
277  if new_version_number <= config.version_number and not args.force:
278    logging.info('The checked in version of the library is already the latest '
279                 'one. No update is needed. Please rerun with --force to skip '
280                 'this check.')
281    return 0
282
283  tmp_root = tempfile.mkdtemp()
284  try:
285    new_lib_zip = os.path.join(tmp_root, ZIP_FILE_NAME)
286    new_license = os.path.join(tmp_root, LICENSE_FILE_NAME)
287
288    # need to strip '.zip' from the file name here
289    shutil.make_archive(new_lib_zip[:-4], 'zip', paths.lib)
290    _ExtractLicenseFile(new_license, paths.source_prop)
291
292    bucket_path = _VerifyBucketPathFormat(args.bucket, new_version_number,
293                                          args.dry_run)
294    files_to_upload = [new_lib_zip, new_license]
295    logging.debug('Uploading %s to %s', files_to_upload, bucket_path)
296    _UploadToBucket(bucket_path, files_to_upload, args.dry_run)
297
298    new_lib_zip_sha1 = os.path.join(SHA1_DIRECTORY,
299                                    ZIP_FILE_NAME + '.sha1')
300    new_license_sha1 = os.path.join(SHA1_DIRECTORY,
301                                    LICENSE_FILE_NAME + '.sha1')
302    shutil.copy(new_lib_zip + '.sha1', new_lib_zip_sha1)
303    shutil.copy(new_license + '.sha1', new_license_sha1)
304  finally:
305    shutil.rmtree(tmp_root)
306
307  config.UpdateVersionNumber(new_version_number)
308
309  if not args.skip_git:
310    commit_message = ('Update the Google Play services dependency to %s\n'
311                      '\n') % new_version_number
312    utils.MakeLocalCommit(host_paths.DIR_SOURCE_ROOT,
313                          [new_lib_zip_sha1, new_license_sha1, config.path],
314                          commit_message)
315
316  return 0
317
318
319def _DownloadFromBucket(bucket_path, sha1_file, destination, verbose,
320                        is_dry_run):
321  '''Downloads the file designated by the provided sha1 from a cloud bucket.'''
322
323  download_from_google_storage.download_from_google_storage(
324      input_filename=sha1_file,
325      base_url=bucket_path,
326      gsutil=_InitGsutil(is_dry_run),
327      num_threads=1,
328      directory=None,
329      recursive=False,
330      force=False,
331      output=destination,
332      ignore_errors=False,
333      sha1_file=sha1_file,
334      verbose=verbose,
335      auto_platform=True,
336      extract=False)
337
338
339def _UploadToBucket(bucket_path, files_to_upload, is_dry_run):
340  '''Uploads the files designated by the provided paths to a cloud bucket. '''
341
342  upload_to_google_storage.upload_to_google_storage(
343      input_filenames=files_to_upload,
344      base_url=bucket_path,
345      gsutil=_InitGsutil(is_dry_run),
346      force=False,
347      use_md5=False,
348      num_threads=1,
349      skip_hashing=False,
350      gzip=None)
351
352
353def _InitGsutil(is_dry_run):
354  '''Initialize the Gsutil object as regular or dummy version for dry runs. '''
355
356  if is_dry_run:
357    return DummyGsutil()
358  else:
359    return download_from_google_storage.Gsutil(
360        download_from_google_storage.GSUTIL_DEFAULT_PATH)
361
362
363def _ExtractLicenseFile(license_path, prop_file_path):
364  with open(prop_file_path, 'r') as prop_file:
365    prop_file_content = prop_file.read()
366
367  match = LICENSE_PATTERN.search(prop_file_content)
368  if not match:
369    raise AttributeError('The license was not found in ' +
370                         os.path.abspath(prop_file_path))
371
372  with open(license_path, 'w') as license_file:
373    license_file.write(match.group('text'))
374
375
376def _CheckLicenseAgreement(expected_license_path, actual_license_path,
377                           version_number):
378  '''
379  Checks that the new license is the one already accepted by the user. If it
380  isn't, it prompts the user to accept it. Returns whether the expected license
381  has been accepted.
382  '''
383
384  if utils.FileEquals(expected_license_path, actual_license_path):
385    return True
386
387  with open(expected_license_path) as license_file:
388    # Uses plain print rather than logging to make sure this is not formatted
389    # by the logger.
390    print ('Updating the Google Play services SDK to '
391           'version %d.' % version_number)
392
393    # The output is buffered when running as part of gclient hooks. We split
394    # the text here and flush is explicitly to avoid having part of it dropped
395    # out.
396    # Note: text contains *escaped* new lines, so we split by '\\n', not '\n'.
397    for license_part in license_file.read().split('\\n'):
398      print license_part
399      sys.stdout.flush()
400
401  # Need to put the prompt on a separate line otherwise the gclient hook buffer
402  # only prints it after we received an input.
403  print 'Do you accept the license? [y/n]: '
404  sys.stdout.flush()
405  return raw_input('> ') in ('Y', 'y')
406
407
408def _IsBotEnvironment():
409  return bool(os.environ.get('CHROME_HEADLESS'))
410
411
412def _VerifyBucketPathFormat(bucket_name, version_number, is_dry_run):
413  '''
414  Formats and checks the download/upload path depending on whether we are
415  running in dry run mode or not. Returns a supposedly safe path to use with
416  Gsutil.
417  '''
418
419  if is_dry_run:
420    bucket_path = os.path.abspath(os.path.join(bucket_name,
421                                               str(version_number)))
422    if not os.path.isdir(bucket_path):
423      os.makedirs(bucket_path)
424  else:
425    if bucket_name.startswith('gs://'):
426      # We enforce the syntax without gs:// for consistency with the standalone
427      # download/upload scripts and to make dry run transition easier.
428      raise AttributeError('Please provide the bucket name without the gs:// '
429                           'prefix (e.g. %s)' % GMS_CLOUD_STORAGE)
430    bucket_path = 'gs://%s/%d' % (bucket_name, version_number)
431
432  return bucket_path
433
434
435class PlayServicesPaths(object):
436  '''
437  Describes the different paths to be used in the update process.
438
439         Filesystem hierarchy                        | Exposed property / notes
440  ---------------------------------------------------|-------------------------
441  [sdk_root]                                         | sdk_root / (1)
442   +- extras                                         |
443      +- google                                      |
444         +- google_play_services                     | package / (2)
445            +- source.properties                     | source_prop / (3)
446            +- LICENSE                               | license / (4)
447            +- google_play_services_library.zip.sha1 | lib_zip_sha1 / (5)
448            +- libproject                            |
449               +- google-play-services_lib           | lib / (6)
450                  +- res                             |
451                     +- values                       |
452                        +- version.xml               | version_xml (7)
453
454  Notes:
455
456   1. sdk_root: Path provided as a parameter to the script (--sdk_root)
457   2. package: This directory contains the Google Play services SDK itself.
458      When downloaded via the Android SDK manager, it will contain,
459      documentation, samples and other files in addition to the library. When
460      the update script downloads the library from our cloud storage, it is
461      cleared.
462   3. source_prop: File created by the Android SDK manager that contains
463      the package information, such as the version info and the license.
464   4. license: File created by the update script. Contains the license accepted
465      by the user.
466   5. lib_zip_sha1: sha1 of the library zip that has been installed by the
467      update script. It is compared with the one required by the config file to
468      check if an update is necessary.
469   6. lib: Contains the library itself: jar and resources. This is what is
470      downloaded from the cloud storage.
471   7. version_xml: File that contains the exact Google Play services library
472      version, the one that we track. The version looks like 811500, is used in
473      the code and the on-device APK, as opposed to the SDK package version
474      which looks like 27.0.0 and is used only by the Android SDK manager.
475
476  '''
477
478  def __init__(self, sdk_root, version_xml_path):
479    relative_package = os.path.join('extras', 'google', 'google_play_services')
480    relative_lib = os.path.join(relative_package, 'libproject',
481                                'google-play-services_lib')
482    self.sdk_root = sdk_root
483
484    self.package = os.path.join(sdk_root, relative_package)
485    self.lib_zip_sha1 = os.path.join(self.package, ZIP_FILE_NAME + '.sha1')
486    self.license = os.path.join(self.package, LICENSE_FILE_NAME)
487    self.source_prop = os.path.join(self.package, 'source.properties')
488
489    self.lib = os.path.join(sdk_root, relative_lib)
490    self.version_xml = os.path.join(self.lib, version_xml_path)
491
492
493class DummyGsutil(download_from_google_storage.Gsutil):
494  '''
495  Class that replaces Gsutil to use a local directory instead of an online
496  bucket. It relies on the fact that Gsutil commands are very similar to shell
497  ones, so for the ones used here (ls, cp), it works to just use them with a
498  local directory.
499  '''
500
501  def __init__(self):
502    super(DummyGsutil, self).__init__(
503        download_from_google_storage.GSUTIL_DEFAULT_PATH)
504
505  def call(self, *args):
506    logging.debug('Calling command "%s"', str(args))
507    return cmd_helper.GetCmdStatusOutputAndError(args)
508
509  def check_call(self, *args):
510    logging.debug('Calling command "%s"', str(args))
511    return cmd_helper.GetCmdStatusOutputAndError(args)
512
513
514if __name__ == '__main__':
515  sys.exit(main(sys.argv[1:]))
516