blob: 1f0ca92ce73612d7c10c9d3807f34e7c4aa31942 [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 static com.android.SdkConstants.ANDROID_PREFIX;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_COLOR;
import static com.android.SdkConstants.ATTR_DRAWABLE;
import static com.android.SdkConstants.ATTR_LAYOUT;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_PARENT;
import static com.android.SdkConstants.ATTR_TYPE;
import static com.android.SdkConstants.COLOR_RESOURCE_PREFIX;
import static com.android.SdkConstants.DRAWABLE_PREFIX;
import static com.android.SdkConstants.LAYOUT_RESOURCE_PREFIX;
import static com.android.SdkConstants.NEW_ID_PREFIX;
import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX;
import static com.android.SdkConstants.TAG_COLOR;
import static com.android.SdkConstants.TAG_ITEM;
import static com.android.SdkConstants.TAG_STYLE;
import static com.android.SdkConstants.VIEW_INCLUDE;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
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.ResourceXmlDetector;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.Speed;
import com.android.tools.lint.detector.api.XmlContext;
import com.google.common.base.Joiner;
import com.google.common.base.Supplier;
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 org.w3c.dom.NodeList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* Checks for cycles in resource definitions
*/
public class ResourceCycleDetector extends ResourceXmlDetector {
private static final Implementation IMPLEMENTATION = new Implementation(
ResourceCycleDetector.class,
Scope.RESOURCE_FILE_SCOPE);
/** Style parent cycles, resource alias cycles, layout include cycles, etc */
public static final Issue CYCLE = Issue.create(
"ResourceCycle", //$NON-NLS-1$
"Cycle in resource definitions",
"There should be no cycles in resource definitions as this can lead to runtime " +
"exceptions.",
Category.CORRECTNESS,
8,
Severity.FATAL,
IMPLEMENTATION
);
/** Parent cycles */
public static final Issue CRASH = Issue.create(
"AaptCrash", //$NON-NLS-1$
"Potential AAPT crash",
"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.CORRECTNESS,
8,
Severity.FATAL,
IMPLEMENTATION)
.addMoreInfo("https://code.google.com/p/android/issues/detail?id=20479"); //$NON-NLS-1$
/**
* 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 Map<ResourceType, Multimap<String, String>> mReferences;
/**
* 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 {@link #mReferences}
*/
private Map<ResourceType, Multimap<String, Location>> mLocations;
/**
* 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 {@link #mReferences})
*/
private Map<ResourceType, List<List<String>>> mChains;
/** Constructs a new {@link ResourceCycleDetector} */
public ResourceCycleDetector() {
}
@Override
public void beforeCheckProject(@NonNull 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.getScope().contains(Scope.ALL_RESOURCE_FILES)) {
mReferences = Maps.newEnumMap(ResourceType.class);
}
}
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return folderType == ResourceFolderType.VALUES
|| folderType == ResourceFolderType.COLOR
|| folderType == ResourceFolderType.DRAWABLE
|| folderType == ResourceFolderType.LAYOUT;
}
@NonNull
@Override
public Speed getSpeed() {
return Speed.FAST;
}
@Override
public Collection<String> getApplicableElements() {
return Arrays.asList(
VIEW_INCLUDE,
TAG_STYLE,
TAG_COLOR,
TAG_ITEM
);
}
private void recordReference(@NonNull ResourceType type, @NonNull String from,
@NonNull String to) {
if (to.isEmpty() || to.startsWith(ANDROID_PREFIX)) {
return;
}
assert mReferences != null;
Multimap<String, String> map = mReferences.get(type);
if (map == null) {
// Multimap which preserves insert order (for predictable output order)
map = Multimaps.newListMultimap(
new TreeMap<String, Collection<String>>(),
new Supplier<List<String>>() {
@Override
public List<String> get() {
return Lists.newArrayListWithExpectedSize(6);
}
});
mReferences.put(type, map);
}
if (to.charAt(0) == '@') {
int index = to.indexOf('/');
if (index != -1) {
to = to.substring(index + 1);
}
}
map.put(from, to);
}
private void recordLocation(@NonNull XmlContext context, @NonNull Node node,
@NonNull ResourceType type, @NonNull String from) {
assert mLocations != null;
// Cycles were already found; we're now in phase 2 looking up specific
// locations
Multimap<String, Location> map = mLocations.get(type);
if (map == null) {
map = ArrayListMultimap.create(30, 4);
mLocations.put(type, map);
}
Location location = context.getLocation(node);
map.put(from, location);
}
@SuppressWarnings("VariableNotUsedInsideIf")
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
String tagName = element.getTagName();
if (tagName.equals(TAG_ITEM)) {
if (mReferences == null) {
// Nothing to do in incremental mode
return;
}
ResourceFolderType folderType = context.getResourceFolderType();
if (folderType == ResourceFolderType.VALUES) {
// Aliases
Attr typeNode = element.getAttributeNode(ATTR_TYPE);
if (typeNode != null) {
String typeName = typeNode.getValue();
ResourceType type = ResourceType.getEnum(typeName);
Attr nameNode = element.getAttributeNode(ATTR_NAME);
if (type != null && nameNode != null) {
NodeList childNodes = element.getChildNodes();
for (int i = 0, n = childNodes.getLength(); i < n; i++) {
Node child = childNodes.item(i);
if (child.getNodeType() == Node.TEXT_NODE) {
String text = child.getNodeValue();
for (int k = 0, max = text.length(); k < max; k++) {
char c = text.charAt(k);
if (Character.isWhitespace(c)) {
break;
} else if (c == '@' &&
text.startsWith(type.getName(), k + 1)) {
String to = text.trim();
if (mReferences != null) {
String name = nameNode.getValue();
if (mLocations != null) {
recordLocation(context, child, type,
name);
} else {
recordReference(type, name, to);
}
}
} else {
break;
}
}
}
}
}
}
} else if (folderType == ResourceFolderType.COLOR ) {
String color = element.getAttributeNS(ANDROID_URI, ATTR_COLOR);
if (color != null && color.startsWith(COLOR_RESOURCE_PREFIX)) {
String currentColor = LintUtils.getBaseName(context.file.getName());
if (mLocations != null) {
recordLocation(context, element, ResourceType.COLOR,
currentColor);
} else {
recordReference(ResourceType.COLOR, currentColor,
color.substring(COLOR_RESOURCE_PREFIX.length()));
}
}
} else if (folderType == ResourceFolderType.DRAWABLE) {
String drawable = element.getAttributeNS(ANDROID_URI, ATTR_DRAWABLE);
if (drawable != null && drawable.startsWith(DRAWABLE_PREFIX)) {
String currentColor = LintUtils.getBaseName(context.file.getName());
if (mLocations != null) {
recordLocation(context, element, ResourceType.DRAWABLE,
currentColor);
} else {
recordReference(ResourceType.DRAWABLE, currentColor,
drawable.substring(DRAWABLE_PREFIX.length()));
}
}
}
} else if (tagName.equals(TAG_STYLE)) {
Attr nameNode = element.getAttributeNode(ATTR_NAME);
// Look for recursive style parent declarations
Attr parentNode = element.getAttributeNode(ATTR_PARENT);
if (parentNode != null && nameNode != null) {
String name = nameNode.getValue();
String parent = parentNode.getValue();
if (parent.endsWith(name) &&
parent.equals(STYLE_RESOURCE_PREFIX + name) && context.isEnabled(CYCLE)
&& context.getDriver().getPhase() == 1) {
context.report(CYCLE, parentNode, context.getLocation(parentNode),
String.format("Style `%1$s` should not extend itself", name));
} else if (parent.startsWith(STYLE_RESOURCE_PREFIX)
&& parent.startsWith(name, STYLE_RESOURCE_PREFIX.length())
&& parent.startsWith(".", STYLE_RESOURCE_PREFIX.length() + name.length())
&& context.isEnabled(CYCLE) && context.getDriver().getPhase() == 1) {
context.report(CYCLE, parentNode, context.getLocation(parentNode),
String.format("Potential cycle: `%1$s` is the implied parent of `%2$s` and " +
"this defines the opposite", name,
parent.substring(STYLE_RESOURCE_PREFIX.length())));
// Don't record this reference; we don't want to double report this
// as a chain, since this error is more helpful
return;
}
if (mReferences != null && !parent.isEmpty()) {
if (mLocations != null) {
recordLocation(context, parentNode, ResourceType.STYLE, name);
} else {
recordReference(ResourceType.STYLE, name, parent);
}
}
} else if (mReferences != null && nameNode != null) {
String name = nameNode.getValue();
int index = name.lastIndexOf('.');
if (index > 0) {
String parent = name.substring(0, index);
if (mReferences != null) {
if (mLocations != null) {
Attr node = element.getAttributeNode(ATTR_NAME);
recordLocation(context, node, ResourceType.STYLE, name);
} else {
recordReference(ResourceType.STYLE, name, parent);
}
}
}
}
if (context.isEnabled(CRASH) && context.getDriver().getPhase() == 1) {
for (Element item : LintUtils.getChildren(element)) {
if ("android:id".equals(item.getAttribute(ATTR_NAME))) {
checkCrashItem(context, item);
}
}
}
} else if (tagName.equals(VIEW_INCLUDE)) {
Attr layoutNode = element.getAttributeNode(ATTR_LAYOUT);
if (layoutNode != null) {
String layout = layoutNode.getValue();
if (layout.startsWith(LAYOUT_RESOURCE_PREFIX)) {
String currentLayout = LintUtils.getBaseName(context.file.getName());
if (mReferences != null) {
if (mLocations != null) {
recordLocation(context, layoutNode, ResourceType.LAYOUT,
currentLayout);
} else {
recordReference(ResourceType.LAYOUT, currentLayout, layout);
}
}
if (layout.startsWith(currentLayout, LAYOUT_RESOURCE_PREFIX.length()) &&
layout.length() == currentLayout.length()
+ LAYOUT_RESOURCE_PREFIX.length()
&& context.isEnabled(CYCLE)
&& context.getDriver().getPhase() == 1) {
String message = String.format("Layout `%1$s` should not include itself",
currentLayout);
context.report(CYCLE, layoutNode, context.getLocation(layoutNode),
message);
}
}
}
} else if (tagName.equals(TAG_COLOR)) {
NodeList childNodes = element.getChildNodes();
for (int i = 0, n = childNodes.getLength(); i < n; i++) {
Node child = childNodes.item(i);
if (child.getNodeType() == Node.TEXT_NODE) {
String text = child.getNodeValue();
for (int k = 0, max = text.length(); k < max; k++) {
char c = text.charAt(k);
if (Character.isWhitespace(c)) {
break;
} else if (text.startsWith(COLOR_RESOURCE_PREFIX, k)) {
String color = text.trim().substring(COLOR_RESOURCE_PREFIX.length());
String name = element.getAttribute(ATTR_NAME);
if (mReferences != null) {
if (mLocations != null) {
recordLocation(context, child, ResourceType.COLOR, name);
} else {
recordReference(ResourceType.COLOR, name, color);
}
}
if (color.equals(name)
&& context.isEnabled(CYCLE)
&& context.getDriver().getPhase() == 1) {
context.report(CYCLE, child, context.getLocation(child),
String.format("Color `%1$s` should not reference itself",
color));
}
} else {
break;
}
}
}
}
}
}
private static void checkCrashItem(@NonNull XmlContext context, @NonNull Element item) {
NodeList childNodes = item.getChildNodes();
for (int i = 0, n = childNodes.getLength(); i < n; i++) {
Node child = childNodes.item(i);
if (child.getNodeType() == Node.TEXT_NODE) {
String text = child.getNodeValue();
for (int k = 0, max = text.length(); k < max; k++) {
char c = text.charAt(k);
if (Character.isWhitespace(c)) {
return;
} else if (text.startsWith(NEW_ID_PREFIX, k)) {
String name = text.trim().substring(NEW_ID_PREFIX.length());
String 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;
}
}
}
}
}
@Override
public void afterCheckProject(@NonNull Context context) {
if (mReferences == null) {
// Incremental analysis in a single file only; nothing to do
return;
}
int phase = context.getDriver().getPhase();
if (phase == 1) {
// Perform DFS of each resource type and look for cycles
for (Map.Entry<ResourceType, Multimap<String, String>> entry
: mReferences.entrySet()) {
ResourceType type = entry.getKey();
Multimap<String, String> map = entry.getValue();
findCycles(context, type, map);
}
} else {
assert phase == 2;
// Emit cycle report
for (Map.Entry<ResourceType, List<List<String>>> entry : mChains.entrySet()) {
ResourceType type = entry.getKey();
Multimap<String, Location> locations = mLocations.get(type);
if (locations == null) {
// No locations found. Unlikely.
locations = ArrayListMultimap.create();
}
List<List<String>> chains = entry.getValue();
for (List<String> chain : chains) {
Location location = null;
assert !chain.isEmpty();
for (int i = 0, n = chain.size(); i < n; i++) {
String item = chain.get(i);
Collection<Location> itemLocations = locations.get(item);
if (!itemLocations.isEmpty()) {
Location itemLocation = itemLocations.iterator().next();
String next = chain.get((i + 1) % chain.size());
String label = "Reference from @" + type.getName() + "/" + item
+ " to " + type.getName() + "/" + next + " here";
itemLocation.setMessage(label);
itemLocation.setSecondary(location);
location = itemLocation;
}
}
if (location == null) {
location = Location.create(context.getProject().getDir());
} else {
// Break off chain
Location curr = location.getSecondary();
while (curr != null) {
Location next = curr.getSecondary();
if (next == location) {
curr.setSecondary(null);
break;
}
curr = next;
}
}
String message = String.format("%1$s Resource definition cycle: %2$s",
type.getDisplayName(), Joiner.on(" => ").join(chain));
context.report(CYCLE, location, message);
}
}
}
}
private void findCycles(
@NonNull Context context,
@NonNull ResourceType type,
@NonNull Multimap<String, String> map) {
Set<String> visiting = Sets.newHashSetWithExpectedSize(map.size());
Set<String> seen = Sets.newHashSetWithExpectedSize(map.size());
for (String from : map.keySet()) {
if (seen.contains(from)) {
continue;
}
List<String> chain = dfs(map, from, visiting);
if (chain != null && chain.size() > 2) { // size 1 chains are handled directly
seen.addAll(chain);
Collections.reverse(chain);
if (mChains == null) {
mChains = Maps.newEnumMap(ResourceType.class);
mLocations = Maps.newEnumMap(ResourceType.class);
context.getDriver().requestRepeat(this, Scope.RESOURCE_FILE_SCOPE);
}
List<List<String>> list = mChains.get(type);
if (list == null) {
list = Lists.newArrayList();
mChains.put(type, list);
}
list.add(chain);
}
}
}
// ----- Cycle detection -----
@Nullable
private static List<String> dfs(
@NonNull Multimap<String, String> map,
@NonNull String from,
@NonNull Set<String> visiting) {
visiting.add(from);
Collection<String> targets = map.get(from);
if (targets != null && !targets.isEmpty()) {
for (String target : targets) {
if (visiting.contains(target)) {
List<String> chain = Lists.newArrayList();
chain.add(target);
chain.add(from);
return chain;
}
List<String> chain = dfs(map, target, visiting);
if (chain != null) {
chain.add(from);
return chain;
}
}
}
visiting.remove(from);
return null;
}
}