| /* |
| * 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_URI; |
| import static com.android.SdkConstants.ATTR_ID; |
| import static com.android.SdkConstants.ATTR_LAYOUT; |
| import static com.android.SdkConstants.DOT_XML; |
| import static com.android.SdkConstants.LAYOUT_RESOURCE_PREFIX; |
| import static com.android.SdkConstants.NEW_ID_PREFIX; |
| import static com.android.SdkConstants.VIEW_INCLUDE; |
| import static com.android.ide.common.resources.configuration.FolderConfiguration.QUALIFIER_SPLITTER; |
| |
| import com.android.annotations.NonNull; |
| 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.LayoutDetector; |
| import com.android.tools.lint.detector.api.LintUtils; |
| import com.android.tools.lint.detector.api.Location; |
| 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.collect.ArrayListMultimap; |
| import com.google.common.collect.Multimap; |
| |
| 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.ArrayDeque; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.Deque; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.Iterator; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| /** |
| * Checks for duplicate ids within a layout and within an included layout |
| */ |
| public class DuplicateIdDetector extends LayoutDetector { |
| private Set<String> mIds; |
| private Map<File, Set<String>> mFileToIds; |
| private Map<File, List<String>> mIncludes; |
| |
| // Data structures used for location collection in phase 2 |
| |
| // Map from include files to include names to pairs of message and location |
| // Map from file defining id, to the id to be defined, to a pair of location and message |
| private Multimap<File, Multimap<String, Occurrence>> mLocations; |
| private List<Occurrence> mErrors; |
| |
| private static final Implementation IMPLEMENTATION = new Implementation( |
| DuplicateIdDetector.class, |
| Scope.RESOURCE_FILE_SCOPE); |
| |
| /** The main issue discovered by this detector */ |
| public static final Issue WITHIN_LAYOUT = Issue.create( |
| "DuplicateIds", //$NON-NLS-1$ |
| "Duplicate ids within a single layout", |
| "Within a layout, id's should be unique since otherwise `findViewById()` can " + |
| "return an unexpected view.", |
| Category.CORRECTNESS, |
| 7, |
| Severity.FATAL, |
| IMPLEMENTATION); |
| |
| /** The main issue discovered by this detector */ |
| public static final Issue CROSS_LAYOUT = Issue.create( |
| "DuplicateIncludedIds", //$NON-NLS-1$ |
| "Duplicate ids across layouts combined with include tags", |
| "It's okay for two independent layouts to use the same ids. However, if " + |
| "layouts are combined with include tags, then the id's need to be unique " + |
| "within any chain of included layouts, or `Activity#findViewById()` can " + |
| "return an unexpected view.", |
| Category.CORRECTNESS, |
| 6, |
| Severity.WARNING, |
| IMPLEMENTATION); |
| |
| /** Constructs a duplicate id check */ |
| public DuplicateIdDetector() { |
| } |
| |
| |
| @Override |
| public boolean appliesTo(@NonNull ResourceFolderType folderType) { |
| return folderType == ResourceFolderType.LAYOUT || folderType == ResourceFolderType.MENU; |
| } |
| |
| @NonNull |
| @Override |
| public Speed getSpeed() { |
| return Speed.FAST; |
| } |
| |
| @Override |
| public Collection<String> getApplicableAttributes() { |
| return Collections.singletonList(ATTR_ID); |
| } |
| |
| @Override |
| public Collection<String> getApplicableElements() { |
| return Collections.singletonList(VIEW_INCLUDE); |
| } |
| |
| @Override |
| public void beforeCheckFile(@NonNull Context context) { |
| if (context.getPhase() == 1) { |
| mIds = new HashSet<String>(); |
| } |
| } |
| |
| @Override |
| public void afterCheckFile(@NonNull Context context) { |
| if (context.getPhase() == 1) { |
| // Store this layout's set of ids for full project analysis in afterCheckProject |
| mFileToIds.put(context.file, mIds); |
| |
| mIds = null; |
| } |
| } |
| |
| @Override |
| public void beforeCheckProject(@NonNull Context context) { |
| if (context.getPhase() == 1) { |
| mFileToIds = new HashMap<File, Set<String>>(); |
| mIncludes = new HashMap<File, List<String>>(); |
| } |
| } |
| |
| @Override |
| public void afterCheckProject(@NonNull Context context) { |
| if (context.getPhase() == 1) { |
| // Look for duplicates |
| if (!mIncludes.isEmpty()) { |
| // Traverse all the include chains and ensure that there are no duplicates |
| // across. |
| if (context.isEnabled(CROSS_LAYOUT) |
| && context.getScope().contains(Scope.ALL_RESOURCE_FILES)) { |
| IncludeGraph graph = new IncludeGraph(context); |
| graph.check(); |
| } |
| } |
| } else { |
| assert context.getPhase() == 2; |
| |
| if (mErrors != null) { |
| for (Occurrence occurrence : mErrors) { |
| //assert location != null : occurrence; |
| Location location = occurrence.location; |
| if (location == null) { |
| location = Location.create(occurrence.file); |
| } else { |
| Object clientData = location.getClientData(); |
| if (clientData instanceof Node) { |
| Node node = (Node) clientData; |
| if (context.getDriver().isSuppressed(null, CROSS_LAYOUT, node)) { |
| continue; |
| } |
| } |
| } |
| |
| List<Occurrence> sorted = new ArrayList<Occurrence>(); |
| Occurrence curr = occurrence.next; |
| while (curr != null) { |
| sorted.add(curr); |
| curr = curr.next; |
| } |
| Collections.sort(sorted); |
| Location prev = location; |
| for (Occurrence o : sorted) { |
| if (o.location != null) { |
| prev.setSecondary(o.location); |
| prev = o.location; |
| } |
| } |
| |
| context.report(CROSS_LAYOUT, location, occurrence.message); |
| } |
| } |
| } |
| } |
| |
| @Override |
| public void visitElement(@NonNull XmlContext context, @NonNull Element element) { |
| // Record include graph such that we can look for inter-layout duplicates after the |
| // project has been fully checked |
| |
| String layout = element.getAttribute(ATTR_LAYOUT); // NOTE: Not in android: namespace |
| if (layout.startsWith(LAYOUT_RESOURCE_PREFIX)) { // Ignore @android:layout/ layouts |
| layout = layout.substring(LAYOUT_RESOURCE_PREFIX.length()); |
| |
| if (context.getPhase() == 1) { |
| if (!context.getProject().getReportIssues()) { |
| // If this is a library project not being analyzed, ignore it |
| return; |
| } |
| |
| List<String> to = mIncludes.get(context.file); |
| if (to == null) { |
| to = new ArrayList<String>(); |
| mIncludes.put(context.file, to); |
| } |
| to.add(layout); |
| } else { |
| assert context.getPhase() == 2; |
| |
| Collection<Multimap<String, Occurrence>> maps = mLocations.get(context.file); |
| if (maps != null && !maps.isEmpty()) { |
| for (Multimap<String, Occurrence> map : maps) { |
| if (!maps.isEmpty()) { |
| Collection<Occurrence> occurrences = map.get(layout); |
| if (occurrences != null && !occurrences.isEmpty()) { |
| for (Occurrence occurrence : occurrences) { |
| Location location = context.getLocation(element); |
| location.setClientData(element); |
| location.setMessage(occurrence.message); |
| location.setSecondary(occurrence.location); |
| occurrence.location = location; |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| @Override |
| public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) { |
| assert attribute.getName().equals(ATTR_ID) || ATTR_ID.equals(attribute.getLocalName()); |
| String id = attribute.getValue(); |
| if (context.getPhase() == 1) { |
| if (mIds.contains(id)) { |
| Location location = context.getLocation(attribute); |
| |
| Attr first = findIdAttribute(attribute.getOwnerDocument(), id); |
| if (first != null && first != attribute) { |
| Location secondLocation = context.getLocation(first); |
| secondLocation.setMessage(String.format("`%1$s` originally defined here", id)); |
| location.setSecondary(secondLocation); |
| } |
| |
| context.report(WITHIN_LAYOUT, attribute, location, |
| String.format("Duplicate id `%1$s`, already defined earlier in this layout", |
| id)); |
| } else if (id.startsWith(NEW_ID_PREFIX)) { |
| // Skip id's on include tags |
| if (attribute.getOwnerElement().getTagName().equals(VIEW_INCLUDE)) { |
| return; |
| } |
| |
| mIds.add(id); |
| } |
| } else { |
| Collection<Multimap<String, Occurrence>> maps = mLocations.get(context.file); |
| if (maps != null && !maps.isEmpty()) { |
| for (Multimap<String, Occurrence> map : maps) { |
| if (!maps.isEmpty()) { |
| Collection<Occurrence> occurrences = map.get(id); |
| if (occurrences != null && !occurrences.isEmpty()) { |
| for (Occurrence occurrence : occurrences) { |
| if (context.getDriver().isSuppressed(context, CROSS_LAYOUT, |
| attribute)) { |
| return; |
| } |
| Location location = context.getLocation(attribute); |
| location.setClientData(attribute); |
| location.setMessage(occurrence.message); |
| location.setSecondary(occurrence.location); |
| occurrence.location = location; |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| /** Find the first id attribute with the given value below the given node */ |
| private static Attr findIdAttribute(Node node, String targetValue) { |
| if (node.getNodeType() == Node.ELEMENT_NODE) { |
| Attr attribute = ((Element) node).getAttributeNodeNS(ANDROID_URI, ATTR_ID); |
| if (attribute != null && attribute.getValue().equals(targetValue)) { |
| return attribute; |
| } |
| } |
| |
| NodeList children = node.getChildNodes(); |
| for (int i = 0, n = children.getLength(); i < n; i++) { |
| Node child = children.item(i); |
| Attr result = findIdAttribute(child, targetValue); |
| if (result != null) { |
| return result; |
| } |
| } |
| |
| return null; |
| } |
| |
| /** Include Graph Node */ |
| private static class Layout { |
| private final File mFile; |
| private final Set<String> mIds; |
| private List<Layout> mIncludes; |
| private List<Layout> mIncludedBy; |
| |
| Layout(File file, Set<String> ids) { |
| mFile = file; |
| mIds = ids; |
| } |
| |
| Set<String> getIds() { |
| return mIds; |
| } |
| |
| String getLayoutName() { |
| return LintUtils.getLayoutName(mFile); |
| } |
| |
| String getDisplayName() { |
| return mFile.getParentFile().getName() + File.separator + mFile.getName(); |
| } |
| |
| void include(Layout target) { |
| if (mIncludes == null) { |
| mIncludes = new ArrayList<Layout>(); |
| } |
| mIncludes.add(target); |
| |
| if (target.mIncludedBy == null) { |
| target.mIncludedBy = new ArrayList<Layout>(); |
| } |
| target.mIncludedBy.add(this); |
| } |
| |
| boolean isIncluded() { |
| return mIncludedBy != null && !mIncludedBy.isEmpty(); |
| } |
| |
| File getFile() { |
| return mFile; |
| } |
| |
| List<Layout> getIncludes() { |
| return mIncludes; |
| } |
| |
| @Override |
| public String toString() { |
| return getDisplayName(); |
| } |
| } |
| |
| private class IncludeGraph { |
| private final Context mContext; |
| private final Map<File, Layout> mFileToLayout; |
| |
| public IncludeGraph(Context context) { |
| mContext = context; |
| |
| // Produce a DAG of the files to be included, and compute edges to all eligible |
| // includes. |
| // Then visit the DAG and whenever you find a duplicate emit a warning about the |
| // include path which reached it. |
| mFileToLayout = new HashMap<File, Layout>(2 * mIncludes.size()); |
| for (File file : mIncludes.keySet()) { |
| if (!mFileToLayout.containsKey(file)) { |
| mFileToLayout.put(file, new Layout(file, mFileToIds.get(file))); |
| } |
| } |
| for (File file : mFileToIds.keySet()) { |
| Set<String> ids = mFileToIds.get(file); |
| if (ids != null && !ids.isEmpty()) { |
| if (!mFileToLayout.containsKey(file)) { |
| mFileToLayout.put(file, new Layout(file, ids)); |
| } |
| } |
| } |
| Multimap<String, Layout> nameToLayout = |
| ArrayListMultimap.create(mFileToLayout.size(), 4); |
| for (File file : mFileToLayout.keySet()) { |
| String name = LintUtils.getLayoutName(file); |
| nameToLayout.put(name, mFileToLayout.get(file)); |
| } |
| |
| // Build up the DAG |
| for (File file : mIncludes.keySet()) { |
| Layout from = mFileToLayout.get(file); |
| assert from != null : file; |
| |
| List<String> includedLayouts = mIncludes.get(file); |
| for (String name : includedLayouts) { |
| Collection<Layout> layouts = nameToLayout.get(name); |
| if (layouts != null && !layouts.isEmpty()) { |
| if (layouts.size() == 1) { |
| from.include(layouts.iterator().next()); |
| } else { |
| // See if we have an obvious match |
| File folder = from.getFile().getParentFile(); |
| File candidate = new File(folder, name + DOT_XML); |
| Layout candidateLayout = mFileToLayout.get(candidate); |
| if (candidateLayout != null) { |
| from.include(candidateLayout); |
| } else if (mFileToIds.containsKey(candidate)) { |
| // We had an entry in mFileToIds, but not a layout: this |
| // means that the file exists, but had no includes or ids. |
| // This can't be a valid match: there is a layout that we know |
| // the include will pick, but it has no includes (to other layouts) |
| // and no ids, so no need to look at it |
| continue; |
| } else { |
| for (Layout to : layouts) { |
| // Decide if the two targets are compatible |
| if (isCompatible(from, to)) { |
| from.include(to); |
| } |
| } |
| } |
| } |
| } else { |
| // The layout is including some layout which has no ids or other includes |
| // so it's not relevant for a duplicate id search |
| continue; |
| } |
| } |
| } |
| } |
| |
| /** Determine whether two layouts are compatible. They are not if they (for example) |
| * specify conflicting qualifiers such as {@code -land} and {@code -port}. |
| * @param from the include from |
| * @param to the include to |
| * @return true if the two are compatible */ |
| boolean isCompatible(Layout from, Layout to) { |
| File fromFolder = from.mFile.getParentFile(); |
| File toFolder = to.mFile.getParentFile(); |
| if (fromFolder.equals(toFolder)) { |
| return true; |
| } |
| |
| Iterable<String> fromQualifiers = QUALIFIER_SPLITTER.split(fromFolder.getName()); |
| Iterable<String> toQualifiers = QUALIFIER_SPLITTER.split(toFolder.getName()); |
| |
| return isPortrait(fromQualifiers) == isPortrait(toQualifiers); |
| } |
| |
| private boolean isPortrait(Iterable<String> qualifiers) { |
| for (String qualifier : qualifiers) { |
| if (qualifier.equals("port")) { //$NON-NLS-1$ |
| return true; |
| } else if (qualifier.equals("land")) { //$NON-NLS-1$ |
| return false; |
| } |
| } |
| |
| return true; // it's the default |
| } |
| |
| public void check() { |
| // Visit the DAG, looking for conflicts |
| for (Layout layout : mFileToLayout.values()) { |
| if (!layout.isIncluded()) { // Only check from "root" nodes |
| Deque<Layout> stack = new ArrayDeque<Layout>(); |
| getIds(layout, stack, new HashSet<Layout>()); |
| } |
| } |
| } |
| |
| /** |
| * Computes the cumulative set of ids used in a given layout. We can't |
| * just depth-first-search the graph and check the set of ids |
| * encountered along the way, because we need to detect when multiple |
| * includes contribute the same ids. For example, if a file is included |
| * more than once, that would result in duplicates. |
| */ |
| private Set<String> getIds(Layout layout, Deque<Layout> stack, Set<Layout> seen) { |
| seen.add(layout); |
| |
| Set<String> layoutIds = layout.getIds(); |
| List<Layout> includes = layout.getIncludes(); |
| if (includes != null) { |
| Set<String> ids = new HashSet<String>(); |
| if (layoutIds != null) { |
| ids.addAll(layoutIds); |
| } |
| |
| stack.push(layout); |
| |
| Multimap<String, Set<String>> nameToIds = |
| ArrayListMultimap.create(includes.size(), 4); |
| |
| for (Layout included : includes) { |
| if (seen.contains(included)) { |
| continue; |
| } |
| Set<String> includedIds = getIds(included, stack, seen); |
| if (includedIds != null) { |
| String layoutName = included.getLayoutName(); |
| |
| idCheck: |
| for (String id : includedIds) { |
| if (ids.contains(id)) { |
| Collection<Set<String>> idSets = nameToIds.get(layoutName); |
| if (idSets != null) { |
| for (Set<String> siblingIds : idSets) { |
| if (siblingIds.contains(id)) { |
| // The id reference was added by a sibling, |
| // so no need to complain (again) |
| continue idCheck; |
| } |
| } |
| } |
| |
| // Duplicate! Record location request for new phase. |
| if (mLocations == null) { |
| mErrors = new ArrayList<Occurrence>(); |
| mLocations = ArrayListMultimap.create(); |
| mContext.getDriver().requestRepeat(DuplicateIdDetector.this, |
| Scope.ALL_RESOURCES_SCOPE); |
| } |
| |
| Map<Layout, Occurrence> occurrences = |
| new HashMap<Layout, Occurrence>(); |
| findId(layout, id, new ArrayDeque<Layout>(), occurrences, |
| new HashSet<Layout>()); |
| assert occurrences.size() >= 2; |
| |
| // Stash a request to find the given include |
| Collection<Occurrence> values = occurrences.values(); |
| List<Occurrence> sorted = new ArrayList<Occurrence>(values); |
| Collections.sort(sorted); |
| String msg = String.format( |
| "Duplicate id %1$s, defined or included multiple " + |
| "times in %2$s: %3$s", |
| id, layout.getDisplayName(), |
| sorted.toString()); |
| |
| // Store location request for the <include> tag |
| Occurrence primary = new Occurrence(layout.getFile(), msg, null); |
| Multimap<String, Occurrence> m = ArrayListMultimap.create(); |
| m.put(layoutName, primary); |
| mLocations.put(layout.getFile(), m); |
| mErrors.add(primary); |
| |
| Occurrence prev = primary; |
| |
| // Now store all the included occurrences of the id |
| for (Occurrence occurrence : values) { |
| if (occurrence.file.equals(layout.getFile())) { |
| occurrence.message = "Defined here"; |
| } else { |
| occurrence.message = String.format( |
| "Defined here, included via %1$s", |
| occurrence.includePath); |
| } |
| |
| m = ArrayListMultimap.create(); |
| m.put(id, occurrence); |
| mLocations.put(occurrence.file, m); |
| |
| // Link locations together |
| prev.next = occurrence; |
| prev = occurrence; |
| } |
| } |
| ids.add(id); |
| } |
| |
| // Store these ids such that on a conflict, we can tell when |
| // an id was added by a single variation of this file |
| nameToIds.put(layoutName, includedIds); |
| } |
| } |
| Layout visited = stack.pop(); |
| assert visited == layout; |
| return ids; |
| } else { |
| return layoutIds; |
| } |
| } |
| |
| private void findId(Layout layout, String id, Deque<Layout> stack, |
| Map<Layout, Occurrence> occurrences, Set<Layout> seen) { |
| seen.add(layout); |
| |
| Set<String> layoutIds = layout.getIds(); |
| if (layoutIds != null && layoutIds.contains(id)) { |
| StringBuilder path = new StringBuilder(80); |
| |
| if (!stack.isEmpty()) { |
| Iterator<Layout> iterator = stack.descendingIterator(); |
| while (iterator.hasNext()) { |
| path.append(iterator.next().getDisplayName()); |
| path.append(" => "); |
| } |
| } |
| path.append(layout.getDisplayName()); |
| path.append(" defines "); |
| path.append(id); |
| |
| assert occurrences.get(layout) == null : id + ',' + layout; |
| occurrences.put(layout, new Occurrence(layout.getFile(), null, path.toString())); |
| } |
| |
| List<Layout> includes = layout.getIncludes(); |
| if (includes != null) { |
| stack.push(layout); |
| for (Layout included : includes) { |
| if (!seen.contains(included)) { |
| findId(included, id, stack, occurrences, seen); |
| } |
| } |
| Layout visited = stack.pop(); |
| assert visited == layout; |
| } |
| } |
| } |
| |
| private static class Occurrence implements Comparable<Occurrence> { |
| public final File file; |
| public final String includePath; |
| public Occurrence next; |
| public Location location; |
| public String message; |
| |
| public Occurrence(File file, String message, String includePath) { |
| this.file = file; |
| this.message = message; |
| this.includePath = includePath; |
| } |
| |
| @Override |
| public String toString() { |
| return includePath != null ? includePath : message; |
| } |
| |
| @Override |
| public int compareTo(@NonNull Occurrence other) { |
| // First sort by length, then sort by name |
| int delta = toString().length() - other.toString().length(); |
| if (delta != 0) { |
| return delta; |
| } |
| |
| return toString().compareTo(other.toString()); |
| } |
| } |
| } |