com.facebook.buck.android.FilterResourcesStep.java Source code

Java tutorial

Introduction

Here is the source code for com.facebook.buck.android.FilterResourcesStep.java

Source

/*
 * 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.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.ProjectFilesystem;
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.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);

    @VisibleForTesting
    static final Pattern NON_ENGLISH_STRING_PATH = Pattern.compile("(\\b|.*/)res/values-.+/strings.xml",
            Pattern.CASE_INSENSITIVE);

    private final ImmutableBiMap<Path, Path> inResDirToOutResDirMap;
    private final boolean filterDrawables;
    private final boolean filterStrings;
    private final ImmutableSet<Path> whitelistedStringDirs;
    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,
            FilteredDirectoryCopier filteredDirectoryCopier, @Nullable Set<Filters.Density> targetDensities,
            @Nullable DrawableFinder drawableFinder, @Nullable ImageScaler imageScaler) {

        Preconditions.checkArgument(filterDrawables || filterStrings);
        Preconditions.checkArgument(!filterDrawables || (targetDensities != null && drawableFinder != null));
        this.inResDirToOutResDirMap = Preconditions.checkNotNull(inResDirToOutResDirMap);
        this.filterDrawables = filterDrawables;
        this.filterStrings = filterStrings;
        this.whitelistedStringDirs = Preconditions.checkNotNull(whitelistedStringDirs);
        this.filteredDirectoryCopier = Preconditions.checkNotNull(filteredDirectoryCopier);
        this.targetDensities = targetDensities;
        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<Path> getNonEnglishStringFiles() {
        return nonEnglishStringFilesBuilder.build();
    }

    public ImmutableSet<Path> getOutputResourceDirs() {
        return inResDirToOutResDirMap.values();
    }

    private int doExecute(ExecutionContext context) throws IOException {
        List<Predicate<Path>> pathPredicates = Lists.newArrayList();

        final boolean canDownscale = imageScaler != null && imageScaler.isAvailable(context);

        if (filterDrawables) {
            Set<Path> drawables = drawableFinder.findDrawables(inResDirToOutResDirMap.keySet(),
                    context.getProjectFilesystem());
            pathPredicates.add(Filters.createImageDensityFilter(drawables, targetDensities, canDownscale));
        }

        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 {
        ProjectFilesystem filesystem = context.getProjectFilesystem();
        Filters.Density targetDensity = Filters.Density.ORDERING.max(targetDensities);

        // Go over all the images that remain after filtering.
        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.
            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);
                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);

        public void scale(double factor, Path source, Path 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;
        }

        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) {
            return 0 == new BashStep("which convert").execute(getContextWithSilentConsole(context));
        }

        @Override
        public void scale(double factor, Path source, Path destination, ExecutionContext context) {
            Step convertStep = new BashStep("convert", "-adaptive-resize", (int) (factor * 100) + "%",
                    Escaper.escapeAsBashString(source), Escaper.escapeAsBashString(destination));

            if (0 != convertStep.execute(getContextWithSilentConsole(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();
        }
    }

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {

        private ImmutableBiMap<Path, Path> inResDirToOutResDirMap;
        private ResourceFilter resourceFilter;
        private boolean filterStrings = false;
        private ImmutableSet<Path> whitelistedStringDirs = 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 FilterResourcesStep build() {
            return new FilterResourcesStep(inResDirToOutResDirMap, resourceFilter.isEnabled(), filterStrings,
                    whitelistedStringDirs, DefaultFilteredDirectoryCopier.getInstance(),
                    resourceFilter.getDensities(), DefaultDrawableFinder.getInstance(),
                    resourceFilter.shouldDownscale() ? ImageMagickScaler.getInstance() : null);
        }
    }
}