/* * 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 static com.google.common.collect.Ordering.natural; import com.facebook.buck.android.aapt.RDotTxtEntry; import com.facebook.buck.android.aapt.RDotTxtEntry.IdType; import com.facebook.buck.android.aapt.RDotTxtEntry.RType; import com.facebook.buck.io.ProjectFilesystem; import com.facebook.buck.log.Logger; import com.facebook.buck.rules.SourcePath; import com.facebook.buck.rules.SourcePathResolver; import com.facebook.buck.step.ExecutionContext; import com.facebook.buck.step.Step; import com.facebook.buck.step.StepExecutionResult; import com.facebook.buck.util.ThrowingPrintWriter; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.common.collect.FluentIterable; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedSet; import com.google.common.collect.Sets; import com.google.common.collect.SortedSetMultimap; import com.google.common.collect.TreeMultimap; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; public class MergeAndroidResourcesStep implements Step { private static final Logger LOG = Logger.get(MergeAndroidResourcesStep.class); private final ProjectFilesystem filesystem; private final SourcePathResolver pathResolver; private final ImmutableList<HasAndroidResourceDeps> androidResourceDeps; private final Optional<Path> uberRDotTxt; private final Path outputDir; private final boolean forceFinalResourceIds; private final EnumSet<RType> bannedDuplicateResourceTypes; private final Optional<String> unionPackage; private final String rName; private final boolean useOldStyleableFormat; /** * Merges text symbols files from {@code aapt} for each of the input {@code android_resource} into * a set of resources per R.java package and writes an {@code R.java} file per package under the * output directory. Also, if {@code uberRDotTxt} is present, the IDs in the output {@code R.java} * file will be taken from the {@code R.txt} file. */ @VisibleForTesting MergeAndroidResourcesStep( ProjectFilesystem filesystem, SourcePathResolver pathResolver, List<HasAndroidResourceDeps> androidResourceDeps, Optional<Path> uberRDotTxt, Path outputDir, boolean forceFinalResourceIds, EnumSet<RType> bannedDuplicateResourceTypes, Optional<String> unionPackage, Optional<String> rName, boolean useOldStyleableFormat) { this.filesystem = filesystem; this.pathResolver = pathResolver; this.androidResourceDeps = ImmutableList.copyOf(androidResourceDeps); this.uberRDotTxt = uberRDotTxt; this.outputDir = outputDir; this.forceFinalResourceIds = forceFinalResourceIds; this.bannedDuplicateResourceTypes = bannedDuplicateResourceTypes; this.unionPackage = unionPackage; this.rName = rName.orElse("R"); this.useOldStyleableFormat = useOldStyleableFormat; } public static MergeAndroidResourcesStep createStepForDummyRDotJava( ProjectFilesystem filesystem, SourcePathResolver pathResolver, List<HasAndroidResourceDeps> androidResourceDeps, Path outputDir, boolean forceFinalResourceIds, Optional<String> unionPackage, Optional<String> rName, boolean useOldStyleableFormat) { return new MergeAndroidResourcesStep( filesystem, pathResolver, androidResourceDeps, /* uberRDotTxt */ Optional.empty(), outputDir, forceFinalResourceIds, /* bannedDuplicateResourceTypes */ EnumSet.noneOf(RType.class), unionPackage, rName, useOldStyleableFormat); } public static MergeAndroidResourcesStep createStepForUberRDotJava( ProjectFilesystem filesystem, SourcePathResolver pathResolver, List<HasAndroidResourceDeps> androidResourceDeps, Path uberRDotTxt, Path outputDir, EnumSet<RType> bannedDuplicateResourceTypes, Optional<String> unionPackage) { return new MergeAndroidResourcesStep( filesystem, pathResolver, androidResourceDeps, Optional.of(uberRDotTxt), outputDir, /* forceFinalResourceIds */ true, bannedDuplicateResourceTypes, unionPackage, /* rName */ Optional.empty(), false); } public ImmutableSortedSet<Path> getRDotJavaFiles() { return FluentIterable.from(androidResourceDeps) .transform(HasAndroidResourceDeps::getRDotJavaPackage) .append(unionPackage.map(Collections::singletonList).orElse(Collections.emptyList())) .transform(this::getPathToRDotJava) .toSortedSet(natural()); } @Override public StepExecutionResult execute(ExecutionContext context) { try { doExecute(); return StepExecutionResult.SUCCESS; } catch (IOException e) { e.printStackTrace(context.getStdErr()); return StepExecutionResult.ERROR; } catch (DuplicateResourceException e) { return StepExecutionResult.of(1, Optional.of(e.getMessage())); } } private void doExecute() throws IOException, DuplicateResourceException { // In order to convert a symbols file to R.java, all resources of the same type are grouped // into a static class of that name. The static class contains static values that correspond to // the resource (type, name, value) tuples. See RDotTxtEntry. // // The first step is to merge symbol files of the same package type and resource type/name. // That is, within a package type, each resource type/name pair must be unique. If there are // multiple pairs, only one will be written to the R.java file. // // Because the resulting files do not match their respective resources.arsc, the values are // meaningless and do not represent the usable final result. This is why the R.java file is // written without using final so that javac will not inline the values. Unfortunately, // though Robolectric doesn't read resources.arsc, it does assert that all the R.java resource // ids are unique. This forces us to re-enumerate new unique ids. ImmutableMap.Builder<Path, String> rDotTxtToPackage = ImmutableMap.builder(); ImmutableMap.Builder<Path, HasAndroidResourceDeps> symbolsFileToResourceDeps = ImmutableMap.builder(); for (HasAndroidResourceDeps res : androidResourceDeps) { Path rDotTxtPath = filesystem.relativize(pathResolver.getAbsolutePath(res.getPathToTextSymbolsFile())); rDotTxtToPackage.put(rDotTxtPath, res.getRDotJavaPackage()); symbolsFileToResourceDeps.put(rDotTxtPath, res); } Optional<ImmutableMap<RDotTxtEntry, String>> uberRDotTxtIds; if (uberRDotTxt.isPresent()) { // re-assign Ids uberRDotTxtIds = Optional.of( FluentIterable.from(RDotTxtEntry.readResources(filesystem, uberRDotTxt.get())) .toMap(input -> input.idValue)); } else { uberRDotTxtIds = Optional.empty(); } ImmutableMap<Path, String> symbolsFileToRDotJavaPackage = rDotTxtToPackage.build(); SortedSetMultimap<String, RDotTxtEntry> rDotJavaPackageToResources = sortSymbols( symbolsFileToRDotJavaPackage, uberRDotTxtIds, symbolsFileToResourceDeps.build(), bannedDuplicateResourceTypes, filesystem, useOldStyleableFormat); // If a resource_union_package was specified, copy all resource into that package, // unless they are already present. if (unionPackage.isPresent()) { String unionPackageName = unionPackage.get(); // Create a temporary list to avoid concurrent modification problems. for (Map.Entry<String, RDotTxtEntry> entry : new ArrayList<>(rDotJavaPackageToResources.entries())) { if (rDotJavaPackageToResources.containsEntry(unionPackageName, entry.getValue())) { continue; } rDotJavaPackageToResources.put(unionPackageName, entry.getValue()); } } writePerPackageRDotJava(rDotJavaPackageToResources, filesystem); Set<String> emptyPackages = Sets.difference( ImmutableSet.copyOf(symbolsFileToRDotJavaPackage.values()), rDotJavaPackageToResources.keySet()); if (!emptyPackages.isEmpty()) { writeEmptyRDotJavaForPackages(emptyPackages, filesystem); } } private void writeEmptyRDotJavaForPackages( Set<String> rDotJavaPackages, ProjectFilesystem filesystem) throws IOException { for (String rDotJavaPackage : rDotJavaPackages) { Path outputFile = getPathToRDotJava(rDotJavaPackage); filesystem.mkdirs(outputFile.getParent()); filesystem.writeContentsToPath( String.format("package %s;\n\npublic class %s {}\n", rDotJavaPackage, rName), outputFile); } } @VisibleForTesting void writePerPackageRDotJava( SortedSetMultimap<String, RDotTxtEntry> packageToResources, ProjectFilesystem filesystem) throws IOException { for (String rDotJavaPackage : packageToResources.keySet()) { Path outputFile = getPathToRDotJava(rDotJavaPackage); filesystem.mkdirs(outputFile.getParent()); try (ThrowingPrintWriter writer = new ThrowingPrintWriter(filesystem.newFileOutputStream(outputFile))) { writer.format("package %s;\n\n", rDotJavaPackage); writer.format("public class %s {\n", rName); ImmutableList.Builder<String> customDrawablesBuilder = ImmutableList.builder(); ImmutableList.Builder<String> grayscaleImagesBuilder = ImmutableList.builder(); RType lastType = null; for (RDotTxtEntry res : packageToResources.get(rDotJavaPackage)) { RType type = res.type; if (!type.equals(lastType)) { // If the previous type needs to be closed, close it. if (lastType != null) { writer.println(" }\n"); } // Now start the block for the new type. writer.format(" public static class %s {\n", type); lastType = type; } // Write out the resource. // Write as an int. writer.format( " public static%s%s %s=%s;\n", forceFinalResourceIds ? " final " : " ", res.idType, res.name, res.idValue); if (type == RType.DRAWABLE && res.customType == RDotTxtEntry.CustomDrawableType.CUSTOM) { customDrawablesBuilder.add(res.idValue); } else if (type == RType.DRAWABLE && res.customType == RDotTxtEntry.CustomDrawableType.GRAYSCALE_IMAGE) { grayscaleImagesBuilder.add(res.idValue); } } // If some type was written (e.g., the for loop was entered), then the last type needs to be // closed. if (lastType != null) { writer.println(" }\n"); } ImmutableList<String> customDrawables = customDrawablesBuilder.build(); if (customDrawables.size() > 0) { // Add a new field for the custom drawables. writer.format(" public static final int[] custom_drawables = "); writer.format("{ %s };\n", Joiner.on(",").join(customDrawables)); writer.format("\n"); } ImmutableList<String> grayscaleImages = grayscaleImagesBuilder.build(); if (grayscaleImages.size() > 0) { // Add a new field for the custom drawables. writer.format(" public static final int[] grayscale_images = "); writer.format("{ %s };\n", Joiner.on(",").join(grayscaleImages)); writer.format("\n"); } // Close the class definition. writer.println("}"); } } } @VisibleForTesting static SortedSetMultimap<String, RDotTxtEntry> sortSymbols( Map<Path, String> symbolsFileToRDotJavaPackage, Optional<ImmutableMap<RDotTxtEntry, String>> uberRDotTxtIds, ImmutableMap<Path, HasAndroidResourceDeps> symbolsFileToResourceDeps, EnumSet<RType> bannedDuplicateResourceTypes, ProjectFilesystem filesystem, boolean useOldStyleableFormat) throws DuplicateResourceException { // If we're reenumerating, start at 0x7f01001 so that the resulting file is human readable. // This value range (0x7f010001 - ...) is easier to spot as an actual resource id instead of // other values in styleable which can be enumerated integers starting at 0. Map<RDotTxtEntry, String> finalIds = null; IntEnumerator enumerator = null; if (uberRDotTxtIds.isPresent()) { finalIds = uberRDotTxtIds.get(); } else { enumerator = new IntEnumerator(0x7f01001); } SortedSetMultimap<String, RDotTxtEntry> rDotJavaPackageToSymbolsFiles = TreeMultimap.create(); SortedSetMultimap<RDotTxtEntry, Path> bannedDuplicateResourceToSymbolsFiles = TreeMultimap.create(); HashMap<RDotTxtEntry, RDotTxtEntry> resourceToIdValuesMap = new HashMap<>(); for (Map.Entry<Path, String> entry : symbolsFileToRDotJavaPackage.entrySet()) { Path symbolsFile = entry.getKey(); // Read the symbols file and parse each line as a Resource. List<String> linesInSymbolsFile; try { linesInSymbolsFile = filesystem .readLines(symbolsFile) .stream() .filter(input -> !Strings.isNullOrEmpty(input)) .collect(Collectors.toList()); } catch (IOException e) { throw new RuntimeException(e); } String packageName = entry.getValue(); for (int index = 0; index < linesInSymbolsFile.size(); index++) { RDotTxtEntry resource = getResourceAtIndex(linesInSymbolsFile, index); if (uberRDotTxtIds.isPresent()) { Preconditions.checkNotNull(finalIds); if (!finalIds.containsKey(resource)) { LOG.debug("Cannot find resource '%s' in the uber R.txt.", resource); continue; } resource = resource.copyWithNewIdValue(finalIds.get(resource)); } else if (useOldStyleableFormat && resource.idValue.startsWith("0x7f")) { Preconditions.checkNotNull(enumerator); resource = resource.copyWithNewIdValue(String.format("0x%08x", enumerator.next())); } else if (useOldStyleableFormat) { // NOPMD more readable this way, IMO. // Nothing extra to do in this case. } else if (resourceToIdValuesMap.containsKey(resource)) { resource = resourceToIdValuesMap.get(resource); } else if (resource.idType == IdType.INT_ARRAY && resource.type == RType.STYLEABLE) { Map<RDotTxtEntry, String> styleableResourcesMap = getStyleableResources(resourceToIdValuesMap, linesInSymbolsFile, resource, index + 1); for (RDotTxtEntry styleableResource : styleableResourcesMap.keySet()) { resourceToIdValuesMap.put(styleableResource, styleableResource); } // int[] styleable entry is not added to the cache as // the number of child can differ in dependent libraries resource = resource.copyWithNewIdValue( String.format( "{ %s }", Joiner.on(RDotTxtEntry.INT_ARRAY_SEPARATOR) .join(styleableResourcesMap.values()))); } else { Preconditions.checkNotNull(enumerator); resource = resource.copyWithNewIdValue(String.format("0x%08x", enumerator.next())); // Add resource to cache so that the id value is consistent across all R.txt resourceToIdValuesMap.put(resource, resource); } if (bannedDuplicateResourceTypes.contains(resource.type)) { bannedDuplicateResourceToSymbolsFiles.put(resource, symbolsFile); } rDotJavaPackageToSymbolsFiles.put(packageName, resource); } } StringBuilder duplicateResourcesMessage = new StringBuilder(); for (Map.Entry<RDotTxtEntry, Collection<Path>> resourceAndSymbolsFiles : bannedDuplicateResourceToSymbolsFiles.asMap().entrySet()) { Collection<Path> paths = resourceAndSymbolsFiles.getValue(); if (paths.size() > 1) { RDotTxtEntry resource = resourceAndSymbolsFiles.getKey(); duplicateResourcesMessage.append( String.format( "Resource '%s' (%s) is duplicated across: ", resource.name, resource.type)); List<SourcePath> resourceDirs = new ArrayList<>(paths.size()); for (Path path : paths) { SourcePath res = symbolsFileToResourceDeps.get(path).getRes(); if (res != null) { resourceDirs.add(res); } } duplicateResourcesMessage.append(Joiner.on(", ").join(resourceDirs)); duplicateResourcesMessage.append("\n"); } } if (duplicateResourcesMessage.length() > 0) { throw new DuplicateResourceException(duplicateResourcesMessage.toString()); } return rDotJavaPackageToSymbolsFiles; } private static Map<RDotTxtEntry, String> getStyleableResources( Map<RDotTxtEntry, RDotTxtEntry> resourceToIdValuesMap, List<String> linesInSymbolsFile, RDotTxtEntry resource, int index) { Map<RDotTxtEntry, String> styleableResourceMap = new LinkedHashMap<>(); List<String> givenResourceIds = null; for (int styleableIndex = 0; styleableIndex + index < linesInSymbolsFile.size(); styleableIndex++) { RDotTxtEntry styleableResource = getResourceAtIndex(linesInSymbolsFile, styleableIndex + index) .copyWithNewParent(resource.name); String styleablePrefix = resource.name + "_"; if (styleableResource.idType == IdType.INT && styleableResource.type == RType.STYLEABLE && styleableResource.name.startsWith(styleablePrefix)) { String attrName = styleableResource.name.substring(styleablePrefix.length()); RDotTxtEntry attrResource = new RDotTxtEntry(IdType.INT, RType.ATTR, attrName, ""); if (resourceToIdValuesMap.containsKey(attrResource)) { attrResource = resourceToIdValuesMap.get(attrResource); } if (Strings.isNullOrEmpty(attrResource.idValue)) { String attrIdValue; if (givenResourceIds == null) { if (resource.idValue.startsWith("{") && resource.idValue.endsWith("}")) { givenResourceIds = Arrays.stream( resource .idValue .substring(1, resource.idValue.length() - 1) .split(RDotTxtEntry.INT_ARRAY_SEPARATOR)) .map(String::trim) .filter(s -> s.length() > 0) .collect(Collectors.toList()); } else { givenResourceIds = new ArrayList<>(); } } int styleableResourceIndex = Integer.valueOf(styleableResource.idValue); if (styleableResourceIndex < givenResourceIds.size()) { // These are attributes coming from android SDK -- `android_*` attrIdValue = givenResourceIds.get(styleableResourceIndex); } else { // If not value is found just put the index. attrIdValue = String.valueOf(styleableIndex); } // Add resource to cache so that the id value is consistent across all R.txt attrResource = attrResource.copyWithNewIdValue(attrIdValue); resourceToIdValuesMap.put(attrResource, attrResource); } styleableResourceMap.put( styleableResource.copyWithNewIdValue(String.valueOf(styleableIndex)), attrResource.idValue); } else { break; } } return styleableResourceMap; } private static RDotTxtEntry getResourceAtIndex(List<String> linesInSymbolsFile, int index) { String line = linesInSymbolsFile.get(index); Optional<RDotTxtEntry> parsedEntry = RDotTxtEntry.parse(line); Preconditions.checkState(parsedEntry.isPresent(), "Should be able to match '%s'.", line); return parsedEntry.get(); } @Override public String getShortName() { return "android-res-merge"; } @Override public String getDescription(ExecutionContext context) { List<String> resources = androidResourceDeps .stream() .map(Object::toString) .sorted(natural()) .collect(Collectors.toList()); return getShortName() + " " + Joiner.on(' ').join(resources); } private Path getPathToRDotJava(String rDotJavaPackage) { return outputDir .resolve(rDotJavaPackage.replace('.', '/')) .resolve(String.format("%s.java", rName)); } private static class IntEnumerator { private int value; IntEnumerator(int start) { value = start; } public int next() { Preconditions.checkState(value < Integer.MAX_VALUE, "Stop goofing off"); return value++; } } @VisibleForTesting public static class DuplicateResourceException extends Exception { DuplicateResourceException(String messageFormat, Object... args) { super(String.format(messageFormat, args)); } } @VisibleForTesting public EnumSet<RType> getBannedDuplicateResourceTypes() { return bannedDuplicateResourceTypes; } }