blob: 2eaab73e218be6e2ad68a1fc7e8e9b96ac325c37 [file] [log] [blame]
/*
* Copyright (C) 2011 The Android Open Source Project
*
* 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.android.tools.lint.checks;
import static com.android.SdkConstants.ANDROID_PREFIX;
import static com.android.SdkConstants.ATTR_LOCALE;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_TRANSLATABLE;
import static com.android.SdkConstants.FD_RES_VALUES;
import static com.android.SdkConstants.STRING_PREFIX;
import static com.android.SdkConstants.TAG_ITEM;
import static com.android.SdkConstants.TAG_STRING;
import static com.android.SdkConstants.TAG_STRING_ARRAY;
import static com.android.SdkConstants.TOOLS_URI;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.builder.model.AndroidProject;
import com.android.builder.model.ProductFlavor;
import com.android.builder.model.ProductFlavorContainer;
import com.android.builder.model.Variant;
import com.android.ide.common.resources.LocaleManager;
import com.android.ide.common.resources.configuration.FolderConfiguration;
import com.android.ide.common.resources.configuration.LocaleQualifier;
import com.android.resources.ResourceFolderType;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.LintUtils;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Project;
import com.android.tools.lint.detector.api.ResourceXmlDetector;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.XmlContext;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* Checks for incomplete translations - e.g. keys that are only present in some
* locales but not all.
*/
public class TranslationDetector extends ResourceXmlDetector {
@VisibleForTesting
static boolean sCompleteRegions =
System.getenv("ANDROID_LINT_COMPLETE_REGIONS") != null; //$NON-NLS-1$
private static final Implementation IMPLEMENTATION = new Implementation(
TranslationDetector.class,
Scope.ALL_RESOURCES_SCOPE);
/** Are all translations complete? */
public static final Issue MISSING = Issue.create(
"MissingTranslation", //$NON-NLS-1$
"Incomplete translation",
"If an application has more than one locale, then all the strings declared in " +
"one language should also be translated in all other languages.\n" +
"\n" +
"If the string should *not* be translated, you can add the attribute " +
"`translatable=\"false\"` on the `<string>` element, or you can define all " +
"your non-translatable strings in a resource file called `donottranslate.xml`. " +
"Or, you can ignore the issue with a `tools:ignore=\"MissingTranslation\"` " +
"attribute.\n" +
"\n" +
"By default this detector allows regions of a language to just provide a " +
"subset of the strings and fall back to the standard language strings. " +
"You can require all regions to provide a full translation by setting the " +
"environment variable `ANDROID_LINT_COMPLETE_REGIONS`.\n" +
"\n" +
"You can tell lint (and other tools) which language is the default language " +
"in your `res/values/` folder by specifying `tools:locale=\"languageCode\"` for " +
"the root `<resources>` element in your resource file. (The `tools` prefix refers " +
"to the namespace declaration `http://schemas.android.com/tools`.)",
Category.MESSAGES,
8,
Severity.FATAL,
IMPLEMENTATION);
/** Are there extra translations that are "unused" (appear only in specific languages) ? */
public static final Issue EXTRA = Issue.create(
"ExtraTranslation", //$NON-NLS-1$
"Extra translation",
"If a string appears in a specific language translation file, but there is " +
"no corresponding string in the default locale, then this string is probably " +
"unused. (It's technically possible that your application is only intended to " +
"run in a specific locale, but it's still a good idea to provide a fallback.).\n" +
"\n" +
"Note that these strings can lead to crashes if the string is looked up on any " +
"locale not providing a translation, so it's important to clean them up.",
Category.MESSAGES,
6,
Severity.FATAL,
IMPLEMENTATION);
private Set<String> mNames;
private Set<String> mTranslatedArrays;
private Set<String> mNonTranslatable;
private boolean mIgnoreFile;
private Map<File, Set<String>> mFileToNames;
private Map<File, String> mFileToLocale;
/** Locations for each untranslated string name. Populated during phase 2, if necessary */
private Map<String, Location> mMissingLocations;
/** Locations for each extra translated string name. Populated during phase 2, if necessary */
private Map<String, Location> mExtraLocations;
/** Error messages for each untranslated string name. Populated during phase 2, if necessary */
private Map<String, String> mDescriptions;
/** Constructs a new {@link TranslationDetector} */
public TranslationDetector() {
}
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return folderType == ResourceFolderType.VALUES;
}
@Override
public Collection<String> getApplicableElements() {
return Arrays.asList(
TAG_STRING,
TAG_STRING_ARRAY
);
}
@Override
public void beforeCheckProject(@NonNull Context context) {
if (context.getDriver().getPhase() == 1) {
mFileToNames = new HashMap<File, Set<String>>();
}
}
@Override
public void beforeCheckFile(@NonNull Context context) {
if (context.getPhase() == 1) {
mNames = new HashSet<String>();
}
// Convention seen in various projects
mIgnoreFile = context.file.getName().startsWith("donottranslate") //$NON-NLS-1$
|| UnusedResourceDetector.isAnalyticsFile(context);
if (!context.getProject().getReportIssues()) {
mIgnoreFile = true;
}
}
@Override
public void afterCheckFile(@NonNull Context context) {
if (context.getPhase() == 1) {
// Store this layout's set of ids for full project analysis in afterCheckProject
if (context.getProject().getReportIssues() && mNames != null && !mNames.isEmpty()) {
mFileToNames.put(context.file, mNames);
Element root = ((XmlContext) context).document.getDocumentElement();
if (root != null) {
String locale = root.getAttributeNS(TOOLS_URI, ATTR_LOCALE);
if (locale != null && !locale.isEmpty()) {
if (mFileToLocale == null) {
mFileToLocale = Maps.newHashMap();
}
mFileToLocale.put(context.file, locale);
}
// Add in English here if not specified? Worry about false positives listing "en" explicitly
}
}
mNames = null;
}
}
@Override
public void afterCheckProject(@NonNull Context context) {
if (context.getPhase() == 1) {
// NOTE - this will look for the presence of translation strings.
// If you create a resource folder but don't actually place a file in it
// we won't detect that, but it seems like a smaller problem.
checkTranslations(context);
mFileToNames = null;
if (mMissingLocations != null || mExtraLocations != null) {
context.getDriver().requestRepeat(this, Scope.ALL_RESOURCES_SCOPE);
}
} else {
assert context.getPhase() == 2;
reportMap(context, MISSING, mMissingLocations);
reportMap(context, EXTRA, mExtraLocations);
mMissingLocations = null;
mExtraLocations = null;
mDescriptions = null;
}
}
private void reportMap(Context context, Issue issue, Map<String, Location> map) {
if (map != null) {
for (Map.Entry<String, Location> entry : map.entrySet()) {
Location location = entry.getValue();
String name = entry.getKey();
String message = mDescriptions.get(name);
if (location == null) {
location = Location.create(context.getProject().getDir());
}
// We were prepending locations, but we want to prefer the base folders
location = Location.reverse(location);
context.report(issue, location, message);
}
}
}
private void checkTranslations(Context context) {
// Only one file defining strings? If so, no problems.
Set<File> files = mFileToNames.keySet();
Set<File> parentFolders = new HashSet<File>();
for (File file : files) {
parentFolders.add(file.getParentFile());
}
if (parentFolders.size() == 1
&& FD_RES_VALUES.equals(parentFolders.iterator().next().getName())) {
// Only one language - no problems.
return;
}
boolean reportMissing = context.isEnabled(MISSING);
boolean reportExtra = context.isEnabled(EXTRA);
// res/strings.xml etc
String defaultLanguage = "Default";
Map<File, String> parentFolderToLanguage = new HashMap<File, String>();
for (File parent : parentFolders) {
String name = parent.getName();
// Look up the language for this folder.
String language = getLanguageTag(name);
if (language == null) {
language = defaultLanguage;
}
parentFolderToLanguage.put(parent, language);
}
int languageCount = parentFolderToLanguage.values().size();
if (languageCount == 0 || languageCount == 1 && defaultLanguage.equals(
parentFolderToLanguage.values().iterator().next())) {
// At most one language -- no problems.
return;
}
// Merge together the various files building up the translations for each language
Map<String, Set<String>> languageToStrings =
new HashMap<String, Set<String>>(languageCount);
Set<String> allStrings = new HashSet<String>(200);
for (File file : files) {
String language = null;
if (mFileToLocale != null) {
String locale = mFileToLocale.get(file);
if (locale != null) {
int index = locale.indexOf('-');
if (index != -1) {
locale = locale.substring(0, index);
}
language = locale;
}
}
if (language == null) {
language = parentFolderToLanguage.get(file.getParentFile());
}
assert language != null : file.getParent();
Set<String> fileStrings = mFileToNames.get(file);
Set<String> languageStrings = languageToStrings.get(language);
if (languageStrings == null) {
// We don't need a copy; we're done with the string tables now so we
// can modify them
languageToStrings.put(language, fileStrings);
} else {
languageStrings.addAll(fileStrings);
}
allStrings.addAll(fileStrings);
}
Set<String> defaultStrings = languageToStrings.get(defaultLanguage);
if (defaultStrings == null) {
defaultStrings = new HashSet<String>();
}
// See if it looks like the user has named a specific locale as the base language
// (this impacts whether we report strings as "extra" or "missing")
if (mFileToLocale != null) {
Set<String> specifiedLocales = Sets.newHashSet();
for (Map.Entry<File, String> entry : mFileToLocale.entrySet()) {
String locale = entry.getValue();
int index = locale.indexOf('-');
if (index != -1) {
locale = locale.substring(0, index);
}
specifiedLocales.add(locale);
}
if (specifiedLocales.size() == 1) {
String first = specifiedLocales.iterator().next();
Set<String> languageStrings = languageToStrings.get(first);
assert languageStrings != null;
defaultStrings.addAll(languageStrings);
}
}
int stringCount = allStrings.size();
// Treat English is the default language if not explicitly specified
if (!sCompleteRegions && !languageToStrings.containsKey("en")
&& mFileToLocale == null) { //$NON-NLS-1$
// But only if we have an actual region
for (String l : languageToStrings.keySet()) {
if (l.startsWith("en-")) { //$NON-NLS-1$
languageToStrings.put("en", defaultStrings); //$NON-NLS-1$
break;
}
}
}
List<String> resConfigLanguages = getResConfigLanguages(context.getMainProject());
if (resConfigLanguages != null) {
List<String> keys = Lists.newArrayList(languageToStrings.keySet());
for (String locale : keys) {
if (defaultLanguage.equals(locale)) {
continue;
}
String language = locale;
int index = language.indexOf('-');
if (index != -1) {
// Strip off region
language = language.substring(0, index);
}
if (!resConfigLanguages.contains(language)) {
languageToStrings.remove(locale);
}
}
}
// Do we need to resolve fallback strings for regions that only define a subset
// of the strings in the language and fall back on the main language for the rest?
if (!sCompleteRegions) {
for (String l : languageToStrings.keySet()) {
if (l.indexOf('-') != -1) {
// Yes, we have regions. Merge all base language string names into each region.
for (Map.Entry<String, Set<String>> entry : languageToStrings.entrySet()) {
Set<String> strings = entry.getValue();
if (stringCount != strings.size()) {
String languageRegion = entry.getKey();
int regionIndex = languageRegion.indexOf('-');
if (regionIndex != -1) {
String language = languageRegion.substring(0, regionIndex);
Set<String> fallback = languageToStrings.get(language);
if (fallback != null) {
strings.addAll(fallback);
}
}
}
}
// We only need to do this once; when we see the first region we know
// we need to do it; once merged we can bail
break;
}
}
}
// Fast check to see if there's no problem: if the default locale set is the
// same as the all set (meaning there are no extra strings in the other languages)
// then we can quickly determine if everything is okay by just making sure that
// each language defines everything. If that's the case they will all have the same
// string count.
if (stringCount == defaultStrings.size()) {
boolean haveError = false;
for (Map.Entry<String, Set<String>> entry : languageToStrings.entrySet()) {
Set<String> strings = entry.getValue();
if (stringCount != strings.size()) {
haveError = true;
break;
}
}
if (!haveError) {
return;
}
}
List<String> languages = new ArrayList<String>(languageToStrings.keySet());
Collections.sort(languages);
for (String language : languages) {
Set<String> strings = languageToStrings.get(language);
if (defaultLanguage.equals(language)) {
continue;
}
// if strings.size() == stringCount, then this language is defining everything,
// both all the default language strings and the union of all extra strings
// defined in other languages, so there's no problem.
if (stringCount != strings.size()) {
if (reportMissing) {
Set<String> difference = Sets.difference(defaultStrings, strings);
if (!difference.isEmpty()) {
if (mMissingLocations == null) {
mMissingLocations = new HashMap<String, Location>();
}
if (mDescriptions == null) {
mDescriptions = new HashMap<String, String>();
}
for (String s : difference) {
mMissingLocations.put(s, null);
String message = mDescriptions.get(s);
if (message == null) {
message = String.format("\"`%1$s`\" is not translated in %2$s",
s, getLanguageDescription(language));
} else {
message = message + ", " + getLanguageDescription(language);
}
mDescriptions.put(s, message);
}
}
}
}
if (stringCount != defaultStrings.size()) {
if (reportExtra) {
Set<String> difference = Sets.difference(strings, defaultStrings);
if (!difference.isEmpty()) {
if (mExtraLocations == null) {
mExtraLocations = new HashMap<String, Location>();
}
if (mDescriptions == null) {
mDescriptions = new HashMap<String, String>();
}
for (String s : difference) {
if (mTranslatedArrays != null && mTranslatedArrays.contains(s)) {
continue;
}
if (mNonTranslatable != null && mNonTranslatable.contains(s)) {
continue;
}
mExtraLocations.put(s, null);
String message = String.format(
"\"`%1$s`\" is translated here but not found in default locale", s);
mDescriptions.put(s, message);
}
}
}
}
}
}
public static String getLanguageDescription(@NonNull String locale) {
int index = locale.indexOf('-');
String regionCode = null;
String languageCode = locale;
if (index != -1) {
regionCode = locale.substring(index + 1).toUpperCase(Locale.US);
languageCode = locale.substring(0, index).toLowerCase(Locale.US);
}
String languageName = LocaleManager.getLanguageName(languageCode);
if (languageName != null) {
if (regionCode != null) {
String regionName = LocaleManager.getRegionName(regionCode);
if (regionName != null) {
languageName = languageName + ": " + regionName;
}
}
return String.format("\"%1$s\" (%2$s)", locale, languageName);
} else {
return '"' + locale + '"';
}
}
/** Look up the language for the given folder name */
private static String getLanguageTag(String name) {
if (FD_RES_VALUES.equals(name)) {
return null;
}
FolderConfiguration configuration = FolderConfiguration.getConfigForFolder(name);
if (configuration != null) {
LocaleQualifier locale = configuration.getLocaleQualifier();
if (locale != null && !locale.hasFakeValue()) {
return locale.getTag();
}
}
return null;
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
if (mIgnoreFile) {
return;
}
Attr attribute = element.getAttributeNode(ATTR_NAME);
if (context.getPhase() == 2) {
// Just locating names requested in the {@link #mLocations} map
if (attribute == null) {
return;
}
String name = attribute.getValue();
if (mMissingLocations != null && mMissingLocations.containsKey(name)) {
String language = getLanguageTag(context.file.getParentFile().getName());
if (language == null) {
if (context.getDriver().isSuppressed(context, MISSING, element)) {
mMissingLocations.remove(name);
return;
}
Location location = context.getLocation(attribute);
location.setClientData(element);
location.setSecondary(mMissingLocations.get(name));
mMissingLocations.put(name, location);
}
}
if (mExtraLocations != null && mExtraLocations.containsKey(name)) {
String language = getLanguageTag(context.file.getParentFile().getName());
if (language != null) {
if (context.getDriver().isSuppressed(context, EXTRA, element)) {
mExtraLocations.remove(name);
return;
}
Location location = context.getLocation(attribute);
location.setClientData(element);
location.setMessage("Also translated here");
location.setSecondary(mExtraLocations.get(name));
mExtraLocations.put(name, location);
}
}
return;
}
assert context.getPhase() == 1;
if (attribute == null || attribute.getValue().isEmpty()) {
context.report(MISSING, element, context.getLocation(element),
"Missing `name` attribute in `<string>` declaration");
} else {
String name = attribute.getValue();
Attr translatable = element.getAttributeNode(ATTR_TRANSLATABLE);
if (translatable != null && !Boolean.valueOf(translatable.getValue())) {
String l = LintUtils.getLocaleAndRegion(context.file.getParentFile().getName());
//noinspection VariableNotUsedInsideIf
if (l != null) {
context.report(EXTRA, translatable, context.getLocation(translatable),
"Non-translatable resources should only be defined in the base " +
"`values/` folder");
} else {
if (mNonTranslatable == null) {
mNonTranslatable = new HashSet<String>();
}
mNonTranslatable.add(name);
}
return;
} else if (name.equals("google_maps_key") //$NON-NLS-1$
|| name.equals("google_maps_key_instructions")) { //$NON-NLS-1$
// Older versions of the templates shipped with these not marked as
// non-translatable; don't flag them
if (mNonTranslatable == null) {
mNonTranslatable = new HashSet<String>();
}
mNonTranslatable.add(name);
return;
}
if (element.getTagName().equals(TAG_STRING_ARRAY) &&
allItemsAreReferences(element)) {
// No need to provide translations for string arrays where all
// the children items are defined as translated string resources,
// e.g.
// <string-array name="foo">
// <item>@string/item1</item>
// <item>@string/item2</item>
// </string-array>
// However, we need to remember these names such that we don't consider
// these arrays "extra" if one of the *translated* versions of the array
// perform an inline translation of an array item
if (mTranslatedArrays == null) {
mTranslatedArrays = new HashSet<String>();
}
mTranslatedArrays.add(name);
return;
}
// Check for duplicate name definitions? No, because there can be
// additional customizations like product=
//if (mNames.contains(name)) {
// context.mClient.report(ISSUE, context.getLocation(attribute),
// String.format("Duplicate name %1$s, already defined earlier in this file",
// name));
//}
mNames.add(name);
if (mNonTranslatable != null && mNonTranslatable.contains(name)) {
String message = String.format("The resource string \"`%1$s`\" has been marked as " +
"`translatable=\"false\"`", name);
context.report(EXTRA, attribute, context.getLocation(attribute), message);
}
// TBD: Also make sure that the strings are not empty or placeholders?
}
}
private static boolean allItemsAreReferences(Element element) {
assert element.getTagName().equals(TAG_STRING_ARRAY);
NodeList childNodes = element.getChildNodes();
for (int i = 0, n = childNodes.getLength(); i < n; i++) {
Node item = childNodes.item(i);
if (item.getNodeType() == Node.ELEMENT_NODE &&
TAG_ITEM.equals(item.getNodeName())) {
NodeList itemChildren = item.getChildNodes();
for (int j = 0, m = itemChildren.getLength(); j < m; j++) {
Node valueNode = itemChildren.item(j);
if (valueNode.getNodeType() == Node.TEXT_NODE) {
String value = valueNode.getNodeValue().trim();
if (!value.startsWith(ANDROID_PREFIX)
&& !value.startsWith(STRING_PREFIX)) {
return false;
}
}
}
}
}
return true;
}
@Nullable
private static List<String> getResConfigLanguages(@NonNull Project project) {
if (project.isGradleProject() && project.getGradleProjectModel() != null &&
project.getCurrentVariant() != null) {
Set<String> relevantDensities = Sets.newHashSet();
Variant variant = project.getCurrentVariant();
List<String> variantFlavors = variant.getProductFlavors();
AndroidProject gradleProjectModel = project.getGradleProjectModel();
addResConfigsFromFlavor(relevantDensities, null,
project.getGradleProjectModel().getDefaultConfig());
for (ProductFlavorContainer container : gradleProjectModel.getProductFlavors()) {
addResConfigsFromFlavor(relevantDensities, variantFlavors, container);
}
if (!relevantDensities.isEmpty()) {
ArrayList<String> strings = Lists.newArrayList(relevantDensities);
Collections.sort(strings);
return strings;
}
}
return null;
}
/**
* Adds in the resConfig values specified by the given flavor container, assuming
* it's in one of the relevant variantFlavors, into the given set
*/
private static void addResConfigsFromFlavor(@NonNull Set<String> relevantLanguages,
@Nullable List<String> variantFlavors,
@NonNull ProductFlavorContainer container) {
ProductFlavor flavor = container.getProductFlavor();
if (variantFlavors == null || variantFlavors.contains(flavor.getName())) {
if (!flavor.getResourceConfigurations().isEmpty()) {
for (String resConfig : flavor.getResourceConfigurations()) {
// Look for languages; these are of length 2. (ResConfigs
// can also refer to densities, etc.)
if (resConfig.length() == 2) {
relevantLanguages.add(resConfig);
}
}
}
}
}
}