| /* |
| * 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.event.ThrowableLogEvent; |
| 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.getBuckEventBus().post(ThrowableLogEvent.create(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); |
| } |
| } |
| } |