"""Create an Android application bundle from one or more bundle modules."""
import argparse
import concurrent.futures
import json
import logging
import os
import posixpath
import shutil
import sys
from xml.etree import ElementTree
import zipfile
sys.path.append(
os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)))
from pylib.utils import dexdump
import bundletool
from util import build_utils
from util import manifest_utils
from util import resource_utils
import action_helpers
import zip_helpers
_LOCALES_SUBDIR = 'assets/locales/'
_FALLBACK_LOCALE = 'en-US'
_ALL_SPLIT_DIMENSIONS = [ 'ABI', 'SCREEN_DENSITY', 'LANGUAGE' ]
_SHORTEN_LANGUAGE_CODE_MAP = {
'fil': 'tl',
}
_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'
]
_COMPONENT_TYPES = ('activity', 'provider', 'receiver', 'service')
_DEDUPE_ENTRY_TYPES = _COMPONENT_TYPES + ('activity-alias', 'meta-data')
_ROTATION_METADATA_KEY = 'com.google.play.apps.signing/RotationConfig.textproto'
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-dex',
action='store_true',
help='Compress .dex files')
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('--rotation-config',
help='Path to a RotationConfig.textproto')
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 = action_helpers.parse_gn_list(options.module_zips)
if len(options.module_zips) == 0:
parser.error('The module zip list cannot be empty.')
if len(options.module_zips) != len(options.module_names):
parser.error('# module zips != # names.')
if 'base' not in options.module_names:
parser.error('Missing base module.')
per_module_values = list(
zip(options.module_names, options.module_zips,
options.uncompressed_assets, options.rtxt_in_paths,
options.pathmap_in_paths))
per_module_values.sort(key=lambda x: (x[0] != 'base', x[0]))
options.module_names = [x[0] for x in per_module_values]
options.module_zips = [x[1] for x in per_module_values]
options.uncompressed_assets = [x[2] for x in per_module_values]
options.rtxt_in_paths = [x[3] for x in per_module_values]
options.pathmap_in_paths = [x[4] for x in per_module_values]
options.rtxt_in_paths = action_helpers.parse_gn_list(options.rtxt_in_paths)
options.pathmap_in_paths = action_helpers.parse_gn_list(
options.pathmap_in_paths)
uncompressed_list = []
for entry in action_helpers.parse_gn_list(options.uncompressed_assets):
pos = entry.find(':')
if pos >= 0:
uncompressed_list.append(entry[pos + 1:])
else:
uncompressed_list.append(entry)
options.uncompressed_assets = set(uncompressed_list)
if options.split_dimensions:
options.split_dimensions = action_helpers.parse_gn_list(
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)))
if options.base_allowlist_rtxt_path == '':
options.base_module_rtxt_path = None
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_dex,
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.
compressed_dex: Boolean, whether to compress .dex.
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.
"""
split_dimensions = [ _MakeSplitDimension(dim, dim in split_dimensions)
for dim in _ALL_SPLIT_DIMENSIONS ]
uncompressed_globs = [
'assets/locales#lang_*/*.pak', 'assets/fallback-locales/*.pak'
]
uncompressed_globs.extend(
posixpath.normpath('assets/' + x) for x in uncompressed_assets)
uncompressed_globs.extend('**.' + ext for ext in _UNCOMPRESSED_FILE_EXTS)
if not compress_dex:
uncompressed_globs.append('classesX.dex')
data = {
'optimizations': {
'splitsConfig': {
'splitDimension': split_dimensions,
},
'uncompressNativeLibraries': {
'enabled': True,
'alignment': 'PAGE_ALIGNMENT_16K'
},
'uncompressDexFiles': {
'enabled': True,
}
},
'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)
pos = android_locale.find('-')
if android_locale.startswith('b+'):
android_language = android_locale.split('+')[1]
elif pos >= 0:
android_language = android_locale[:pos]
else:
android_language = android_locale
if locale == _FALLBACK_LOCALE:
result_path = 'assets/fallback-locales/%s.pak' % locale
else:
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:
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:
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)
zip_helpers.add_to_zip_hermetic(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):
data = bundletool.RunBundleTool(
['dump', 'manifest', '--bundle', bundle_path, '--module', module_name])
try:
return ElementTree.fromstring(data)
except ElementTree.ParseError:
sys.stderr.write('Failed to parse:\n')
sys.stderr.write(data)
raise
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 _ClassesFromZip(module_zip):
classes = set()
for package in dexdump.Dump(module_zip):
for java_package, package_dict in package.items():
java_package += '.' if java_package else ''
classes.update(java_package + c for c in package_dict['classes'])
return classes
def _ValidateSplits(bundle_path, module_zips):
logging.info('Reading manifests and running dexdump')
base_zip = next(p for p in module_zips if os.path.basename(p) == 'base.zip')
module_names = sorted(os.path.basename(p)[:-len('.zip')] for p in module_zips)
with concurrent.futures.ThreadPoolExecutor() as executor:
classes_future = executor.submit(_ClassesFromZip, base_zip)
manifest_futures = [
executor.submit(_GetManifestForModule, bundle_path, n)
for n in module_names
]
manifests_by_name = dict(
zip(module_names, (f.result() for f in manifest_futures)))
base_classes = classes_future.result()
logging.info('Performing checks')
errors = []
splits_by_component = {}
for module_name, cur_manifest in manifests_by_name.items():
for kind in _DEDUPE_ENTRY_TYPES:
for component in _GetComponentNames(cur_manifest, kind):
owner_module_name = splits_by_component.setdefault((kind, component),
module_name)
if (owner_module_name != module_name
and not component.endswith('HolderService')):
errors.append(f'The {kind} "{component}" appeared in both '
f'{owner_module_name} and {module_name}.')
for (kind, component), module_name in splits_by_component.items():
if module_name == 'base' and kind in _COMPONENT_TYPES:
if component not in base_classes:
errors.append(f"{component} is defined in the base manfiest, "
f"but the class does not exist in the base splits' dex")
isolated_splits = manifests_by_name['base'].get(
f'{manifest_utils.ANDROID_NAMESPACE}isolatedSplits')
if isolated_splits != 'true':
return errors
for module_name, cur_manifest in manifests_by_name.items():
if module_name == 'base':
continue
provider_names = _GetComponentNames(cur_manifest, 'provider')
if provider_names:
errors.append('Providers should all be declared in the base manifest.'
' "%s" module declared: %s' % (module_name, provider_names))
for cur_manifest in manifests_by_name.values():
for service_name in _GetComponentNames(cur_manifest, 'service'):
if service_name not in base_classes:
errors.append("Service %s should be present in the base module's dex."
" See b/169196314 for more details." % service_name)
return errors
def main(args):
build_utils.InitLogging('AAB_DEBUG')
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:
logging.info('Splitting locale assets')
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:
logging.info('Creating R.txt allowlist')
base_master_resource_ids = _GenerateBaseResourcesAllowList(
options.base_module_rtxt_path, options.base_allowlist_rtxt_path)
logging.info('Creating BundleConfig.pb.json')
bundle_config = _GenerateBundleConfigJson(options.uncompressed_assets,
options.compress_dex,
split_dimensions,
base_master_resource_ids)
tmp_bundle = os.path.join(tmp_dir, 'tmp_bundle')
tmp_bundle_config = tmp_bundle + '.BundleConfig.pb.json'
with open(tmp_bundle_config, 'w') as f:
f.write(bundle_config)
logging.info('Running bundletool')
cmd_args = build_utils.JavaCmd() + [
'-jar',
bundletool.BUNDLETOOL_JAR_PATH,
'build-bundle',
'--modules=' + ','.join(module_zips),
'--output=' + tmp_bundle,
'--config=' + tmp_bundle_config,
]
if options.rotation_config:
cmd_args += [
f'--metadata-file={_ROTATION_METADATA_KEY}:{options.rotation_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:
logging.info('Validating isolated split manifests')
errors = _ValidateSplits(tmp_bundle, module_zips)
if errors:
sys.stderr.write('Bundle failed sanity checks:\n ')
sys.stderr.write('\n '.join(errors))
sys.stderr.write('\n')
sys.exit(1)
logging.info('Writing final output artifacts')
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:])