| /* |
| * Copyright (C) 2018 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 com.android.SdkConstants.ATTR_LOCALE |
| import com.android.SdkConstants.ATTR_NAME |
| import com.android.SdkConstants.ATTR_TRANSLATABLE |
| import com.android.SdkConstants.FD_RES_VALUES |
| import com.android.SdkConstants.TAG_RESOURCES |
| import com.android.SdkConstants.TAG_STRING_ARRAY |
| import com.android.SdkConstants.TOOLS_URI |
| import com.android.SdkConstants.VALUE_FALSE |
| import com.android.ide.common.resources.LocaleManager |
| import com.android.ide.common.resources.ResourceItem |
| import com.android.ide.common.resources.configuration.DensityQualifier |
| import com.android.ide.common.resources.configuration.FolderConfiguration |
| import com.android.ide.common.resources.configuration.VersionQualifier |
| import com.android.ide.common.resources.getLocales |
| import com.android.ide.common.resources.resourceNameToFieldName |
| import com.android.ide.common.resources.usage.ResourceUsageModel |
| import com.android.resources.FolderTypeRelationship |
| import com.android.resources.ResourceFolderType |
| import com.android.resources.ResourceFolderType.VALUES |
| import com.android.resources.ResourceType |
| import com.android.resources.ResourceType.AAPT |
| import com.android.resources.ResourceType.ARRAY |
| import com.android.resources.ResourceType.DRAWABLE |
| import com.android.resources.ResourceType.ID |
| import com.android.resources.ResourceType.MIPMAP |
| import com.android.resources.ResourceType.PUBLIC |
| import com.android.resources.ResourceType.STRING |
| import com.android.resources.ResourceType.STYLE |
| import com.android.resources.ResourceType.STYLEABLE |
| import com.android.tools.lint.detector.api.BinaryResourceScanner |
| import com.android.tools.lint.detector.api.Category |
| import com.android.tools.lint.detector.api.Context |
| import com.android.tools.lint.detector.api.Detector |
| import com.android.tools.lint.detector.api.Implementation |
| import com.android.tools.lint.detector.api.Issue |
| import com.android.tools.lint.detector.api.Location |
| import com.android.tools.lint.detector.api.Project |
| import com.android.tools.lint.detector.api.ResourceContext |
| import com.android.tools.lint.detector.api.ResourceFolderScanner |
| 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.android.tools.lint.detector.api.XmlScanner |
| import com.android.tools.lint.detector.api.getLocaleAndRegion |
| import com.android.utils.SdkUtils.fileNameToResourceName |
| import com.android.utils.SdkUtils.isServiceKey |
| import com.android.utils.XmlUtils |
| import com.google.common.collect.Maps |
| import com.google.common.collect.Sets |
| import org.w3c.dom.Attr |
| import org.w3c.dom.Document |
| import org.w3c.dom.Element |
| import org.w3c.dom.Node |
| import java.util.EnumSet |
| import java.util.Locale |
| import kotlin.collections.set |
| |
| /** |
| * Checks for incomplete translations - e.g. keys that are only present in some |
| * locales but not all. |
| */ |
| class TranslationDetector : Detector(), XmlScanner, ResourceFolderScanner, BinaryResourceScanner { |
| /** The names of resources, for each resource type, defined in the base folder */ |
| private val baseNames: MutableMap<ResourceType, MutableSet<String>> = |
| Maps.newEnumMap(ResourceType::class.java) |
| |
| /** The names of resources, for each resource type, defined in a non-base folder */ |
| private val nonBaseNames: MutableMap<ResourceType, MutableSet<String>> = |
| Maps.newEnumMap(ResourceType::class.java) |
| |
| /** For missing strings, a map from the string name to the set of locales where it's missing */ |
| private var missingMap: MutableMap<String, Set<String>>? = null |
| |
| /** In incremental mode, a cache for the set of locales in the module */ |
| private var locales: Set<String>? = null |
| |
| /** In batch mode, the in-progress view of the set of locales we've come across in pass 1 */ |
| private var pendingLocales: MutableSet<String>? = null |
| |
| /** In batch mode, the set of strings we've come across marked as translatable=false */ |
| private var nonTranslatable: MutableSet<String>? = null |
| |
| /** In batch mode, the translations: a map from each string name to the set of locales */ |
| private var translations: MutableMap<String, MutableSet<String>>? = null |
| |
| private fun ignoreFile(context: Context) = |
| context.file.name.startsWith("donottranslate") || |
| ResourceUsageModel.isAnalyticsFile(context.file) || |
| !context.project.reportIssues |
| |
| override fun afterCheckRootProject(context: Context) { |
| if (context.phase == 2) { |
| return |
| } |
| |
| processMissingTranslations(context) |
| processExtraTranslations(context) |
| } |
| |
| private fun processMissingTranslations(context: Context) { |
| val nameToLocales = translations |
| if (nameToLocales?.isNotEmpty() == true && |
| context.isEnabled(MISSING) && |
| pendingLocales != null |
| ) { |
| val allLocales = filterLocalesByResConfigs(context.project, pendingLocales!!) |
| |
| // TODO: Complain if we have languages that only have region specific folders |
| // Check to make sure |
| |
| // Map from key name to error message to show at that location |
| val localeCount = allLocales.size |
| val nonTranslatable = nonTranslatable ?: emptySet<String>() |
| for (key in baseNames[STRING] ?: return) { |
| val locales = nameToLocales[key] ?: emptySet<String>() |
| if (locales.size < localeCount) { |
| // Missing translations! |
| if (nonTranslatable.contains(key)) { |
| // ...but that's fine for non-translatable strings! |
| continue |
| } |
| val missing = Sets.difference(allLocales, locales) |
| val map = missingMap ?: run { |
| val map = HashMap<String, Set<String>>() |
| missingMap = map |
| map |
| } |
| map[key] = missing |
| } |
| } |
| |
| if (missingMap != null) { |
| context.driver.requestRepeat(this, Scope.ALL_RESOURCES_SCOPE) |
| } |
| } |
| } |
| |
| private fun processExtraTranslations(context: Context) { |
| if (nonBaseNames.isNotEmpty()) { |
| // See if we have any strings that aren't translated |
| for (type in nonBaseNames.keys) { |
| val base = baseNames[type] ?: emptySet<String>() |
| for (name in nonBaseNames[type]!!) { |
| if (!base.contains(name)) { |
| // Found at least one resource in the non-base folders that |
| // does not have a basename: request a second pass to report these |
| context.driver.requestRepeat(this, Scope.ALL_RESOURCES_SCOPE) |
| return |
| } |
| } |
| } |
| } |
| } |
| |
| override fun checkFolder(context: ResourceContext, folderName: String) { |
| if (context.driver.scope.contains(Scope.ALL_RESOURCE_FILES) && |
| context.driver.phase == 1 && |
| context.resourceFolderType == ResourceFolderType.VALUES && |
| // Only count locales from non-reporting libraries |
| context.project.reportIssues |
| ) { |
| val language = getLanguageTagFromFolder(folderName) ?: return |
| if (pendingLocales == null) { |
| pendingLocales = HashSet() |
| } |
| pendingLocales?.add(language) |
| } |
| } |
| |
| override fun checkBinaryResource(context: ResourceContext) { |
| val folderType = context.resourceFolderType ?: return |
| val file = context.file |
| if (folderType != VALUES) { |
| // Record resource for the whole file |
| val types = |
| FolderTypeRelationship.getRelatedResourceTypes( |
| folderType |
| ) |
| val type = types[0] |
| assert(type != ID) { folderType } |
| val name = fileNameToResourceName(file.name) |
| |
| visitResource(context, type, name, name, null, null) |
| } |
| } |
| |
| override fun visitDocument(context: XmlContext, document: Document) { |
| if (ignoreFile(context)) { |
| return |
| } |
| |
| val folderType = context.resourceFolderType ?: return |
| |
| val file = context.file |
| val root = document.documentElement |
| if (folderType != VALUES) { |
| // Record resource for the whole file |
| val types = |
| FolderTypeRelationship.getRelatedResourceTypes( |
| folderType |
| ) |
| val type = types[0] |
| assert(type != ID) { folderType } |
| val name = fileNameToResourceName(file.name) |
| |
| visitResource(context, type, name, name, root, null) |
| } else { |
| // For value files, and drawables and colors etc also pull in resource |
| // references inside the context.file |
| root ?: return |
| if (root.tagName != TAG_RESOURCES) { |
| return |
| } |
| |
| val defaultLocale = run { |
| val attribute = root.getAttributeNS(TOOLS_URI, ATTR_LOCALE) |
| when { |
| attribute.isNotBlank() -> attribute |
| else -> null |
| } |
| } |
| |
| if (defaultLocale != null && context.driver.phase == 1) { |
| val parentFolderName = context.file.parentFile.name |
| val folderLanguage = getLanguageTagFromFolder(parentFolderName) |
| if (folderLanguage != null) { |
| val defaultLanguage = defaultLocale.substringBefore("-") |
| if (folderLanguage != defaultLanguage) { |
| context.report( |
| MISSING, |
| context.getValueLocation( |
| root.getAttributeNodeNS( |
| TOOLS_URI, |
| ATTR_LOCALE |
| ) |
| ), |
| "Suspicious `tools:locale` declaration of language `$defaultLanguage`; the parent folder `$parentFolderName` implies language $folderLanguage" |
| ) |
| } |
| } |
| } |
| |
| // Visit top level children within the resource file |
| var child = XmlUtils.getFirstSubTag(root) |
| while (child != null) { |
| val type = ResourceType.fromXmlTag(child) |
| if (type != null && type != ID && type != PUBLIC && type != AAPT) { |
| val originalName = child.getAttribute(ATTR_NAME) |
| if (originalName.isNullOrBlank()) { |
| if (!child.hasAttribute(ATTR_NAME)) { |
| val fix = fix().set().todo(null, ATTR_NAME).build() |
| context.report( |
| MISSING, child, context.getLocation(child), |
| "Missing `name` attribute in `<${child.tagName}>` declaration", |
| fix |
| ) |
| } |
| } else { |
| val name = resourceNameToFieldName(originalName) |
| visitResource(context, type, name, originalName, child, defaultLocale) |
| } |
| } |
| child = XmlUtils.getNextTag(child) |
| } |
| } |
| } |
| |
| private fun visitResource( |
| context: ResourceContext, |
| type: ResourceType, |
| name: String, |
| originalName: String, |
| element: Element?, |
| defaultLocale: String? |
| ) { |
| when (type) { |
| MIPMAP, |
| // Extra translation checks don't apply to some of the resource types |
| // (It generally does apply to styleables, but we're avoiding reporting those |
| // for now since these often extend library styles |
| STYLE, STYLEABLE -> return |
| else -> { |
| val folderName = context.file.parentFile.name |
| // Determine if we're in IDE mode or in batch mode |
| if (context.driver.scope.contains(Scope.ALL_RESOURCE_FILES)) { |
| batchVisitResource( |
| type, |
| folderName, |
| context, |
| name, |
| element, |
| defaultLocale |
| ) |
| } else { |
| incrementalVisitResource( |
| context, |
| type, |
| name, |
| originalName, |
| element, |
| folderName, |
| defaultLocale |
| ) |
| } |
| } |
| } |
| } |
| |
| // On-the-fly analysis in the IDE |
| private fun incrementalVisitResource( |
| context: ResourceContext, |
| type: ResourceType, |
| name: String, |
| originalName: String, |
| element: Element?, |
| folderName: String, |
| defaultLocale: String? |
| ) { |
| // Incremental mode |
| val client = context.client |
| if (!client.supportsProjectResources()) { |
| return |
| } |
| val resources = client |
| .getResourceRepository(context.mainProject, true, false) ?: return |
| |
| val namespace = context.project.resourceNamespace |
| // See https://issuetracker.google.com/147213347 |
| var items: List<ResourceItem> = resources.getResources(namespace, type, originalName) |
| if (items.isEmpty() && originalName != name) { // name contains .'s, -'s, etc |
| items = resources.getResources(namespace, type, name) |
| if (items.isEmpty()) { |
| // Something is wrong with the resource repository; can't analyze here |
| return |
| } |
| } |
| val hasDefault = items.filter { isDefaultFolder(it.configuration, null) }.any() |
| if (!hasDefault) { |
| reportExtraResource(type, name, context, element) |
| } else if (type == STRING && |
| !folderName.contains('-') && |
| element != null && |
| context is XmlContext |
| ) { |
| // Incrementally check for missing translations |
| |
| if (handleNonTranslatable(name, element, context, true)) { |
| return |
| } |
| // In default folder, flag any strings missing translations |
| if (locales == null) { |
| locales = filterLocalesByResConfigs( |
| context.project, |
| resources.getLocales().mapNotNull { |
| if (it.hasLanguage()) { |
| it.language |
| } else { |
| defaultLocale |
| } |
| }.toSet() |
| ) |
| } |
| val locales = locales!!.toHashSet() |
| |
| for (item in items) { |
| val qualifiers = run { |
| val s = item.configuration.qualifierString |
| if (defaultLocale != null && s.isEmpty()) { |
| defaultLocale.substringBefore('-') |
| } else { |
| s.substringBefore('-') |
| } |
| } |
| |
| val language = getLanguageTagFromQualifiers(qualifiers) ?: continue |
| locales.remove(language) |
| } |
| |
| if (locales.isNotEmpty()) { |
| reportMissingTranslation(name, context, element, locales) |
| } |
| } |
| } |
| |
| // Batch analysis of resources |
| private fun batchVisitResource( |
| type: ResourceType, |
| folderName: String, |
| context: ResourceContext, |
| name: String, |
| element: Element?, |
| defaultLocale: String? |
| ) { |
| // Batch mode |
| val isDefault = isDefaultFolder(context.getFolderConfiguration(), folderName) |
| if (isDefault) { |
| // Base folder |
| if (context.phase == 1) { |
| // Default folder: record the sets of names in the default folder |
| val names = baseNames[type] ?: run { |
| val set = mutableSetOf<String>() |
| baseNames[type] = set |
| set |
| } |
| names.add(name) |
| } else if (element != null && type == STRING && context is XmlContext) { |
| // Second pass: that means we're reporting already determined |
| // missing translations |
| val missingFrom = missingMap?.get(name) |
| if (missingFrom != null) { |
| reportMissingTranslation(name, context, element, missingFrom) |
| } |
| } |
| |
| if (type == STRING && element != null && context is XmlContext) { |
| if (defaultLocale != null) { |
| val language = getLanguageTagFromQualifiers(defaultLocale) |
| if (language != null) { |
| recordTranslation(name, language) |
| } |
| } |
| |
| handleNonTranslatable(name, element, context, true) |
| } |
| } else { |
| // Non-base folder |
| if (context.phase == 1) { |
| val names = nonBaseNames[type] ?: run { |
| val set = mutableSetOf<String>() |
| nonBaseNames[type] = set |
| set |
| } |
| names.add(name) |
| |
| if (type == STRING && element != null && context is XmlContext) { |
| if (handleNonTranslatable(name, element, context, false)) { |
| return |
| } |
| |
| val language = |
| getLanguageTagFromFolder(folderName) |
| ?: getLanguageTagFromQualifiers(defaultLocale) |
| ?: "" |
| |
| recordTranslation(name, language) |
| } |
| } else { |
| // We report extra resources in pass 2. Even though |
| // resource folders are processed alphabetically so |
| // that we always see the base folder before the overlays, |
| // drawables introduce a complication, e.g. "drawable-da-mdpi" |
| // will be visited before "drawable-mdpi" even though the |
| // latter is a "base" folder. Therefore we need to visit |
| // all folders in pass 1 before we have the complete set of |
| // base names. |
| if (baseNames[type]?.contains(name) != true) { |
| reportExtraResource(type, name, context, element) |
| } |
| } |
| } |
| } |
| |
| /** |
| * Determines whether for the sake of this check the given folder is |
| * a default folder. Normally this would mean that it has no resource |
| * qualifiers, but (a) we allow density qualifiers (since the resource |
| * system will pick among them and (b) we allow version qualifiers since |
| * it's a common practice to create version specific resources only used |
| * from themes (and indirectly theme drawables) dedicates to a specific |
| * platform version, e.g. Material-theme only theme resources, and we |
| * don't want false positives in this area. |
| */ |
| private fun isDefaultFolder( |
| configuration: FolderConfiguration?, |
| folderName: String? |
| ): Boolean { |
| val config: FolderConfiguration = when { |
| configuration != null -> configuration |
| folderName != null -> { |
| if (!folderName.contains('-')) { |
| return true |
| } |
| |
| // Cheap underestimate (some false positives, like -vrheadset will look |
| // like a version qualifier, which is why we do a more extensive test below) |
| if (!folderName.contains("dpi") && !folderName.contains("-v")) { |
| return false |
| } |
| |
| FolderConfiguration.getConfigForFolder(folderName) ?: return false |
| } |
| else -> { |
| assert(false) |
| return true |
| } |
| } |
| |
| return !config.any { it !is DensityQualifier && it !is VersionQualifier } |
| } |
| |
| private fun recordTranslation(name: String, language: String) { |
| val translations = translations ?: run { |
| translations = HashMap() |
| translations!! |
| } |
| |
| val languages = translations[name] ?: run { |
| val set = HashSet<String>() |
| translations[name] = set |
| set |
| } |
| |
| languages.add(language) |
| } |
| |
| private fun handleNonTranslatable( |
| name: String, |
| element: Element, |
| context: XmlContext, |
| isDefaultFolder: Boolean |
| ): Boolean { |
| val translatable: Attr? = element.getAttributeNode(ATTR_TRANSLATABLE) |
| if (translatable != null && !translatable.value!!.toBoolean()) { |
| if (!isDefaultFolder && |
| // Ensure that we're really in a locale folder, not just some non-default |
| // folder (for example, values-en is a locale folder, values-v19 is not) |
| getLocaleAndRegion(context.file.parentFile.name) != null |
| ) { |
| reportTranslatedUntranslatable(context, name, element, translatable, true) |
| } |
| recordTranslatable(context, name) |
| return true |
| } else if ((isServiceKey(name) || |
| // Older versions of the templates shipped with these not marked as |
| // non-translatable; don't flag them |
| name == "google_maps_key" || |
| name == "google_maps_key_instructions") |
| ) { |
| // Mark translatable, but don't flag it as an error do have these translatable |
| // in other folders |
| recordTranslatable(context, name) |
| return true |
| } else if (!isDefaultFolder && nonTranslatable?.contains(name) == true && |
| getLocaleAndRegion(context.file.parentFile.name) != null |
| ) { |
| reportTranslatedUntranslatable( |
| context, |
| name, |
| element, |
| element.getAttributeNode(ATTR_NAME) ?: element, |
| false |
| ) |
| } |
| return false |
| } |
| |
| private fun recordTranslatable( |
| context: XmlContext, |
| name: String |
| ) { |
| if (context.driver.scope.contains(Scope.ALL_RESOURCE_FILES)) { |
| // Batch mode: record that this is a non-translatable string |
| if (nonTranslatable == null) { |
| nonTranslatable = mutableSetOf() |
| } |
| nonTranslatable!!.add(name) |
| } |
| } |
| |
| private fun reportTranslatedUntranslatable( |
| context: XmlContext, |
| name: String, |
| element: Element, |
| locationNode: Node, |
| translatableDefinedLocally: Boolean |
| ) { |
| val language = getLanguageTagFromFolder(context.file.parentFile.name) ?: return |
| |
| // Check to make sure it's not suppressed with the older flag, EXTRA, |
| // which this issue used to be reported under. |
| if (context.driver.isSuppressed( |
| context, |
| EXTRA, |
| locationNode |
| ) |
| ) { |
| return |
| } |
| |
| val languageDescription = getLanguageDescription(language) |
| val message = if (translatableDefinedLocally) { |
| "The resource string \"$name\" is marked as translatable=\"false\", but is translated to $languageDescription here" |
| } else { |
| "The resource string \"$name\" has been marked as translatable=\"false\" elsewhere (usually in the `values` folder), but is translated to $languageDescription here" |
| } |
| val fix = |
| fix().name("Remove translation").replace().range(context.getLocation(element)).with("") |
| .build() |
| context.report( |
| TRANSLATED_UNTRANSLATABLE, locationNode, |
| context.getLocation(locationNode), |
| message, |
| fix |
| ) |
| } |
| |
| private fun reportExtraResource( |
| type: ResourceType, |
| name: String, |
| context: ResourceContext, |
| element: Element? |
| ) { |
| // Found resource in folder that isn't present in the base folder; |
| // this can lead to a crash |
| val parentFolder = context.file.parentFile.name |
| val message = when (type) { |
| STRING -> "\"$name\" is translated here but not found in default locale" |
| DRAWABLE -> "The drawable \"$name\" in $parentFolder has no declaration in " + |
| "the base `drawable` folder or in a `drawable-`*density*`dpi` " + |
| "folder; this can lead to crashes when the drawable is queried in " + |
| "a configuration that does not match this qualifier" |
| else -> { |
| val typeName = type.getName() |
| val baseFolder = context.resourceFolderType?.getName() |
| "The $typeName \"$name\" in $parentFolder has no declaration in " + |
| "the base `$baseFolder` folder; this can lead to crashes " + |
| "when the resource is queried in a configuration that " + |
| "does not match this qualifier" |
| } |
| } |
| |
| if (element != null && context is XmlContext) { |
| // Offer quickfix only for resource item values for now, not whole files |
| // (which would require additional to LintFix infrastructure) |
| val fix = if (context.resourceFolderType == VALUES) { |
| val fixLabel = if (type == STRING) { |
| "Remove translation" |
| } else { |
| "Remove resource override" |
| } |
| fix().name(fixLabel).replace().range(context.getLocation(element)).with("").build() |
| } else { |
| null |
| } |
| // Use the ExtraTranslation id for string related problems (historical) and |
| // the new MissingDefaultResource for everything else |
| val issue = if (type == STRING || |
| type == ARRAY && element.tagName == TAG_STRING_ARRAY |
| ) |
| EXTRA |
| else MISSING_BASE |
| val location = context.getElementLocation(element, attribute = ATTR_NAME) |
| context.report(issue, element, location, message, fix) |
| } else { |
| // Non-XML violation: bitmaps in drawable folders |
| val location = Location.create(context.file) |
| context.report(MISSING_BASE, location, message) |
| } |
| } |
| |
| private fun reportMissingTranslation( |
| name: String, |
| context: XmlContext, |
| element: Element, |
| missingFrom: Set<String> |
| ) { |
| // Found resource in folder that isn't present in the base folder; |
| // this can lead to a crash |
| val separator = if (missingFrom.size == 2) " or " else ", " |
| val localeList = missingFrom.joinToString(separator = separator) { |
| getLanguageDescription(it) |
| } |
| val message = "\"$name\" is not translated in $localeList" |
| val locationNode = element.getAttributeNode(ATTR_NAME) ?: element |
| val fix = fix() |
| .name("Mark non-translatable") |
| .set(null, ATTR_TRANSLATABLE, VALUE_FALSE) |
| .build() |
| |
| context.report( |
| MISSING, element, context.getLocation(locationNode), message, fix |
| ) |
| } |
| |
| /** Look up the language for the given folder name */ |
| private fun getLanguageTagFromFolder(name: String): String? { |
| val locale = getLocaleTagFromFolder(name) ?: return null |
| val index = locale.indexOf('-') |
| return if (index != -1) { |
| locale.substring(0, index) |
| } else { |
| locale |
| } |
| } |
| |
| /** Look up the locale for the given folder name */ |
| private fun getLocaleTagFromFolder(name: String): String? { |
| if (name == FD_RES_VALUES) { |
| return null |
| } |
| |
| val configuration = FolderConfiguration.getConfigForFolder(name) |
| if (configuration != null) { |
| val locale = configuration.localeQualifier |
| if (locale != null && !locale.hasFakeValue()) { |
| return locale.tag |
| } |
| } |
| |
| return null |
| } |
| |
| /** Look up the language for the given qualifiers */ |
| private fun getLanguageTagFromQualifiers(name: String?): String? { |
| name ?: return null |
| val locale = getLocaleTagFromQualifiers(name) ?: return null |
| val index = locale.indexOf('-') |
| return if (index != -1) { |
| locale.substring(0, index) |
| } else { |
| locale |
| } |
| } |
| |
| /** Look up the locale for the given qualifiers */ |
| private fun getLocaleTagFromQualifiers(name: String): String? { |
| assert(!name.startsWith(FD_RES_VALUES)) |
| if (name.isEmpty()) { |
| return null |
| } |
| |
| val configuration = FolderConfiguration.getConfigForQualifierString(name) |
| if (configuration != null) { |
| val locale = configuration.localeQualifier |
| if (locale != null && !locale.hasFakeValue()) { |
| return locale.tag |
| } |
| } |
| |
| return null |
| } |
| |
| private fun filterLocalesByResConfigs(project: Project, locales: Set<String>): Set<String> { |
| val configLanguages = getResConfigLanguages(project) ?: return locales |
| return locales.intersect(configLanguages) |
| } |
| |
| private fun getResConfigLanguages(project: Project): List<String>? { |
| val variant = project.buildVariant ?: return null |
| val resourceConfigurations = variant.resourceConfigurations |
| if (resourceConfigurations.isEmpty()) { |
| return null |
| } |
| return resourceConfigurations.filter { resConfig -> |
| // Look for languages; these are of length 2. (ResConfigs |
| // can also refer to densities, etc.) |
| resConfig.length == 2 |
| }.sorted().toList() |
| } |
| |
| companion object { |
| private val IMPLEMENTATION = Implementation( |
| TranslationDetector::class.java, |
| EnumSet.of( |
| Scope.ALL_RESOURCE_FILES, |
| Scope.RESOURCE_FILE, |
| Scope.RESOURCE_FOLDER, |
| Scope.BINARY_RESOURCE_FILE |
| ), |
| Scope.RESOURCE_FILE_SCOPE |
| ) |
| |
| /** Are all translations complete? */ |
| @JvmField |
| val MISSING = Issue.create( |
| id = "MissingTranslation", |
| briefDescription = "Incomplete translation", |
| explanation = """ |
| If an application has more than one locale, then all the strings declared \ |
| in one language should also be translated in all other languages. |
| |
| 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. |
| |
| 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 = Category.MESSAGES, |
| priority = 8, |
| severity = Severity.ERROR, |
| implementation = IMPLEMENTATION |
| ) |
| |
| /** Are there extra translations that are "unused" (appear only in specific languages) ? */ |
| @JvmField |
| val EXTRA = Issue.create( |
| id = "ExtraTranslation", |
| briefDescription = "Extra translation", |
| explanation = """ |
| 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.) |
| |
| 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 = Category.MESSAGES, |
| priority = 6, |
| severity = Severity.FATAL, |
| implementation = IMPLEMENTATION |
| ) |
| |
| /** Are there extra resources that are "unused" (appear only in non-default folders) ? */ |
| @JvmField |
| val MISSING_BASE = Issue.create( |
| id = "MissingDefaultResource", |
| briefDescription = "Missing Default", |
| explanation = """ |
| If a resource is only defined in folders with qualifiers like `-land` or \ |
| `-en`, and there is no default declaration in the base folder (`layout` or \ |
| `values` etc), then the app will crash if that resource is accessed on a \ |
| device where the device is in a configuration missing the given qualifier. |
| |
| As a special case, drawables do not have to be specified in the base folder; \ |
| if there is a match in a density folder (such as `drawable-mdpi`) that image \ |
| will be used and scaled. Note however that if you only specify a drawable in \ |
| a folder like `drawable-en-hdpi`, the app will crash in non-English locales. |
| |
| There may be scenarios where you have a resource, such as a `-fr` drawable, \ |
| which is only referenced from some other resource with the same qualifiers \ |
| (such as a `-fr` style), which itself has safe fallbacks. However, this still \ |
| makes it possible for somebody to accidentally reference the drawable and \ |
| crash, so it is safer to create a default dummy fallback in the base folder. \ |
| Alternatively, you can suppress the issue by adding \ |
| `tools:ignore="MissingDefaultResource"` on the element. |
| |
| (This scenario frequently happens with string translations, where you might \ |
| delete code and the corresponding resources, but forget to delete a \ |
| translation. There is a dedicated issue id for that scenario, with the id \ |
| `ExtraTranslation`.)""", |
| category = Category.CORRECTNESS, |
| priority = 6, |
| severity = Severity.FATAL, |
| implementation = IMPLEMENTATION |
| ) |
| |
| /** Are there extra translations that are "unused" (appear only in specific languages) ? */ |
| @JvmField |
| val TRANSLATED_UNTRANSLATABLE = Issue.create( |
| id = "Untranslatable", |
| briefDescription = "Translated Untranslatable", |
| explanation = """ |
| Strings can be marked with `translatable=false` to indicate that they are not \ |
| intended to be translated, but are present in the resource file for other \ |
| purposes (for example for non-display strings that should vary by some other \ |
| configuration qualifier such as screen size or API level). |
| |
| There are cases where translators accidentally translate these strings anyway, \ |
| and lint will flag these occurrences with this lint check.""", |
| category = Category.MESSAGES, |
| priority = 6, |
| severity = Severity.WARNING, |
| implementation = IMPLEMENTATION |
| ) |
| |
| @JvmStatic |
| fun getLanguageDescription(locale: String): String { |
| val index = locale.indexOf('-') |
| var regionCode: String? = null |
| var languageCode = locale |
| if (index != -1) { |
| regionCode = locale.substring(index + 1).toUpperCase(Locale.US) |
| languageCode = locale.substring(0, index).toLowerCase(Locale.US) |
| } |
| |
| var languageName = LocaleManager.getLanguageName(languageCode) |
| return if (languageName != null) { |
| if (regionCode != null) { |
| val regionName = LocaleManager.getRegionName(regionCode) |
| if (regionName != null) { |
| languageName = "$languageName: $regionName" |
| } |
| } |
| |
| String.format("\"%1\$s\" (%2\$s)", locale, languageName) |
| } else { |
| '"'.toString() + locale + '"'.toString() |
| } |
| } |
| } |
| } |