Home | History | Annotate | Download | only in checks
      1 /*
      2  * Copyright (C) 2011 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.tools.lint.checks;
     18 
     19 import static com.android.tools.lint.detector.api.LintConstants.ANDROID_URI;
     20 import static com.android.tools.lint.detector.api.LintConstants.ATTR_ID;
     21 import static com.android.tools.lint.detector.api.LintConstants.ATTR_LAYOUT;
     22 import static com.android.tools.lint.detector.api.LintConstants.DOT_XML;
     23 import static com.android.tools.lint.detector.api.LintConstants.INCLUDE;
     24 import static com.android.tools.lint.detector.api.LintConstants.LAYOUT_RESOURCE_PREFIX;
     25 import static com.android.tools.lint.detector.api.LintConstants.NEW_ID_RESOURCE_PREFIX;
     26 
     27 import com.android.resources.ResourceFolderType;
     28 import com.android.tools.lint.detector.api.Category;
     29 import com.android.tools.lint.detector.api.Context;
     30 import com.android.tools.lint.detector.api.Issue;
     31 import com.android.tools.lint.detector.api.LayoutDetector;
     32 import com.android.tools.lint.detector.api.LintUtils;
     33 import com.android.tools.lint.detector.api.Location;
     34 import com.android.tools.lint.detector.api.Scope;
     35 import com.android.tools.lint.detector.api.Severity;
     36 import com.android.tools.lint.detector.api.Speed;
     37 import com.android.tools.lint.detector.api.XmlContext;
     38 import com.google.common.collect.ArrayListMultimap;
     39 import com.google.common.collect.Multimap;
     40 
     41 import org.w3c.dom.Attr;
     42 import org.w3c.dom.Element;
     43 import org.w3c.dom.Node;
     44 import org.w3c.dom.NodeList;
     45 
     46 import java.io.File;
     47 import java.util.ArrayDeque;
     48 import java.util.ArrayList;
     49 import java.util.Collection;
     50 import java.util.Collections;
     51 import java.util.Deque;
     52 import java.util.HashMap;
     53 import java.util.HashSet;
     54 import java.util.Iterator;
     55 import java.util.List;
     56 import java.util.Map;
     57 import java.util.Set;
     58 
     59 /**
     60  * Checks for duplicate ids within a layout and within an included layout
     61  */
     62 public class DuplicateIdDetector extends LayoutDetector {
     63     private Set<String> mIds;
     64     private Map<File, Set<String>> mFileToIds;
     65     private Map<File, List<String>> mIncludes;
     66 
     67     // Data structures used for location collection in phase 2
     68 
     69     // Map from include files to include names to pairs of message and location
     70     // Map from file defining id, to the id to be defined, to a pair of location and message
     71     private Multimap<File, Multimap<String, Occurrence>> mLocations;
     72     private List<Occurrence> mErrors;
     73 
     74     /** The main issue discovered by this detector */
     75     public static final Issue WITHIN_LAYOUT = Issue.create(
     76             "DuplicateIds", //$NON-NLS-1$
     77             "Checks for duplicate ids within a single layout",
     78             "Within a layout, id's should be unique since otherwise findViewById() can " +
     79             "return an unexpected view.",
     80             Category.CORRECTNESS,
     81             7,
     82             Severity.WARNING,
     83             DuplicateIdDetector.class,
     84             Scope.RESOURCE_FILE_SCOPE);
     85 
     86     /** The main issue discovered by this detector */
     87     public static final Issue CROSS_LAYOUT = Issue.create(
     88             "DuplicateIncludedIds", //$NON-NLS-1$
     89             "Checks for duplicate ids across layouts that are combined with include tags",
     90             "It's okay for two independent layouts to use the same ids. However, if " +
     91             "layouts are combined with include tags, then the id's need to be unique " +
     92             "within any chain of included layouts, or Activity#findViewById() can " +
     93             "return an unexpected view.",
     94             Category.CORRECTNESS,
     95             6,
     96             Severity.WARNING,
     97             DuplicateIdDetector.class,
     98             Scope.ALL_RESOURCES_SCOPE);
     99 
    100     /** Constructs a duplicate id check */
    101     public DuplicateIdDetector() {
    102     }
    103 
    104 
    105     @Override
    106     public boolean appliesTo(ResourceFolderType folderType) {
    107         return folderType == ResourceFolderType.LAYOUT || folderType == ResourceFolderType.MENU;
    108     }
    109 
    110     @Override
    111     public Speed getSpeed() {
    112         return Speed.FAST;
    113     }
    114 
    115     @Override
    116     public Collection<String> getApplicableAttributes() {
    117         return Collections.singletonList(ATTR_ID);
    118     }
    119 
    120     @Override
    121     public Collection<String> getApplicableElements() {
    122         return Collections.singletonList(INCLUDE);
    123     }
    124 
    125     @Override
    126     public void beforeCheckFile(Context context) {
    127         if (context.getPhase() == 1) {
    128             mIds = new HashSet<String>();
    129         }
    130     }
    131 
    132     @Override
    133     public void afterCheckFile(Context context) {
    134         if (context.getPhase() == 1) {
    135             // Store this layout's set of ids for full project analysis in afterCheckProject
    136             mFileToIds.put(context.file, mIds);
    137 
    138             mIds = null;
    139         }
    140     }
    141 
    142     @Override
    143     public void beforeCheckProject(Context context) {
    144         if (context.getPhase() == 1) {
    145             mFileToIds = new HashMap<File, Set<String>>();
    146             mIncludes = new HashMap<File, List<String>>();
    147         }
    148     }
    149 
    150     @Override
    151     public void afterCheckProject(Context context) {
    152         if (context.getPhase() == 1) {
    153             // Look for duplicates
    154             if (mIncludes.size() > 0) {
    155                 // Traverse all the include chains and ensure that there are no duplicates
    156                 // across.
    157                 if (context.isEnabled(CROSS_LAYOUT)
    158                         && context.getScope().contains(Scope.ALL_RESOURCE_FILES)) {
    159                     IncludeGraph graph = new IncludeGraph(context);
    160                     graph.check();
    161                 }
    162             }
    163         } else {
    164             assert context.getPhase() == 2;
    165 
    166             if (mErrors != null) {
    167                 for (Occurrence occurrence : mErrors) {
    168                     //assert location != null : occurrence;
    169                     Location location = occurrence.location;
    170                     if (location == null) {
    171                         location = Location.create(occurrence.file);
    172                     } else {
    173                         Object clientData = location.getClientData();
    174                         if (clientData instanceof Node) {
    175                             Node node = (Node) clientData;
    176                             if (context.getDriver().isSuppressed(CROSS_LAYOUT, node)) {
    177                                 continue;
    178                             }
    179                         }
    180                     }
    181 
    182                     List<Occurrence> sorted = new ArrayList<Occurrence>();
    183                     Occurrence curr = occurrence.next;
    184                     while (curr != null) {
    185                         sorted.add(curr);
    186                         curr = curr.next;
    187                     }
    188                     Collections.sort(sorted);
    189                     Location prev = location;
    190                     for (Occurrence o : sorted) {
    191                         if (o.location != null) {
    192                             prev.setSecondary(o.location);
    193                             prev = o.location;
    194                         }
    195                     }
    196 
    197                     context.report(CROSS_LAYOUT, location, occurrence.message, null);
    198                 }
    199             }
    200         }
    201     }
    202 
    203     @Override
    204     public void visitElement(XmlContext context, Element element) {
    205         // Record include graph such that we can look for inter-layout duplicates after the
    206         // project has been fully checked
    207 
    208         String layout = element.getAttribute(ATTR_LAYOUT); // NOTE: Not in android: namespace
    209         if (layout.startsWith(LAYOUT_RESOURCE_PREFIX)) { // Ignore @android:layout/ layouts
    210             layout = layout.substring(LAYOUT_RESOURCE_PREFIX.length());
    211 
    212             if (context.getPhase() == 1) {
    213                 List<String> to = mIncludes.get(context.file);
    214                 if (to == null) {
    215                     to = new ArrayList<String>();
    216                     mIncludes.put(context.file, to);
    217                 }
    218                 to.add(layout);
    219             } else {
    220                 assert context.getPhase() == 2;
    221 
    222                 Collection<Multimap<String, Occurrence>> maps = mLocations.get(context.file);
    223                 if (maps != null && maps.size() > 0) {
    224                     for (Multimap<String, Occurrence> map : maps) {
    225                         if (maps.size() > 0) {
    226                             Collection<Occurrence> occurrences = map.get(layout);
    227                             if (occurrences != null && occurrences.size() > 0) {
    228                                 for (Occurrence occurrence : occurrences) {
    229                                     Location location = context.getLocation(element);
    230                                     location.setClientData(element);
    231                                     location.setMessage(occurrence.message);
    232                                     location.setSecondary(occurrence.location);
    233                                     occurrence.location = location;
    234                                 }
    235                             }
    236                         }
    237                     }
    238                 }
    239             }
    240         }
    241     }
    242 
    243     @Override
    244     public void visitAttribute(XmlContext context, Attr attribute) {
    245         assert attribute.getName().equals(ATTR_ID) || attribute.getLocalName().equals(ATTR_ID);
    246         String id = attribute.getValue();
    247         if (context.getPhase() == 1) {
    248             if (mIds.contains(id)) {
    249                 Location location = context.getLocation(attribute);
    250 
    251                 Attr first = findIdAttribute(attribute.getOwnerDocument(), id);
    252                 if (first != null && first != attribute) {
    253                     Location secondLocation = context.getLocation(first);
    254                     secondLocation.setMessage(String.format("%1$s originally defined here", id));
    255                     location.setSecondary(secondLocation);
    256                 }
    257 
    258                 context.report(WITHIN_LAYOUT, attribute, location,
    259                         String.format("Duplicate id %1$s, already defined earlier in this layout",
    260                                 id), null);
    261             } else if (id.startsWith(NEW_ID_RESOURCE_PREFIX)) {
    262                 // Skip id's on include tags
    263                 if (attribute.getOwnerElement().getTagName().equals(INCLUDE)) {
    264                     return;
    265                 }
    266 
    267                 mIds.add(id);
    268             }
    269         } else {
    270             Collection<Multimap<String, Occurrence>> maps = mLocations.get(context.file);
    271             if (maps != null && maps.size() > 0) {
    272                 for (Multimap<String, Occurrence> map : maps) {
    273                     if (maps.size() > 0) {
    274                         Collection<Occurrence> occurrences = map.get(id);
    275                         if (occurrences != null && occurrences.size() > 0) {
    276                             for (Occurrence occurrence : occurrences) {
    277                                 if (context.getDriver().isSuppressed(CROSS_LAYOUT, attribute)) {
    278                                     return;
    279                                 }
    280                                 Location location = context.getLocation(attribute);
    281                                 location.setClientData(attribute);
    282                                 location.setMessage(occurrence.message);
    283                                 location.setSecondary(occurrence.location);
    284                                 occurrence.location = location;
    285                             }
    286                         }
    287                     }
    288                 }
    289             }
    290         }
    291     }
    292 
    293     /** Find the first id attribute with the given value below the given node */
    294     private Attr findIdAttribute(Node node, String targetValue) {
    295         if (node.getNodeType() == Node.ELEMENT_NODE) {
    296             Attr attribute = ((Element) node).getAttributeNodeNS(ANDROID_URI, ATTR_ID);
    297             if (attribute != null && attribute.getValue().equals(targetValue)) {
    298                 return attribute;
    299             }
    300         }
    301 
    302         NodeList children = node.getChildNodes();
    303         for (int i = 0, n = children.getLength(); i < n; i++) {
    304             Node child = children.item(i);
    305             Attr result = findIdAttribute(child, targetValue);
    306             if (result != null) {
    307                 return result;
    308             }
    309         }
    310 
    311         return null;
    312     }
    313 
    314     /** Include Graph Node */
    315     private static class Layout {
    316         private File mFile;
    317         private List<Layout> mIncludes;
    318         private List<Layout> mIncludedBy;
    319         private Set<String> mIds;
    320 
    321         Layout(File file, Set<String> ids) {
    322             mFile = file;
    323             mIds = ids;
    324         }
    325 
    326         Set<String> getIds() {
    327             return mIds;
    328         }
    329 
    330         String getLayoutName() {
    331             return LintUtils.getLayoutName(mFile);
    332         }
    333 
    334         String getDisplayName() {
    335             return mFile.getParentFile().getName() + File.separator + mFile.getName();
    336         }
    337 
    338         void include(Layout target) {
    339             if (mIncludes == null) {
    340                 mIncludes = new ArrayList<Layout>();
    341             }
    342             mIncludes.add(target);
    343 
    344             if (target.mIncludedBy == null) {
    345                 target.mIncludedBy = new ArrayList<Layout>();
    346             }
    347             target.mIncludedBy.add(this);
    348         }
    349 
    350         boolean isIncluded() {
    351             return mIncludedBy != null && mIncludedBy.size() > 0;
    352         }
    353 
    354         File getFile() {
    355             return mFile;
    356         }
    357 
    358         List<Layout> getIncludes() {
    359             return mIncludes;
    360         }
    361 
    362         @Override
    363         public String toString() {
    364             return getDisplayName();
    365         }
    366     }
    367 
    368     private class IncludeGraph {
    369         private final Context mContext;
    370         private final Map<File, Layout> mFileToLayout;
    371 
    372         public IncludeGraph(Context context) {
    373             mContext = context;
    374 
    375             // Produce a DAG of the files to be included, and compute edges to all eligible
    376             // includes.
    377             // Then visit the DAG and whenever you find a duplicate emit a warning about the
    378             // include path which reached it.
    379             mFileToLayout = new HashMap<File, Layout>(2 * mIncludes.size());
    380             for (File file : mIncludes.keySet()) {
    381                 if (!mFileToLayout.containsKey(file)) {
    382                     mFileToLayout.put(file, new Layout(file, mFileToIds.get(file)));
    383                 }
    384             }
    385             for (File file : mFileToIds.keySet()) {
    386                 Set<String> ids = mFileToIds.get(file);
    387                 if (ids != null && ids.size() > 0) {
    388                     if (!mFileToLayout.containsKey(file)) {
    389                         mFileToLayout.put(file, new Layout(file, ids));
    390                     }
    391                 }
    392             }
    393             Multimap<String, Layout> nameToLayout =
    394                     ArrayListMultimap.create(mFileToLayout.size(), 4);
    395             for (File file : mFileToLayout.keySet()) {
    396                 String name = LintUtils.getLayoutName(file);
    397                 nameToLayout.put(name, mFileToLayout.get(file));
    398             }
    399 
    400             // Build up the DAG
    401             for (File file : mIncludes.keySet()) {
    402                 Layout from = mFileToLayout.get(file);
    403                 assert from != null : file;
    404 
    405                 List<String> includedLayouts = mIncludes.get(file);
    406                 for (String name : includedLayouts) {
    407                     Collection<Layout> layouts = nameToLayout.get(name);
    408                     if (layouts != null && layouts.size() > 0) {
    409                         if (layouts.size() == 1) {
    410                             from.include(layouts.iterator().next());
    411                         } else {
    412                             // See if we have an obvious match
    413                             File folder = from.getFile().getParentFile();
    414                             File candidate = new File(folder, name + DOT_XML);
    415                             Layout candidateLayout = mFileToLayout.get(candidate);
    416                             if (candidateLayout != null) {
    417                                 from.include(candidateLayout);
    418                             } else if (mFileToIds.containsKey(candidate)) {
    419                                 // We had an entry in mFileToIds, but not a layout: this
    420                                 // means that the file exists, but had no includes or ids.
    421                                 // This can't be a valid match: there is a layout that we know
    422                                 // the include will pick, but it has no includes (to other layouts)
    423                                 // and no ids, so no need to look at it
    424                                 continue;
    425                             } else {
    426                                 for (Layout to : layouts) {
    427                                     // Decide if the two targets are compatible
    428                                     if (isCompatible(from, to)) {
    429                                         from.include(to);
    430                                     }
    431                                 }
    432                             }
    433                         }
    434                     } else {
    435                         // The layout is including some layout which has no ids or other includes
    436                         // so it's not relevant for a duplicate id search
    437                         continue;
    438                     }
    439                 }
    440             }
    441         }
    442 
    443         /** Determine whether two layouts are compatible. They are not if they (for example)
    444          * specify conflicting qualifiers such as {@code -land} and {@code -port}.
    445          * @param from the include from
    446          * @param to the include to
    447          * @return true if the two are compatible */
    448         boolean isCompatible(Layout from, Layout to) {
    449             File fromFolder = from.mFile.getParentFile();
    450             File toFolder = to.mFile.getParentFile();
    451             if (fromFolder.equals(toFolder)) {
    452                 return true;
    453             }
    454 
    455             String[] fromQualifiers = fromFolder.getName().split("-"); //$NON-NLS-1$
    456             String[] toQualifiers = toFolder.getName().split("-"); //$NON-NLS-1$
    457 
    458             if (isPortrait(fromQualifiers) != isPortrait(toQualifiers)) {
    459                 return false;
    460             }
    461 
    462             return true;
    463         }
    464 
    465         private boolean isPortrait(String[] qualifiers) {
    466             for (String qualifier : qualifiers) {
    467                 if (qualifier.equals("port")) { //$NON-NLS-1$
    468                     return true;
    469                 } else if (qualifier.equals("land")) { //$NON-NLS-1$
    470                     return false;
    471                 }
    472             }
    473 
    474             return true; // it's the default
    475         }
    476 
    477         public void check() {
    478             // Visit the DAG, looking for conflicts
    479             for (Layout layout : mFileToLayout.values()) {
    480                 if (!layout.isIncluded()) { // Only check from "root" nodes
    481                     Deque<Layout> stack = new ArrayDeque<Layout>();
    482                     getIds(layout, stack, new HashSet<Layout>());
    483                 }
    484             }
    485         }
    486 
    487         /**
    488          * Computes the cumulative set of ids used in a given layout. We can't
    489          * just depth-first-search the graph and check the set of ids
    490          * encountered along the way, because we need to detect when multiple
    491          * includes contribute the same ids. For example, if a file is included
    492          * more than once, that would result in duplicates.
    493          */
    494         private Set<String> getIds(Layout layout, Deque<Layout> stack, Set<Layout> seen) {
    495             seen.add(layout);
    496 
    497             Set<String> layoutIds = layout.getIds();
    498             List<Layout> includes = layout.getIncludes();
    499             if (includes != null) {
    500                 Set<String> ids = new HashSet<String>();
    501                 if (layoutIds != null) {
    502                     ids.addAll(layoutIds);
    503                 }
    504 
    505                 stack.push(layout);
    506 
    507                 Multimap<String, Set<String>> nameToIds =
    508                         ArrayListMultimap.create(includes.size(), 4);
    509 
    510                 for (Layout included : includes) {
    511                     if (seen.contains(included)) {
    512                         continue;
    513                     }
    514                     Set<String> includedIds = getIds(included, stack, seen);
    515                     if (includedIds != null) {
    516                         String layoutName = included.getLayoutName();
    517 
    518                         idCheck:
    519                         for (String id : includedIds) {
    520                             if (ids.contains(id)) {
    521                                 Collection<Set<String>> idSets = nameToIds.get(layoutName);
    522                                 if (idSets != null) {
    523                                     for (Set<String> siblingIds : idSets) {
    524                                         if (siblingIds.contains(id)) {
    525                                             // The id reference was added by a sibling,
    526                                             // so no need to complain (again)
    527                                             continue idCheck;
    528                                         }
    529                                     }
    530                                 }
    531 
    532                                 // Duplicate! Record location request for new phase.
    533                                 if (mLocations == null) {
    534                                     mErrors = new ArrayList<Occurrence>();
    535                                     mLocations = ArrayListMultimap.create();
    536                                     mContext.getDriver().requestRepeat(DuplicateIdDetector.this,
    537                                             Scope.ALL_RESOURCES_SCOPE);
    538                                 }
    539 
    540                                 Map<Layout, Occurrence> occurrences =
    541                                         new HashMap<Layout, Occurrence>();
    542                                 findId(layout, id, new ArrayDeque<Layout>(), occurrences,
    543                                         new HashSet<Layout>());
    544                                 assert occurrences.size() >= 2;
    545 
    546                                 // Stash a request to find the given include
    547                                 Collection<Occurrence> values = occurrences.values();
    548                                 List<Occurrence> sorted = new ArrayList<Occurrence>(values);
    549                                 Collections.sort(sorted);
    550                                 String msg = String.format(
    551                                         "Duplicate id %1$s, defined or included multiple " +
    552                                         "times in %2$s: %3$s",
    553                                         id, layout.getDisplayName(),
    554                                         sorted.toString());
    555 
    556                                 // Store location request for the <include> tag
    557                                 Occurrence primary = new Occurrence(layout.getFile(), msg, null);
    558                                 Multimap<String, Occurrence> m = ArrayListMultimap.create();
    559                                 m.put(layoutName, primary);
    560                                 mLocations.put(layout.getFile(), m);
    561                                 mErrors.add(primary);
    562 
    563                                 Occurrence prev = primary;
    564 
    565                                 // Now store all the included occurrences of the id
    566                                 for (Occurrence occurrence : values) {
    567                                     if (occurrence.file.equals(layout.getFile())) {
    568                                         occurrence.message = "Defined here";
    569                                     } else {
    570                                         occurrence.message = String.format(
    571                                                 "Defined here, included via %1$s",
    572                                                 occurrence.includePath);
    573                                     }
    574 
    575                                     m = ArrayListMultimap.create();
    576                                     m.put(id, occurrence);
    577                                     mLocations.put(occurrence.file, m);
    578 
    579                                     // Link locations together
    580                                     prev.next = occurrence;
    581                                     prev = occurrence;
    582                                 }
    583                             }
    584                             ids.add(id);
    585                         }
    586 
    587                         // Store these ids such that on a conflict, we can tell when
    588                         // an id was added by a single variation of this file
    589                         nameToIds.put(layoutName, includedIds);
    590                     }
    591                 }
    592                 Layout visited = stack.pop();
    593                 assert visited == layout;
    594                 return ids;
    595             } else {
    596                 return layoutIds;
    597             }
    598         }
    599 
    600         private void findId(Layout layout, String id, Deque<Layout> stack,
    601                 Map<Layout, Occurrence> occurrences, Set<Layout> seen) {
    602             seen.add(layout);
    603 
    604             Set<String> layoutIds = layout.getIds();
    605             if (layoutIds != null && layoutIds.contains(id)) {
    606                 StringBuilder path = new StringBuilder();
    607 
    608                 if (!stack.isEmpty()) {
    609                     Iterator<Layout> iterator = stack.descendingIterator();
    610                     while (iterator.hasNext()) {
    611                         path.append(iterator.next().getDisplayName());
    612                         path.append(" => ");
    613                     }
    614                 }
    615                 path.append(layout.getDisplayName());
    616                 path.append(" defines ");
    617                 path.append(id);
    618 
    619                 assert occurrences.get(layout) == null : id + "," + layout;
    620                 occurrences.put(layout, new Occurrence(layout.getFile(), null, path.toString()));
    621             }
    622 
    623             List<Layout> includes = layout.getIncludes();
    624             if (includes != null) {
    625                 stack.push(layout);
    626                 for (Layout included : includes) {
    627                     if (!seen.contains(included)) {
    628                         findId(included, id, stack, occurrences, seen);
    629                     }
    630                 }
    631                 Layout visited = stack.pop();
    632                 assert visited == layout;
    633             }
    634         }
    635     }
    636 
    637     private static class Occurrence implements Comparable<Occurrence> {
    638         public Occurrence next;
    639         public File file;
    640         public Location location;
    641         public String message;
    642         public String includePath;
    643 
    644         public Occurrence(File file, String message, String includePath) {
    645             this.file = file;
    646             this.message = message;
    647             this.includePath = includePath;
    648         }
    649 
    650         @Override
    651         public String toString() {
    652             return includePath != null ? includePath : message;
    653         }
    654 
    655         @Override
    656         public int compareTo(Occurrence other) {
    657             // First sort by length, then sort by name
    658             int delta = toString().length() - other.toString().length();
    659             if (delta != 0) {
    660                 return delta;
    661             }
    662 
    663             return toString().compareTo(other.toString());
    664         }
    665     }
    666 }
    667