/* * 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.util.HumanReadableException; import com.facebook.buck.util.MoreStrings; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.base.Predicate; import com.google.common.collect.FluentIterable; import com.google.common.collect.HashBasedTable; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Ordering; import com.google.common.collect.Sets; import com.google.common.collect.Table; import java.nio.file.Path; import java.util.Collection; import java.util.Map; import java.util.Set; public class ResourceFilters { /** Utility class: do not instantiate. */ private ResourceFilters() {} /** * The set of supported directories in resource folders. This is defined in * http://developer.android.com/guide/topics/resources/providing-resources.html#table1 */ @VisibleForTesting public static final ImmutableSet<String> SUPPORTED_RESOURCE_DIRECTORIES = ImmutableSet.of( "animator", "anim", "color", "drawable", "mipmap", "layout", "menu", "raw", "values", "xml"); /** * Represents the names and values of valid densities for resources as defined in * http://developer.android.com/guide/topics/resources/providing-resources.html#DensityQualifier */ public enum Density { // Note: ordering here matters and must be increasing by number! LDPI("ldpi", 120.0), NO_QUALIFIER("", 160.0), MDPI("mdpi", 160.0), TVDPI("tvdpi", 213.0), HDPI("hdpi", 240.0), XHDPI("xhdpi", 320.0), XXHDPI("xxhdpi", 480.0), XXXHDPI("xxxhdpi", 640.0); private final String qualifier; private final double value; public static final Ordering<Density> ORDERING = Ordering.natural(); Density(String qualifier, double value) { this.qualifier = qualifier; this.value = value; } public double value() { return value; } @Override public String toString() { return qualifier; } public static Density from(String s) { return s.isEmpty() ? NO_QUALIFIER : valueOf(s.toUpperCase()); } public static boolean isDensity(String s) { for (Density choice : values()) { if (choice.toString().equals(s)) { return true; } } return false; } } public static class Qualifiers { /** e.g. "xhdpi" */ public final ResourceFilters.Density density; /** e.g. "de-v11" */ public final String others; /** * Creates a Qualfiers given the Path to a resource folder, pulls out the density filters and * leaves the rest. */ public static Qualifiers from(Path path) { ResourceFilters.Density density = Density.NO_QUALIFIER; StringBuilder othersBuilder = new StringBuilder(); String parts[] = path.getFileName().toString().split("-"); Preconditions.checkState(parts.length > 0); Preconditions.checkState(SUPPORTED_RESOURCE_DIRECTORIES.contains(parts[0])); for (int i = 1; i < parts.length; i++) { String qualifier = parts[i]; if (ResourceFilters.Density.isDensity(qualifier)) { density = Density.from(qualifier); } else { othersBuilder.append((MoreStrings.isEmpty(othersBuilder) ? "" : "-") + qualifier); } } return new Qualifiers(density, othersBuilder.toString()); } private Qualifiers(Density density, String others) { this.density = density; this.others = others; } } /** * Takes a list of image files (as paths), and a target density (mdpi, hdpi, xhdpi), and returns a * list of files which can be safely left out when building an APK for phones with that screen * density. That APK will run on other screens as well but look worse due to scaling. * * <p>Each combination of non-density qualifiers is processed separately. For example, if we have * {@code drawable-hdpi, drawable-mdpi, drawable-xhdpi, drawable-hdpi-ro}, for a target of {@code * mdpi}, we'll be keeping {@code drawable-mdpi, drawable-hdpi-ro}. * * * @param candidates list of paths to image files * @param targetDensities densities we want to keep * @param canDownscale do we have access to an image scaler * @return set of files to remove */ @VisibleForTesting static ImmutableSet<Path> filterByDensity( Collection<Path> candidates, Set<ResourceFilters.Density> targetDensities, boolean canDownscale) { ImmutableSet.Builder<Path> removals = ImmutableSet.builder(); Table<String, Density, Path> imageValues = HashBasedTable.create(); // Create mappings for drawables. If candidate == "<base>/drawable-<dpi>-<other>/<filename>", // then we'll record a mapping of the form ("<base>/<filename>/<other>", "<dpi>") -> candidate. // For example: // mdpi hdpi // -------------------------------------------------------------------- // key: res/some.png/ | res/drawable-mdpi/some.png res/drawable-hdpi/some.png // key: res/some.png/fr | res/drawable-fr-hdpi/some.png for (Path candidate : candidates) { Qualifiers qualifiers = Qualifiers.from(candidate.getParent()); String filename = candidate.getFileName().toString(); Density density = qualifiers.density; String resDirectory = candidate.getParent().getParent().toString(); String key = String.format("%s/%s/%s", resDirectory, filename, qualifiers.others); imageValues.put(key, density, candidate); } for (String key : imageValues.rowKeySet()) { Map<Density, Path> options = imageValues.row(key); Set<Density> available = options.keySet(); // This is to make sure we preserve the existing structure of drawable/ files. Set<Density> targets = targetDensities; if (available.contains(Density.NO_QUALIFIER) && !available.contains(Density.MDPI)) { targets = Sets.newHashSet( Iterables.transform( targetDensities, input -> (input == Density.MDPI) ? Density.NO_QUALIFIER : input)); } // We intend to keep all available targeted densities. Set<Density> toKeep = Sets.newHashSet(Sets.intersection(available, targets)); // Make sure we have a decent fit for the largest target density. Density largestTarget = Density.ORDERING.max(targets); if (!available.contains(largestTarget)) { Density fallback = null; // Downscaling nine-patch drawables would require extra logic, not doing that yet. if (canDownscale && !options.values().iterator().next().toString().endsWith(".9.png")) { // Highest possible quality, because we'll downscale it. fallback = Density.ORDERING.max(available); } else { // We want to minimize size, so we'll go for the smallest available density that's // still larger than the missing one and, missing that, for the largest available. for (Density candidate : Density.ORDERING.reverse().sortedCopy(available)) { if (fallback == null || Density.ORDERING.compare(candidate, largestTarget) > 0) { fallback = candidate; } } } toKeep.add(fallback); } // Mark remaining densities for removal. for (Density density : Sets.difference(available, toKeep)) { removals.add(options.get(density)); } } return removals.build(); } /** * Given a list of paths of available drawables, and a target screen density, returns a {@link * com.google.common.base.Predicate} that fails for drawables of a different density, whenever * they can be safely removed. * * @param candidates list of available drawables * @param targetDensities set of e.g. {@code "mdpi"}, {@code "ldpi"} etc. * @param canDownscale if no exact match is available, retain the highest quality * @return a predicate as above */ public static Predicate<Path> createImageDensityFilter( Collection<Path> candidates, Set<ResourceFilters.Density> targetDensities, boolean canDownscale) { final Set<Path> pathsToRemove = filterByDensity(candidates, targetDensities, canDownscale); return path -> !pathsToRemove.contains(path); } private static String getResourceType(Path resourceFolder) { String parts[] = resourceFolder.getFileName().toString().split("-"); return parts[0]; } private static Path getResourceFolder(Path resourceFile) { for (int i = 0; i < resourceFile.getNameCount(); i++) { Path part = resourceFile.getName(i); if (SUPPORTED_RESOURCE_DIRECTORIES.contains(getResourceType(part))) { return resourceFile.subpath(0, i + 1); } } throw new HumanReadableException( "Resource file at %s is not in a valid resource folder. See " + "http://developer.android.com/guide/topics/resources/providing-resources.html#table1 " + "for a list of valid resource folders.", resourceFile); } /** * Given a set of target densities, returns a {@link Predicate} that fails for any non-drawable * resource of a different density. Special consideration exists for the default density ({@link * Density#NO_QUALIFIER} when the target does not exists. */ public static Predicate<Path> createDensityFilter( final ProjectFilesystem filesystem, final Set<Density> targetDensities) { return resourceFile -> { final Path resourceFolder = getResourceFolder(resourceFile); if (resourceFolder.getFileName().toString().startsWith("drawable")) { // Drawables are handled independently, so do not do anything with them. return true; } Density density = Qualifiers.from(resourceFolder).density; // We should include the resource in these situations: // * it is one of the target densities // * this is a "values" resource, which we include the fallback and any targets so we do not // have to parse the XML to determine if there are differences. // * there is no resource at any one of the target densities, and this is the fallback. if (targetDensities.contains(density)) { return true; } if (density.equals(Density.NO_QUALIFIER)) { final String resourceType = getResourceType(resourceFolder); return resourceType.equals("values") || FluentIterable.from(targetDensities) .anyMatch( target -> { Path targetResourceFile = resourceFolder .resolveSibling(String.format("%s-%s", resourceType, target)) .resolve(resourceFolder.relativize(resourceFile)); return !filesystem.exists(targetResourceFile); }); } return false; }; } }