blob: ec8b1f07eb96893b3e95b495a474f65253bf17a6 [file] [log] [blame]
/*
* Copyright 2012-present Facebook, Inc.
*
* 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.facebook.buck.android;
import com.facebook.buck.io.ProjectFilesystem;
import com.facebook.buck.log.Logger;
import com.facebook.buck.shell.BashStep;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.step.Step;
import com.facebook.buck.util.Console;
import com.facebook.buck.util.DefaultFilteredDirectoryCopier;
import com.facebook.buck.util.Escaper;
import com.facebook.buck.util.FilteredDirectoryCopier;
import com.facebook.buck.util.Filters;
import com.facebook.buck.util.Filters.Density;
import com.facebook.buck.util.HumanReadableException;
import com.facebook.buck.util.Verbosity;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
/**
* This {@link com.facebook.buck.step.Step} copies {@code res} directories to a different location,
* while filtering out certain resources.
*/
public class FilterResourcesStep implements Step {
private static final Pattern DRAWABLE_PATH_PATTERN = Pattern.compile(
".*drawable.*/.*(png|jpg|jpeg|gif|webp)", Pattern.CASE_INSENSITIVE);
// Android doesn't scale these, so we don't need to scale or filter them either.
private static final Pattern DRAWABLE_EXCLUDE_PATTERN = Pattern.compile(
".*-nodpi.*", Pattern.CASE_INSENSITIVE);
private static final Logger LOG = Logger.get(FilterResourcesStep.class);
@VisibleForTesting
static final Pattern NON_ENGLISH_STRING_PATH = Pattern.compile(
"(\\b|.*/)res/values-.+/strings.xml", Pattern.CASE_INSENSITIVE);
@VisibleForTesting
static final Pattern VALUES_DIR_PATTERN = Pattern.compile(
"\\b|.*/res/values-([a-z]{2})(?:-r([A-Z]{2}))*/.*");
private final ImmutableBiMap<Path, Path> inResDirToOutResDirMap;
private final boolean filterDrawables;
private final boolean filterStrings;
private final ImmutableSet<Path> whitelistedStringDirs;
private final ImmutableSet<String> locales;
private final FilteredDirectoryCopier filteredDirectoryCopier;
@Nullable
private final Set<Filters.Density> targetDensities;
@Nullable
private final DrawableFinder drawableFinder;
@Nullable
private final ImageScaler imageScaler;
private final ImmutableSet.Builder<Path> nonEnglishStringFilesBuilder;
/**
* Creates a command that filters a specified set of directories.
* @param inResDirToOutResDirMap set of {@code res} directories to filter
* @param filterDrawables whether to filter drawables (images)
* @param filterStrings whether to filter non-english strings
* @param whitelistedStringDirs set of directories containing string resource files that must not
* be filtered out.
* @param filteredDirectoryCopier refer {@link FilteredDirectoryCopier}
* @param targetDensities densities we're interested in keeping (e.g. {@code mdpi}, {@code hdpi}
* etc.) Only applicable if filterDrawables is true
* @param drawableFinder refer {@link DrawableFinder}. Only applicable if filterDrawables is true.
* @param imageScaler if not null, use the {@link ImageScaler} to downscale higher-density
* drawables for which we weren't able to find an image file of the proper density (as opposed
* to allowing Android to do it at runtime). Only applicable if filterDrawables. is true.
*/
@VisibleForTesting
FilterResourcesStep(
ImmutableBiMap<Path, Path> inResDirToOutResDirMap,
boolean filterDrawables,
boolean filterStrings,
ImmutableSet<Path> whitelistedStringDirs,
ImmutableSet<String> locales,
FilteredDirectoryCopier filteredDirectoryCopier,
@Nullable Set<Filters.Density> targetDensities,
@Nullable DrawableFinder drawableFinder,
@Nullable ImageScaler imageScaler) {
Preconditions.checkArgument(filterDrawables || filterStrings || !locales.isEmpty());
Preconditions.checkArgument(!filterDrawables ||
(targetDensities != null && drawableFinder != null));
this.inResDirToOutResDirMap = inResDirToOutResDirMap;
this.filterDrawables = filterDrawables;
this.filterStrings = filterStrings;
this.whitelistedStringDirs = whitelistedStringDirs;
this.locales = locales;
this.filteredDirectoryCopier = filteredDirectoryCopier;
this.targetDensities = targetDensities;
this.drawableFinder = drawableFinder;
this.imageScaler = imageScaler;
this.nonEnglishStringFilesBuilder = ImmutableSet.builder();
LOG.info(
"FilterResourcesStep: filterDrawables: %s; filterStrings: %s",
filterDrawables,
filterStrings);
}
@Override
public int execute(ExecutionContext context) {
try {
return doExecute(context);
} catch (Exception e) {
context.logError(e, "There was an error filtering resources.");
return 1;
}
}
/**
* @return If {@code filterStrings} is true, set containing absolute file paths to non-english
* string files, matching NON_ENGLISH_STRING_PATH regex; else empty set.
*/
public ImmutableSet<Path> getNonEnglishStringFiles() {
return nonEnglishStringFilesBuilder.build();
}
private int doExecute(ExecutionContext context) throws IOException, InterruptedException {
List<Predicate<Path>> pathPredicates = Lists.newArrayList();
boolean canDownscale = imageScaler != null && imageScaler.isAvailable(context);
LOG.info(
"FilterResourcesStep: canDownscale: %s. imageScalar non-null: %s.",
canDownscale,
imageScaler != null);
if (filterDrawables) {
Preconditions.checkNotNull(drawableFinder);
Set<Path> drawables = drawableFinder.findDrawables(
inResDirToOutResDirMap.keySet(),
context.getProjectFilesystem());
pathPredicates.add(
Filters.createImageDensityFilter(drawables, targetDensities, canDownscale));
}
if (!locales.isEmpty()) {
pathPredicates.add(
new Predicate<Path>() {
@Override
public boolean apply(Path input) {
Matcher matcher = VALUES_DIR_PATTERN.matcher(input.toString());
if (!matcher.matches()) {
return true;
}
String locale = matcher.group(1);
if (matcher.group(2) != null) {
locale += "_" + matcher.group(2);
}
return locales.contains(locale);
}
});
}
if (filterStrings) {
pathPredicates.add(
new Predicate<Path>() {
@Override
public boolean apply(Path pathRelativeToProjectRoot) {
if (!NON_ENGLISH_STRING_PATH.matcher(pathRelativeToProjectRoot.toString())
.matches()) {
return true;
}
for (Path whitelistedStringDir : whitelistedStringDirs) {
if (pathRelativeToProjectRoot.startsWith(whitelistedStringDir)) {
return true;
}
}
nonEnglishStringFilesBuilder.add(pathRelativeToProjectRoot);
return false;
}
});
}
// Create filtered copies of all resource directories. These will be passed to aapt instead.
filteredDirectoryCopier.copyDirs(
context.getProjectFilesystem(),
inResDirToOutResDirMap,
Predicates.and(pathPredicates));
// If an ImageScaler was specified, but only if it is available, try to apply it.
if (canDownscale && filterDrawables) {
scaleUnmatchedDrawables(context);
}
return 0;
}
@Override
public String getShortName() {
return "resource_filtering";
}
@Override
public String getDescription(ExecutionContext context) {
return "Filtering drawable and string resources.";
}
/**
* Looks through filtered drawables for files not of the target density and replaces them with
* scaled versions.
* <p/>
* Any drawables found by this step didn't have equivalents in the target density. If they are of
* a higher density, we can replicate what Android does and downscale them at compile-time.
*/
private void scaleUnmatchedDrawables(ExecutionContext context)
throws IOException, InterruptedException {
ProjectFilesystem filesystem = context.getProjectFilesystem();
Filters.Density targetDensity = Filters.Density.ORDERING.max(targetDensities);
// Go over all the images that remain after filtering.
Preconditions.checkNotNull(drawableFinder);
Collection<Path> drawables = drawableFinder.findDrawables(
inResDirToOutResDirMap.values(),
context.getProjectFilesystem());
for (Path drawable : drawables) {
if (drawable.toString().endsWith(".9.png")) {
// Skip nine-patch for now.
continue;
}
Filters.Qualifiers qualifiers = new Filters.Qualifiers(drawable);
Filters.Density density = qualifiers.density;
// If the image has a qualifier but it's not the right one.
Preconditions.checkNotNull(targetDensities);
if (!targetDensities.contains(density)) {
// Replace density qualifier with target density using regular expression to match
// the qualifier in the context of a path to a drawable.
String fromDensity = (density == Density.NO_QUALIFIER ? "" : "-") + density.toString();
Path destination = Paths.get(drawable.toString().replaceFirst(
"((?:^|/)drawable[^/]*)" + Pattern.quote(fromDensity) + "(-|$|/)",
"$1-" + targetDensity + "$2"));
double factor = targetDensity.value() / density.value();
if (factor >= 1.0) {
// There is no point in up-scaling, or converting between drawable and drawable-mdpi.
continue;
}
// Make sure destination folder exists and perform downscaling.
filesystem.createParentDirs(destination);
Preconditions.checkNotNull(imageScaler);
imageScaler.scale(factor, drawable, destination, context);
// Delete source file.
if (!filesystem.deleteFileAtPath(drawable)) {
throw new HumanReadableException("Cannot delete file: " + drawable);
}
// Delete newly-empty directories to prevent missing resources errors in apkbuilder.
Path parent = drawable.getParent();
if (filesystem.listFiles(parent).length == 0 && !filesystem.deleteFileAtPath(parent)) {
throw new HumanReadableException("Cannot delete directory: " + parent);
}
}
}
}
public interface DrawableFinder {
public Set<Path> findDrawables(Collection<Path> dirs, ProjectFilesystem filesystem)
throws IOException;
}
public static class DefaultDrawableFinder implements DrawableFinder {
private static final DefaultDrawableFinder instance = new DefaultDrawableFinder();
public static DefaultDrawableFinder getInstance() {
return instance;
}
@Override
public Set<Path> findDrawables(Collection<Path> dirs, ProjectFilesystem filesystem)
throws IOException {
final ImmutableSet.Builder<Path> drawableBuilder = ImmutableSet.builder();
for (Path dir : dirs) {
filesystem.walkRelativeFileTree(dir, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes attributes) {
if (DRAWABLE_PATH_PATTERN.matcher(path.toString()).matches() &&
!DRAWABLE_EXCLUDE_PATTERN.matcher(path.toString()).matches()) {
// The path is normalized so that the value can be matched against patterns.
drawableBuilder.add(path);
}
return FileVisitResult.CONTINUE;
}
});
}
return drawableBuilder.build();
}
}
public interface ImageScaler {
public boolean isAvailable(ExecutionContext context) throws IOException, InterruptedException;
public void scale(double factor, Path source, Path destination, ExecutionContext context)
throws IOException, InterruptedException;
}
/**
* Implementation of {@link ImageScaler} that uses ImageMagick's {@code convert} command.
*
* @see <a href="http://www.imagemagick.org/script/index.php">ImageMagick</a>
*/
public static class ImageMagickScaler implements ImageScaler {
private static final ImageMagickScaler instance = new ImageMagickScaler();
public static ImageMagickScaler getInstance() {
return instance;
}
private ExecutionContext getContextWithSilentConsole(ExecutionContext context) {
// Using the normal console results in the super console freezing.
Console console = context.getConsole();
return ExecutionContext.builder()
.setExecutionContext(context)
.setConsole(new Console(
Verbosity.SILENT,
console.getStdOut(),
console.getStdErr(),
console.getAnsi()
))
.build();
}
@Override
public boolean isAvailable(ExecutionContext context) throws IOException, InterruptedException {
try (ExecutionContext silentContext = getContextWithSilentConsole(context)) {
return 0 == new BashStep("which convert").execute(silentContext);
}
}
@Override
public void scale(double factor, Path source, Path destination, ExecutionContext context)
throws IOException, InterruptedException {
Step convertStep = new BashStep(
"convert",
"-adaptive-resize", (int) (factor * 100) + "%",
Escaper.escapeAsBashString(source),
Escaper.escapeAsBashString(destination));
if (0 != convertStep.execute(context)) {
throw new HumanReadableException("Cannot scale " + source + " to " + destination);
}
}
}
/**
* Helper class for interpreting the resource_filter argument to android_binary().
*/
public static class ResourceFilter {
static final ResourceFilter EMPTY_FILTER = new ResourceFilter(ImmutableList.<String>of());
private final Set<String> filter;
private final Set<Filters.Density> densities;
private final boolean downscale;
public ResourceFilter(List<String> resourceFilter) {
this.filter = ImmutableSet.copyOf(resourceFilter);
this.densities = Sets.newHashSet();
boolean downscale = false;
for (String component : filter) {
if ("downscale".equals(component)) {
downscale = true;
} else {
densities.add(Filters.Density.from(component));
}
}
this.downscale = downscale;
}
public boolean shouldDownscale() {
return isEnabled() && downscale;
}
@Nullable
public Set<Filters.Density> getDensities() {
return densities;
}
public boolean isEnabled() {
return !densities.isEmpty();
}
public String getDescription() {
return filter.toString();
}
@VisibleForTesting
Set<String> getFilter() {
return filter;
}
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
@Nullable
private ImmutableBiMap<Path, Path> inResDirToOutResDirMap;
@Nullable
private ResourceFilter resourceFilter;
private boolean filterStrings = false;
private ImmutableSet<Path> whitelistedStringDirs = ImmutableSet.of();
private ImmutableSet<String> locales = ImmutableSet.of();
private Builder() {
}
public Builder setInResToOutResDirMap(ImmutableBiMap<Path, Path> inResDirToOutResDirMap) {
this.inResDirToOutResDirMap = inResDirToOutResDirMap;
return this;
}
public Builder setResourceFilter(ResourceFilter resourceFilter) {
this.resourceFilter = resourceFilter;
return this;
}
public Builder enableStringsFilter() {
this.filterStrings = true;
return this;
}
public Builder setWhitelistedStringDirs(ImmutableSet<Path> whitelistedStringDirs) {
this.whitelistedStringDirs = whitelistedStringDirs;
return this;
}
public Builder setLocales(ImmutableSet<String> locales) {
this.locales = locales;
return this;
}
public FilterResourcesStep build() {
Preconditions.checkNotNull(resourceFilter);
LOG.info("FilterResourcesStep.Builder: resource filter: %s", resourceFilter);
Preconditions.checkNotNull(inResDirToOutResDirMap);
return new FilterResourcesStep(
inResDirToOutResDirMap,
resourceFilter.isEnabled(),
filterStrings,
whitelistedStringDirs,
locales,
DefaultFilteredDirectoryCopier.getInstance(),
resourceFilter.getDensities(),
DefaultDrawableFinder.getInstance(),
resourceFilter.shouldDownscale() ? ImageMagickScaler.getInstance() : null);
}
}
}