
com.android.tools.lint.checks.DuplicateIdDetector Maven / Gradle / Ivy
/*
* 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 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 mIds;
private Map> mFileToIds;
private Map> 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> mLocations;
private List 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",
"Checks for 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.WARNING,
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",
"Checks for duplicate ids across layouts that are 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 getApplicableAttributes() {
return Collections.singletonList(ATTR_ID);
}
@Override
public Collection getApplicableElements() {
return Collections.singletonList(VIEW_INCLUDE);
}
@Override
public void beforeCheckFile(@NonNull Context context) {
if (context.getPhase() == 1) {
mIds = new HashSet();
}
}
@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>();
mIncludes = new HashMap>();
}
}
@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(CROSS_LAYOUT, node)) {
continue;
}
}
}
List sorted = new ArrayList();
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, null);
}
}
}
}
@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 to = mIncludes.get(context.file);
if (to == null) {
to = new ArrayList();
mIncludes.put(context.file, to);
}
to.add(layout);
} else {
assert context.getPhase() == 2;
Collection> maps = mLocations.get(context.file);
if (maps != null && !maps.isEmpty()) {
for (Multimap map : maps) {
if (!maps.isEmpty()) {
Collection 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) || attribute.getLocalName().equals(ATTR_ID);
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), null);
} 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> maps = mLocations.get(context.file);
if (maps != null && !maps.isEmpty()) {
for (Multimap map : maps) {
if (!maps.isEmpty()) {
Collection occurrences = map.get(id);
if (occurrences != null && !occurrences.isEmpty()) {
for (Occurrence occurrence : occurrences) {
if (context.getDriver().isSuppressed(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 mIds;
private List mIncludes;
private List mIncludedBy;
Layout(File file, Set ids) {
mFile = file;
mIds = ids;
}
Set 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();
}
mIncludes.add(target);
if (target.mIncludedBy == null) {
target.mIncludedBy = new ArrayList();
}
target.mIncludedBy.add(this);
}
boolean isIncluded() {
return mIncludedBy != null && !mIncludedBy.isEmpty();
}
File getFile() {
return mFile;
}
List getIncludes() {
return mIncludes;
}
@Override
public String toString() {
return getDisplayName();
}
}
private class IncludeGraph {
private final Context mContext;
private final Map 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(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 ids = mFileToIds.get(file);
if (ids != null && !ids.isEmpty()) {
if (!mFileToLayout.containsKey(file)) {
mFileToLayout.put(file, new Layout(file, ids));
}
}
}
Multimap 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 includedLayouts = mIncludes.get(file);
for (String name : includedLayouts) {
Collection 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;
}
String[] fromQualifiers = fromFolder.getName().split("-"); //$NON-NLS-1$
String[] toQualifiers = toFolder.getName().split("-"); //$NON-NLS-1$
if (isPortrait(fromQualifiers) != isPortrait(toQualifiers)) {
return false;
}
return true;
}
private boolean isPortrait(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 stack = new ArrayDeque();
getIds(layout, stack, new HashSet());
}
}
}
/**
* 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 getIds(Layout layout, Deque stack, Set seen) {
seen.add(layout);
Set layoutIds = layout.getIds();
List includes = layout.getIncludes();
if (includes != null) {
Set ids = new HashSet();
if (layoutIds != null) {
ids.addAll(layoutIds);
}
stack.push(layout);
Multimap> nameToIds =
ArrayListMultimap.create(includes.size(), 4);
for (Layout included : includes) {
if (seen.contains(included)) {
continue;
}
Set includedIds = getIds(included, stack, seen);
if (includedIds != null) {
String layoutName = included.getLayoutName();
idCheck:
for (String id : includedIds) {
if (ids.contains(id)) {
Collection> idSets = nameToIds.get(layoutName);
if (idSets != null) {
for (Set 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();
mLocations = ArrayListMultimap.create();
mContext.getDriver().requestRepeat(DuplicateIdDetector.this,
Scope.ALL_RESOURCES_SCOPE);
}
Map occurrences =
new HashMap();
findId(layout, id, new ArrayDeque(), occurrences,
new HashSet());
assert occurrences.size() >= 2;
// Stash a request to find the given include
Collection values = occurrences.values();
List sorted = new ArrayList(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 tag
Occurrence primary = new Occurrence(layout.getFile(), msg, null);
Multimap 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 stack,
Map occurrences, Set seen) {
seen.add(layout);
Set layoutIds = layout.getIds();
if (layoutIds != null && layoutIds.contains(id)) {
StringBuilder path = new StringBuilder(80);
if (!stack.isEmpty()) {
Iterator 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 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 {
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(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());
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy