// Copyright 2017 The Bazel Authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.devtools.build.lib.rules.android; import com.android.ide.common.resources.configuration.DensityQualifier; import com.android.ide.common.resources.configuration.FolderConfiguration; import com.android.ide.common.resources.configuration.VersionQualifier; import com.android.resources.Density; import com.android.resources.ResourceFolderType; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import com.google.common.base.Objects; import com.google.common.base.Preconditions; import com.google.common.collect.HashMultimap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Multimap; import com.google.devtools.build.lib.actions.Artifact; import com.google.devtools.build.lib.analysis.RuleContext; import com.google.devtools.build.lib.packages.AttributeMap; import com.google.devtools.build.lib.packages.RuleClass.ConfiguredTargetFactory.RuleErrorException; import com.google.devtools.build.lib.packages.RuleErrorConsumer; import com.google.devtools.build.lib.rules.android.AndroidConfiguration.AndroidAaptVersion; import com.google.devtools.build.lib.syntax.Type; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Filters resources based on their qualifiers. * *

This includes filtering resources based on both the "resource_configuration_filters" and * "densities" attributes. * *

Whenever a new field is added to this class, be sure to add it to the {@link #equals(Object)} * and {@link #hashCode()} methods. Failure to do so isn't just bad practice; it could seriously * interfere with Bazel's caching performance. */ public class ResourceFilterFactory { public static final String RESOURCE_CONFIGURATION_FILTERS_NAME = "resource_configuration_filters"; public static final String DENSITIES_NAME = "densities"; /** * The value of the {@link #RESOURCE_CONFIGURATION_FILTERS_NAME} attribute, as a list of qualifier * strings. */ private final ImmutableList configFilters; /** The value of the {@link #DENSITIES_NAME} attribute, as a list of qualifier strings. */ private final ImmutableList densities; /** A builder for a set of strings representing resources that were filtered using this class. */ private final ImmutableSet.Builder filteredResources = ImmutableSet.builder(); private final boolean filterInAnalysis; /** * Constructor. * * @param configFilters the resource configuration filters, as a list of strings. * @param densities the density filters, as a list of strings. * @param filterInAnalysis whether this filter should filter resources in analysis */ ResourceFilterFactory( ImmutableList configFilters, ImmutableList densities, boolean filterInAnalysis) { this.configFilters = configFilters; this.densities = densities; this.filterInAnalysis = filterInAnalysis; } private static List rawFiltersFromAttrs(AttributeMap attrs, String attrName) { if (attrs.isAttributeValueExplicitlySpecified(attrName)) { List rawValue = attrs.get(attrName, Type.STRING_LIST); if (rawValue != null) { return rawValue; } } return ImmutableList.of(); } /** * Extracts filters from an AttributeMap, as a list of strings. * *

In BUILD files, string lists can be represented as a list of strings, a single * comma-separated string, or a combination of both. This method outputs a single list of * individual string values, which can then be passed directly to resource processing actions. * * @return the values of this attribute contained in the {@link AttributeMap}, as a list. */ private static ImmutableList extractFilters(List rawValues) { if (rawValues.isEmpty()) { return ImmutableList.of(); } /* * To deal with edge cases involving placement of whitespace and multiple strings inside a * single item of the given list, manually build the list here rather than call something like * {@link RuleContext#getTokenizedStringListAttr}. * * Filter out all empty values, even those that were explicitly provided. Paying attention to * empty values is never helpful: even if code handled them correctly (and not all of it does) * empty filter values result in all resources matching the empty filter, meaning that filtering * does nothing (even if non-empty filters were also provided). */ // Use an ImmutableSet to remove duplicate values ImmutableSet.Builder builder = ImmutableSet.builder(); for (String rawValue : rawValues) { if (rawValue.contains(",")) { for (String token : rawValue.split(",")) { if (!token.trim().isEmpty()) { builder.add(token.trim()); } } } else if (!rawValue.isEmpty()) { builder.add(rawValue); } } // Create a sorted copy so that ResourceFilterFactory objects with the same filters are treated // the same regardless of the ordering of those filters. return ImmutableList.sortedCopyOf(builder.build()); } static ResourceFilterFactory fromRuleContextAndAttrs(RuleContext ruleContext) throws RuleErrorException { Preconditions.checkNotNull(ruleContext); if (!ruleContext.isLegalFragment(AndroidConfiguration.class)) { return empty(); } return fromAttrs( AndroidAaptVersion.chooseTargetAaptVersion(ruleContext), ruleContext.attributes()); } @VisibleForTesting static ResourceFilterFactory fromAttrs(AndroidAaptVersion aaptVersion, AttributeMap attrs) { return from( aaptVersion, rawFiltersFromAttrs(attrs, RESOURCE_CONFIGURATION_FILTERS_NAME), rawFiltersFromAttrs(attrs, DENSITIES_NAME)); } static ResourceFilterFactory from( AndroidAaptVersion aaptVersion, List configFilters, List densities) { if (configFilters.isEmpty() && densities.isEmpty()) { return empty(); } // aapt2 must have access to all of the resources in execution, so don't filter in analysis. boolean filterInAnalysis = aaptVersion != AndroidAaptVersion.AAPT2; return new ResourceFilterFactory( extractFilters(configFilters), extractFilters(densities), filterInAnalysis); } private ImmutableList getConfigurationFilters( RuleErrorConsumer ruleErrorConsumer) { ImmutableList.Builder filterBuilder = ImmutableList.builder(); for (String filter : configFilters) { addIfNotNull( getFolderConfiguration(filter), filter, filterBuilder, ruleErrorConsumer, RESOURCE_CONFIGURATION_FILTERS_NAME); } return filterBuilder.build(); } private FolderConfiguration getFolderConfiguration(String filter) { // Clean up deprecated representations of resource qualifiers that FolderConfiguration can't // handle. for (DeprecatedQualifierHandler handler : deprecatedQualifierHandlers) { filter = handler.fixAttributeIfNeeded(filter); } return FolderConfiguration.getConfigForQualifierString(filter); } private static final class DeprecatedQualifierHandler { private final Pattern pattern; private final String replacement; private final String description; private boolean warnedForResources = false; private DeprecatedQualifierHandler(String pattern, String replacement, String description) { this.pattern = Pattern.compile(pattern); this.replacement = replacement; this.description = description; } private String fixAttributeIfNeeded(String qualifier) { Matcher matcher = pattern.matcher(qualifier); if (!matcher.matches()) { return qualifier; } return matcher.replaceFirst(replacement); } private String fixResourceIfNeeded( RuleErrorConsumer ruleErrorConsumer, String qualifier, String resourceFolder) { Matcher matcher = pattern.matcher(qualifier); if (!matcher.matches()) { return qualifier; } String fixed = matcher.replaceFirst(replacement); // We don't want to spam users. Only warn about this kind of issue once per target. // TODO(asteinb): Will this cause problems when settings are propagated via dynamic // configuration? if (!warnedForResources) { warnedForResources = true; ruleErrorConsumer.ruleWarning( String.format( "For resource folder %s, when referring to %s, use of qualifier '%s' is deprecated." + " Use '%s' instead.", resourceFolder, description, matcher.group(), fixed)); } return fixed; } } /** * List of deprecated qualifiers that are not supported by {@link FolderConfiguration}. * *

For resources, we should warn if these qualifiers are encountered, since aapt supports the * fixed version (and aapt2 only supports that version). * *

For resource filters, however, aapt only supports this old version. Convert the qualifiers * so that they can be parsed by FolderConfiguration, but do not warn (since they are, actually, * what aapt expects) and save the original qualifier strings to be passed to aapt. */ private final List deprecatedQualifierHandlers = ImmutableList.of( /* * Aapt used to expect locale configurations of form 'en_US'. It now also supports the * correct 'en-rUS' format. For backwards comparability, use a regex to convert filters * with locales in the old format to filters with locales of the correct format. * * The correct format for locales is defined at * https://developer.android.com/guide/topics/resources/providing-resources.html#LocaleQualifier * * TODO(bazel-team): Migrate consumers away from the old Aapt locale format, then remove * this replacement. * * The regex is a bit complicated to avoid modifying potential new qualifiers that contain * underscores. Specifically, it searches for the entire beginning of the resource * qualifier, including (optionally) MCC and MNC, and then the locale itself. */ new DeprecatedQualifierHandler( "^((mcc[0-9]{3}-(mnc[0-9]{3}-)?)?[a-z]{2})_([A-Z]{2}).*", "$1-r$4", "locale qualifiers with regions"), new DeprecatedQualifierHandler( "sr[_\\-]r?Latn.*", "b+sr+Latn", "Serbian in Latin characters"), new DeprecatedQualifierHandler( "es[_\\-]419.*", "b+es+419", "Spanish for Latin America and the Caribbean")); private ImmutableList getDensities(RuleErrorConsumer ruleErrorConsumer) { ImmutableList.Builder densityBuilder = ImmutableList.builder(); for (String density : densities) { addIfNotNull( Density.getEnum(density), density, densityBuilder, ruleErrorConsumer, DENSITIES_NAME); } return densityBuilder.build(); } /** Reports an attribute error if the given item is null, and otherwise adds it to the builder. */ private static void addIfNotNull( T item, String itemString, ImmutableList.Builder builder, RuleErrorConsumer ruleErrorConsumer, String attrName) { if (item == null) { ruleErrorConsumer.attributeError( attrName, "String '" + itemString + "' is not a valid value for " + attrName); } else { builder.add(item); } } @VisibleForTesting static ResourceFilterFactory empty() { return new ResourceFilterFactory( ImmutableList.of(), ImmutableList.of(), /* filterInAnalysis = */ false); } /** * Gets an {@link ResourceFilter} that can be used to filter collections of artifacts. * *

In density-based filtering, the presence of one resource can affect whether another is * accepted or rejected. As such, both local and dependent resources must be passed. */ ResourceFilter getResourceFilter( RuleErrorConsumer ruleErrorConsumer, ResourceDependencies resourceDeps, AndroidResources localResources) { if (!isPrefiltering()) { return ResourceFilter.empty(); } ImmutableList folderConfigs = getConfigurationFilters(ruleErrorConsumer); ImmutableSet.Builder keptArtifacts = ImmutableSet.builder(); List bestArtifactsForAllDensities = new ArrayList<>(); for (Density density : getDensities(ruleErrorConsumer)) { bestArtifactsForAllDensities.add(new BestArtifactsForDensity(ruleErrorConsumer, density)); } // Look at the local and transitive resources. // TODO(b/68265485): In FILTER_IN_ANALYSIS_WITH_DYNAMIC_CONFIGURATION, this will collapse the // NestedSet of resources at each node of the build graph. Instead, we should only filter local // resources at each non-binary target, and then filter both local and transitive resources in // the binary. for (Artifact artifact : Iterables.concat(localResources.getResources(), resourceDeps.getTransitiveResources())) { FolderConfiguration config = getConfigForArtifact(ruleErrorConsumer, artifact); // aapt explicitly ignores the version qualifier; duplicate this behavior here. config.setVersionQualifier(VersionQualifier.getQualifier("")); if (!matchesConfigurationFilters(folderConfigs, config)) { continue; } if (!shouldFilterByDensity(artifact)) { keptArtifacts.add(artifact); continue; } for (BestArtifactsForDensity bestArtifactsForDensity : bestArtifactsForAllDensities) { bestArtifactsForDensity.maybeAddArtifact(artifact); } } for (BestArtifactsForDensity bestArtifactsForDensity : bestArtifactsForAllDensities) { keptArtifacts.addAll(bestArtifactsForDensity.get()); } return ResourceFilter.of( keptArtifacts.build(), (artifact) -> { // This class needs to record any dependent resources that were filtered out so that // resource processing ignores references to them in symbols files of dependencies. String parentDir = artifact.getExecPath().getParentDirectory().getBaseName(); filteredResources.add(parentDir + "/" + artifact.getFilename()); }); } /** * Tracks the best artifacts for a desired density for each combination of filename and * non-density qualifiers. * *

Filtering resources from multiple targets at once means we may get multiple resources with * the same filename and qualifiers (including density) - we accept all of them with the best * density, and the unwanted ones will be removed during resource merging. */ private class BestArtifactsForDensity { private final RuleErrorConsumer ruleErrorConsumer; private final Density desiredDensity; private final Multimap nameAndConfigurationToBestArtifacts = HashMultimap.create(); BestArtifactsForDensity(RuleErrorConsumer ruleErrorConsumer, Density density) { this.ruleErrorConsumer = ruleErrorConsumer; desiredDensity = density; } /** * @param artifact if this artifact is a better match for this object's desired density than any * other artifacts with the same name and non-density configuration, adds it to this object. */ void maybeAddArtifact(Artifact artifact) { FolderConfiguration config = getConfigForArtifact(ruleErrorConsumer, artifact); // We want to find a single best artifact for each combination of non-density qualifiers and // filename. Combine those two values to create a single unique key. // We might encounter resource conflicts (multiple resources with the same name but different // locations) - in that case, we might have multiple best artifacts. In that case, keep them // all, and resource conflicts should will resolved during merging in execution. config.setDensityQualifier(null); String nameAndConfiguration = Joiner.on('/').join(config.getUniqueKey(), artifact.getFilename()); Collection currentBest = nameAndConfigurationToBestArtifacts.get(nameAndConfiguration); if (currentBest.isEmpty()) { nameAndConfigurationToBestArtifacts.put(nameAndConfiguration, artifact); return; } double affinity = computeAffinity(artifact); // All of the current best artifacts should have the same density, so get the affinity of an // arbitrary one. double currentAffinity = computeAffinity(currentBest.iterator().next()); if (affinity == currentAffinity) { nameAndConfigurationToBestArtifacts.put(nameAndConfiguration, artifact); } if (affinity < currentAffinity) { nameAndConfigurationToBestArtifacts.removeAll(nameAndConfiguration); nameAndConfigurationToBestArtifacts.put(nameAndConfiguration, artifact); } } public Collection get() { return nameAndConfigurationToBestArtifacts.values(); } /** * Compute how well this artifact matches the {@link #desiredDensity}. * *

Various different codebases have different and sometimes contradictory methods for which * resources are better in different situations. All of them agree that an exact match is best, * but: * *

The android common code (see {@link FolderConfiguration#getDensityQualifier()} treats * larger densities as better than non-matching smaller densities. * *

aapt code to filter assets by density prefers the smallest density that is larger than or * the same as the desired density, or, lacking that, the largest available density. * *

Other implementations of density filtering include Gradle (to filter which resources * actually get built into apps) and Android code itself (for the device to decide which * resource to use). * *

This particular implementation is based on {@link * com.google.devtools.build.android.DensitySpecificResourceFilter}, which filters resources by * density during execution. It prefers to use exact matches when possible, then tries to find * resources with exactly double the desired density for particularly easy downsizing, and * otherwise prefers resources that are closest to the desired density, relative to the smaller * of the available and desired densities. * *

Once we always filter resources during analysis, we should be able to completely remove * that code. * * @return a score for how well the artifact matches. Lower scores indicate better matches. */ private double computeAffinity(Artifact artifact) { DensityQualifier resourceQualifier = getConfigForArtifact(ruleErrorConsumer, artifact).getDensityQualifier(); if (resourceQualifier == null) { return Double.MAX_VALUE; } int resourceDensity = resourceQualifier.getValue().getDpiValue(); int density = desiredDensity.getDpiValue(); if (resourceDensity == density) { // Exact match is the best. return -2; } if (resourceDensity == 2 * density) { // It's very efficient to downsample an image that's exactly twice the screen // density, so we prefer that over other non-perfect matches. return -1; } // Find the ratio between the larger and smaller of the available and desired densities. double densityRatio = Math.max(density, resourceDensity) / (double) Math.min(density, resourceDensity); if (density < resourceDensity) { return densityRatio; } // Apply a slight bias against resources that are smaller than those of the desired density. // This becomes relevant only when we are considering multiple resources with the same ratio. return densityRatio + 0.01; } } private FolderConfiguration getConfigForArtifact( RuleErrorConsumer ruleErrorConsumer, Artifact artifact) { String containingFolder = getContainingFolder(artifact); if (containingFolder.contains("-")) { String[] parts = containingFolder.split("-", 2); String prefix = parts[0]; String qualifiers = parts[1]; for (DeprecatedQualifierHandler handler : deprecatedQualifierHandlers) { qualifiers = handler.fixResourceIfNeeded(ruleErrorConsumer, qualifiers, containingFolder); } containingFolder = String.format("%s-%s", prefix, qualifiers); } FolderConfiguration config = FolderConfiguration.getConfigForFolder(containingFolder); if (config == null) { ruleErrorConsumer.ruleError( "Resource folder '" + containingFolder + "' has invalid resource qualifiers"); return FolderConfiguration.getConfigForQualifierString(""); } return config; } /** * Checks if we should filter this artifact by its density. * *

We filter by density if there are densities to filter by, the artifact is in a Drawable * directory, and the artifact is not an XML file. * *

Similarly-named XML files may contain different resource definitions, so it's impossible to * ensure that all required resources will be provided without that XML file unless we parse it. */ private boolean shouldFilterByDensity(Artifact artifact) { return !densities.isEmpty() && !artifact.getExtension().equals("xml") && ResourceFolderType.getFolderType(getContainingFolder(artifact)) == ResourceFolderType.DRAWABLE; } private static String getContainingFolder(Artifact artifact) { return artifact.getExecPath().getParentDirectory().getBaseName(); } private static boolean matchesConfigurationFilters( ImmutableList folderConfigs, FolderConfiguration config) { for (FolderConfiguration filter : folderConfigs) { if (config.isMatchFor(filter)) { return true; } } return folderConfigs.isEmpty(); } /** * Returns if this object contains a non-empty resource configuration filter. * *

Note that non-empty filters are not guaranteed to filter resources during the analysis * phase. */ boolean hasConfigurationFilters() { return !configFilters.isEmpty(); } String getConfigurationFilterString() { return Joiner.on(',').join(configFilters); } /** * Returns if this object contains a non-empty density filter. * *

Note that non-empty filters are not guaranteed to filter resources during the analysis * phase. */ boolean hasDensities() { return !densities.isEmpty(); } String getDensityString() { return Joiner.on(',').join(densities); } ImmutableList getDensities() { return densities; } boolean isPrefiltering() { return filterInAnalysis; } /** * Gets a list of resource names that should be ignored by resource processing if they don't * exist. * *

A target might filter out some of its dependency's targets. However, those filtered targets * have already been built into symbols files. The filtered resources must be passed to resource * processing at execution time so the code knows to ignore resources that were filtered out. * Without this, resource processing code would see references to those resources in * dependencies's symbol files, but then be unable to follow those references or know whether they * were missing due to resource filtering or a bug. */ ImmutableList getResourcesToIgnoreInExecution() { return filteredResources.build().asList(); } /** * {@inheritDoc} * *

ResourceFilterFactory requires an accurately overridden equals() method to work correctly * with Bazel's caching and dynamic configuration. */ @Override public boolean equals(Object object) { if (!(object instanceof ResourceFilterFactory)) { return false; } ResourceFilterFactory other = (ResourceFilterFactory) object; return filterInAnalysis == other.filterInAnalysis && configFilters.equals(other.configFilters) && densities.equals(other.densities) && filteredResources.build().equals(other.filteredResources.build()); } @Override public int hashCode() { return Objects.hashCode(filterInAnalysis, configFilters, densities, filteredResources.build()); } }