blob: d93f7b42ccdc860ba5d69ce1a9850f918720a5b4 [file] [log] [blame]
/*
* 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());
}
}
}