blob: 272574f1174c8a017877f2281c5cabf46fd603f7 [file] [log] [blame]
# Copyright 2020 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.
"""Functions that modify resources in protobuf format.
Format reference:
https://cs.android.com/android/platform/superproject/+/master:frameworks/base/tools/aapt2/Resources.proto
"""
import logging
import os
import struct
import sys
import zipfile
from util import build_utils
from util import resource_utils
sys.path[1:1] = [
# `Resources_pb2` module imports `descriptor`, which imports `six`.
os.path.join(build_utils.DIR_SOURCE_ROOT, 'third_party', 'six', 'src'),
# Make sure the pb2 files are able to import google.protobuf
os.path.join(build_utils.DIR_SOURCE_ROOT, 'third_party', 'protobuf',
'python'),
]
from proto import Resources_pb2
# First bytes in an .flat.arsc file.
# uint32: Magic ("ARSC"), version (1), num_entries (1), type (0)
_FLAT_ARSC_HEADER = b'AAPT\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00'
# The package ID hardcoded for shared libraries. See
# _HardcodeSharedLibraryDynamicAttributes() for more details. If this value
# changes make sure to change REQUIRED_PACKAGE_IDENTIFIER in WebLayerImpl.java.
SHARED_LIBRARY_HARDCODED_ID = 36
def _ProcessZip(zip_path, process_func):
"""Filters a .zip file via: new_bytes = process_func(filename, data)."""
has_changes = False
zip_entries = []
with zipfile.ZipFile(zip_path) as src_zip:
for info in src_zip.infolist():
data = src_zip.read(info)
new_data = process_func(info.filename, data)
if new_data is not data:
has_changes = True
data = new_data
zip_entries.append((info, data))
# Overwrite the original zip file.
if has_changes:
with zipfile.ZipFile(zip_path, 'w') as f:
for info, data in zip_entries:
f.writestr(info, data)
def _ProcessProtoItem(item):
if not item.HasField('ref'):
return
# If this is a dynamic attribute (type ATTRIBUTE, package ID 0), hardcode
# the package to SHARED_LIBRARY_HARDCODED_ID.
if item.ref.type == Resources_pb2.Reference.ATTRIBUTE and not (item.ref.id
& 0xff000000):
item.ref.id |= (0x01000000 * SHARED_LIBRARY_HARDCODED_ID)
item.ref.ClearField('is_dynamic')
def _ProcessProtoValue(value):
if value.HasField('item'):
_ProcessProtoItem(value.item)
return
compound_value = value.compound_value
if compound_value.HasField('style'):
for entry in compound_value.style.entry:
_ProcessProtoItem(entry.item)
elif compound_value.HasField('array'):
for element in compound_value.array.element:
_ProcessProtoItem(element.item)
elif compound_value.HasField('plural'):
for entry in compound_value.plural.entry:
_ProcessProtoItem(entry.item)
def _ProcessProtoXmlNode(xml_node):
if not xml_node.HasField('element'):
return
for attribute in xml_node.element.attribute:
_ProcessProtoItem(attribute.compiled_item)
for child in xml_node.element.child:
_ProcessProtoXmlNode(child)
def _SplitLocaleResourceType(_type, allowed_resource_names):
"""Splits locale specific resources out of |_type| and returns them.
Any locale specific resources will be removed from |_type|, and a new
Resources_pb2.Type value will be returned which contains those resources.
Args:
_type: A Resources_pb2.Type value
allowed_resource_names: Names of locale resources that should be kept in the
main type.
"""
locale_entries = []
for entry in _type.entry:
if entry.name in allowed_resource_names:
continue
# First collect all resources values with a locale set.
config_values_with_locale = []
for config_value in entry.config_value:
if config_value.config.locale:
config_values_with_locale.append(config_value)
if config_values_with_locale:
# Remove the locale resources from the original entry
for value in config_values_with_locale:
entry.config_value.remove(value)
# Add locale resources to a new Entry, and save for later.
locale_entry = Resources_pb2.Entry()
locale_entry.CopyFrom(entry)
del locale_entry.config_value[:]
locale_entry.config_value.extend(config_values_with_locale)
locale_entries.append(locale_entry)
if not locale_entries:
return None
# Copy the original type and replace the entries with |locale_entries|.
locale_type = Resources_pb2.Type()
locale_type.CopyFrom(_type)
del locale_type.entry[:]
locale_type.entry.extend(locale_entries)
return locale_type
def _HardcodeInTable(table, is_bundle_module, shared_resources_allowlist):
translations_package = None
if is_bundle_module:
# A separate top level package will be added to the resources, which
# contains only locale specific resources. The package ID of the locale
# resources is hardcoded to SHARED_LIBRARY_HARDCODED_ID. This causes
# resources in locale splits to all get assigned
# SHARED_LIBRARY_HARDCODED_ID as their package ID, which prevents a bug
# in shared library bundles where each split APK gets a separate dynamic
# ID, and cannot be accessed by the main APK.
translations_package = Resources_pb2.Package()
translations_package.package_id.id = SHARED_LIBRARY_HARDCODED_ID
translations_package.package_name = (table.package[0].package_name +
'_translations')
# These resources are allowed in the base resources, since they are needed
# by WebView.
allowed_resource_names = set()
if shared_resources_allowlist:
allowed_resource_names = set(
resource_utils.GetRTxtStringResourceNames(shared_resources_allowlist))
for package in table.package:
for _type in package.type:
for entry in _type.entry:
for config_value in entry.config_value:
_ProcessProtoValue(config_value.value)
if translations_package is not None:
locale_type = _SplitLocaleResourceType(_type, allowed_resource_names)
if locale_type:
translations_package.type.add().CopyFrom(locale_type)
if translations_package is not None:
table.package.add().CopyFrom(translations_package)
def HardcodeSharedLibraryDynamicAttributes(zip_path,
is_bundle_module,
shared_resources_allowlist=None):
"""Hardcodes the package IDs of dynamic attributes and locale resources.
Hardcoding dynamic attribute package IDs is a workaround for b/147674078,
which affects Android versions pre-N. Hardcoding locale resource package IDs
is a workaround for b/155437035, which affects resources built with
--shared-lib on all Android versions
Args:
zip_path: Path to proto APK file.
is_bundle_module: True for bundle modules.
shared_resources_allowlist: Set of resource names to not extract out of the
main package.
"""
def process_func(filename, data):
if filename == 'resources.pb':
table = Resources_pb2.ResourceTable()
table.ParseFromString(data)
_HardcodeInTable(table, is_bundle_module, shared_resources_allowlist)
data = table.SerializeToString()
elif filename.endswith('.xml') and not filename.startswith('res/raw'):
xml_node = Resources_pb2.XmlNode()
xml_node.ParseFromString(data)
_ProcessProtoXmlNode(xml_node)
data = xml_node.SerializeToString()
return data
_ProcessZip(zip_path, process_func)
class _ResourceStripper(object):
def __init__(self, partial_path, keep_predicate):
self.partial_path = partial_path
self.keep_predicate = keep_predicate
self._has_changes = False
@staticmethod
def _IterStyles(entry):
for config_value in entry.config_value:
value = config_value.value
if value.HasField('compound_value'):
compound_value = value.compound_value
if compound_value.HasField('style'):
yield compound_value.style
def _StripStyles(self, entry, type_and_name):
# Strip style entries that refer to attributes that have been stripped.
for style in self._IterStyles(entry):
entries = style.entry
new_entries = []
for entry in entries:
full_name = '{}/{}'.format(type_and_name, entry.key.name)
if not self.keep_predicate(full_name):
logging.debug('Stripped %s/%s', self.partial_path, full_name)
else:
new_entries.append(entry)
if len(new_entries) != len(entries):
self._has_changes = True
del entries[:]
entries.extend(new_entries)
def _StripEntries(self, entries, type_name):
new_entries = []
for entry in entries:
type_and_name = '{}/{}'.format(type_name, entry.name)
if not self.keep_predicate(type_and_name):
logging.debug('Stripped %s/%s', self.partial_path, type_and_name)
else:
new_entries.append(entry)
self._StripStyles(entry, type_and_name)
if len(new_entries) != len(entries):
self._has_changes = True
del entries[:]
entries.extend(new_entries)
def StripTable(self, table):
self._has_changes = False
for package in table.package:
for _type in package.type:
self._StripEntries(_type.entry, _type.name)
return self._has_changes
def _TableFromFlatBytes(data):
# https://cs.android.com/android/platform/superproject/+/master:frameworks/base/tools/aapt2/format/Container.cpp
size_idx = len(_FLAT_ARSC_HEADER)
proto_idx = size_idx + 8
if data[:size_idx] != _FLAT_ARSC_HEADER:
raise Exception('Error parsing {} in {}'.format(info.filename, zip_path))
# Size is stored as uint64.
size = struct.unpack('<Q', data[size_idx:proto_idx])[0]
table = Resources_pb2.ResourceTable()
proto_bytes = data[proto_idx:proto_idx + size]
table.ParseFromString(proto_bytes)
return table
def _FlatBytesFromTable(table):
proto_bytes = table.SerializeToString()
size = struct.pack('<Q', len(proto_bytes))
overage = len(proto_bytes) % 4
padding = b'\0' * (4 - overage) if overage else b''
return b''.join((_FLAT_ARSC_HEADER, size, proto_bytes, padding))
def StripUnwantedResources(partial_path, keep_predicate):
"""Removes resources from .arsc.flat files inside of a .zip.
Args:
partial_path: Path to a .zip containing .arsc.flat entries
keep_predicate: Given "$partial_path/$res_type/$res_name", returns
whether to keep the resource.
"""
stripper = _ResourceStripper(partial_path, keep_predicate)
def process_file(filename, data):
if filename.endswith('.arsc.flat'):
table = _TableFromFlatBytes(data)
if stripper.StripTable(table):
data = _FlatBytesFromTable(table)
return data
_ProcessZip(partial_path, process_file)