blob: 09bd64a6534404afbd87e78f962e2e7a6edeaa5c [file] [log] [blame]
/*
* 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++;
}
}
}