1# Copyright 2020 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4"""Functions that modify resources in protobuf format.
5
6Format reference:
7https://cs.android.com/android/platform/superproject/+/master:frameworks/base/tools/aapt2/Resources.proto
8"""
9
10import logging
11import os
12import struct
13import sys
14import zipfile
15
16from util import build_utils
17from util import resource_utils
18
19sys.path[1:1] = [
20    # `Resources_pb2` module imports `descriptor`, which imports `six`.
21    os.path.join(build_utils.DIR_SOURCE_ROOT, 'third_party', 'six', 'src'),
22    # Make sure the pb2 files are able to import google.protobuf
23    os.path.join(build_utils.DIR_SOURCE_ROOT, 'third_party', 'protobuf',
24                 'python'),
25]
26
27from proto import Resources_pb2
28
29# First bytes in an .flat.arsc file.
30# uint32: Magic ("ARSC"), version (1), num_entries (1), type (0)
31_FLAT_ARSC_HEADER = 'AAPT\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00'
32
33# The package ID hardcoded for shared libraries. See
34# _HardcodeSharedLibraryDynamicAttributes() for more details. If this value
35# changes make sure to change REQUIRED_PACKAGE_IDENTIFIER in WebLayerImpl.java.
36SHARED_LIBRARY_HARDCODED_ID = 12
37
38
39def _ProcessZip(zip_path, process_func):
40  """Filters a .zip file via: new_bytes = process_func(filename, data)."""
41  has_changes = False
42  zip_entries = []
43  with zipfile.ZipFile(zip_path) as src_zip:
44    for info in src_zip.infolist():
45      data = src_zip.read(info)
46      new_data = process_func(info.filename, data)
47      if new_data is not data:
48        has_changes = True
49        data = new_data
50      zip_entries.append((info, data))
51
52  # Overwrite the original zip file.
53  if has_changes:
54    with zipfile.ZipFile(zip_path, 'w') as f:
55      for info, data in zip_entries:
56        f.writestr(info, data)
57
58
59def _ProcessProtoItem(item):
60  if not item.HasField('ref'):
61    return
62
63  # If this is a dynamic attribute (type ATTRIBUTE, package ID 0), hardcode
64  # the package to SHARED_LIBRARY_HARDCODED_ID.
65  if item.ref.type == Resources_pb2.Reference.ATTRIBUTE and not (item.ref.id
66                                                                 & 0xff000000):
67    item.ref.id |= (0x01000000 * SHARED_LIBRARY_HARDCODED_ID)
68    item.ref.ClearField('is_dynamic')
69
70
71def _ProcessProtoValue(value):
72  if value.HasField('item'):
73    _ProcessProtoItem(value.item)
74    return
75
76  compound_value = value.compound_value
77  if compound_value.HasField('style'):
78    for entry in compound_value.style.entry:
79      _ProcessProtoItem(entry.item)
80  elif compound_value.HasField('array'):
81    for element in compound_value.array.element:
82      _ProcessProtoItem(element.item)
83  elif compound_value.HasField('plural'):
84    for entry in compound_value.plural.entry:
85      _ProcessProtoItem(entry.item)
86
87
88def _ProcessProtoXmlNode(xml_node):
89  if not xml_node.HasField('element'):
90    return
91
92  for attribute in xml_node.element.attribute:
93    _ProcessProtoItem(attribute.compiled_item)
94
95  for child in xml_node.element.child:
96    _ProcessProtoXmlNode(child)
97
98
99def _SplitLocaleResourceType(_type, allowed_resource_names):
100  """Splits locale specific resources out of |_type| and returns them.
101
102  Any locale specific resources will be removed from |_type|, and a new
103  Resources_pb2.Type value will be returned which contains those resources.
104
105  Args:
106    _type: A Resources_pb2.Type value
107    allowed_resource_names: Names of locale resources that should be kept in the
108        main type.
109  """
110  locale_entries = []
111  for entry in _type.entry:
112    if entry.name in allowed_resource_names:
113      continue
114
115    # First collect all resources values with a locale set.
116    config_values_with_locale = []
117    for config_value in entry.config_value:
118      if config_value.config.locale:
119        config_values_with_locale.append(config_value)
120
121    if config_values_with_locale:
122      # Remove the locale resources from the original entry
123      for value in config_values_with_locale:
124        entry.config_value.remove(value)
125
126      # Add locale resources to a new Entry, and save for later.
127      locale_entry = Resources_pb2.Entry()
128      locale_entry.CopyFrom(entry)
129      del locale_entry.config_value[:]
130      locale_entry.config_value.extend(config_values_with_locale)
131      locale_entries.append(locale_entry)
132
133  if not locale_entries:
134    return None
135
136  # Copy the original type and replace the entries with |locale_entries|.
137  locale_type = Resources_pb2.Type()
138  locale_type.CopyFrom(_type)
139  del locale_type.entry[:]
140  locale_type.entry.extend(locale_entries)
141  return locale_type
142
143
144def _HardcodeInTable(table, is_bundle_module, shared_resources_allowlist):
145  translations_package = None
146  if is_bundle_module:
147    # A separate top level package will be added to the resources, which
148    # contains only locale specific resources. The package ID of the locale
149    # resources is hardcoded to SHARED_LIBRARY_HARDCODED_ID. This causes
150    # resources in locale splits to all get assigned
151    # SHARED_LIBRARY_HARDCODED_ID as their package ID, which prevents a bug
152    # in shared library bundles where each split APK gets a separate dynamic
153    # ID, and cannot be accessed by the main APK.
154    translations_package = Resources_pb2.Package()
155    translations_package.package_id.id = SHARED_LIBRARY_HARDCODED_ID
156    translations_package.package_name = (table.package[0].package_name +
157                                         '_translations')
158
159    # These resources are allowed in the base resources, since they are needed
160    # by WebView.
161    allowed_resource_names = set()
162    if shared_resources_allowlist:
163      allowed_resource_names = set(
164          resource_utils.GetRTxtStringResourceNames(shared_resources_allowlist))
165
166  for package in table.package:
167    for _type in package.type:
168      for entry in _type.entry:
169        for config_value in entry.config_value:
170          _ProcessProtoValue(config_value.value)
171
172      if translations_package is not None:
173        locale_type = _SplitLocaleResourceType(_type, allowed_resource_names)
174        if locale_type:
175          translations_package.type.add().CopyFrom(locale_type)
176
177  if translations_package is not None:
178    table.package.add().CopyFrom(translations_package)
179
180
181def HardcodeSharedLibraryDynamicAttributes(zip_path,
182                                           is_bundle_module,
183                                           shared_resources_allowlist=None):
184  """Hardcodes the package IDs of dynamic attributes and locale resources.
185
186  Hardcoding dynamic attribute package IDs is a workaround for b/147674078,
187  which affects Android versions pre-N. Hardcoding locale resource package IDs
188  is a workaround for b/155437035, which affects resources built with
189  --shared-lib on all Android versions
190
191  Args:
192    zip_path: Path to proto APK file.
193    is_bundle_module: True for bundle modules.
194    shared_resources_allowlist: Set of resource names to not extract out of the
195        main package.
196  """
197
198  def process_func(filename, data):
199    if filename == 'resources.pb':
200      table = Resources_pb2.ResourceTable()
201      table.ParseFromString(data)
202      _HardcodeInTable(table, is_bundle_module, shared_resources_allowlist)
203      data = table.SerializeToString()
204    elif filename.endswith('.xml') and not filename.startswith('res/raw'):
205      xml_node = Resources_pb2.XmlNode()
206      xml_node.ParseFromString(data)
207      _ProcessProtoXmlNode(xml_node)
208      data = xml_node.SerializeToString()
209    return data
210
211  _ProcessZip(zip_path, process_func)
212
213
214class _ResourceStripper(object):
215  def __init__(self, partial_path, keep_predicate):
216    self.partial_path = partial_path
217    self.keep_predicate = keep_predicate
218    self._has_changes = False
219
220  @staticmethod
221  def _IterStyles(entry):
222    for config_value in entry.config_value:
223      value = config_value.value
224      if value.HasField('compound_value'):
225        compound_value = value.compound_value
226        if compound_value.HasField('style'):
227          yield compound_value.style
228
229  def _StripStyles(self, entry, type_and_name):
230    # Strip style entries that refer to attributes that have been stripped.
231    for style in self._IterStyles(entry):
232      entries = style.entry
233      new_entries = []
234      for entry in entries:
235        full_name = '{}/{}'.format(type_and_name, entry.key.name)
236        if not self.keep_predicate(full_name):
237          logging.debug('Stripped %s/%s', self.partial_path, full_name)
238        else:
239          new_entries.append(entry)
240
241      if len(new_entries) != len(entries):
242        self._has_changes = True
243        del entries[:]
244        entries.extend(new_entries)
245
246  def _StripEntries(self, entries, type_name):
247    new_entries = []
248    for entry in entries:
249      type_and_name = '{}/{}'.format(type_name, entry.name)
250      if not self.keep_predicate(type_and_name):
251        logging.debug('Stripped %s/%s', self.partial_path, type_and_name)
252      else:
253        new_entries.append(entry)
254        self._StripStyles(entry, type_and_name)
255
256    if len(new_entries) != len(entries):
257      self._has_changes = True
258      del entries[:]
259      entries.extend(new_entries)
260
261  def StripTable(self, table):
262    self._has_changes = False
263    for package in table.package:
264      for _type in package.type:
265        self._StripEntries(_type.entry, _type.name)
266    return self._has_changes
267
268
269def _TableFromFlatBytes(data):
270  # https://cs.android.com/android/platform/superproject/+/master:frameworks/base/tools/aapt2/format/Container.cpp
271  size_idx = len(_FLAT_ARSC_HEADER)
272  proto_idx = size_idx + 8
273  if data[:size_idx] != _FLAT_ARSC_HEADER:
274    raise Exception('Error parsing {} in {}'.format(info.filename, zip_path))
275  # Size is stored as uint64.
276  size = struct.unpack('<Q', data[size_idx:proto_idx])[0]
277  table = Resources_pb2.ResourceTable()
278  proto_bytes = data[proto_idx:proto_idx + size]
279  table.ParseFromString(proto_bytes)
280  return table
281
282
283def _FlatBytesFromTable(table):
284  proto_bytes = table.SerializeToString()
285  size = struct.pack('<Q', len(proto_bytes))
286  overage = len(proto_bytes) % 4
287  padding = '\0' * (4 - overage) if overage else ''
288  return ''.join((_FLAT_ARSC_HEADER, size, proto_bytes, padding))
289
290
291def StripUnwantedResources(partial_path, keep_predicate):
292  """Removes resources from .arsc.flat files inside of a .zip.
293
294  Args:
295    partial_path: Path to a .zip containing .arsc.flat entries
296    keep_predicate: Given "$partial_path/$res_type/$res_name", returns
297      whether to keep the resource.
298  """
299  stripper = _ResourceStripper(partial_path, keep_predicate)
300
301  def process_file(filename, data):
302    if filename.endswith('.arsc.flat'):
303      table = _TableFromFlatBytes(data)
304      if stripper.StripTable(table):
305        data = _FlatBytesFromTable(table)
306    return data
307
308  _ProcessZip(partial_path, process_file)
309