| #!/usr/bin/env python3 |
| # |
| # Copyright 2018 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. |
| |
| """Create an Android application bundle from one or more bundle modules.""" |
| |
| import argparse |
| import json |
| import os |
| import shutil |
| import sys |
| import zipfile |
| |
| sys.path.append( |
| os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))) |
| from pylib.utils import dexdump |
| |
| from util import build_utils |
| from util import manifest_utils |
| from util import resource_utils |
| from xml.etree import ElementTree |
| |
| import bundletool |
| |
| # Location of language-based assets in bundle modules. |
| _LOCALES_SUBDIR = 'assets/locales/' |
| |
| # The fallback locale should always have its .pak file included in |
| # the base apk, i.e. not use language-based asset targetting. This ensures |
| # that Chrome won't crash on startup if its bundle is installed on a device |
| # with an unsupported system locale (e.g. fur-rIT). |
| _FALLBACK_LOCALE = 'en-US' |
| |
| # List of split dimensions recognized by this tool. |
| _ALL_SPLIT_DIMENSIONS = [ 'ABI', 'SCREEN_DENSITY', 'LANGUAGE' ] |
| |
| # Due to historical reasons, certain languages identified by Chromium with a |
| # 3-letters ISO 639-2 code, are mapped to a nearly equivalent 2-letters |
| # ISO 639-1 code instead (due to the fact that older Android releases only |
| # supported the latter when matching resources). |
| # |
| # the same conversion as for Java resources. |
| _SHORTEN_LANGUAGE_CODE_MAP = { |
| 'fil': 'tl', # Filipino to Tagalog. |
| } |
| |
| # A list of extensions corresponding to files that should never be compressed |
| # in the bundle. This used to be handled by bundletool automatically until |
| # release 0.8.0, which required that this be passed to the BundleConfig |
| # file instead. |
| # |
| # This is the original list, which was taken from aapt2, with 'webp' added to |
| # it (which curiously was missing from the list). |
| _UNCOMPRESSED_FILE_EXTS = [ |
| '3g2', '3gp', '3gpp', '3gpp2', 'aac', 'amr', 'awb', 'git', 'imy', 'jet', |
| 'jpeg', 'jpg', 'm4a', 'm4v', 'mid', 'midi', 'mkv', 'mp2', 'mp3', 'mp4', |
| 'mpeg', 'mpg', 'ogg', 'png', 'rtttl', 'smf', 'wav', 'webm', 'webp', 'wmv', |
| 'xmf' |
| ] |
| |
| |
| def _ParseArgs(args): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('--out-bundle', required=True, |
| help='Output bundle zip archive.') |
| parser.add_argument('--module-zips', required=True, |
| help='GN-list of module zip archives.') |
| parser.add_argument( |
| '--pathmap-in-paths', |
| action='append', |
| help='List of module pathmap files.') |
| parser.add_argument( |
| '--module-name', |
| action='append', |
| dest='module_names', |
| help='List of module names.') |
| parser.add_argument( |
| '--pathmap-out-path', help='Path to combined pathmap file for bundle.') |
| parser.add_argument( |
| '--rtxt-in-paths', action='append', help='GN-list of module R.txt files.') |
| parser.add_argument( |
| '--rtxt-out-path', help='Path to combined R.txt file for bundle.') |
| parser.add_argument('--uncompressed-assets', action='append', |
| help='GN-list of uncompressed assets.') |
| parser.add_argument( |
| '--compress-shared-libraries', |
| action='store_true', |
| help='Whether to store native libraries compressed.') |
| parser.add_argument('--split-dimensions', |
| help="GN-list of split dimensions to support.") |
| parser.add_argument( |
| '--base-module-rtxt-path', |
| help='Optional path to the base module\'s R.txt file, only used with ' |
| 'language split dimension.') |
| parser.add_argument( |
| '--base-allowlist-rtxt-path', |
| help='Optional path to an R.txt file, string resources ' |
| 'listed there _and_ in --base-module-rtxt-path will ' |
| 'be kept in the base bundle module, even if language' |
| ' splitting is enabled.') |
| parser.add_argument('--warnings-as-errors', |
| action='store_true', |
| help='Treat all warnings as errors.') |
| |
| parser.add_argument( |
| '--validate-services', |
| action='store_true', |
| help='Check if services are in base module if isolatedSplits is enabled.') |
| |
| options = parser.parse_args(args) |
| options.module_zips = build_utils.ParseGnList(options.module_zips) |
| options.rtxt_in_paths = build_utils.ParseGnList(options.rtxt_in_paths) |
| options.pathmap_in_paths = build_utils.ParseGnList(options.pathmap_in_paths) |
| |
| if len(options.module_zips) == 0: |
| raise Exception('The module zip list cannot be empty.') |
| |
| # Merge all uncompressed assets into a set. |
| uncompressed_list = [] |
| if options.uncompressed_assets: |
| for l in options.uncompressed_assets: |
| for entry in build_utils.ParseGnList(l): |
| # Each entry has the following format: 'zipPath' or 'srcPath:zipPath' |
| pos = entry.find(':') |
| if pos >= 0: |
| uncompressed_list.append(entry[pos + 1:]) |
| else: |
| uncompressed_list.append(entry) |
| |
| options.uncompressed_assets = set(uncompressed_list) |
| |
| # Check that all split dimensions are valid |
| if options.split_dimensions: |
| options.split_dimensions = build_utils.ParseGnList(options.split_dimensions) |
| for dim in options.split_dimensions: |
| if dim.upper() not in _ALL_SPLIT_DIMENSIONS: |
| parser.error('Invalid split dimension "%s" (expected one of: %s)' % ( |
| dim, ', '.join(x.lower() for x in _ALL_SPLIT_DIMENSIONS))) |
| |
| # As a special case, --base-allowlist-rtxt-path can be empty to indicate |
| # that the module doesn't need such a allowlist. That's because it is easier |
| # to check this condition here than through GN rules :-( |
| if options.base_allowlist_rtxt_path == '': |
| options.base_module_rtxt_path = None |
| |
| # Check --base-module-rtxt-path and --base-allowlist-rtxt-path usage. |
| if options.base_module_rtxt_path: |
| if not options.base_allowlist_rtxt_path: |
| parser.error( |
| '--base-module-rtxt-path requires --base-allowlist-rtxt-path') |
| if 'language' not in options.split_dimensions: |
| parser.error('--base-module-rtxt-path is only valid with ' |
| 'language-based splits.') |
| |
| return options |
| |
| |
| def _MakeSplitDimension(value, enabled): |
| """Return dict modelling a BundleConfig splitDimension entry.""" |
| return {'value': value, 'negate': not enabled} |
| |
| |
| def _GenerateBundleConfigJson(uncompressed_assets, compress_shared_libraries, |
| split_dimensions, base_master_resource_ids): |
| """Generate a dictionary that can be written to a JSON BuildConfig. |
| |
| Args: |
| uncompressed_assets: A list or set of file paths under assets/ that always |
| be stored uncompressed. |
| compress_shared_libraries: Boolean, whether to compress native libs. |
| split_dimensions: list of split dimensions. |
| base_master_resource_ids: Optional list of 32-bit resource IDs to keep |
| inside the base module, even when split dimensions are enabled. |
| Returns: |
| A dictionary that can be written as a json file. |
| """ |
| # Compute splitsConfig list. Each item is a dictionary that can have |
| # the following keys: |
| # 'value': One of ['LANGUAGE', 'DENSITY', 'ABI'] |
| # 'negate': Boolean, True to indicate that the bundle should *not* be |
| # split (unused at the moment by this script). |
| |
| split_dimensions = [ _MakeSplitDimension(dim, dim in split_dimensions) |
| for dim in _ALL_SPLIT_DIMENSIONS ] |
| |
| # Native libraries loaded by the crazy linker. |
| # Whether other .so files are compressed is controlled by |
| # "uncompressNativeLibraries". |
| uncompressed_globs = ['lib/*/crazy.*'] |
| # Locale-specific pak files stored in bundle splits need not be compressed. |
| uncompressed_globs.extend( |
| ['assets/locales#lang_*/*.pak', 'assets/fallback-locales/*.pak']) |
| uncompressed_globs.extend('assets/' + x for x in uncompressed_assets) |
| # NOTE: Use '**' instead of '*' to work through directories! |
| uncompressed_globs.extend('**.' + ext for ext in _UNCOMPRESSED_FILE_EXTS) |
| |
| data = { |
| 'optimizations': { |
| 'splitsConfig': { |
| 'splitDimension': split_dimensions, |
| }, |
| 'uncompressNativeLibraries': { |
| 'enabled': not compress_shared_libraries, |
| }, |
| 'uncompressDexFiles': { |
| 'enabled': True, # Applies only for P+. |
| } |
| }, |
| 'compression': { |
| 'uncompressedGlob': sorted(uncompressed_globs), |
| }, |
| } |
| |
| if base_master_resource_ids: |
| data['master_resources'] = { |
| 'resource_ids': list(base_master_resource_ids), |
| } |
| |
| return json.dumps(data, indent=2) |
| |
| |
| def _RewriteLanguageAssetPath(src_path): |
| """Rewrite the destination path of a locale asset for language-based splits. |
| |
| Should only be used when generating bundles with language-based splits. |
| This will rewrite paths that look like locales/<locale>.pak into |
| locales#<language>/<locale>.pak, where <language> is the language code |
| from the locale. |
| |
| Returns new path. |
| """ |
| if not src_path.startswith(_LOCALES_SUBDIR) or not src_path.endswith('.pak'): |
| return [src_path] |
| |
| locale = src_path[len(_LOCALES_SUBDIR):-4] |
| android_locale = resource_utils.ToAndroidLocaleName(locale) |
| |
| # The locale format is <lang>-<region> or <lang> or BCP-47 (e.g b+sr+Latn). |
| # Extract the language. |
| pos = android_locale.find('-') |
| if android_locale.startswith('b+'): |
| # If locale is in BCP-47 the language is the second tag (e.g. b+sr+Latn) |
| android_language = android_locale.split('+')[1] |
| elif pos >= 0: |
| android_language = android_locale[:pos] |
| else: |
| android_language = android_locale |
| |
| if locale == _FALLBACK_LOCALE: |
| # Fallback locale .pak files must be placed in a different directory |
| # to ensure they are always stored in the base module. |
| result_path = 'assets/fallback-locales/%s.pak' % locale |
| else: |
| # Other language .pak files go into a language-specific asset directory |
| # that bundletool will store in separate split APKs. |
| result_path = 'assets/locales#lang_%s/%s.pak' % (android_language, locale) |
| |
| return result_path |
| |
| |
| def _SplitModuleForAssetTargeting(src_module_zip, tmp_dir, split_dimensions): |
| """Splits assets in a module if needed. |
| |
| Args: |
| src_module_zip: input zip module path. |
| tmp_dir: Path to temporary directory, where the new output module might |
| be written to. |
| split_dimensions: list of split dimensions. |
| |
| Returns: |
| If the module doesn't need asset targeting, doesn't do anything and |
| returns src_module_zip. Otherwise, create a new module zip archive under |
| tmp_dir with the same file name, but which contains assets paths targeting |
| the proper dimensions. |
| """ |
| split_language = 'LANGUAGE' in split_dimensions |
| if not split_language: |
| # Nothing to target, so return original module path. |
| return src_module_zip |
| |
| with zipfile.ZipFile(src_module_zip, 'r') as src_zip: |
| language_files = [ |
| f for f in src_zip.namelist() if f.startswith(_LOCALES_SUBDIR)] |
| |
| if not language_files: |
| # Not language-based assets to split in this module. |
| return src_module_zip |
| |
| tmp_zip = os.path.join(tmp_dir, os.path.basename(src_module_zip)) |
| with zipfile.ZipFile(tmp_zip, 'w') as dst_zip: |
| for info in src_zip.infolist(): |
| src_path = info.filename |
| is_compressed = info.compress_type != zipfile.ZIP_STORED |
| |
| dst_path = src_path |
| if src_path in language_files: |
| dst_path = _RewriteLanguageAssetPath(src_path) |
| |
| build_utils.AddToZipHermetic( |
| dst_zip, |
| dst_path, |
| data=src_zip.read(src_path), |
| compress=is_compressed) |
| |
| return tmp_zip |
| |
| |
| def _GenerateBaseResourcesAllowList(base_module_rtxt_path, |
| base_allowlist_rtxt_path): |
| """Generate a allowlist of base master resource ids. |
| |
| Args: |
| base_module_rtxt_path: Path to base module R.txt file. |
| base_allowlist_rtxt_path: Path to base allowlist R.txt file. |
| Returns: |
| list of resource ids. |
| """ |
| ids_map = resource_utils.GenerateStringResourcesAllowList( |
| base_module_rtxt_path, base_allowlist_rtxt_path) |
| return ids_map.keys() |
| |
| |
| def _ConcatTextFiles(in_paths, out_path): |
| """Concatenate the contents of multiple text files into one. |
| |
| The each file contents is preceded by a line containing the original filename. |
| |
| Args: |
| in_paths: List of input file paths. |
| out_path: Path to output file. |
| """ |
| with open(out_path, 'w') as out_file: |
| for in_path in in_paths: |
| if not os.path.exists(in_path): |
| continue |
| with open(in_path, 'r') as in_file: |
| out_file.write('-- Contents of {}\n'.format(os.path.basename(in_path))) |
| out_file.write(in_file.read()) |
| |
| |
| def _LoadPathmap(pathmap_path): |
| """Load the pathmap of obfuscated resource paths. |
| |
| Returns: A dict mapping from obfuscated paths to original paths or an |
| empty dict if passed a None |pathmap_path|. |
| """ |
| if pathmap_path is None: |
| return {} |
| |
| pathmap = {} |
| with open(pathmap_path, 'r') as f: |
| for line in f: |
| line = line.strip() |
| if line.startswith('--') or line == '': |
| continue |
| original, renamed = line.split(' -> ') |
| pathmap[renamed] = original |
| return pathmap |
| |
| |
| def _WriteBundlePathmap(module_pathmap_paths, module_names, |
| bundle_pathmap_path): |
| """Combine the contents of module pathmaps into a bundle pathmap. |
| |
| This rebases the resource paths inside the module pathmap before adding them |
| to the bundle pathmap. So res/a.xml inside the base module pathmap would be |
| base/res/a.xml in the bundle pathmap. |
| """ |
| with open(bundle_pathmap_path, 'w') as bundle_pathmap_file: |
| for module_pathmap_path, module_name in zip(module_pathmap_paths, |
| module_names): |
| if not os.path.exists(module_pathmap_path): |
| continue |
| module_pathmap = _LoadPathmap(module_pathmap_path) |
| for short_path, long_path in module_pathmap.items(): |
| rebased_long_path = '{}/{}'.format(module_name, long_path) |
| rebased_short_path = '{}/{}'.format(module_name, short_path) |
| line = '{} -> {}\n'.format(rebased_long_path, rebased_short_path) |
| bundle_pathmap_file.write(line) |
| |
| |
| def _GetManifestForModule(bundle_path, module_name): |
| return ElementTree.fromstring( |
| bundletool.RunBundleTool([ |
| 'dump', 'manifest', '--bundle', bundle_path, '--module', module_name |
| ])) |
| |
| |
| def _GetComponentNames(manifest, tag_name): |
| android_name = '{%s}name' % manifest_utils.ANDROID_NAMESPACE |
| return [s.attrib.get(android_name) for s in manifest.iter(tag_name)] |
| |
| |
| def _MaybeCheckServicesAndProvidersPresentInBase(bundle_path, module_zips): |
| """Checks bundles with isolated splits define all services in the base module. |
| |
| Due to b/169196314, service classes are not found if they are not present in |
| the base module. Providers are also checked because they are loaded early in |
| startup, and keeping them in the base module gives more time for the chrome |
| split to load. |
| """ |
| base_manifest = _GetManifestForModule(bundle_path, 'base') |
| isolated_splits = base_manifest.get('{%s}isolatedSplits' % |
| manifest_utils.ANDROID_NAMESPACE) |
| if isolated_splits != 'true': |
| return |
| |
| # Collect service names from all split manifests. |
| base_zip = None |
| service_names = _GetComponentNames(base_manifest, 'service') |
| provider_names = _GetComponentNames(base_manifest, 'provider') |
| for module_zip in module_zips: |
| name = os.path.basename(module_zip)[:-len('.zip')] |
| if name == 'base': |
| base_zip = module_zip |
| else: |
| service_names.extend( |
| _GetComponentNames(_GetManifestForModule(bundle_path, name), |
| 'service')) |
| module_providers = _GetComponentNames( |
| _GetManifestForModule(bundle_path, name), 'provider') |
| if module_providers: |
| raise Exception("Providers should all be declared in the base manifest." |
| " '%s' module declared: %s" % (name, module_providers)) |
| |
| # Extract classes from the base module's dex. |
| classes = set() |
| base_package_name = manifest_utils.GetPackage(base_manifest) |
| for package in dexdump.Dump(base_zip): |
| for name, package_dict in package.items(): |
| if not name: |
| name = base_package_name |
| classes.update('%s.%s' % (name, c) |
| for c in package_dict['classes'].keys()) |
| |
| ignored_service_names = { |
| # Defined in the chime DFM manifest, but unused. |
| # org.chromium.chrome.browser.chime.ScheduledTaskService is used instead. |
| ("com.google.android.libraries.notifications.entrypoints.scheduled." |
| "ScheduledTaskService"), |
| |
| # Defined in the chime DFM manifest, only used pre-O (where isolated |
| # splits are not supported). |
| ("com.google.android.libraries.notifications.executor.impl.basic." |
| "ChimeExecutorApiService"), |
| } |
| |
| # Ensure all services are present in base module. |
| for service_name in service_names: |
| if service_name not in classes: |
| if service_name in ignored_service_names: |
| continue |
| raise Exception("Service %s should be present in the base module's dex." |
| " See b/169196314 for more details." % service_name) |
| |
| # Ensure all providers are present in base module. |
| for provider_name in provider_names: |
| if provider_name not in classes: |
| raise Exception( |
| "Provider %s should be present in the base module's dex." % |
| provider_name) |
| |
| |
| def main(args): |
| args = build_utils.ExpandFileArgs(args) |
| options = _ParseArgs(args) |
| |
| split_dimensions = [] |
| if options.split_dimensions: |
| split_dimensions = [x.upper() for x in options.split_dimensions] |
| |
| |
| with build_utils.TempDir() as tmp_dir: |
| module_zips = [ |
| _SplitModuleForAssetTargeting(module, tmp_dir, split_dimensions) \ |
| for module in options.module_zips] |
| |
| base_master_resource_ids = None |
| if options.base_module_rtxt_path: |
| base_master_resource_ids = _GenerateBaseResourcesAllowList( |
| options.base_module_rtxt_path, options.base_allowlist_rtxt_path) |
| |
| bundle_config = _GenerateBundleConfigJson( |
| options.uncompressed_assets, options.compress_shared_libraries, |
| split_dimensions, base_master_resource_ids) |
| |
| tmp_bundle = os.path.join(tmp_dir, 'tmp_bundle') |
| |
| # Important: bundletool requires that the bundle config file is |
| # named with a .pb.json extension. |
| tmp_bundle_config = tmp_bundle + '.BundleConfig.pb.json' |
| |
| with open(tmp_bundle_config, 'w') as f: |
| f.write(bundle_config) |
| |
| cmd_args = build_utils.JavaCmd(options.warnings_as_errors) + [ |
| '-jar', |
| bundletool.BUNDLETOOL_JAR_PATH, |
| 'build-bundle', |
| '--modules=' + ','.join(module_zips), |
| '--output=' + tmp_bundle, |
| '--config=' + tmp_bundle_config, |
| ] |
| |
| build_utils.CheckOutput( |
| cmd_args, |
| print_stdout=True, |
| print_stderr=True, |
| stderr_filter=build_utils.FilterReflectiveAccessJavaWarnings, |
| fail_on_output=options.warnings_as_errors) |
| |
| if options.validate_services: |
| # TODO(crbug.com/1126301): This step takes 0.4s locally for bundles with |
| # isolated splits disabled and 2s for bundles with isolated splits |
| # enabled. Consider making this run in parallel or move into a separate |
| # step before enabling isolated splits by default. |
| _MaybeCheckServicesAndProvidersPresentInBase(tmp_bundle, module_zips) |
| |
| shutil.move(tmp_bundle, options.out_bundle) |
| |
| if options.rtxt_out_path: |
| _ConcatTextFiles(options.rtxt_in_paths, options.rtxt_out_path) |
| |
| if options.pathmap_out_path: |
| _WriteBundlePathmap(options.pathmap_in_paths, options.module_names, |
| options.pathmap_out_path) |
| |
| |
| if __name__ == '__main__': |
| main(sys.argv[1:]) |