1056 lines
39 KiB
Python
1056 lines
39 KiB
Python
# Copyright 2018 The Chromium Authors
|
|
# Use of this source code is governed by a BSD-style license that can be
|
|
# found in the LICENSE file.
|
|
|
|
import collections
|
|
import contextlib
|
|
import itertools
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
from xml.etree import ElementTree
|
|
|
|
import util.build_utils as build_utils
|
|
|
|
_SOURCE_ROOT = os.path.abspath(
|
|
os.path.join(os.path.dirname(__file__), '..', '..', '..', '..'))
|
|
# Import jinja2 from third_party/jinja2
|
|
sys.path.insert(1, os.path.join(_SOURCE_ROOT, 'third_party'))
|
|
from jinja2 import Template # pylint: disable=F0401
|
|
|
|
|
|
# A variation of these maps also exists in:
|
|
# //base/android/java/src/org/chromium/base/LocaleUtils.java
|
|
# //ui/android/java/src/org/chromium/base/LocalizationUtils.java
|
|
_CHROME_TO_ANDROID_LOCALE_MAP = {
|
|
'es-419': 'es-rUS',
|
|
'sr-Latn': 'b+sr+Latn',
|
|
'fil': 'tl',
|
|
'he': 'iw',
|
|
'id': 'in',
|
|
'yi': 'ji',
|
|
}
|
|
_ANDROID_TO_CHROMIUM_LANGUAGE_MAP = {
|
|
'tl': 'fil',
|
|
'iw': 'he',
|
|
'in': 'id',
|
|
'ji': 'yi',
|
|
'no': 'nb', # 'no' is not a real language. http://crbug.com/920960
|
|
}
|
|
|
|
ALL_RESOURCE_TYPES = {
|
|
'anim', 'animator', 'array', 'attr', 'bool', 'color', 'dimen', 'drawable',
|
|
'font', 'fraction', 'id', 'integer', 'interpolator', 'layout', 'macro',
|
|
'menu', 'mipmap', 'plurals', 'raw', 'string', 'style', 'styleable',
|
|
'transition', 'xml'
|
|
}
|
|
|
|
AAPT_IGNORE_PATTERN = ':'.join([
|
|
'*OWNERS', # Allow OWNERS files within res/
|
|
'DIR_METADATA', # Allow DIR_METADATA files within res/
|
|
'*.py', # PRESUBMIT.py sometimes exist.
|
|
'*.pyc',
|
|
'*~', # Some editors create these as temp files.
|
|
'.*', # Never makes sense to include dot(files/dirs).
|
|
'*.d.stamp', # Ignore stamp files
|
|
'*.backup', # Some tools create temporary backup files.
|
|
])
|
|
|
|
MULTIPLE_RES_MAGIC_STRING = b'magic'
|
|
|
|
|
|
def ToAndroidLocaleName(chromium_locale):
|
|
"""Convert a Chromium locale name into a corresponding Android one."""
|
|
# Should be in sync with build/config/locales.gni.
|
|
# First handle the special cases, these are needed to deal with Android
|
|
# releases *before* 5.0/Lollipop.
|
|
android_locale = _CHROME_TO_ANDROID_LOCALE_MAP.get(chromium_locale)
|
|
if android_locale:
|
|
return android_locale
|
|
|
|
# Format of Chromium locale name is '<lang>' or '<lang>-<region>'
|
|
# where <lang> is a 2 or 3 letter language code (ISO 639-1 or 639-2)
|
|
# and region is a capitalized locale region name.
|
|
lang, _, region = chromium_locale.partition('-')
|
|
if not region:
|
|
return lang
|
|
|
|
# Translate newer language tags into obsolete ones. Only necessary if
|
|
# region is not None (e.g. 'he-IL' -> 'iw-rIL')
|
|
lang = _CHROME_TO_ANDROID_LOCALE_MAP.get(lang, lang)
|
|
|
|
# Using '<lang>-r<region>' is now acceptable as a locale name for all
|
|
# versions of Android.
|
|
return '%s-r%s' % (lang, region)
|
|
|
|
|
|
# ISO 639 language code + optional ("-r" + capitalized region code).
|
|
# Note that before Android 5.0/Lollipop, only 2-letter ISO 639-1 codes
|
|
# are supported.
|
|
_RE_ANDROID_LOCALE_QUALIFIER_1 = re.compile(r'^([a-z]{2,3})(\-r([A-Z]+))?$')
|
|
|
|
# Starting with Android 7.0/Nougat, BCP 47 codes are supported but must
|
|
# be prefixed with 'b+', and may include optional tags.
|
|
# e.g. 'b+en+US', 'b+ja+Latn', 'b+ja+Latn+JP'
|
|
_RE_ANDROID_LOCALE_QUALIFIER_2 = re.compile(r'^b\+([a-z]{2,3})(\+.+)?$')
|
|
|
|
|
|
def ToChromiumLocaleName(android_locale):
|
|
"""Convert an Android locale name into a Chromium one."""
|
|
lang = None
|
|
region = None
|
|
script = None
|
|
m = _RE_ANDROID_LOCALE_QUALIFIER_1.match(android_locale)
|
|
if m:
|
|
lang = m.group(1)
|
|
if m.group(2):
|
|
region = m.group(3)
|
|
elif _RE_ANDROID_LOCALE_QUALIFIER_2.match(android_locale):
|
|
# Split an Android BCP-47 locale (e.g. b+sr+Latn+RS)
|
|
tags = android_locale.split('+')
|
|
|
|
# The Lang tag is always the first tag.
|
|
lang = tags[1]
|
|
|
|
# The optional region tag is 2ALPHA or 3DIGIT tag in pos 1 or 2.
|
|
# The optional script tag is 4ALPHA and always in pos 1.
|
|
optional_tags = iter(tags[2:])
|
|
|
|
next_tag = next(optional_tags, None)
|
|
if next_tag and len(next_tag) == 4:
|
|
script = next_tag
|
|
next_tag = next(optional_tags, None)
|
|
if next_tag and len(next_tag) < 4:
|
|
region = next_tag
|
|
|
|
if not lang:
|
|
return None
|
|
|
|
# Special case for es-rUS -> es-419
|
|
if lang == 'es' and region == 'US':
|
|
return 'es-419'
|
|
|
|
lang = _ANDROID_TO_CHROMIUM_LANGUAGE_MAP.get(lang, lang)
|
|
|
|
if script:
|
|
lang = '%s-%s' % (lang, script)
|
|
|
|
if not region:
|
|
return lang
|
|
|
|
return '%s-%s' % (lang, region)
|
|
|
|
|
|
def IsAndroidLocaleQualifier(string):
|
|
"""Returns true if |string| is a valid Android resource locale qualifier."""
|
|
return (_RE_ANDROID_LOCALE_QUALIFIER_1.match(string)
|
|
or _RE_ANDROID_LOCALE_QUALIFIER_2.match(string))
|
|
|
|
|
|
def FindLocaleInStringResourceFilePath(file_path):
|
|
"""Return Android locale name of a string resource file path.
|
|
|
|
Args:
|
|
file_path: A file path.
|
|
Returns:
|
|
If |file_path| is of the format '.../values-<locale>/<name>.xml', return
|
|
the value of <locale> (and Android locale qualifier). Otherwise return None.
|
|
"""
|
|
if not file_path.endswith('.xml'):
|
|
return None
|
|
prefix = 'values-'
|
|
dir_name = os.path.basename(os.path.dirname(file_path))
|
|
if not dir_name.startswith(prefix):
|
|
return None
|
|
qualifier = dir_name[len(prefix):]
|
|
return qualifier if IsAndroidLocaleQualifier(qualifier) else None
|
|
|
|
|
|
def ToAndroidLocaleList(locale_list):
|
|
"""Convert a list of Chromium locales into the corresponding Android list."""
|
|
return sorted(ToAndroidLocaleName(locale) for locale in locale_list)
|
|
|
|
# Represents a line from a R.txt file.
|
|
_TextSymbolEntry = collections.namedtuple('RTextEntry',
|
|
('java_type', 'resource_type', 'name', 'value'))
|
|
|
|
|
|
def _GenerateGlobs(pattern):
|
|
# This function processes the aapt ignore assets pattern into a list of globs
|
|
# to be used to exclude files using build_utils.MatchesGlob. It removes the
|
|
# '!', which is used by aapt to mean 'not chatty' so it does not output if the
|
|
# file is ignored (we dont output anyways, so it is not required). This
|
|
# function does not handle the <dir> and <file> prefixes used by aapt and are
|
|
# assumed not to be included in the pattern string.
|
|
return pattern.replace('!', '').split(':')
|
|
|
|
|
|
def DeduceResourceDirsFromFileList(resource_files):
|
|
"""Return a list of resource directories from a list of resource files."""
|
|
# Directory list order is important, cannot use set or other data structures
|
|
# that change order. This is because resource files of the same name in
|
|
# multiple res/ directories ellide one another (the last one passed is used).
|
|
# Thus the order must be maintained to prevent non-deterministic and possibly
|
|
# flakey builds.
|
|
resource_dirs = []
|
|
for resource_path in resource_files:
|
|
# Resources are always 1 directory deep under res/.
|
|
res_dir = os.path.dirname(os.path.dirname(resource_path))
|
|
if res_dir not in resource_dirs:
|
|
resource_dirs.append(res_dir)
|
|
|
|
# Check if any resource_dirs are children of other ones. This indicates that a
|
|
# file was listed that is not exactly 1 directory deep under res/.
|
|
# E.g.:
|
|
# sources = ["java/res/values/foo.xml", "java/res/README.md"]
|
|
# ^^ This will cause "java" to be detected as resource directory.
|
|
for a, b in itertools.permutations(resource_dirs, 2):
|
|
if not os.path.relpath(a, b).startswith('..'):
|
|
bad_sources = (s for s in resource_files
|
|
if os.path.dirname(os.path.dirname(s)) == b)
|
|
msg = """\
|
|
Resource(s) found that are not in a proper directory structure:
|
|
{}
|
|
All resource files must follow a structure of "$ROOT/$SUBDIR/$FILE"."""
|
|
raise Exception(msg.format('\n '.join(bad_sources)))
|
|
|
|
return resource_dirs
|
|
|
|
|
|
def IterResourceFilesInDirectories(directories,
|
|
ignore_pattern=AAPT_IGNORE_PATTERN):
|
|
globs = _GenerateGlobs(ignore_pattern)
|
|
for d in directories:
|
|
for root, _, files in os.walk(d):
|
|
for f in files:
|
|
archive_path = f
|
|
parent_dir = os.path.relpath(root, d)
|
|
if parent_dir != '.':
|
|
archive_path = os.path.join(parent_dir, f)
|
|
path = os.path.join(root, f)
|
|
if build_utils.MatchesGlob(archive_path, globs):
|
|
continue
|
|
yield path, archive_path
|
|
|
|
|
|
class ResourceInfoFile:
|
|
"""Helper for building up .res.info files."""
|
|
|
|
def __init__(self):
|
|
# Dict of archive_path -> source_path for the current target.
|
|
self._entries = {}
|
|
# List of (old_archive_path, new_archive_path) tuples.
|
|
self._renames = []
|
|
# We don't currently support using both AddMapping and MergeInfoFile.
|
|
self._add_mapping_was_called = False
|
|
|
|
def AddMapping(self, archive_path, source_path):
|
|
"""Adds a single |archive_path| -> |source_path| entry."""
|
|
self._add_mapping_was_called = True
|
|
# "values/" files do not end up in the apk except through resources.arsc.
|
|
if archive_path.startswith('values'):
|
|
return
|
|
source_path = os.path.normpath(source_path)
|
|
new_value = self._entries.setdefault(archive_path, source_path)
|
|
if new_value != source_path:
|
|
raise Exception('Duplicate AddMapping for "{}". old={} new={}'.format(
|
|
archive_path, new_value, source_path))
|
|
|
|
def RegisterRename(self, old_archive_path, new_archive_path):
|
|
"""Records an archive_path rename.
|
|
|
|
|old_archive_path| does not need to currently exist in the mappings. Renames
|
|
are buffered and replayed only when Write() is called.
|
|
"""
|
|
if not old_archive_path.startswith('values'):
|
|
self._renames.append((old_archive_path, new_archive_path))
|
|
|
|
def MergeInfoFile(self, info_file_path):
|
|
"""Merges the mappings from |info_file_path| into this object.
|
|
|
|
Any existing entries are overridden.
|
|
"""
|
|
assert not self._add_mapping_was_called
|
|
# Allows clobbering, which is used when overriding resources.
|
|
with open(info_file_path) as f:
|
|
self._entries.update(l.rstrip().split('\t') for l in f)
|
|
|
|
def _ApplyRenames(self):
|
|
applied_renames = set()
|
|
ret = self._entries
|
|
for rename_tup in self._renames:
|
|
# Duplicate entries happen for resource overrides.
|
|
# Use a "seen" set to ensure we still error out if multiple renames
|
|
# happen for the same old_archive_path with different new_archive_paths.
|
|
if rename_tup in applied_renames:
|
|
continue
|
|
applied_renames.add(rename_tup)
|
|
old_archive_path, new_archive_path = rename_tup
|
|
ret[new_archive_path] = ret[old_archive_path]
|
|
del ret[old_archive_path]
|
|
|
|
self._entries = None
|
|
self._renames = None
|
|
return ret
|
|
|
|
def Write(self, info_file_path):
|
|
"""Applies renames and writes out the file.
|
|
|
|
No other methods may be called after this.
|
|
"""
|
|
entries = self._ApplyRenames()
|
|
lines = []
|
|
for archive_path, source_path in entries.items():
|
|
lines.append('{}\t{}\n'.format(archive_path, source_path))
|
|
with open(info_file_path, 'w') as info_file:
|
|
info_file.writelines(sorted(lines))
|
|
|
|
|
|
def _ParseTextSymbolsFile(path, fix_package_ids=False):
|
|
"""Given an R.txt file, returns a list of _TextSymbolEntry.
|
|
|
|
Args:
|
|
path: Input file path.
|
|
fix_package_ids: if True, 0x00 and 0x02 package IDs read from the file
|
|
will be fixed to 0x7f.
|
|
Returns:
|
|
A list of _TextSymbolEntry instances.
|
|
Raises:
|
|
Exception: An unexpected line was detected in the input.
|
|
"""
|
|
ret = []
|
|
with open(path) as f:
|
|
for line in f:
|
|
m = re.match(r'(int(?:\[\])?) (\w+) (\w+) (.+)$', line)
|
|
if not m:
|
|
raise Exception('Unexpected line in R.txt: %s' % line)
|
|
java_type, resource_type, name, value = m.groups()
|
|
if fix_package_ids:
|
|
value = _FixPackageIds(value)
|
|
ret.append(_TextSymbolEntry(java_type, resource_type, name, value))
|
|
return ret
|
|
|
|
|
|
def _FixPackageIds(resource_value):
|
|
# Resource IDs for resources belonging to regular APKs have their first byte
|
|
# as 0x7f (package id). However with webview, since it is not a regular apk
|
|
# but used as a shared library, aapt is passed the --shared-resources flag
|
|
# which changes some of the package ids to 0x00. This function normalises
|
|
# these (0x00) package ids to 0x7f, which the generated code in R.java changes
|
|
# to the correct package id at runtime. resource_value is a string with
|
|
# either, a single value '0x12345678', or an array of values like '{
|
|
# 0xfedcba98, 0x01234567, 0x56789abc }'
|
|
return resource_value.replace('0x00', '0x7f')
|
|
|
|
|
|
def ResolveStyleableReferences(r_txt_path):
|
|
# Convert lines like:
|
|
# int[] styleable ViewBack { 0x010100d4, com.android.webview.R.attr.backTint }
|
|
# to:
|
|
# int[] styleable ViewBack { 0x010100d4, 0xREALVALUE }
|
|
entries = _ParseTextSymbolsFile(r_txt_path)
|
|
lookup_table = {(e.resource_type, e.name): e.value for e in entries}
|
|
|
|
sb = []
|
|
with open(r_txt_path, encoding='utf8') as f:
|
|
for l in f:
|
|
if l.startswith('int[] styleable'):
|
|
brace_start = l.index('{') + 2
|
|
brace_end = l.index('}') - 1
|
|
values = [x for x in l[brace_start:brace_end].split(', ') if x]
|
|
new_values = []
|
|
for v in values:
|
|
try:
|
|
if not v.startswith('0x'):
|
|
resource_type, name = v.split('.')[-2:]
|
|
new_values.append(lookup_table[(resource_type, name)])
|
|
else:
|
|
new_values.append(v)
|
|
except:
|
|
logging.warning('Failed line: %r %r', l, v)
|
|
raise
|
|
l = l[:brace_start] + ', '.join(new_values) + l[brace_end:]
|
|
sb.append(l)
|
|
|
|
with open(r_txt_path, 'w', encoding='utf8') as f:
|
|
f.writelines(sb)
|
|
|
|
|
|
def _GetRTxtResourceNames(r_txt_path):
|
|
"""Parse an R.txt file and extract the set of resource names from it."""
|
|
return {entry.name for entry in _ParseTextSymbolsFile(r_txt_path)}
|
|
|
|
|
|
def GetRTxtStringResourceNames(r_txt_path):
|
|
"""Parse an R.txt file and the list of its string resource names."""
|
|
return sorted({
|
|
entry.name
|
|
for entry in _ParseTextSymbolsFile(r_txt_path)
|
|
if entry.resource_type == 'string'
|
|
})
|
|
|
|
|
|
def GenerateStringResourcesAllowList(module_r_txt_path, allowlist_r_txt_path):
|
|
"""Generate a allowlist of string resource IDs.
|
|
|
|
Args:
|
|
module_r_txt_path: Input base module R.txt path.
|
|
allowlist_r_txt_path: Input allowlist R.txt path.
|
|
Returns:
|
|
A dictionary mapping numerical resource IDs to the corresponding
|
|
string resource names. The ID values are taken from string resources in
|
|
|module_r_txt_path| that are also listed by name in |allowlist_r_txt_path|.
|
|
"""
|
|
allowlisted_names = {
|
|
entry.name
|
|
for entry in _ParseTextSymbolsFile(allowlist_r_txt_path)
|
|
if entry.resource_type == 'string'
|
|
}
|
|
return {
|
|
int(entry.value, 0): entry.name
|
|
for entry in _ParseTextSymbolsFile(module_r_txt_path)
|
|
if entry.resource_type == 'string' and entry.name in allowlisted_names
|
|
}
|
|
|
|
|
|
class RJavaBuildOptions:
|
|
"""A class used to model the various ways to build an R.java file.
|
|
|
|
This is used to control which resource ID variables will be final or
|
|
non-final, and whether an onResourcesLoaded() method will be generated
|
|
to adjust the non-final ones, when the corresponding library is loaded
|
|
at runtime.
|
|
|
|
Note that by default, all resources are final, and there is no
|
|
method generated, which corresponds to calling ExportNoResources().
|
|
"""
|
|
def __init__(self):
|
|
self.has_constant_ids = True
|
|
self.resources_allowlist = None
|
|
self.has_on_resources_loaded = False
|
|
self.export_const_styleable = False
|
|
self.final_package_id = None
|
|
self.fake_on_resources_loaded = False
|
|
|
|
def ExportNoResources(self):
|
|
"""Make all resource IDs final, and don't generate a method."""
|
|
self.has_constant_ids = True
|
|
self.resources_allowlist = None
|
|
self.has_on_resources_loaded = False
|
|
self.export_const_styleable = False
|
|
|
|
def ExportAllResources(self):
|
|
"""Make all resource IDs non-final in the R.java file."""
|
|
self.has_constant_ids = False
|
|
self.resources_allowlist = None
|
|
|
|
def ExportSomeResources(self, r_txt_file_path):
|
|
"""Only select specific resource IDs to be non-final.
|
|
|
|
Args:
|
|
r_txt_file_path: The path to an R.txt file. All resources named
|
|
int it will be non-final in the generated R.java file, all others
|
|
will be final.
|
|
"""
|
|
self.has_constant_ids = True
|
|
self.resources_allowlist = _GetRTxtResourceNames(r_txt_file_path)
|
|
|
|
def ExportAllStyleables(self):
|
|
"""Make all styleable constants non-final, even non-resources ones.
|
|
|
|
Resources that are styleable but not of int[] type are not actually
|
|
resource IDs but constants. By default they are always final. Call this
|
|
method to make them non-final anyway in the final R.java file.
|
|
"""
|
|
self.export_const_styleable = True
|
|
|
|
def GenerateOnResourcesLoaded(self, fake=False):
|
|
"""Generate an onResourcesLoaded() method.
|
|
|
|
This Java method will be called at runtime by the framework when
|
|
the corresponding library (which includes the R.java source file)
|
|
will be loaded at runtime. This corresponds to the --shared-resources
|
|
or --app-as-shared-lib flags of 'aapt package'.
|
|
|
|
if |fake|, then the method will be empty bodied to compile faster. This
|
|
useful for dummy R.java files that will eventually be replaced by real
|
|
ones.
|
|
"""
|
|
self.has_on_resources_loaded = True
|
|
self.fake_on_resources_loaded = fake
|
|
|
|
def SetFinalPackageId(self, package_id):
|
|
"""Sets a package ID to be used for resources marked final."""
|
|
self.final_package_id = package_id
|
|
|
|
def _MaybeRewriteRTxtPackageIds(self, r_txt_path):
|
|
"""Rewrites package IDs in the R.txt file if necessary.
|
|
|
|
If SetFinalPackageId() was called, some of the resource IDs may have had
|
|
their package ID changed. This function rewrites the R.txt file to match
|
|
those changes.
|
|
"""
|
|
if self.final_package_id is None:
|
|
return
|
|
|
|
entries = _ParseTextSymbolsFile(r_txt_path)
|
|
with open(r_txt_path, 'w') as f:
|
|
for entry in entries:
|
|
value = entry.value
|
|
if self._IsResourceFinal(entry):
|
|
value = re.sub(r'0x(?:00|7f)',
|
|
'0x{:02x}'.format(self.final_package_id), value)
|
|
f.write('{} {} {} {}\n'.format(entry.java_type, entry.resource_type,
|
|
entry.name, value))
|
|
|
|
def _IsResourceFinal(self, entry):
|
|
"""Determines whether a resource should be final or not.
|
|
|
|
Args:
|
|
entry: A _TextSymbolEntry instance.
|
|
Returns:
|
|
True iff the corresponding entry should be final.
|
|
"""
|
|
if entry.resource_type == 'styleable' and entry.java_type != 'int[]':
|
|
# A styleable constant may be exported as non-final after all.
|
|
return not self.export_const_styleable
|
|
if not self.has_constant_ids:
|
|
# Every resource is non-final
|
|
return False
|
|
if not self.resources_allowlist:
|
|
# No allowlist means all IDs are non-final.
|
|
return True
|
|
# Otherwise, only those in the
|
|
return entry.name not in self.resources_allowlist
|
|
|
|
|
|
def CreateRJavaFiles(srcjar_dir,
|
|
package,
|
|
main_r_txt_file,
|
|
extra_res_packages,
|
|
rjava_build_options,
|
|
srcjar_out,
|
|
custom_root_package_name=None,
|
|
grandparent_custom_package_name=None,
|
|
ignore_mismatched_values=False):
|
|
"""Create all R.java files for a set of packages and R.txt files.
|
|
|
|
Args:
|
|
srcjar_dir: The top-level output directory for the generated files.
|
|
package: Package name for R java source files which will inherit
|
|
from the root R java file.
|
|
main_r_txt_file: The main R.txt file containing the valid values
|
|
of _all_ resource IDs.
|
|
extra_res_packages: A list of extra package names.
|
|
rjava_build_options: An RJavaBuildOptions instance that controls how
|
|
exactly the R.java file is generated.
|
|
srcjar_out: Path of desired output srcjar.
|
|
custom_root_package_name: Custom package name for module root R.java file,
|
|
(eg. vr for gen.vr package).
|
|
grandparent_custom_package_name: Custom root package name for the root
|
|
R.java file to inherit from. DFM root R.java files will have "base"
|
|
as the grandparent_custom_package_name. The format of this package name
|
|
is identical to custom_root_package_name.
|
|
(eg. for vr grandparent_custom_package_name would be "base")
|
|
ignore_mismatched_values: If True, ignores if a resource appears multiple
|
|
times with different entry values (useful when all the values are
|
|
dummy anyways).
|
|
Raises:
|
|
Exception if a package name appears several times in |extra_res_packages|
|
|
"""
|
|
rjava_build_options._MaybeRewriteRTxtPackageIds(main_r_txt_file)
|
|
|
|
packages = list(extra_res_packages)
|
|
|
|
if package and package not in packages:
|
|
# Sometimes, an apk target and a resources target share the same
|
|
# AndroidManifest.xml and thus |package| will already be in |packages|.
|
|
packages.append(package)
|
|
|
|
# Map of (resource_type, name) -> Entry.
|
|
# Contains the correct values for resources.
|
|
all_resources = {}
|
|
all_resources_by_type = collections.defaultdict(list)
|
|
|
|
main_r_text_files = [main_r_txt_file]
|
|
for r_txt_file in main_r_text_files:
|
|
for entry in _ParseTextSymbolsFile(r_txt_file, fix_package_ids=True):
|
|
entry_key = (entry.resource_type, entry.name)
|
|
if entry_key in all_resources:
|
|
if not ignore_mismatched_values:
|
|
assert entry == all_resources[entry_key], (
|
|
'Input R.txt %s provided a duplicate resource with a different '
|
|
'entry value. Got %s, expected %s.' %
|
|
(r_txt_file, entry, all_resources[entry_key]))
|
|
else:
|
|
all_resources[entry_key] = entry
|
|
all_resources_by_type[entry.resource_type].append(entry)
|
|
assert entry.resource_type in ALL_RESOURCE_TYPES, (
|
|
'Unknown resource type: %s, add to ALL_RESOURCE_TYPES!' %
|
|
entry.resource_type)
|
|
|
|
if custom_root_package_name:
|
|
# Custom package name is available, thus use it for root_r_java_package.
|
|
root_r_java_package = GetCustomPackagePath(custom_root_package_name)
|
|
else:
|
|
# Create a unique name using srcjar_out. Underscores are added to ensure
|
|
# no reserved keywords are used for directory names.
|
|
root_r_java_package = re.sub('[^\w\.]', '', srcjar_out.replace('/', '._'))
|
|
|
|
root_r_java_dir = os.path.join(srcjar_dir, *root_r_java_package.split('.'))
|
|
build_utils.MakeDirectory(root_r_java_dir)
|
|
root_r_java_path = os.path.join(root_r_java_dir, 'R.java')
|
|
root_java_file_contents = _RenderRootRJavaSource(
|
|
root_r_java_package, all_resources_by_type, rjava_build_options,
|
|
grandparent_custom_package_name)
|
|
with open(root_r_java_path, 'w') as f:
|
|
f.write(root_java_file_contents)
|
|
|
|
for p in packages:
|
|
_CreateRJavaSourceFile(srcjar_dir, p, root_r_java_package,
|
|
rjava_build_options)
|
|
|
|
|
|
def _CreateRJavaSourceFile(srcjar_dir, package, root_r_java_package,
|
|
rjava_build_options):
|
|
"""Generates an R.java source file."""
|
|
package_r_java_dir = os.path.join(srcjar_dir, *package.split('.'))
|
|
build_utils.MakeDirectory(package_r_java_dir)
|
|
package_r_java_path = os.path.join(package_r_java_dir, 'R.java')
|
|
java_file_contents = _RenderRJavaSource(package, root_r_java_package,
|
|
rjava_build_options)
|
|
with open(package_r_java_path, 'w') as f:
|
|
f.write(java_file_contents)
|
|
|
|
|
|
# Resource IDs inside resource arrays are sorted. Application resource IDs start
|
|
# with 0x7f but system resource IDs start with 0x01 thus system resource ids are
|
|
# always at the start of the array. This function finds the index of the first
|
|
# non system resource id to be used for package ID rewriting (we should not
|
|
# rewrite system resource ids).
|
|
def _GetNonSystemIndex(entry):
|
|
"""Get the index of the first application resource ID within a resource
|
|
array."""
|
|
res_ids = re.findall(r'0x[0-9a-f]{8}', entry.value)
|
|
for i, res_id in enumerate(res_ids):
|
|
if res_id.startswith('0x7f'):
|
|
return i
|
|
return len(res_ids)
|
|
|
|
|
|
def _RenderRJavaSource(package, root_r_java_package, rjava_build_options):
|
|
"""Generates the contents of a R.java file."""
|
|
template = Template(
|
|
"""/* AUTO-GENERATED FILE. DO NOT MODIFY. */
|
|
|
|
package {{ package }};
|
|
|
|
public final class R {
|
|
{% for resource_type in resource_types %}
|
|
public static final class {{ resource_type }} extends
|
|
{{ root_package }}.R.{{ resource_type }} {}
|
|
{% endfor %}
|
|
{% if has_on_resources_loaded %}
|
|
public static void onResourcesLoaded(int packageId) {
|
|
{{ root_package }}.R.onResourcesLoaded(packageId);
|
|
}
|
|
{% endif %}
|
|
}
|
|
""",
|
|
trim_blocks=True,
|
|
lstrip_blocks=True)
|
|
|
|
return template.render(
|
|
package=package,
|
|
resource_types=sorted(ALL_RESOURCE_TYPES),
|
|
root_package=root_r_java_package,
|
|
has_on_resources_loaded=rjava_build_options.has_on_resources_loaded)
|
|
|
|
|
|
def GetCustomPackagePath(package_name):
|
|
return 'gen.' + package_name + '_module'
|
|
|
|
|
|
def _RenderRootRJavaSource(package, all_resources_by_type, rjava_build_options,
|
|
grandparent_custom_package_name):
|
|
"""Render an R.java source file. See _CreateRJaveSourceFile for args info."""
|
|
final_resources_by_type = collections.defaultdict(list)
|
|
non_final_resources_by_type = collections.defaultdict(list)
|
|
for res_type, resources in all_resources_by_type.items():
|
|
for entry in resources:
|
|
# Entries in stylable that are not int[] are not actually resource ids
|
|
# but constants.
|
|
if rjava_build_options._IsResourceFinal(entry):
|
|
final_resources_by_type[res_type].append(entry)
|
|
else:
|
|
non_final_resources_by_type[res_type].append(entry)
|
|
|
|
# Here we diverge from what aapt does. Because we have so many
|
|
# resources, the onResourcesLoaded method was exceeding the 64KB limit that
|
|
# Java imposes. For this reason we split onResourcesLoaded into different
|
|
# methods for each resource type.
|
|
extends_string = ''
|
|
dep_path = ''
|
|
if grandparent_custom_package_name:
|
|
extends_string = 'extends {{ parent_path }}.R.{{ resource_type }} '
|
|
dep_path = GetCustomPackagePath(grandparent_custom_package_name)
|
|
|
|
# Don't actually mark fields as "final" or else R8 complain when aapt2 uses
|
|
# --proguard-conditional-keep-rules. E.g.:
|
|
# Rule precondition matches static final fields javac has inlined.
|
|
# Such rules are unsound as the shrinker cannot infer the inlining precisely.
|
|
template = Template("""/* AUTO-GENERATED FILE. DO NOT MODIFY. */
|
|
|
|
package {{ package }};
|
|
|
|
public final class R {
|
|
{% for resource_type in resource_types %}
|
|
public static class {{ resource_type }} """ + extends_string + """ {
|
|
{% for e in final_resources[resource_type] %}
|
|
public static {{ e.java_type }} {{ e.name }} = {{ e.value }};
|
|
{% endfor %}
|
|
{% for e in non_final_resources[resource_type] %}
|
|
{% if e.value != '0' %}
|
|
public static {{ e.java_type }} {{ e.name }} = {{ e.value }};
|
|
{% else %}
|
|
public static {{ e.java_type }} {{ e.name }};
|
|
{% endif %}
|
|
{% endfor %}
|
|
}
|
|
{% endfor %}
|
|
{% if has_on_resources_loaded %}
|
|
{% if fake_on_resources_loaded %}
|
|
public static void onResourcesLoaded(int packageId) {
|
|
}
|
|
{% else %}
|
|
private static boolean sResourcesDidLoad;
|
|
|
|
private static void patchArray(
|
|
int[] arr, int startIndex, int packageIdTransform) {
|
|
for (int i = startIndex; i < arr.length; ++i) {
|
|
arr[i] ^= packageIdTransform;
|
|
}
|
|
}
|
|
|
|
public static void onResourcesLoaded(int packageId) {
|
|
if (sResourcesDidLoad) {
|
|
return;
|
|
}
|
|
sResourcesDidLoad = true;
|
|
int packageIdTransform = (packageId ^ 0x7f) << 24;
|
|
{# aapt2 makes int[] resources refer to other resources by reference
|
|
rather than by value. Thus, need to transform the int[] resources
|
|
first, before the referenced resources are transformed in order to
|
|
ensure the transform applies exactly once.
|
|
See https://crbug.com/1237059 for context.
|
|
#}
|
|
{% for resource_type in resource_types %}
|
|
{% for e in non_final_resources[resource_type] %}
|
|
{% if e.java_type == 'int[]' %}
|
|
patchArray({{ e.resource_type }}.{{ e.name }}, {{ startIndex(e) }}, \
|
|
packageIdTransform);
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% endfor %}
|
|
{% for resource_type in resource_types %}
|
|
onResourcesLoaded{{ resource_type|title }}(packageIdTransform);
|
|
{% endfor %}
|
|
}
|
|
{% for res_type in resource_types %}
|
|
private static void onResourcesLoaded{{ res_type|title }} (
|
|
int packageIdTransform) {
|
|
{% for e in non_final_resources[res_type] %}
|
|
{% if res_type != 'styleable' and e.java_type != 'int[]' %}
|
|
{{ e.resource_type }}.{{ e.name }} ^= packageIdTransform;
|
|
{% endif %}
|
|
{% endfor %}
|
|
}
|
|
{% endfor %}
|
|
{% endif %}
|
|
{% endif %}
|
|
}
|
|
""",
|
|
trim_blocks=True,
|
|
lstrip_blocks=True)
|
|
return template.render(
|
|
package=package,
|
|
resource_types=sorted(ALL_RESOURCE_TYPES),
|
|
has_on_resources_loaded=rjava_build_options.has_on_resources_loaded,
|
|
fake_on_resources_loaded=rjava_build_options.fake_on_resources_loaded,
|
|
final_resources=final_resources_by_type,
|
|
non_final_resources=non_final_resources_by_type,
|
|
startIndex=_GetNonSystemIndex,
|
|
parent_path=dep_path)
|
|
|
|
|
|
def ExtractBinaryManifestValues(aapt2_path, apk_path):
|
|
"""Returns (version_code, version_name, package_name) for the given apk."""
|
|
output = subprocess.check_output([
|
|
aapt2_path, 'dump', 'xmltree', apk_path, '--file', 'AndroidManifest.xml'
|
|
]).decode('utf-8')
|
|
version_code = re.search(r'versionCode.*?=(\d*)', output).group(1)
|
|
version_name = re.search(r'versionName.*?="(.*?)"', output).group(1)
|
|
package_name = re.search(r'package.*?="(.*?)"', output).group(1)
|
|
return version_code, version_name, package_name
|
|
|
|
|
|
def ExtractArscPackage(aapt2_path, apk_path):
|
|
"""Returns (package_name, package_id) of resources.arsc from apk_path.
|
|
|
|
When the apk does not have any entries in its resources file, in recent aapt2
|
|
versions it will not contain a "Package" line. The package is not even in the
|
|
actual resources.arsc/resources.pb file (which itself is mostly empty). Thus
|
|
return (None, None) when dump succeeds and there are no errors to indicate
|
|
that the package name does not exist in the resources file.
|
|
"""
|
|
proc = subprocess.Popen([aapt2_path, 'dump', 'resources', apk_path],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
for line in proc.stdout:
|
|
line = line.decode('utf-8')
|
|
# Package name=org.chromium.webview_shell id=7f
|
|
if line.startswith('Package'):
|
|
proc.kill()
|
|
parts = line.split()
|
|
package_name = parts[1].split('=')[1]
|
|
package_id = parts[2][3:]
|
|
return package_name, int(package_id, 16)
|
|
|
|
# aapt2 currently crashes when dumping webview resources, but not until after
|
|
# it prints the "Package" line (b/130553900).
|
|
stderr_output = proc.stderr.read().decode('utf-8')
|
|
if stderr_output:
|
|
sys.stderr.write(stderr_output)
|
|
raise Exception('Failed to find arsc package name')
|
|
return None, None
|
|
|
|
|
|
def _RenameSubdirsWithPrefix(dir_path, prefix):
|
|
subdirs = [
|
|
d for d in os.listdir(dir_path)
|
|
if os.path.isdir(os.path.join(dir_path, d))
|
|
]
|
|
renamed_subdirs = []
|
|
for d in subdirs:
|
|
old_path = os.path.join(dir_path, d)
|
|
new_path = os.path.join(dir_path, '{}_{}'.format(prefix, d))
|
|
renamed_subdirs.append(new_path)
|
|
os.rename(old_path, new_path)
|
|
return renamed_subdirs
|
|
|
|
|
|
def _HasMultipleResDirs(zip_path):
|
|
"""Checks for magic comment set by prepare_resources.py
|
|
|
|
Returns: True iff the zipfile has the magic comment that means it contains
|
|
multiple res/ dirs inside instead of just contents of a single res/ dir
|
|
(without a wrapping res/).
|
|
"""
|
|
with zipfile.ZipFile(zip_path) as z:
|
|
return z.comment == MULTIPLE_RES_MAGIC_STRING
|
|
|
|
|
|
def ExtractDeps(dep_zips, deps_dir):
|
|
"""Extract a list of resource dependency zip files.
|
|
|
|
Args:
|
|
dep_zips: A list of zip file paths, each one will be extracted to
|
|
a subdirectory of |deps_dir|, named after the zip file's path (e.g.
|
|
'/some/path/foo.zip' -> '{deps_dir}/some_path_foo/').
|
|
deps_dir: Top-level extraction directory.
|
|
Returns:
|
|
The list of all sub-directory paths, relative to |deps_dir|.
|
|
Raises:
|
|
Exception: If a sub-directory already exists with the same name before
|
|
extraction.
|
|
"""
|
|
dep_subdirs = []
|
|
for z in dep_zips:
|
|
subdirname = z.replace(os.path.sep, '_')
|
|
subdir = os.path.join(deps_dir, subdirname)
|
|
if os.path.exists(subdir):
|
|
raise Exception('Resource zip name conflict: ' + subdirname)
|
|
build_utils.ExtractAll(z, path=subdir)
|
|
if _HasMultipleResDirs(z):
|
|
# basename of the directory is used to create a zip during resource
|
|
# compilation, include the path in the basename to help blame errors on
|
|
# the correct target. For example directory 0_res may be renamed
|
|
# chrome_android_chrome_app_java_resources_0_res pointing to the name and
|
|
# path of the android_resources target from whence it came.
|
|
subdir_subdirs = _RenameSubdirsWithPrefix(subdir, subdirname)
|
|
dep_subdirs.extend(subdir_subdirs)
|
|
else:
|
|
dep_subdirs.append(subdir)
|
|
return dep_subdirs
|
|
|
|
|
|
class _ResourceBuildContext:
|
|
"""A temporary directory for packaging and compiling Android resources.
|
|
|
|
Args:
|
|
temp_dir: Optional root build directory path. If None, a temporary
|
|
directory will be created, and removed in Close().
|
|
"""
|
|
|
|
def __init__(self, temp_dir=None, keep_files=False):
|
|
"""Initialized the context."""
|
|
# The top-level temporary directory.
|
|
if temp_dir:
|
|
self.temp_dir = temp_dir
|
|
os.makedirs(temp_dir)
|
|
else:
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.remove_on_exit = not keep_files
|
|
|
|
# A location to store resources extracted form dependency zip files.
|
|
self.deps_dir = os.path.join(self.temp_dir, 'deps')
|
|
os.mkdir(self.deps_dir)
|
|
# A location to place aapt-generated files.
|
|
self.gen_dir = os.path.join(self.temp_dir, 'gen')
|
|
os.mkdir(self.gen_dir)
|
|
# A location to place generated R.java files.
|
|
self.srcjar_dir = os.path.join(self.temp_dir, 'java')
|
|
os.mkdir(self.srcjar_dir)
|
|
# Temporary file locacations.
|
|
self.r_txt_path = os.path.join(self.gen_dir, 'R.txt')
|
|
self.srcjar_path = os.path.join(self.temp_dir, 'R.srcjar')
|
|
self.info_path = os.path.join(self.temp_dir, 'size.info')
|
|
self.stable_ids_path = os.path.join(self.temp_dir, 'in_ids.txt')
|
|
self.emit_ids_path = os.path.join(self.temp_dir, 'out_ids.txt')
|
|
self.proguard_path = os.path.join(self.temp_dir, 'keeps.flags')
|
|
self.proguard_main_dex_path = os.path.join(self.temp_dir, 'maindex.flags')
|
|
self.arsc_path = os.path.join(self.temp_dir, 'out.ap_')
|
|
self.proto_path = os.path.join(self.temp_dir, 'out.proto.ap_')
|
|
self.optimized_arsc_path = os.path.join(self.temp_dir, 'out.opt.ap_')
|
|
self.optimized_proto_path = os.path.join(self.temp_dir, 'out.opt.proto.ap_')
|
|
|
|
def Close(self):
|
|
"""Close the context and destroy all temporary files."""
|
|
if self.remove_on_exit:
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def BuildContext(temp_dir=None, keep_files=False):
|
|
"""Generator for a _ResourceBuildContext instance."""
|
|
context = None
|
|
try:
|
|
context = _ResourceBuildContext(temp_dir, keep_files)
|
|
yield context
|
|
finally:
|
|
if context:
|
|
context.Close()
|
|
|
|
|
|
def ParseAndroidResourceStringsFromXml(xml_data):
|
|
"""Parse and Android xml resource file and extract strings from it.
|
|
|
|
Args:
|
|
xml_data: XML file data.
|
|
Returns:
|
|
A (dict, namespaces) tuple, where |dict| maps string names to their UTF-8
|
|
encoded value, and |namespaces| is a dictionary mapping prefixes to URLs
|
|
corresponding to namespaces declared in the <resources> element.
|
|
"""
|
|
# NOTE: This uses regular expression matching because parsing with something
|
|
# like ElementTree makes it tedious to properly parse some of the structured
|
|
# text found in string resources, e.g.:
|
|
# <string msgid="3300176832234831527" \
|
|
# name="abc_shareactionprovider_share_with_application">\
|
|
# "Condividi tramite <ns1:g id="APPLICATION_NAME">%s</ns1:g>"\
|
|
# </string>
|
|
result = {}
|
|
|
|
# Find <resources> start tag and extract namespaces from it.
|
|
m = re.search('<resources([^>]*)>', xml_data, re.MULTILINE)
|
|
if not m:
|
|
raise Exception('<resources> start tag expected: ' + xml_data)
|
|
input_data = xml_data[m.end():]
|
|
resource_attrs = m.group(1)
|
|
re_namespace = re.compile('\s*(xmlns:(\w+)="([^"]+)")')
|
|
namespaces = {}
|
|
while resource_attrs:
|
|
m = re_namespace.match(resource_attrs)
|
|
if not m:
|
|
break
|
|
namespaces[m.group(2)] = m.group(3)
|
|
resource_attrs = resource_attrs[m.end(1):]
|
|
|
|
# Find each string element now.
|
|
re_string_element_start = re.compile('<string ([^>]* )?name="([^">]+)"[^>]*>')
|
|
re_string_element_end = re.compile('</string>')
|
|
while input_data:
|
|
m = re_string_element_start.search(input_data)
|
|
if not m:
|
|
break
|
|
name = m.group(2)
|
|
input_data = input_data[m.end():]
|
|
m2 = re_string_element_end.search(input_data)
|
|
if not m2:
|
|
raise Exception('Expected closing string tag: ' + input_data)
|
|
text = input_data[:m2.start()]
|
|
input_data = input_data[m2.end():]
|
|
if len(text) != 0 and text[0] == '"' and text[-1] == '"':
|
|
text = text[1:-1]
|
|
result[name] = text
|
|
|
|
return result, namespaces
|
|
|
|
|
|
def GenerateAndroidResourceStringsXml(names_to_utf8_text, namespaces=None):
|
|
"""Generate an XML text corresponding to an Android resource strings map.
|
|
|
|
Args:
|
|
names_to_text: A dictionary mapping resource names to localized
|
|
text (encoded as UTF-8).
|
|
namespaces: A map of namespace prefix to URL.
|
|
Returns:
|
|
New non-Unicode string containing an XML data structure describing the
|
|
input as an Android resource .xml file.
|
|
"""
|
|
result = '<?xml version="1.0" encoding="utf-8"?>\n'
|
|
result += '<resources'
|
|
if namespaces:
|
|
for prefix, url in sorted(namespaces.items()):
|
|
result += ' xmlns:%s="%s"' % (prefix, url)
|
|
result += '>\n'
|
|
if not names_to_utf8_text:
|
|
result += '<!-- this file intentionally empty -->\n'
|
|
else:
|
|
for name, utf8_text in sorted(names_to_utf8_text.items()):
|
|
result += '<string name="%s">"%s"</string>\n' % (name, utf8_text)
|
|
result += '</resources>\n'
|
|
return result.encode('utf8')
|
|
|
|
|
|
def FilterAndroidResourceStringsXml(xml_file_path, string_predicate):
|
|
"""Remove unwanted localized strings from an Android resource .xml file.
|
|
|
|
This function takes a |string_predicate| callable object that will
|
|
receive a resource string name, and should return True iff the
|
|
corresponding <string> element should be kept in the file.
|
|
|
|
Args:
|
|
xml_file_path: Android resource strings xml file path.
|
|
string_predicate: A predicate function which will receive the string name
|
|
and shal
|
|
"""
|
|
with open(xml_file_path) as f:
|
|
xml_data = f.read()
|
|
strings_map, namespaces = ParseAndroidResourceStringsFromXml(xml_data)
|
|
|
|
string_deletion = False
|
|
for name in list(strings_map.keys()):
|
|
if not string_predicate(name):
|
|
del strings_map[name]
|
|
string_deletion = True
|
|
|
|
if string_deletion:
|
|
new_xml_data = GenerateAndroidResourceStringsXml(strings_map, namespaces)
|
|
with open(xml_file_path, 'wb') as f:
|
|
f.write(new_xml_data)
|