| /* |
| * 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."; |
| } |
| } |