| /* |
| * 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.step.ExecutionContext; |
| import com.facebook.buck.step.Step; |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Charsets; |
| import com.google.common.base.Function; |
| import com.google.common.base.Joiner; |
| import com.google.common.base.Objects; |
| import com.google.common.base.Preconditions; |
| import com.google.common.base.Throwables; |
| import com.google.common.collect.ComparisonChain; |
| import com.google.common.collect.ImmutableMap; |
| import com.google.common.collect.ImmutableSortedSet; |
| import com.google.common.collect.SortedSetMultimap; |
| import com.google.common.collect.TreeMultimap; |
| import com.google.common.io.CharStreams; |
| import com.google.common.io.Closer; |
| import com.google.common.io.Files; |
| |
| import java.io.BufferedWriter; |
| import java.io.File; |
| import java.io.FileNotFoundException; |
| import java.io.FileReader; |
| import java.io.IOException; |
| import java.io.PrintWriter; |
| import java.util.Map; |
| import java.util.Scanner; |
| import java.util.SortedSet; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| public class MergeAndroidResourcesStep implements Step { |
| |
| private static final Pattern TEXT_SYMBOLS_LINE = Pattern.compile("(\\S+) (\\S+) (\\S+) (.+)"); |
| |
| private final ImmutableMap<String, String> symbolsFileToRDotJavaPackage; |
| private final String pathToGeneratedJavaFiles; |
| |
| /** |
| * Merges text symbols files from {@code aapt} into R.java files that can be compiled. |
| * @param symbolsFileToRDotJavaPackage For each entry in the map, the key is a path to a symbols |
| * file generated by {@code aapt} using the {@code --output-text-symbols} flag. The value is |
| * the Java package for the corresponding R.java file. |
| * @param pathToGeneratedJavaFiles the directory where the generated R.java files should be |
| * written. Admittedly, this command could write such files to a {@code /tmp} directory, but |
| * it is convenient to have the R.java files written to a known location for debugging. This |
| * directory should exist and be empty before this command is run. |
| */ |
| public MergeAndroidResourcesStep( |
| Map<String, String> symbolsFileToRDotJavaPackage, |
| String pathToGeneratedJavaFiles) { |
| this.symbolsFileToRDotJavaPackage = ImmutableMap.copyOf(symbolsFileToRDotJavaPackage); |
| this.pathToGeneratedJavaFiles = Preconditions.checkNotNull(pathToGeneratedJavaFiles); |
| } |
| |
| @Override |
| public int execute(ExecutionContext context) { |
| try { |
| doExecute(); |
| return 0; |
| } catch (IOException e) { |
| e.printStackTrace(context.getStdErr()); |
| return 1; |
| } |
| } |
| |
| private void doExecute() throws IOException { |
| // A symbols file may look like: |
| // |
| // int id placeholder 0x7f020000 |
| // int string debug_http_proxy_dialog_title 0x7f030004 |
| // int string debug_http_proxy_hint 0x7f030005 |
| // int string debug_http_proxy_summary 0x7f030003 |
| // int string debug_http_proxy_title 0x7f030002 |
| // int string debug_ssl_cert_check_summary 0x7f030001 |
| // int string debug_ssl_cert_check_title 0x7f030000 |
| // |
| // Note that there are four columns of information: |
| // - the type of the resource id (always seems to be int or int[], in practice) |
| // - the type of the resource |
| // - the name of the resource |
| // - the value of the resource id |
| // |
| // In order to convert this 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. |
| // |
| // 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. |
| SortedSetMultimap<String, Resource> rDotJavaPackageToResources = sortSymbols( |
| new Function<String, Readable>() { |
| @Override |
| public Readable apply(String pathToFile) { |
| try { |
| return new FileReader(pathToFile); |
| } catch (FileNotFoundException e) { |
| throw new RuntimeException(e); |
| } |
| } |
| }, |
| symbolsFileToRDotJavaPackage, |
| true /* reenumerate */); |
| |
| // Create an R.java file for each package. |
| for (String rDotJavaPackage : rDotJavaPackageToResources.keySet()) { |
| // Create the content of R.java. |
| SortedSet<Resource> resources = rDotJavaPackageToResources.get(rDotJavaPackage); |
| |
| // Write R.java in the pathToGeneratedJavaFiles directory. Admittedly, this will be written |
| // to /tmp/com.example.stuff/R.java rather than /tmp/com/example/stuff/R.java. It turns out |
| // that directory structure does not matter to javac. |
| |
| // Determine the path to R.java. |
| File rDotJava = getOutputFile(pathToGeneratedJavaFiles, rDotJavaPackage); |
| |
| // Then write R.java to the output directory. |
| Files.createParentDirs(rDotJava); |
| BufferedWriter writer = Files.newWriter(rDotJava, Charsets.UTF_8); |
| try { |
| writeJavaCodeForPackageAndResources(new PrintWriter(writer), |
| rDotJavaPackage, resources); |
| } finally { |
| writer.close(); |
| } |
| } |
| } |
| |
| @VisibleForTesting |
| static SortedSetMultimap<String, Resource> sortSymbols( |
| Function<String, Readable> filePathToReadable, |
| Map<String, String> symbolsFileToRDotJavaPackage, |
| boolean reenumerate) { |
| // 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. |
| IntEnumerator enumerator = reenumerate ? new IntEnumerator(0x7f01001) : null; |
| SortedSetMultimap<String, Resource> rDotJavaPackageToSymbolsFiles = TreeMultimap.create(); |
| for (Map.Entry<String, String> entry : symbolsFileToRDotJavaPackage.entrySet()) { |
| String symbolsFile = entry.getKey(); |
| String packageName = entry.getValue(); |
| |
| // Read the symbols file and parse each line as a Resource. |
| Readable readable = filePathToReadable.apply(symbolsFile); |
| Scanner scanner = new Scanner(readable); |
| while (scanner.hasNext()) { |
| String line = scanner.nextLine(); |
| Matcher matcher = TEXT_SYMBOLS_LINE.matcher(line); |
| boolean isMatch = matcher.matches(); |
| Preconditions.checkState(isMatch, "Should be able to match '%s'.", line); |
| String idType = matcher.group(1); |
| String type = matcher.group(2); |
| String name = matcher.group(3); |
| String idValue = matcher.group(4); |
| |
| // We're only doing the remapping so Roboelectric is happy and it is already ignoring the |
| // id references found in the styleable section. So let's do that as well so we don't have |
| // to get fancier than is needed. That is, just re-enumerate all app-level resource ids |
| // and ignore everything else, allowing the styleable references to be messed up. |
| String idValueToUse = idValue; |
| if (reenumerate && idValue.startsWith("0x7f")) { |
| idValueToUse = String.format("0x%08x", enumerator.next()); |
| } |
| |
| Resource resource = new Resource(idType, type, name, idValue, idValueToUse); |
| rDotJavaPackageToSymbolsFiles.put(packageName, resource); |
| } |
| } |
| return rDotJavaPackageToSymbolsFiles; |
| } |
| |
| public static String generateJavaCodeForPackageWithoutResources(String packageName) { |
| return generateJavaCodeForPackageAndResources(packageName, ImmutableSortedSet.<Resource>of()); |
| } |
| |
| public static String generateJavaCodeForPackageAndResources(String packageName, |
| SortedSet<Resource> resources) { |
| StringBuilder b = new StringBuilder(); |
| Closer closer = Closer.create(); |
| PrintWriter writer = closer.register(new PrintWriter(CharStreams.asWriter(b))); |
| try { |
| writeJavaCodeForPackageAndResources(writer, packageName, resources); |
| } catch (IOException e) { |
| // Impossible. |
| throw new RuntimeException(e); |
| } finally { |
| try { |
| closer.close(); |
| } catch (IOException e) { |
| Throwables.propagate(e); |
| } |
| } |
| return b.toString(); |
| } |
| |
| /** |
| * Writes an intermediate R.java with dummy values influenced by the also dummy values created by |
| * {@code aapt} when building intermediate artifacts. |
| * |
| * @param writer Output writer for the Java source. |
| * @param packageName Package of the resulting R.java file. |
| * @param resources Sorted set of resources parsed from R.txt. First sorted by type then name. |
| */ |
| private static void writeJavaCodeForPackageAndResources( |
| PrintWriter writer, |
| String packageName, |
| SortedSet<Resource> resources) throws IOException { |
| Preconditions.checkNotNull(writer); |
| Preconditions.checkNotNull(packageName); |
| Preconditions.checkNotNull(resources); |
| |
| writer.append("package ").append(packageName).append(';').print('\n'); |
| writer.print('\n'); |
| writer.print("public class R {\n"); |
| writer.print('\n'); |
| |
| String lastType = null; |
| for (Resource res : resources) { |
| String type = res.type; |
| if (!type.equals(lastType)) { |
| // If the previous type needs, to be closed, then close it. |
| if (lastType != null) { |
| writer.print(" }\n"); |
| writer.print('\n'); |
| } |
| |
| // Now start the block for the new type. |
| writer.append(" public static class ").append(type).append(" {").print('\n'); |
| lastType = type; |
| } |
| |
| // Write out the resource. |
| // Write as an int. |
| writer.print(String.format(" public static %s %s=%s;\n", |
| res.idType, |
| res.name, |
| res.idValueToWrite)); |
| } |
| |
| // If some type was written (e.g., the for loop was entered), then the last type needs to be |
| // closed. |
| if (lastType != null) { |
| writer.print(" }\n"); |
| writer.print('\n'); |
| } |
| |
| // Close the class definition. |
| writer.print("}\n"); |
| } |
| |
| public static String getOutputFilePath(String pathToGeneratedJavaFiles, String rDotJavaPackage) { |
| return getOutputFile(pathToGeneratedJavaFiles, rDotJavaPackage).getPath(); |
| } |
| |
| private static File getOutputFile(String pathToGeneratedJavaFiles, String rDotJavaPackage) { |
| File outputDir = new File(pathToGeneratedJavaFiles, rDotJavaPackage); |
| File rDotJava = new File(outputDir, "R.java"); |
| return rDotJava; |
| } |
| |
| /** Represents a row from a symbols file generated by {@code aapt}. */ |
| @VisibleForTesting |
| static class Resource implements Comparable<Resource> { |
| @VisibleForTesting final String idType; |
| @VisibleForTesting final String type; |
| @VisibleForTesting final String name; |
| @VisibleForTesting final String originalIdValue; |
| @VisibleForTesting final String idValueToWrite; |
| |
| public Resource(String idType, String type, String name, String originalIdValue, |
| String idValueToWrite) { |
| this.idType = Preconditions.checkNotNull(idType); |
| this.type = Preconditions.checkNotNull(type); |
| this.name = Preconditions.checkNotNull(name); |
| this.originalIdValue = Preconditions.checkNotNull(originalIdValue); |
| this.idValueToWrite = Preconditions.checkNotNull(idValueToWrite); |
| } |
| |
| /** |
| * A collection of Resources should be sorted such that Resources of the same type should be |
| * grouped together, and should be alphabetized within that group. |
| */ |
| @Override |
| public int compareTo(Resource that) { |
| return ComparisonChain.start() |
| .compare(this.type, that.type) |
| .compare(this.name, that.name) |
| .result(); |
| } |
| |
| @Override |
| public boolean equals(Object obj) { |
| if (!(obj instanceof Resource)) { |
| return false; |
| } |
| |
| Resource that = (Resource)obj; |
| return Objects.equal(this.type, that.type) && Objects.equal(this.name, that.name); |
| } |
| |
| @Override |
| public int hashCode() { |
| return Objects.hashCode(type, name); |
| } |
| |
| @Override |
| public String toString() { |
| return Objects.toStringHelper(Resource.class) |
| .add("idType", idType) |
| .add("type", type) |
| .add("name", name) |
| .add("originalIdValue", originalIdValue) |
| .add("idValueToWrite", idValueToWrite) |
| .toString(); |
| } |
| } |
| |
| @Override |
| public String getShortName() { |
| return "android-res-merge"; |
| } |
| |
| @Override |
| public String getDescription(ExecutionContext context) { |
| return getShortName() + " " + Joiner.on(' ').join(symbolsFileToRDotJavaPackage.keySet()); |
| } |
| |
| private static class IntEnumerator { |
| private int value; |
| |
| public IntEnumerator(int start) { |
| value = start; |
| } |
| |
| public int next() { |
| Preconditions.checkState(value < Integer.MAX_VALUE, "Stop goofing off"); |
| return value++; |
| } |
| } |
| |
| } |