blob: 966c0c22c54eb8932859451f76176ae78a96c1da [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.shell.BashStep;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.step.Step;
import com.facebook.buck.util.DefaultFilteredDirectoryCopier;
import com.facebook.buck.util.DirectoryTraversal;
import com.facebook.buck.util.Escaper;
import com.facebook.buck.util.FilteredDirectoryCopier;
import com.facebook.buck.util.Filters;
import com.facebook.buck.util.HumanReadableException;
import com.facebook.buck.util.MorePaths;
import com.facebook.buck.util.ProjectFilesystem;
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.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
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 {
/**
* We use this to compute scaling factors between different densities.
*/
private static Map<String, Double> DPI_VALUES = ImmutableMap.of(
"mdpi", 160.0,
"hdpi", 240.0,
"xhdpi", 320.0);
@VisibleForTesting
static final Pattern DRAWABLE_PATH_PATTERN = Pattern.compile(
".*drawable.*/.*(png|jpg|jpeg|gif)", Pattern.CASE_INSENSITIVE);
@VisibleForTesting
static final Pattern NON_ENGLISH_STRING_PATH = Pattern.compile(
"(\\b|.*/)res/values-.+/strings.xml", Pattern.CASE_INSENSITIVE);
private final ImmutableBiMap<String, String> inResDirToOutResDirMap;
private final boolean filterDrawables;
private final boolean filterStrings;
private final FilteredDirectoryCopier filteredDirectoryCopier;
@Nullable
private final String resourceFilter;
@Nullable
private final DrawableFinder drawableFinder;
@Nullable
private final ImageScaler imageScaler;
private final ImmutableSet.Builder<String> 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 filteredDirectoryCopier refer {@link FilteredDirectoryCopier}
*
* @param resourceFilter filtering to perform (currently based on density; allowed values are
* {@code "mdpi"}, {@code "hdpi"} and {@code "xhdpi"}). 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<String, String> inResDirToOutResDirMap,
boolean filterDrawables,
boolean filterStrings,
FilteredDirectoryCopier filteredDirectoryCopier,
@Nullable String resourceFilter,
@Nullable DrawableFinder drawableFinder,
@Nullable ImageScaler imageScaler) {
Preconditions.checkArgument(filterDrawables || filterStrings);
Preconditions.checkArgument(!filterDrawables ||
(resourceFilter != null && drawableFinder != null));
this.inResDirToOutResDirMap = Preconditions.checkNotNull(inResDirToOutResDirMap);
this.filterDrawables = filterDrawables;
this.filterStrings = filterStrings;
this.filteredDirectoryCopier = Preconditions.checkNotNull(filteredDirectoryCopier);
this.resourceFilter = resourceFilter;
this.drawableFinder = drawableFinder;
this.imageScaler = imageScaler;
this.nonEnglishStringFilesBuilder = ImmutableSet.builder();
}
@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<String> getNonEnglishStringFiles() {
return nonEnglishStringFilesBuilder.build();
}
private int doExecute(ExecutionContext context) throws IOException {
List<Predicate<File>> filePredicates = Lists.newArrayList();
if (filterDrawables) {
Set<String> drawables = drawableFinder.findDrawables(inResDirToOutResDirMap.keySet());
filePredicates.add(Filters.createImageDensityFilter(drawables, resourceFilter));
}
if (filterStrings) {
filePredicates.add(new Predicate<File>() {
@Override
public boolean apply(File input) {
String inputPath = input.getAbsolutePath();
if (NON_ENGLISH_STRING_PATH.matcher(inputPath).matches()) {
nonEnglishStringFilesBuilder.add(inputPath);
return false;
}
return true;
}
});
}
// Create filtered copies of all resource directories. These will be passed to aapt instead.
filteredDirectoryCopier.copyDirs(inResDirToOutResDirMap, Predicates.and(filePredicates));
// If an ImageScaler was specified, but only if it is available, try to apply it.
if ((imageScaler != null) && imageScaler.isAvailable(context)) {
scaleUnmatchedDrawables(context);
}
return 0;
}
@Override
public String getShortName() {
return "resource_filtering";
}
@Override
public String getDescription(ExecutionContext context) {
return "Filtering drawable and string resources.";
}
@VisibleForTesting
String getResourceFilter() {
return resourceFilter;
}
@VisibleForTesting
boolean isFilterStrings() {
return filterStrings;
}
@VisibleForTesting
ImmutableBiMap<String, String> getInResDirToOutResDirMap() {
return inResDirToOutResDirMap;
}
/**
* 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 {
ProjectFilesystem filesystem = context.getProjectFilesystem();
// Go over all the images that remain after filtering.
for (String drawable : drawableFinder.findDrawables(inResDirToOutResDirMap.values())) {
File drawableFile = filesystem.getFileForRelativePath(drawable);
if (drawable.endsWith(".9.png")) {
// Skip nine-patch for now.
continue;
}
Filters.Qualifiers qualifiers = new Filters.Qualifiers(drawableFile);
// If the image has a qualifier but it's not the right one.
if (!qualifiers.density.equals(this.resourceFilter) && !qualifiers.density.isEmpty()) {
// Replace density qualifier with target density using regular expression to match
// the qualifier in the context of a path to a drawable.
String destination = drawable.replaceFirst(
"((?:^|/)drawable[^/]*-)" + Pattern.quote(qualifiers.density) + "(-|$|/)",
"$1" + resourceFilter + "$2");
double factor = DPI_VALUES.get(resourceFilter) / (DPI_VALUES.get(qualifiers.density));
if (factor > 1.0) {
// There is no point in up-scaling.
continue;
}
// Make sure destination folder exists and perform downscaling.
filesystem.createParentDirs(destination);
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.
String parent = drawableFile.getParent();
if (filesystem.listFiles(parent).length == 0 && !filesystem.deleteFileAtPath(parent)) {
throw new HumanReadableException("Cannot delete directory: " + parent);
}
}
}
}
public interface DrawableFinder {
public Set<String> findDrawables(Iterable<String> dirs) throws IOException;
}
public static class DefaultDrawableFinder implements DrawableFinder {
private static final DefaultDrawableFinder instance = new DefaultDrawableFinder();
public static DefaultDrawableFinder getInstance() {
return instance;
}
@Override
public Set<String> findDrawables(Iterable<String> dirs) throws IOException {
final ImmutableSet.Builder<String> drawableBuilder = ImmutableSet.builder();
for (String dir : dirs) {
new DirectoryTraversal(new File(dir)) {
@Override
public void visit(File file, String relativePath) {
if (DRAWABLE_PATH_PATTERN.matcher(relativePath).matches()) {
// The path is normalized so that the value can be matched against patterns.
drawableBuilder.add(MorePaths.newPathInstance(file).toString());
}
}
}.traverse();
}
return drawableBuilder.build();
}
}
public interface ImageScaler {
public boolean isAvailable(ExecutionContext context);
public void scale(double factor, String source, String destination, ExecutionContext context);
}
/**
* 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;
}
@Override
public boolean isAvailable(ExecutionContext context) {
return 0 == new BashStep("which convert").execute(context);
}
@Override
public void scale(double factor, String source, String destination, ExecutionContext context) {
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);
}
}
}
public static class ResourceFilter {
public ResourceFilter(List<String> resourceFilter) {
this.filter = ImmutableList.copyOf(resourceFilter);
}
private final List<String> filter;
public boolean shouldDownscale() {
return filter.contains("downscale");
}
@Nullable
public String getDensity() {
String density = null;
for (String option : filter) {
if (Filters.ORDERING.containsKey(option)) {
if (density == null) {
density = option;
} else {
throw new HumanReadableException("Multiple target densities not supported yet.");
}
}
}
return density;
}
public boolean isEnabled() {
return getDensity() != null;
}
public String getDescription() {
return filter.toString();
}
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private ImmutableBiMap<String, String> inResDirToOutResDirMap;
private ResourceFilter resourceFilter;
private boolean filterStrings = false;
private Builder() {
}
public Builder setInResToOutResDirMap(ImmutableBiMap<String, String> 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 FilterResourcesStep build() {
return new FilterResourcesStep(
inResDirToOutResDirMap,
resourceFilter.isEnabled(),
filterStrings,
DefaultFilteredDirectoryCopier.getInstance(),
resourceFilter.getDensity(),
DefaultDrawableFinder.getInstance(),
resourceFilter.shouldDownscale() ? ImageMagickScaler.getInstance() : null);
}
}
}