blob: 2c006df04840aec798b99c65a149fd78dedce7ca [file] [log] [blame]
/*
* Copyright 2013-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.facebook.buck.util.ProjectFilesystem;
import com.facebook.buck.util.XmlDomParser;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.TreeMultimap;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* This {@link Step} takes in a {@link FilterResourcesStep} that provides a list of string resource
* files (strings.xml), groups them by locales, and for each locale generates a file with all the
* string resources for that locale.
*
* <p>A typical strings.xml file looks like:
* <pre>
* {@code
* <?xml version="1.0" encoding="utf-8"?>
* <resources>
* <string name="resource_name1">I am a string.</string>
* <string name="resource_name2">I am another string.</string>
* <plurals name="time_hours_ago">
* <item quantity="one">1 minute ago</item>
* <item quantity="other">%d minutes ago</item>
* </plurals>
* <string-array name="logging_levels">
* <item>Default</item>
* <item>Verbose</item>
* <item>Debug</item>
* </stri (which is accepted, hurray!) and bump .buckversionng-array>
* </resources>
* }
* </pre></p>
*
* <p>For more information on the xml file format, refer to:
* <a href="http://developer.android.com/guide/topics/resources/string-resource.html">
* String Resources - Android Developers
* </a></p>
*
* <p>So for each supported locale in a project, this step goes through all such xml files for that
* locale, and builds a map of resource name to resource value, where resource value is either:
* <ol>
* <li> a string </li>
* <li> a map of plurals </li>
* <li> a list of strings </li>
* </ol>
* and dumps this map into the output file. See {@link StringResources} for the file format.</p>
*/
public class CompileStringsStep implements Step {
@VisibleForTesting
static final Pattern STRING_FILE_PATTERN = Pattern.compile(
".*res/values-([a-z]{2})(?:-r([A-Z]{2}))*/strings.xml");
@VisibleForTesting
static final Pattern R_DOT_TXT_STRING_RESOURCE_PATTERN = Pattern.compile(
"^int (string|plurals|array) (\\w+) 0x([0-9a-f]+)$");
private final FilterResourcesStep filterResourcesStep;
private final Path rDotJavaSrcDir;
private final Path destinationDir;
private final Map<String, String> regionSpecificToBaseLocaleMap;
private final Map<String, Integer> resourceNameToIdMap;
/**
* Note: The ordering of files in the input list determines which resource value ends up in the
* output json file, in the event of multiple xml files of a locale sharing the same string
* resource name - file that appears first in the list wins.
*
* @param filterResourcesStep {@link FilterResourcesStep} that filters non english string files.
* @param rDotJavaSrcDir Path to the directory where aapt generates R.txt file along with the
* final R.java files per package.
* @param destinationDir Output directory for the generated json files.
*/
public CompileStringsStep(
FilterResourcesStep filterResourcesStep,
Path rDotJavaSrcDir,
Path destinationDir) {
this.filterResourcesStep = Preconditions.checkNotNull(filterResourcesStep);
this.rDotJavaSrcDir = Preconditions.checkNotNull(rDotJavaSrcDir);
this.destinationDir = Preconditions.checkNotNull(destinationDir);
this.regionSpecificToBaseLocaleMap = Maps.newHashMap();
this.resourceNameToIdMap = Maps.newHashMap();
}
@Override
public int execute(ExecutionContext context) {
ProjectFilesystem filesystem = context.getProjectFilesystem();
try {
buildResourceNameToIdMap(filesystem);
} catch (IOException e) {
context.logError(e, "Failure parsing R.txt file.");
return 1;
}
ImmutableSet<String> filteredStringFiles = filterResourcesStep.getNonEnglishStringFiles();
ImmutableMultimap<String, String> filesByLocale = groupFilesByLocale(filteredStringFiles);
Map<String, StringResources> resourcesByLocale = Maps.newHashMap();
for (String locale : filesByLocale.keySet()) {
try {
resourcesByLocale.put(locale, compileStringFiles(filesByLocale.get(locale)));
} catch (IOException e) {
context.logError(e, "Error parsing string file for locale: %s", locale);
return 1;
}
}
// Merge region specific locale resources with the corresponding base locale resources.
//
// For example, if there are separate string resources in an android project for locale
// "es" and "es_US", when an application running on a device with locale set to "Spanish
// (United States)" requests for a string, the Android runtime first looks for the string in
// "es_US" set of resources, and if not found, returns the resource from the "es" set.
// We merge these because we want the individual string json files to be self contained for
// simplicity.
for (String regionSpecificLocale : regionSpecificToBaseLocaleMap.keySet()) {
String baseLocale = regionSpecificToBaseLocaleMap.get(regionSpecificLocale);
if (!resourcesByLocale.containsKey(baseLocale)) {
continue;
}
resourcesByLocale.put(regionSpecificLocale,
resourcesByLocale.get(regionSpecificLocale)
.getMergedResources(resourcesByLocale.get(baseLocale)));
}
for (String locale : filesByLocale.keySet()) {
try {
filesystem.writeBytesToPath(resourcesByLocale.get(locale).getBinaryFileContent(),
destinationDir.resolve(locale + ".fbstr"));
} catch (IOException e) {
context.logError(e, "Error creating binary file for locale: %s", locale);
return 1;
}
}
return 0;
}
/**
* Groups a list of file paths matching STRING_FILE_PATTERN by the locale.
* eg. given the following list:
*
* ImmutableSet.of(
* '/one/res/values-es/strings.xml',
* '/two/res/values-es/strings.xml',
* '/three/res/values-pt-rBR/strings.xml',
* '/four/res/values/-pt-rPT/strings.xml');
*
* returns:
*
* ImmutableMap.of(
* 'es', ImmutableSet.of('/one/res/values-es/strings.xml', '/two/res/values-es/strings.xml'),
* 'pt_BR', ImmutableSet.of('/three/res/values-pt-rBR/strings.xml'),
* 'pt_PT', ImmutableSet.of('/four/res/values/-pt-rPT/strings.xml'));
*/
@VisibleForTesting
ImmutableMultimap<String, String> groupFilesByLocale(ImmutableSet<String> files) {
ImmutableMultimap.Builder<String, String> localeToFiles = ImmutableMultimap.builder();
for (String filepath : files) {
Matcher matcher = STRING_FILE_PATTERN.matcher(filepath);
if (!matcher.matches()) {
continue;
}
String baseLocale = matcher.group(1);
String country = matcher.group(2);
String locale = country == null ? baseLocale : baseLocale + "_" + country;
if (country != null && !regionSpecificToBaseLocaleMap.containsKey(locale)) {
regionSpecificToBaseLocaleMap.put(locale, baseLocale);
}
localeToFiles.put(locale, filepath);
}
return localeToFiles.build();
}
/**
* Parses the R.txt file generated by aapt, looks for resources of type {@code string},
* {@code plurals} and {@code array}, and builds a map of resource names to their corresponding
* ids.
*/
@VisibleForTesting
void buildResourceNameToIdMap(ProjectFilesystem filesystem)
throws IOException {
List<String> fileLines = filesystem.readLines(rDotJavaSrcDir.resolve("R.txt"));
for (String line : fileLines) {
Matcher matcher = R_DOT_TXT_STRING_RESOURCE_PATTERN.matcher(line);
if (!matcher.matches()) {
continue;
}
resourceNameToIdMap.put(matcher.group(2), Integer.parseInt(matcher.group(3), 16));
}
}
private StringResources compileStringFiles(Collection<String> filepaths)
throws IOException {
TreeMap<Integer, String> stringsMap = Maps.newTreeMap();
TreeMap<Integer, ImmutableMap<String, String>> pluralsMap = Maps.newTreeMap();
TreeMultimap<Integer, String> arraysMap = TreeMultimap.create();
for (String stringFilePath : filepaths) {
File stringFile = (Paths.get(stringFilePath)).toFile();
Document dom = XmlDomParser.parse(stringFile);
NodeList stringNodes = dom.getElementsByTagName("string");
scrapeStringNodes(stringNodes, stringsMap);
NodeList pluralNodes = dom.getElementsByTagName("plurals");
scrapePluralsNodes(pluralNodes, pluralsMap);
NodeList arrayNodes = dom.getElementsByTagName("string-array");
scrapeStringArrayNodes(arrayNodes, arraysMap);
}
return new StringResources(stringsMap, pluralsMap, arraysMap);
}
/**
* Scrapes string resource names and values from the list of xml nodes passed and populates
* {@code stringsMap}, ignoring resource names that are already present in the map.
*
* @param stringNodes A list of {@code <string></string>} nodes.
* @param stringsMap Map from string resource name to its value.
*/
@VisibleForTesting
void scrapeStringNodes(NodeList stringNodes, Map<Integer, String> stringsMap) {
for (int i = 0; i < stringNodes.getLength(); ++i) {
Node node = stringNodes.item(i);
String resourceName = node.getAttributes().getNamedItem("name").getNodeValue();
if (!resourceNameToIdMap.containsKey(resourceName)) {
continue;
}
int resourceId = resourceNameToIdMap.get(resourceName);
// Ignore a resource if it has already been found.
if (!stringsMap.containsKey(resourceId)) {
stringsMap.put(resourceId, node.getTextContent());
}
}
}
/**
* Similar to {@code scrapeStringNodes}, but for plurals nodes.
*/
@VisibleForTesting
void scrapePluralsNodes(
NodeList pluralNodes,
Map<Integer, ImmutableMap<String, String>> pluralsMap) {
for (int i = 0; i < pluralNodes.getLength(); ++i) {
Node node = pluralNodes.item(i);
String resourceName = node.getAttributes().getNamedItem("name").getNodeValue();
if (!resourceNameToIdMap.containsKey(resourceName)) {
continue;
}
int resourceId = resourceNameToIdMap.get(resourceName);
// Ignore a resource if it has already been found.
if (pluralsMap.containsKey(resourceId)) {
continue;
}
ImmutableMap.Builder<String, String> quantityToStringBuilder = ImmutableMap.builder();
NodeList itemNodes = ((Element) node).getElementsByTagName("item");
for (int j = 0; j < itemNodes.getLength(); ++j) {
Node itemNode = itemNodes.item(j);
String quantity = itemNode.getAttributes().getNamedItem("quantity").getNodeValue();
quantityToStringBuilder.put(quantity, itemNode.getTextContent());
}
pluralsMap.put(resourceId, quantityToStringBuilder.build());
}
}
/**
* Similar to {@code scrapeStringNodes}, but for string array nodes.
*/
@VisibleForTesting
void scrapeStringArrayNodes(NodeList arrayNodes, Multimap<Integer, String> arraysMap) {
for (int i = 0; i < arrayNodes.getLength(); ++i) {
Node node = arrayNodes.item(i);
String resourceName = node.getAttributes().getNamedItem("name").getNodeValue();
// Ignore a resource if R.txt does not contain an entry for it.
if (!resourceNameToIdMap.containsKey(resourceName)) {
continue;
}
int resourceId = resourceNameToIdMap.get(resourceName);
// Ignore a resource if it has already been found.
if (arraysMap.containsKey(resourceId)) {
continue;
}
NodeList itemNodes = ((Element)node).getElementsByTagName("item");
for (int j = 0; j < itemNodes.getLength(); ++j) {
arraysMap.put(resourceId, itemNodes.item(j).getTextContent());
}
}
}
/**
* Used in unit tests to inject the resource name to id map.
*/
@VisibleForTesting
void addResourceNameToIdMap(Map<String, Integer> nameToIdMap) {
resourceNameToIdMap.putAll(nameToIdMap);
}
@Override
public String getShortName() {
return "compile_strings";
}
@Override
public String getDescription(ExecutionContext context) {
return "Combine, parse string resource xml files into one binary file per locale.";
}
}