blob: ecc845a6723a06c6a0043b7deb34c9c9843fbfac [file] [log] [blame]
/*
* Copyright (C) 2014 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.ANDROID_PREFIX
import com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX
import com.android.SdkConstants.ANDROID_URI
import com.android.SdkConstants.ATTR_COLOR
import com.android.SdkConstants.ATTR_DRAWABLE
import com.android.SdkConstants.ATTR_FONT
import com.android.SdkConstants.ATTR_LAYOUT
import com.android.SdkConstants.ATTR_NAME
import com.android.SdkConstants.ATTR_PARENT
import com.android.SdkConstants.ATTR_TYPE
import com.android.SdkConstants.COLOR_RESOURCE_PREFIX
import com.android.SdkConstants.DRAWABLE_PREFIX
import com.android.SdkConstants.FONT_PREFIX
import com.android.SdkConstants.ID_PREFIX
import com.android.SdkConstants.LAYOUT_RESOURCE_PREFIX
import com.android.SdkConstants.NEW_ID_PREFIX
import com.android.SdkConstants.PREFIX_RESOURCE_REF
import com.android.SdkConstants.STYLE_RESOURCE_PREFIX
import com.android.SdkConstants.TAG_COLOR
import com.android.SdkConstants.TAG_DIMEN
import com.android.SdkConstants.TAG_FONT
import com.android.SdkConstants.TAG_ITEM
import com.android.SdkConstants.TAG_STRING
import com.android.SdkConstants.TAG_STYLE
import com.android.SdkConstants.TOOLS_URI
import com.android.SdkConstants.VIEW_INCLUDE
import com.android.resources.FolderTypeRelationship
import com.android.resources.ResourceFolderType
import com.android.resources.ResourceType
import com.android.resources.ResourceUrl
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.Location
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.android.tools.lint.detector.api.getBaseName
import com.android.utils.XmlUtils
import com.google.common.base.Joiner
import com.google.common.collect.ArrayListMultimap
import com.google.common.collect.Lists
import com.google.common.collect.Maps
import com.google.common.collect.Multimap
import com.google.common.collect.Multimaps
import com.google.common.collect.Sets
import org.w3c.dom.Attr
import org.w3c.dom.Element
import org.w3c.dom.Node
import java.util.Arrays
import java.util.TreeMap
/**
* Checks for cycles in resource definitions
*/
/** Constructs a new [ResourceCycleDetector] */
class ResourceCycleDetector : ResourceXmlDetector() {
/**
* For each resource type, a map from a key (style name, layout name, color name, etc) to
* a value (parent style, included layout, referenced color, etc). Note that we only initialize
* this if we are in "batch mode" (not editor incremental mode) since we allow this detector
* to also run incrementally to look for trivial chains (e.g. of length 1).
*/
private var mReferences: MutableMap<ResourceType, Multimap<String, String>>? = null
/**
* If in batch analysis and cycles were found, in phase 2 this map should be initialized
* with locations for declaration definitions of the keys and values in [.mReferences]
*/
private var mLocations: MutableMap<ResourceType, Multimap<String, Location>>? = null
/**
* If in batch analysis and cycles were found, for each resource type this is a list
* of chains (where each chain is a list of keys as described in [.mReferences])
*/
private var mChains: MutableMap<ResourceType, MutableList<MutableList<String>>>? = null
override fun beforeCheckRootProject(context: Context) {
// In incremental mode, or checking all files (full lint analysis) ? If the latter,
// we should store state and look for deeper cycles
if (context.scope.contains(Scope.ALL_RESOURCE_FILES)) {
mReferences = Maps.newEnumMap(ResourceType::class.java)
}
}
override fun appliesTo(folderType: ResourceFolderType): Boolean {
return (folderType == ResourceFolderType.VALUES ||
folderType == ResourceFolderType.FONT ||
folderType == ResourceFolderType.COLOR ||
folderType == ResourceFolderType.DRAWABLE ||
folderType == ResourceFolderType.LAYOUT)
}
override fun getApplicableElements(): Collection<String>? {
return Arrays.asList(
VIEW_INCLUDE,
TAG_STYLE,
TAG_COLOR,
TAG_ITEM,
TAG_FONT,
TAG_STRING,
TAG_DIMEN
)
}
override fun getApplicableAttributes(): Collection<String>? = ALL
private fun recordReference(type: ResourceType, from: String, to: String) {
if (to.isEmpty() || to.startsWith(ANDROID_PREFIX)) {
return
}
val map = getTypeMap(type) ?: return
val name = if (to[0] == '@') {
val index = to.indexOf('/')
if (index != -1) {
to.substring(index + 1)
} else {
to
}
} else {
to
}
map.put(from, name)
}
private fun getTypeMap(type: ResourceType): Multimap<String, String>? {
val references = mReferences ?: return null
val map: Multimap<String, String>? = references[type]
if (map != null) {
return map
}
// Multimap which preserves insert order (for predictable output order)
val newMap: Multimap<String, String> = Multimaps.newListMultimap(TreeMap()) {
Lists.newArrayListWithExpectedSize<String>(6)
}
references[type] = newMap
return newMap
}
private fun recordLocation(
context: XmlContext,
node: Node,
type: ResourceType,
from: String
) {
// Cycles were already found; we're now in phase 2 looking up specific
// locations
val map = getLocationMap(type) ?: return
val location = context.getLocation(node)
map.put(from, location)
}
private fun getLocationMap(type: ResourceType): Multimap<String, Location>? {
val locations = mLocations ?: return null
val map: Multimap<String, Location>? = locations[type]
if (map != null) {
return map
}
// Multimap which preserves insert order (for predictable output order)
val newMap: Multimap<String, Location> = ArrayListMultimap.create(30, 4)
locations[type] = newMap
return newMap
}
override fun visitElement(context: XmlContext, element: Element) {
val tagName = element.tagName
if (tagName == TAG_ITEM) {
if (mReferences == null) {
// Nothing to do in incremental mode
return
}
val folderType = context.resourceFolderType
if (folderType == ResourceFolderType.VALUES) {
// Aliases
val typeNode = element.getAttributeNode(ATTR_TYPE)
if (typeNode != null) {
val typeName = typeNode.value
val type = ResourceType.fromXmlValue(typeName)
val nameNode = element.getAttributeNode(ATTR_NAME)
if (type != null && nameNode != null) {
val childNodes = element.childNodes
var i = 0
val n = childNodes.length
while (i < n) {
val child = childNodes.item(i)
if (child.nodeType == Node.TEXT_NODE) {
val text = child.nodeValue
var k = 0
val max = text.length
while (k < max) {
val c = text[k]
if (Character.isWhitespace(c)) {
break
} else if (c == '@' && text.startsWith(type.getName(), k + 1)) {
val to = text.trim { it <= ' ' }
if (mReferences != null) {
val name = nameNode.value
if (mLocations != null) {
recordLocation(
context, child, type,
name
)
} else {
recordReference(type, name, to)
}
}
} else {
break
}
k++
}
}
i++
}
}
}
} else if (folderType == ResourceFolderType.COLOR) {
val color = element.getAttributeNS(ANDROID_URI, ATTR_COLOR)
if (color != null && color.startsWith(COLOR_RESOURCE_PREFIX)) {
val currentColor = getBaseName(context.file.name)
handleReference(
context,
element,
ResourceType.COLOR,
currentColor,
color.substring(COLOR_RESOURCE_PREFIX.length)
)
}
} else if (folderType == ResourceFolderType.DRAWABLE) {
val drawable = element.getAttributeNS(ANDROID_URI, ATTR_DRAWABLE)
if (drawable != null && drawable.startsWith(DRAWABLE_PREFIX)) {
val currentColor = getBaseName(context.file.name)
handleReference(
context,
element,
ResourceType.DRAWABLE,
currentColor,
drawable.substring(DRAWABLE_PREFIX.length)
)
}
}
} else if (tagName == TAG_STYLE) {
val nameNode = element.getAttributeNode(ATTR_NAME)
// Look for recursive style parent declarations
val parentNode = element.getAttributeNode(ATTR_PARENT)
if (parentNode != null && nameNode != null) {
val name = nameNode.value
val parent = parentNode.value
if (parent.startsWith(STYLE_RESOURCE_PREFIX) &&
parent.startsWith(name, STYLE_RESOURCE_PREFIX.length) &&
parent.startsWith(".", STYLE_RESOURCE_PREFIX.length + name.length)
) {
if (context.isEnabled(CYCLE) && context.driver.phase == 1) {
context.report(
CYCLE, parentNode, context.getLocation(parentNode),
"Potential cycle: `$name` is the implied parent of `${
parent.substring(STYLE_RESOURCE_PREFIX.length)}` and " +
"this defines the opposite"
)
}
// Don't record this reference; we don't want to double report this
// as a chain, since this error is more helpful
return
}
if (!parent.isEmpty() && !parent.startsWith(ANDROID_STYLE_RESOURCE_PREFIX)) {
val parentName = parent.substring(parent.lastIndexOf('/') + 1)
handleReference(context, parentNode, ResourceType.STYLE, name, parentName)
if (parent.startsWith(PREFIX_RESOURCE_REF) && !parent.contains("style/")) {
context.report(
CYCLE, parentNode, context.getLocation(parentNode),
"Invalid parent reference: expected a @style"
)
}
}
} else if (mReferences != null && nameNode != null) {
val name = nameNode.value
val index = name.lastIndexOf('.')
if (index > 0) {
val parent = name.substring(0, index)
if (mReferences != null) {
if (mLocations != null) {
val node = element.getAttributeNode(ATTR_NAME)
recordLocation(context, node, ResourceType.STYLE, name)
} else {
recordReference(ResourceType.STYLE, name, parent)
}
}
}
}
if (context.isEnabled(CRASH) && context.driver.phase == 1) {
for (item in XmlUtils.getSubTags(element)) {
if ("android:id" == item.getAttribute(ATTR_NAME)) {
checkCrashItem(context, item)
}
}
}
} else if (tagName == VIEW_INCLUDE) {
val layoutNode = element.getAttributeNode(ATTR_LAYOUT)
if (layoutNode != null) {
val layout = layoutNode.value
if (layout.startsWith(LAYOUT_RESOURCE_PREFIX)) {
val currentLayout = getBaseName(context.file.name)
handleReference(
context,
layoutNode,
ResourceType.LAYOUT,
currentLayout,
layout
)
}
}
} else if (tagName == TAG_COLOR || tagName == TAG_STRING || tagName == TAG_DIMEN) {
val childNodes = element.childNodes
var i = 0
val n = childNodes.length
while (i < n) {
val child = childNodes.item(i)
if (child.nodeType == Node.TEXT_NODE) {
val text = child.nodeValue
var k = 0
val max = text.length
while (k < max) {
val c = text[k]
if (Character.isWhitespace(c)) {
break
} else if (c == '@' && text.startsWith(tagName, k + 1)) {
val to = text.trim { it <= ' ' }.substring(tagName.length + 2)
val name = element.getAttribute(ATTR_NAME)
val type = ResourceType.fromXmlTagName(tagName)
if (type != null) {
handleReference(context, child, type, name, to)
}
} else {
break
}
k++
}
}
i++
}
} else if (tagName == TAG_FONT) {
val text = element.getAttributeNodeNS(ANDROID_URI, ATTR_FONT)
if (text != null && text.value.startsWith(FONT_PREFIX)) {
val font = text.value.trim { it <= ' ' }.substring(FONT_PREFIX.length)
val currentFont = getBaseName(context.file.name)
handleReference(context, text, ResourceType.FONT, currentFont, font)
}
}
}
override fun afterCheckRootProject(context: Context) {
// No references? Incremental analysis in a single file only; nothing to do
val references = this.mReferences ?: return
val phase = context.driver.phase
if (phase == 1) {
// Perform DFS of each resource type and look for cycles
for ((type, map) in references) {
findCycles(context, type, map)
}
} else {
assert(phase == 2)
// Emit cycle report
val chainsMap = mChains ?: return
val locationMap = mLocations ?: return
for ((type, chains) in chainsMap) {
val locations = locationMap[type] ?: ArrayListMultimap.create() // Unlikely.
for (chain in chains) {
var location: Location? = null
assert(!chain.isEmpty())
var i = 0
val n = chain.size
while (i < n) {
val item = chain[i]
val itemLocations = locations.get(item)
if (!itemLocations.isEmpty()) {
val itemLocation = itemLocations.iterator().next()
val next = chain[(i + 1) % chain.size]
val label = ("Reference from @" + type.getName() + "/" + item +
" to " + type.getName() + "/" + next + " here")
itemLocation.message = label
itemLocation.secondary = location
location = itemLocation
}
i++
}
if (location == null) {
location = Location.create(context.project.dir)
} else {
// Break off chain
var curr = location.secondary
while (curr != null) {
val next = curr.secondary
if (next === location) {
curr.secondary = null
break
}
curr = next
}
}
val message = String.format(
"%1\$s Resource definition cycle: %2\$s",
type.displayName, Joiner.on(" => ").join(chain)
)
context.report(CYCLE, location, message)
}
}
}
}
override fun visitAttribute(context: XmlContext, attribute: Attr) {
val resourceFolderType = context.resourceFolderType
if (resourceFolderType == null || resourceFolderType == ResourceFolderType.VALUES) {
// Null resource type means manifest, and there are no cycles there.
// Within values there are special considerations (for example around styles)
// and this is all handled from visitElement
return
}
val value = attribute.value
if (value.isEmpty() ||
!value.startsWith(PREFIX_RESOURCE_REF) ||
value.startsWith(NEW_ID_PREFIX) || // id's can't have cycles
value.startsWith(ID_PREFIX)
) {
return
}
// Optimization to avoid parsing URLs for the very common case where the referenced
// resource is unrelated to the current folder type (e.g. a drawable reference
// in a layout file etc)
val types = FolderTypeRelationship.getRelatedResourceTypes(resourceFolderType)
val primary = types[0].getName() // Guaranteed to not be the primary type, not the id
if (!value.regionMatches(1, primary, 0, primary.length, false)) {
return
}
val url = ResourceUrl.parse(value) ?: return
// We don't need to check !url.framework here since our optimization above
// already made sure the resource types matched *and* there was no "android:" prefix
// before the resource type
// Ensure that we're referring to the same resource type here; e.g. we're not complaining
// that @layout/foo references @drawable/foo
if (!types.contains(url.type)) {
return
}
if (TOOLS_URI == attribute.namespaceURI) {
// tools attribute references to resources aren't real resource references,
// and sometimes are intentionally cyclic, such as tools:showIn
return
}
val from = getBaseName(context.file.name)
handleReference(context, attribute, url.type, from, url.name)
}
private fun handleReference(
context: XmlContext,
node: Node,
type: ResourceType,
from: String,
to: String
) {
if (from == to) {
// Report immediately; don't record
if (context.isEnabled(CYCLE) &&
context.driver.phase == 1
) {
context.report(
CYCLE, node, context.getLocation(node),
"${type.displayName} `$to` should not ${
when (type) {
ResourceType.LAYOUT -> "include"
ResourceType.STYLE -> "extend"
else -> "reference"
}
} itself"
)
}
} else if (mReferences != null) {
if (mLocations != null) {
recordLocation(context, node, type, from)
} else {
recordReference(type, from, to)
}
}
}
private fun findCycles(
context: Context,
type: ResourceType,
map: Multimap<String, String>
) {
val visiting = Sets.newHashSet<String>()
val visited = Sets.newHashSetWithExpectedSize<String>(map.size())
val seen = Sets.newHashSetWithExpectedSize<String>(map.size())
for (from in map.keySet()) {
if (seen.contains(from)) {
continue
}
val chain = dfs(map, from, visiting, visited)
if (chain != null && chain.size > 2) { // size 1 chains are handled directly
seen.addAll(chain)
chain.reverse()
val chains: MutableMap<ResourceType, MutableList<MutableList<String>>> =
mChains ?: run {
val newMap = Maps.newEnumMap<ResourceType,
MutableList<MutableList<String>>>(ResourceType::class.java)
mChains = newMap
mLocations = Maps.newEnumMap(ResourceType::class.java)
context.driver.requestRepeat(this, Scope.RESOURCE_FILE_SCOPE)
newMap
}
val list = chains[type]
if (list == null) {
chains[type] = mutableListOf(chain)
} else {
list.add(chain)
}
}
}
}
private fun checkCrashItem(context: XmlContext, item: Element) {
val childNodes = item.childNodes
var i = 0
val n = childNodes.length
while (i < n) {
val child = childNodes.item(i)
if (child.nodeType == Node.TEXT_NODE) {
val text = child.nodeValue
var k = 0
val max = text.length
while (k < max) {
val c = text[k]
when {
Character.isWhitespace(c) -> return
text.startsWith(NEW_ID_PREFIX, k) -> {
val name = text.trim { it <= ' ' }.substring(NEW_ID_PREFIX.length)
val message = ("This construct can potentially crash `aapt` during a " +
"build. Change `@+id/" + name + "` to `@id/" + name + "` and define " +
"the id explicitly using " +
"`<item type=\"id\" name=\"" + name + "\"/>` instead.")
context.report(
CRASH, item, context.getLocation(item),
message
)
}
else -> return
}
k++
}
}
i++
}
}
// ----- Cycle detection -----
private fun dfs(
map: Multimap<String, String>,
from: String,
visiting: MutableSet<String>,
visited: MutableSet<String>
): MutableList<String>? {
visiting.add(from)
visited.add(from)
val targets = map.get(from)
if (targets != null && !targets.isEmpty()) {
for (target in targets) {
if (visiting.contains(target)) {
val chain = Lists.newArrayList<String>()
chain.add(target)
chain.add(from)
return chain
} else if (visited.contains(target)) {
continue
}
val chain = dfs(map, target, visiting, visited)
if (chain != null) {
chain.add(from)
return chain
}
}
}
visiting.remove(from)
return null
}
companion object {
private val IMPLEMENTATION = Implementation(
ResourceCycleDetector::class.java,
Scope.RESOURCE_FILE_SCOPE
)
/** Style parent cycles, resource alias cycles, layout include cycles, etc */
@JvmField
val CYCLE = Issue.create(
id = "ResourceCycle",
briefDescription = "Cycle in resource definitions",
explanation = """
There should be no cycles in resource definitions as this can lead to \
runtime exceptions.""",
category = Category.CORRECTNESS,
priority = 8,
severity = Severity.FATAL,
implementation = IMPLEMENTATION
)
/** Parent cycles */
@JvmField
val CRASH = Issue.create(
id = "AaptCrash",
briefDescription = "Potential AAPT crash",
explanation = """
Defining a style which sets `android:id` to a dynamically generated id can \
cause many versions of `aapt`, the resource packaging tool, to crash. \
To work around this, declare the id explicitly with \
`<item type="id" name="..." />` instead.""",
category = Category.CORRECTNESS,
priority = 8,
severity = Severity.FATAL,
implementation = IMPLEMENTATION
)
}
}