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