/**
 * Check which looks for potential errors in declarations of GridLayouts, such as specifying
 * row/column numbers outside the declared dimensions of the grid.
 */
public class GridLayoutDetector extends LayoutDetector {
  /** The main issue discovered by this detector */
  public static final Issue ISSUE =
      Issue.create(
          "GridLayout", //$NON-NLS-1$
          "GridLayout validation",
          "Declaring a layout_row or layout_column that falls outside the declared size "
              + "of a GridLayout's `rowCount` or `columnCount` is usually an unintentional error.",
          Category.CORRECTNESS,
          4,
          Severity.FATAL,
          new Implementation(GridLayoutDetector.class, Scope.RESOURCE_FILE_SCOPE));

  /** Constructs a new {@link GridLayoutDetector} check */
  public GridLayoutDetector() {}

  @NonNull
  @Override
  public Speed getSpeed() {
    return Speed.FAST;
  }

  @Override
  public Collection<String> getApplicableElements() {
    return Arrays.asList(GRID_LAYOUT, FQCN_GRID_LAYOUT_V7);
  }

  private static int getInt(Element element, String attribute, int defaultValue) {
    String valueString = element.getAttributeNS(ANDROID_URI, attribute);
    if (valueString != null && !valueString.isEmpty()) {
      try {
        return Integer.decode(valueString);
      } catch (NumberFormatException nufe) {
        // Ignore - error in user's XML
      }
    }

    return defaultValue;
  }

  @Override
  public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
    int declaredRowCount = getInt(element, ATTR_ROW_COUNT, -1);
    int declaredColumnCount = getInt(element, ATTR_COLUMN_COUNT, -1);

    if (declaredColumnCount != -1 || declaredRowCount != -1) {
      for (Element child : LintUtils.getChildren(element)) {
        if (declaredColumnCount != -1) {
          int column = getInt(child, ATTR_LAYOUT_COLUMN, -1);
          if (column >= declaredColumnCount) {
            Attr node = child.getAttributeNodeNS(ANDROID_URI, ATTR_LAYOUT_COLUMN);
            context.report(
                ISSUE,
                node,
                context.getLocation(node),
                String.format(
                    "Column attribute (%1$d) exceeds declared grid column count (%2$d)",
                    column, declaredColumnCount));
          }
        }
        if (declaredRowCount != -1) {
          int row = getInt(child, ATTR_LAYOUT_ROW, -1);
          if (row > declaredRowCount) {
            Attr node = child.getAttributeNodeNS(ANDROID_URI, ATTR_LAYOUT_ROW);
            context.report(
                ISSUE,
                node,
                context.getLocation(node),
                String.format(
                    "Row attribute (%1$d) exceeds declared grid row count (%2$d)",
                    row, declaredRowCount));
          }
        }
      }
    }

    if (element.getTagName().equals(FQCN_GRID_LAYOUT_V7)) {
      // Make sure that we're not using android: namespace attributes where we should
      // be using app namespace attributes!
      ensureAppNamespace(context, element, ATTR_COLUMN_COUNT);
      ensureAppNamespace(context, element, ATTR_ORIENTATION);
      ensureAppNamespace(context, element, ATTR_ROW_COUNT);
      ensureAppNamespace(context, element, ATTR_USE_DEFAULT_MARGINS);
      ensureAppNamespace(context, element, "alignmentMode");
      ensureAppNamespace(context, element, "columnOrderPreserved");
      ensureAppNamespace(context, element, "rowOrderPreserved");

      for (Element child : LintUtils.getChildren(element)) {
        ensureAppNamespace(context, child, ATTR_LAYOUT_COLUMN);
        ensureAppNamespace(context, child, ATTR_LAYOUT_COLUMN_SPAN);
        ensureAppNamespace(context, child, ATTR_LAYOUT_GRAVITY);
        ensureAppNamespace(context, child, ATTR_LAYOUT_ROW);
        ensureAppNamespace(context, child, ATTR_LAYOUT_ROW_SPAN);
      }
    }
  }

  private static void ensureAppNamespace(XmlContext context, Element element, String name) {
    Attr attribute = element.getAttributeNodeNS(ANDROID_URI, name);
    if (attribute != null) {
      String prefix = getNamespacePrefix(element.getOwnerDocument(), AUTO_URI);
      boolean haveNamespace = prefix != null;
      if (!haveNamespace) {
        prefix = "app";
      }

      StringBuilder sb = new StringBuilder();
      sb.append("Wrong namespace; with v7 `GridLayout` you should use ")
          .append(prefix)
          .append(":")
          .append(name);
      if (!haveNamespace) {
        sb.append(" (and add `xmlns:app=\"").append(AUTO_URI).append("\"` to your root element.)");
      }
      String message = sb.toString();

      context.report(ISSUE, attribute, context.getLocation(attribute), message);
    }
  }

  @Nullable
  private static String getNamespacePrefix(Document document, String uri) {
    Element root = document.getDocumentElement();
    if (root == null) {
      return null;
    }
    NamedNodeMap attributes = root.getAttributes();
    for (int i = 0, n = attributes.getLength(); i < n; i++) {
      Node attribute = attributes.item(i);
      if (attribute.getNodeName().startsWith(XMLNS_PREFIX)
          && attribute.getNodeValue().equals(uri)) {
        return attribute.getNodeName().substring(XMLNS_PREFIX.length());
      }
    }

    return null;
  }

  /**
   * Given an error message produced by this lint detector, returns the old value to be replaced in
   * the source code.
   *
   * <p>Intended for IDE quickfix implementations.
   *
   * @param errorMessage the error message associated with the error
   * @param format the format of the error message
   * @return the corresponding old value, or null if not recognized
   */
  @Nullable
  public static String getOldValue(@NonNull String errorMessage, @NonNull TextFormat format) {
    errorMessage = format.toText(errorMessage);
    String attribute = LintUtils.findSubstring(errorMessage, " should use ", " ");
    if (attribute == null) {
      attribute = LintUtils.findSubstring(errorMessage, " should use ", null);
    }
    if (attribute != null) {
      int index = attribute.indexOf(':');
      if (index != -1) {
        return ANDROID_NS_NAME + attribute.substring(index);
      }
    }

    return null;
  }

  /**
   * Given an error message produced by this lint detector, returns the new value to be put into the
   * source code.
   *
   * <p>Intended for IDE quickfix implementations.
   *
   * @param errorMessage the error message associated with the error
   * @param format the format of the error message
   * @return the corresponding new value, or null if not recognized
   */
  @Nullable
  public static String getNewValue(@NonNull String errorMessage, @NonNull TextFormat format) {
    errorMessage = format.toText(errorMessage);
    String attribute = LintUtils.findSubstring(errorMessage, " should use ", " ");
    if (attribute == null) {
      attribute = LintUtils.findSubstring(errorMessage, " should use ", null);
    }
    return attribute;
  }
}
/** Looks for hardcoded references to /sdcard/. */
public class SdCardDetector extends Detector implements Detector.JavaScanner {
  /** Hardcoded /sdcard/ references */
  public static final Issue ISSUE =
      Issue.create(
              "SdCardPath", //$NON-NLS-1$
              "Looks for hardcoded references to /sdcard",
              "Your code should not reference the `/sdcard` path directly; instead use "
                  + "`Environment.getExternalStorageDirectory().getPath()`",
              Category.CORRECTNESS,
              6,
              Severity.WARNING,
              SdCardDetector.class,
              Scope.JAVA_FILE_SCOPE)
          .setMoreInfo(
              "http://developer.android.com/guide/topics/data/data-storage.html#filesExternal"); //$NON-NLS-1$

  /** Constructs a new {@link SdCardDetector} check */
  public SdCardDetector() {}

  @Override
  public boolean appliesTo(@NonNull Context context, @NonNull File file) {
    return true;
  }

  @Override
  public @NonNull Speed getSpeed() {
    return Speed.FAST;
  }

  // ---- Implements JavaScanner ----

  @Override
  public List<Class<? extends Node>> getApplicableNodeTypes() {
    return Collections.<Class<? extends Node>>singletonList(StringLiteral.class);
  }

  @Override
  public AstVisitor createJavaVisitor(@NonNull JavaContext context) {
    return new StringChecker(context);
  }

  private static class StringChecker extends ForwardingAstVisitor {
    private final JavaContext mContext;

    public StringChecker(JavaContext context) {
      mContext = context;
    }

    @Override
    public boolean visitStringLiteral(StringLiteral node) {
      String s = node.astValue();
      // Other potential String prefixes to check for:
      //    /mnt/sdcard/
      //    /system/media/sdcard
      //    file://sdcard
      //    file:///sdcard
      if (s.startsWith("/sdcard")) { // $NON-NLS-1$
        String message =
            "Do not hardcode \"/sdcard/\"; "
                + "use Environment.getExternalStorageDirectory().getPath() instead";
        Location location = mContext.getLocation(node);
        mContext.report(ISSUE, node, location, message, s);
      }

      return false;
    }
  }
}
/**
 * Looks for cases where the code attempts to set a resource id, rather than a resolved color, as
 * the RGB int.
 */
public class ColorUsageDetector extends Detector implements Detector.JavaScanner {
  /** Attempting to set a resource id as a color */
  public static final Issue ISSUE =
      Issue.create(
          "ResourceAsColor", //$NON-NLS-1$
          "Should pass resolved color instead of resource id",
          "Methods that take a color in the form of an integer should be passed "
              + "an RGB triple, not the actual color resource id. You must call "
              + "`getResources().getColor(resource)` to resolve the actual color value first.",
          Category.CORRECTNESS,
          7,
          Severity.ERROR,
          new Implementation(ColorUsageDetector.class, Scope.JAVA_FILE_SCOPE));

  /** Constructs a new {@link ColorUsageDetector} check */
  public ColorUsageDetector() {}

  @Override
  public boolean appliesTo(@NonNull Context context, @NonNull File file) {
    return true;
  }

  @NonNull
  @Override
  public Speed getSpeed() {
    return Speed.FAST;
  }

  // ---- Implements JavaScanner ----

  @Override
  public boolean appliesToResourceRefs() {
    return true;
  }

  @Override
  public void visitResourceReference(
      @NonNull JavaContext context,
      @Nullable AstVisitor visitor,
      @NonNull Node select,
      @NonNull String type,
      @NonNull String name,
      boolean isFramework) {
    if (type.equals(RESOURCE_CLZ_COLOR)) {
      while (select.getParent() instanceof Select) {
        select = select.getParent();
      }

      Node current = select.getParent();
      while (current != null) {
        if (current.getClass() == MethodInvocation.class) {
          MethodInvocation call = (MethodInvocation) current;
          String methodName = call.astName().astValue();
          if (methodName.endsWith("Color") // $NON-NLS-1$
              && methodName.startsWith("set")) { // $NON-NLS-1$
            if ("setProgressBackgroundColor".equals(methodName)) {
              // Special exception: SwipeRefreshLayout does not follow the normal
              // naming convention: its setProgressBackgroundColor does *not* take
              // an ARGB color integer, it takes a resource id.
              // This method name is unique across the framework and support
              // libraries.
              return;
            }
            context.report(
                ISSUE,
                select,
                context.getLocation(select),
                String.format(
                    "Should pass resolved color instead of resource id here: "
                        + "`getResources().getColor(%1$s)`",
                    select.toString()));
          }
          break;
        } else if (current.getClass() == MethodDeclaration.class) {
          break;
        }
        current = current.getParent();
      }
    }
  }
}
/** 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",
          "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<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(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, 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<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) || 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<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(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;
      }

      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<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());
    }
  }
}
/** Checks for missing onClick handlers */
public class OnClickDetector extends LayoutDetector implements ClassScanner {
  /** Missing onClick handlers */
  public static final Issue ISSUE =
      Issue.create(
          "OnClick", //$NON-NLS-1$
          "Ensures that onClick attribute values refer to real methods",
          "The `onClick` attribute value should be the name of a method in this View's context "
              + "to invoke when the view is clicked. This name must correspond to a public method "
              + "that takes exactly one parameter of type `View`.\n"
              + "\n"
              + "Must be a string value, using '\\;' to escape characters such as '\\n' or "
              + "'\\uxxxx' for a unicode character.",
          Category.CORRECTNESS,
          10,
          Severity.ERROR,
          OnClickDetector.class,
          EnumSet.of(Scope.ALL_RESOURCE_FILES, Scope.CLASS_FILE));

  private Map<String, Location.Handle> mNames;
  private Map<String, List<String>> mSimilar;
  private boolean mHaveBytecode;

  /** Constructs a new {@link OnClickDetector} */
  public OnClickDetector() {}

  @Override
  public @NonNull Speed getSpeed() {
    return Speed.FAST;
  }

  @Override
  public void afterCheckProject(@NonNull Context context) {
    if (mNames != null && mNames.size() > 0 && mHaveBytecode) {
      List<String> names = new ArrayList<String>(mNames.keySet());
      Collections.sort(names);
      LintDriver driver = context.getDriver();
      for (String name : names) {
        Handle handle = mNames.get(name);

        Object clientData = handle.getClientData();
        if (clientData instanceof Node) {
          if (driver.isSuppressed(ISSUE, (Node) clientData)) {
            continue;
          }
        }

        Location location = handle.resolve();
        String message =
            String.format(
                "Corresponding method handler 'public void %1$s(android.view.View)' not found",
                name);
        List<String> similar = mSimilar != null ? mSimilar.get(name) : null;
        if (similar != null) {
          Collections.sort(similar);
          message =
              message + String.format(" (did you mean %1$s ?)", Joiner.on(", ").join(similar));
        }
        context.report(ISSUE, location, message, null);
      }
    }
  }

  // ---- Implements XmlScanner ----

  @Override
  public Collection<String> getApplicableAttributes() {
    return Collections.singletonList(ATTR_ON_CLICK);
  }

  @Override
  public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) {
    String value = attribute.getValue();
    if (value.isEmpty() || value.trim().isEmpty()) {
      context.report(
          ISSUE,
          attribute,
          context.getLocation(attribute),
          "onClick attribute value cannot be empty",
          null);
    } else if (!value.equals(value.trim())) {
      context.report(
          ISSUE,
          attribute,
          context.getLocation(attribute),
          "There should be no whitespace around attribute values",
          null);
    } else {
      if (mNames == null) {
        mNames = new HashMap<String, Location.Handle>();
      }
      Handle handle = context.parser.createLocationHandle(context, attribute);
      handle.setClientData(attribute);

      // Replace unicode characters with the actual value since that's how they
      // appear in the ASM signatures
      if (value.contains("\\u")) { // $NON-NLS-1$
        Pattern pattern = Pattern.compile("\\\\u(\\d\\d\\d\\d)"); // $NON-NLS-1$
        Matcher matcher = pattern.matcher(value);
        StringBuilder sb = new StringBuilder(value.length());
        int remainder = 0;
        while (matcher.find()) {
          sb.append(value.substring(0, matcher.start()));
          String unicode = matcher.group(1);
          int hex = Integer.parseInt(unicode, 16);
          sb.append((char) hex);
          remainder = matcher.end();
        }
        sb.append(value.substring(remainder));
        value = sb.toString();
      }

      mNames.put(value, handle);
    }
  }

  // ---- Implements ClassScanner ----

  @SuppressWarnings("rawtypes")
  @Override
  public void checkClass(@NonNull ClassContext context, @NonNull ClassNode classNode) {
    if (mNames == null) {
      // No onClick attributes in the XML files
      return;
    }

    mHaveBytecode = true;

    List methodList = classNode.methods;
    for (Object m : methodList) {
      MethodNode method = (MethodNode) m;
      boolean rightArguments = method.desc.equals("(Landroid/view/View;)V"); // $NON-NLS-1$
      if (!mNames.containsKey(method.name)) {
        if (rightArguments) {
          // See if there's a possible typo instead
          for (String n : mNames.keySet()) {
            if (LintUtils.editDistance(n, method.name) <= 2) {
              recordSimilar(n, classNode, method);
              break;
            }
          }
        }
        continue;
      }

      // TODO: Validate class hierarchy: should extend a context method
      // Longer term, also validate that it's in a layout that corresponds to
      // the given activity

      if (rightArguments) {
        // Found: remove from list to be checked
        mNames.remove(method.name);

        // Make sure the method is public
        if ((method.access & Opcodes.ACC_PUBLIC) == 0) {
          Location location = context.getLocation(method, classNode);
          String message = String.format("On click handler %1$s(View) must be public", method.name);
          context.report(ISSUE, location, message, null);
        } else if ((method.access & Opcodes.ACC_STATIC) != 0) {
          Location location = context.getLocation(method, classNode);
          String message =
              String.format("On click handler %1$s(View) should not be static", method.name);
          context.report(ISSUE, location, message, null);
        }

        if (mNames.isEmpty()) {
          mNames = null;
          return;
        }
      }
    }
  }

  private void recordSimilar(String name, ClassNode classNode, MethodNode method) {
    if (mSimilar == null) {
      mSimilar = new HashMap<String, List<String>>();
    }
    List<String> list = mSimilar.get(name);
    if (list == null) {
      list = new ArrayList<String>();
      mSimilar.put(name, list);
    }

    String signature = ClassContext.createSignature(classNode.name, method.name, method.desc);
    list.add(signature);
  }
}
/**
 * Check which looks at the children of ScrollViews and ensures that they fill/match the parent
 * width instead of setting wrap_content.
 */
public class HardcodedValuesDetector extends LayoutDetector {
  /** The main issue discovered by this detector */
  public static final Issue ISSUE =
      Issue.create(
          "HardcodedText", //$NON-NLS-1$
          "Hardcoded text",
          "Hardcoding text attributes directly in layout files is bad for several reasons:\n"
              + "\n"
              + "* When creating configuration variations (for example for landscape or portrait)"
              + "you have to repeat the actual text (and keep it up to date when making changes)\n"
              + "\n"
              + "* The application cannot be translated to other languages by just adding new "
              + "translations for existing string resources.\n"
              + "\n"
              + "In Android Studio and Eclipse there are quickfixes to automatically extract this "
              + "hardcoded string into a resource lookup.",
          Category.I18N,
          5,
          Severity.WARNING,
          new Implementation(HardcodedValuesDetector.class, Scope.RESOURCE_FILE_SCOPE));

  // TODO: Add additional issues here, such as hardcoded colors, hardcoded sizes, etc

  /** Constructs a new {@link HardcodedValuesDetector} */
  public HardcodedValuesDetector() {}

  @NonNull
  @Override
  public Speed getSpeed() {
    return Speed.FAST;
  }

  @Override
  public Collection<String> getApplicableAttributes() {
    return Arrays.asList(
        // Layouts
        ATTR_TEXT,
        ATTR_CONTENT_DESCRIPTION,
        ATTR_HINT,
        ATTR_LABEL,
        ATTR_PROMPT,

        // Menus
        ATTR_TITLE);
  }

  @Override
  public boolean appliesTo(@NonNull ResourceFolderType folderType) {
    return folderType == ResourceFolderType.LAYOUT || folderType == ResourceFolderType.MENU;
  }

  @Override
  public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) {
    String value = attribute.getValue();
    if (!value.isEmpty() && (value.charAt(0) != '@' && value.charAt(0) != '?')) {
      // Make sure this is really one of the android: attributes
      if (!ANDROID_URI.equals(attribute.getNamespaceURI())) {
        return;
      }

      context.report(
          ISSUE,
          attribute,
          context.getLocation(attribute),
          String.format("[I18N] Hardcoded string \"%1$s\", should use `@string` resource", value));
    }
  }
}
/** Checks for hardcoded seeds with random numbers. */
public class SecureRandomDetector extends Detector implements ClassScanner {
  /** Unregistered activities and services */
  public static final Issue ISSUE =
      Issue.create(
              "SecureRandom", //$NON-NLS-1$
              "Using a fixed seed with `SecureRandom`",
              "Looks for suspicious usage of the SecureRandom class",
              "Specifying a fixed seed will cause the instance to return a predictable sequence "
                  + "of numbers. This may be useful for testing but it is not appropriate for secure use.",
              Category.PERFORMANCE,
              9,
              Severity.WARNING,
              new Implementation(SecureRandomDetector.class, Scope.CLASS_FILE_SCOPE))
          .addMoreInfo("http://developer.android.com/reference/java/security/SecureRandom.html");

  private static final String SET_SEED = "setSeed"; // $NON-NLS-1$
  private static final String OWNER_SECURE_RANDOM = "java/security/SecureRandom"; // $NON-NLS-1$
  private static final String OWNER_RANDOM = "java/util/Random"; // $NON-NLS-1$
  private static final String VM_SECURE_RANDOM = 'L' + OWNER_SECURE_RANDOM + ';';
  /** Method description for a method that takes a long argument (no return type specified */
  private static final String LONG_ARG = "(J)"; // $NON-NLS-1$

  /** Constructs a new {@link SecureRandomDetector} */
  public SecureRandomDetector() {}

  // ---- Implements ClassScanner ----

  @Override
  @Nullable
  public List<String> getApplicableCallNames() {
    return Collections.singletonList(SET_SEED);
  }

  @Override
  public void checkCall(
      @NonNull ClassContext context,
      @NonNull ClassNode classNode,
      @NonNull MethodNode method,
      @NonNull MethodInsnNode call) {
    String owner = call.owner;
    String desc = call.desc;
    if (owner.equals(OWNER_SECURE_RANDOM)) {
      if (desc.startsWith(LONG_ARG)) {
        checkValidSetSeed(context, call);
      } else if (desc.startsWith("([B)")) { // $NON-NLS-1$
        // setSeed(byte[]) ...
        // We could do some flow analysis here to see whether the byte array getting
        // passed in appears to be fixed.
        // However, people calling this constructor rather than the simpler one
        // with a fixed integer are probably less likely to make that mistake... right?
      }
    } else if (owner.equals(OWNER_RANDOM) && desc.startsWith(LONG_ARG)) {
      // Called setSeed(long) on an instanceof a Random object. Flag this if the instance
      // is likely a SecureRandom.

      // Track allocations such that we know whether the type of the call
      // is on a SecureRandom rather than a Random
      Analyzer analyzer =
          new Analyzer(
              new BasicInterpreter() {
                @Override
                public BasicValue newValue(Type type) {
                  if (type != null && type.getDescriptor().equals(VM_SECURE_RANDOM)) {
                    return new BasicValue(type);
                  }
                  return super.newValue(type);
                }
              });
      try {
        Frame[] frames = analyzer.analyze(classNode.name, method);
        InsnList instructions = method.instructions;
        Frame frame = frames[instructions.indexOf(call)];
        int stackSlot = frame.getStackSize();
        for (Type type : Type.getArgumentTypes(desc)) {
          stackSlot -= type.getSize();
        }
        BasicValue stackValue = (BasicValue) frame.getStack(stackSlot);
        Type type = stackValue.getType();
        if (type != null && type.getDescriptor().equals(VM_SECURE_RANDOM)) {
          checkValidSetSeed(context, call);
        }
      } catch (AnalyzerException e) {
        context.log(e, null);
      }
    } else if (owner.equals(OWNER_RANDOM) && desc.startsWith(LONG_ARG)) {
      // Called setSeed(long) on an instanceof a Random object. Flag this if the instance
      // is likely a SecureRandom.
      // TODO
    }
  }

  private static void checkValidSetSeed(ClassContext context, MethodInsnNode call) {
    assert call.name.equals(SET_SEED);

    // Make sure the argument passed is not a literal
    AbstractInsnNode prev = LintUtils.getPrevInstruction(call);
    if (prev == null) {
      return;
    }
    int opcode = prev.getOpcode();
    if (opcode == Opcodes.LCONST_0 || opcode == Opcodes.LCONST_1 || opcode == Opcodes.LDC) {
      context.report(
          ISSUE,
          context.getLocation(call),
          "Do not call setSeed() on a SecureRandom with a fixed seed: "
              + "it is not secure. Use getSeed().",
          null);
    } else if (opcode == Opcodes.INVOKESTATIC) {
      String methodName = ((MethodInsnNode) prev).name;
      if (methodName.equals("currentTimeMillis") || methodName.equals("nanoTime")) {
        context.report(
            ISSUE,
            context.getLocation(call),
            "It is dangerous to seed SecureRandom with the current time because "
                + "that value is more predictable to an attacker than the default seed.",
            null);
      }
    }
  }
}
/**
 * Check for potential item overlaps in a RelativeLayout when left- and right-aligned text items are
 * used.
 */
public class RelativeOverlapDetector extends LayoutDetector {
  public static final Issue ISSUE =
      Issue.create(
          "RelativeOverlap",
          "Overlapping items in RelativeLayout",
          "If relative layout has text or button items aligned to left and right "
              + "sides they can overlap each other due to localized text expansion "
              + "unless they have mutual constraints like `toEndOf`/`toStartOf`.",
          Category.I18N,
          3,
          Severity.WARNING,
          new Implementation(RelativeOverlapDetector.class, Scope.RESOURCE_FILE_SCOPE));

  private static class LayoutNode {
    private enum Bucket {
      TOP,
      BOTTOM,
      SKIP
    }

    private int mIndex;
    private boolean mProcessed;
    private Element mNode;
    private Bucket mBucket;
    private LayoutNode mToLeft;
    private LayoutNode mToRight;
    private boolean mLastLeft;
    private boolean mLastRight;

    public LayoutNode(@NonNull Element node, int index) {
      mNode = node;
      mIndex = index;
      mProcessed = false;
      mLastLeft = true;
      mLastRight = true;
    }

    @NonNull
    public String getNodeId() {
      String nodeid = mNode.getAttributeNS(ANDROID_URI, ATTR_ID);
      if (nodeid.isEmpty()) {
        return String.format("%1$s-%2$d", mNode.getTagName(), mIndex);
      } else {
        return uniformId(nodeid);
      }
    }

    @NonNull
    public String getNodeTextId() {
      String text = mNode.getAttributeNS(ANDROID_URI, ATTR_TEXT);
      if (text.isEmpty()) {
        return getNodeId();
      } else {
        return uniformId(text);
      }
    }

    @NonNull
    @Override
    public String toString() {
      return getNodeTextId();
    }

    public boolean isInvisible() {
      String visibility = mNode.getAttributeNS(ANDROID_URI, ATTR_VISIBILITY);
      return visibility.equals("gone") || visibility.equals("invisible");
    }

    /** Determine if not can grow due to localization or not. */
    public boolean fixedWidth() {
      String width = mNode.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH);
      if (width.equals(VALUE_WRAP_CONTENT)) {
        // First check child nodes. If at least one of them is not
        // fixed-width,
        // treat whole layout as non-fixed-width
        NodeList childNodes = mNode.getChildNodes();
        for (int i = 0; i < childNodes.getLength(); i++) {
          Node child = childNodes.item(i);
          if (child.getNodeType() == Node.ELEMENT_NODE) {
            LayoutNode childLayout = new LayoutNode((Element) child, i);
            if (!childLayout.fixedWidth()) {
              return false;
            }
          }
        }
        // If node contains text attribute, consider it fixed-width if
        // text is hard-coded, otherwise it is not fixed-width.
        String text = mNode.getAttributeNS(ANDROID_URI, ATTR_TEXT);
        if (!text.isEmpty()) {
          return !text.startsWith(PREFIX_RESOURCE_REF) && !text.startsWith(PREFIX_THEME_REF);
        }

        String nodeName = mNode.getTagName();
        if (nodeName.contains("Image")
            || nodeName.contains("Progress")
            || nodeName.contains("Radio")) {
          return true;
        } else if (nodeName.contains("Button") || nodeName.contains("Text")) {
          return false;
        }
      }
      return true;
    }

    @NonNull
    public Element getNode() {
      return mNode;
    }

    /**
     * Process a node of a layout. Put it into one of three processing units and determine its right
     * and left neighbours.
     */
    public void processNode(@NonNull Map<String, LayoutNode> nodes) {
      if (mProcessed) {
        return;
      }
      mProcessed = true;

      if (isInvisible()
          || hasAttr(ATTR_LAYOUT_ALIGN_RIGHT)
          || hasAttr(ATTR_LAYOUT_ALIGN_END)
          || hasAttr(ATTR_LAYOUT_ALIGN_LEFT)
          || hasAttr(ATTR_LAYOUT_ALIGN_START)) {
        mBucket = Bucket.SKIP;
      } else if (hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_TOP)) {
        mBucket = Bucket.TOP;
      } else if (hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_BOTTOM)) {
        mBucket = Bucket.BOTTOM;
      } else {
        if (hasAttr(ATTR_LAYOUT_ABOVE) || hasAttr(ATTR_LAYOUT_BELOW)) {
          mBucket = Bucket.SKIP;
        } else {
          String[] checkAlignment = {
            ATTR_LAYOUT_ALIGN_TOP, ATTR_LAYOUT_ALIGN_BOTTOM, ATTR_LAYOUT_ALIGN_BASELINE
          };
          for (String alignment : checkAlignment) {
            String value = mNode.getAttributeNS(ANDROID_URI, alignment);
            if (!value.isEmpty()) {
              LayoutNode otherNode = nodes.get(uniformId(value));
              if (otherNode != null) {
                otherNode.processNode(nodes);
                mBucket = otherNode.mBucket;
              }
            }
          }
        }
      }
      if (mBucket == null) {
        mBucket = Bucket.TOP;
      }

      // Check relative placement
      mToLeft = findNodeByAttr(nodes, ATTR_LAYOUT_TO_START_OF);
      if (mToLeft == null) {
        mToLeft = findNodeByAttr(nodes, ATTR_LAYOUT_TO_LEFT_OF);
      }
      if (mToLeft != null) {
        mToLeft.mLastLeft = false;
        mLastRight = false;
      }
      mToRight = findNodeByAttr(nodes, ATTR_LAYOUT_TO_END_OF);
      if (mToRight == null) {
        mToRight = findNodeByAttr(nodes, ATTR_LAYOUT_TO_RIGHT_OF);
      }
      if (mToRight != null) {
        mToRight.mLastLeft = false;
        mLastRight = false;
      }

      if (hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_END)
          || hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_RIGHT)) {
        mLastRight = false;
      }
      if (hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_START)
          || hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_LEFT)) {
        mLastLeft = false;
      }
      if (mToLeft == null && mToRight == null && mLastRight && mLastLeft) {
        mLastLeft = false;
      }
    }

    @NonNull
    public Set<LayoutNode> canGrowLeft() {
      Set<LayoutNode> nodes;
      if (mToRight != null) {
        nodes = mToRight.canGrowLeft();
      } else {
        nodes = new LinkedHashSet<LayoutNode>();
      }
      if (!fixedWidth()) {
        nodes.add(this);
      }
      return nodes;
    }

    @NonNull
    public Set<LayoutNode> canGrowRight() {
      Set<LayoutNode> nodes;
      if (mToLeft != null) {
        nodes = mToLeft.canGrowRight();
      } else {
        nodes = new LinkedHashSet<LayoutNode>();
      }
      if (!fixedWidth()) {
        nodes.add(this);
      }
      return nodes;
    }

    /** Determines if not should be skipped from checking. */
    public boolean skip() {
      if (mBucket == Bucket.SKIP) {
        return true;
      }

      // Skip all includes and Views
      if (mNode.getTagName().equals(VIEW_INCLUDE) || mNode.getTagName().equals(VIEW)) {
        return true;
      }
      return false;
    }

    public boolean sameBucket(@NonNull LayoutNode node) {
      return mBucket == node.mBucket;
    }

    @Nullable
    private LayoutNode findNodeByAttr(
        @NonNull Map<String, LayoutNode> nodes, @NonNull String attrName) {
      String value = mNode.getAttributeNS(ANDROID_URI, attrName);
      if (!value.isEmpty()) {
        return nodes.get(uniformId(value));
      } else {
        return null;
      }
    }

    private boolean hasAttr(@NonNull String key) {
      return mNode.hasAttributeNS(ANDROID_URI, key);
    }

    private boolean hasTrueAttr(@NonNull String key) {
      return mNode.getAttributeNS(ANDROID_URI, key).equals(VALUE_TRUE);
    }

    @NonNull
    private static String uniformId(@NonNull String value) {
      return value.replaceFirst("@\\+", "@");
    }
  }

  public RelativeOverlapDetector() {}

  @Override
  public Collection<String> getApplicableElements() {
    return Collections.singletonList(RELATIVE_LAYOUT);
  }

  @Override
  public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
    // Traverse all child elements
    NodeList childNodes = element.getChildNodes();
    int count = childNodes.getLength();
    Map<String, LayoutNode> nodes = Maps.newHashMap();
    for (int i = 0; i < count; i++) {
      Node node = childNodes.item(i);
      if (node.getNodeType() == Node.ELEMENT_NODE) {
        LayoutNode ln = new LayoutNode((Element) node, i);
        nodes.put(ln.getNodeId(), ln);
      }
    }

    // Node map is populated, recalculate nodes sizes
    for (LayoutNode ln : nodes.values()) {
      ln.processNode(nodes);
    }
    for (LayoutNode right : nodes.values()) {
      if (!right.mLastLeft || right.skip()) {
        continue;
      }
      Set<LayoutNode> canGrowLeft = right.canGrowLeft();
      for (LayoutNode left : nodes.values()) {
        if (left == right || !left.mLastRight || left.skip() || !left.sameBucket(right)) {
          continue;
        }
        Set<LayoutNode> canGrowRight = left.canGrowRight();
        if (canGrowLeft.size() > 0 || canGrowRight.size() > 0) {
          canGrowRight.addAll(canGrowLeft);
          LayoutNode nodeToBlame = right;
          LayoutNode otherNode = left;
          if (!canGrowRight.contains(right) && canGrowRight.contains(left)) {
            nodeToBlame = left;
            otherNode = right;
          }
          context.report(
              ISSUE,
              nodeToBlame.getNode(),
              context.getLocation(nodeToBlame.getNode()),
              String.format(
                  "`%1$s` can overlap `%2$s` if %3$s %4$s due to localized text expansion",
                  nodeToBlame.getNodeId(),
                  otherNode.getNodeId(),
                  Joiner.on(", ").join(canGrowRight),
                  canGrowRight.size() > 1 ? "grow" : "grows"));
        }
      }
    }
  }
}
Ejemplo n.º 9
0
/**
 * Registry which provides a list of checks to be performed on an Android project
 *
 * <p><b>NOTE: This is not a public or final API; if you rely on this be prepared to adjust your
 * code for the next tools release.</b>
 */
@Beta
public abstract class IssueRegistry {
  private static List<Category> sCategories;
  private static Map<String, Issue> sIdToIssue;

  /**
   * Issue reported by lint (not a specific detector) when it cannot even parse an XML file prior to
   * analysis
   */
  @NonNull
  public static final Issue PARSER_ERROR =
      Issue.create(
          "ParserError", //$NON-NLS-1$
          "Finds files that contain fatal parser errors",
          "Lint will ignore any files that contain fatal parsing errors. These may contain "
              + "other errors, or contain code which affects issues in other files.",
          Category.CORRECTNESS,
          10,
          Severity.ERROR,
          Detector.class,
          Scope.RESOURCE_FILE_SCOPE);

  /**
   * Issue reported by lint for various other issues which prevents lint from running normally when
   * it's not necessarily an error in the user's code base.
   */
  @NonNull
  public static final Issue LINT_ERROR =
      Issue.create(
          "LintError", //$NON-NLS-1$
          "Isues related to running lint itself, such as failure to read files, etc",
          "This issue type represents a problem running lint itself. Examples include "
              + "failure to find bytecode for source files (which means certain detectors "
              + "could not be run), parsing errors in lint configuration files, etc."
              + "\n"
              + "These errors are not errors in your own code, but they are shown to make "
              + "it clear that some checks were not completed.",
          Category.LINT,
          10,
          Severity.ERROR,
          Detector.class,
          Scope.RESOURCE_FILE_SCOPE);

  /**
   * Returns the list of issues that can be found by all known detectors.
   *
   * @return the list of issues to be checked (including those that may be disabled!)
   */
  @NonNull
  public abstract List<Issue> getIssues();

  /**
   * Creates a list of detectors applicable to the given cope, and with the given configuration.
   *
   * @param client the client to report errors to
   * @param configuration the configuration to look up which issues are enabled etc from
   * @param scope the scope for the analysis, to filter out detectors that require wider analysis
   *     than is currently being performed
   * @param scopeToDetectors an optional map which (if not null) will be filled by this method to
   *     contain mappings from each scope to the applicable detectors for that scope
   * @return a list of new detector instances
   */
  @NonNull
  final List<? extends Detector> createDetectors(
      @NonNull LintClient client,
      @NonNull Configuration configuration,
      @NonNull EnumSet<Scope> scope,
      @Nullable Map<Scope, List<Detector>> scopeToDetectors) {
    List<Issue> issues = getIssues();
    Set<Class<? extends Detector>> detectorClasses = new HashSet<Class<? extends Detector>>();
    Map<Class<? extends Detector>, EnumSet<Scope>> detectorToScope =
        new HashMap<Class<? extends Detector>, EnumSet<Scope>>();
    for (Issue issue : issues) {
      Class<? extends Detector> detectorClass = issue.getDetectorClass();
      EnumSet<Scope> issueScope = issue.getScope();
      if (!detectorClasses.contains(detectorClass)) {
        // Determine if the issue is enabled
        if (!configuration.isEnabled(issue)) {
          continue;
        }

        // Determine if the scope matches
        if (!issue.isAdequate(scope)) {
          continue;
        }

        detectorClass = client.replaceDetector(detectorClass);

        assert detectorClass != null : issue.getId();
        detectorClasses.add(detectorClass);
      }

      if (scopeToDetectors != null) {
        EnumSet<Scope> s = detectorToScope.get(detectorClass);
        if (s == null) {
          detectorToScope.put(detectorClass, issueScope);
        } else if (!s.containsAll(issueScope)) {
          EnumSet<Scope> union = EnumSet.copyOf(s);
          union.addAll(issueScope);
          detectorToScope.put(detectorClass, union);
        }
      }
    }

    List<Detector> detectors = new ArrayList<Detector>(detectorClasses.size());
    for (Class<? extends Detector> clz : detectorClasses) {
      try {
        Detector detector = clz.newInstance();
        detectors.add(detector);

        if (scopeToDetectors != null) {
          EnumSet<Scope> union = detectorToScope.get(clz);
          for (Scope s : union) {
            List<Detector> list = scopeToDetectors.get(s);
            if (list == null) {
              list = new ArrayList<Detector>();
              scopeToDetectors.put(s, list);
            }
            list.add(detector);
          }
        }
      } catch (Throwable t) {
        client.log(t, "Can't initialize detector %1$s", clz.getName()); // $NON-NLS-1$
      }
    }

    return detectors;
  }

  /**
   * Returns true if the given id represents a valid issue id
   *
   * @param id the id to be checked
   * @return true if the given id is valid
   */
  public final boolean isIssueId(@NonNull String id) {
    return getIssue(id) != null;
  }

  /**
   * Returns true if the given category is a valid category
   *
   * @param name the category name to be checked
   * @return true if the given string is a valid category
   */
  public final boolean isCategoryName(@NonNull String name) {
    for (Category c : getCategories()) {
      if (c.getName().equals(name) || c.getFullName().equals(name)) {
        return true;
      }
    }

    return false;
  }

  /**
   * Returns the available categories
   *
   * @return an iterator for all the categories, never null
   */
  @NonNull
  public List<Category> getCategories() {
    if (sCategories == null) {
      final Set<Category> categories = new HashSet<Category>();
      for (Issue issue : getIssues()) {
        categories.add(issue.getCategory());
      }
      List<Category> sorted = new ArrayList<Category>(categories);
      Collections.sort(sorted);
      sCategories = Collections.unmodifiableList(sorted);
    }

    return sCategories;
  }

  /**
   * Returns the issue for the given id, or null if it's not a valid id
   *
   * @param id the id to be checked
   * @return the corresponding issue, or null
   */
  @Nullable
  public final Issue getIssue(@NonNull String id) {
    if (sIdToIssue == null) {
      List<Issue> issues = getIssues();
      sIdToIssue = new HashMap<String, Issue>(issues.size());
      for (Issue issue : issues) {
        sIdToIssue.put(issue.getId(), issue);
      }

      sIdToIssue.put(PARSER_ERROR.getId(), PARSER_ERROR);
      sIdToIssue.put(LINT_ERROR.getId(), LINT_ERROR);
    }
    return sIdToIssue.get(id);
  }
}
Ejemplo n.º 10
0
/**
 * Checks for common icon problems, such as wrong icon sizes, placing icons in the density
 * independent drawable folder, etc.
 */
public class IconDetector extends Detector implements Detector.XmlScanner {

  private static final boolean INCLUDE_LDPI;

  static {
    boolean includeLdpi = false;

    String value = System.getenv("ANDROID_LINT_INCLUDE_LDPI"); // $NON-NLS-1$
    if (value != null) {
      includeLdpi = Boolean.valueOf(value);
    }
    INCLUDE_LDPI = includeLdpi;
  }

  /** Pattern for the expected density folders to be found in the project */
  private static final Pattern DENSITY_PATTERN =
      Pattern.compile(
          "^drawable-(nodpi|xhdpi|hdpi|mdpi" //$NON-NLS-1$
              + (INCLUDE_LDPI ? "|ldpi" : "")
              + ")$"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$

  /** Pattern for version qualifiers */
  private static final Pattern VERSION_PATTERN = Pattern.compile("^v(\\d+)$"); // $NON-NLS-1$

  private static final String[] REQUIRED_DENSITIES =
      INCLUDE_LDPI
          ? new String[] {DRAWABLE_LDPI, DRAWABLE_MDPI, DRAWABLE_HDPI, DRAWABLE_XHDPI}
          : new String[] {DRAWABLE_MDPI, DRAWABLE_HDPI, DRAWABLE_XHDPI};

  private static final String[] DENSITY_QUALIFIERS =
      new String[] {
        "-ldpi", //$NON-NLS-1$
        "-mdpi", //$NON-NLS-1$
        "-hdpi", //$NON-NLS-1$
        "-xhdpi" //$NON-NLS-1$
      };

  /** Wrong icon size according to published conventions */
  public static final Issue ICON_EXPECTED_SIZE =
      Issue.create(
              "IconExpectedSize", //$NON-NLS-1$
              "Ensures that launcher icons, notification icons etc have the correct size",
              "There are predefined sizes (for each density) for launcher icons. You "
                  + "should follow these conventions to make sure your icons fit in with the "
                  + "overall look of the platform.",
              Category.ICONS,
              5,
              Severity.WARNING,
              IconDetector.class,
              Scope.ALL_RESOURCES_SCOPE)
          // Still some potential false positives:
          .setEnabledByDefault(false)
          .setMoreInfo(
              "http://developer.android.com/guide/practices/ui_guidelines/icon_design_launcher.html#size"); //$NON-NLS-1$

  /** Inconsistent dip size across densities */
  public static final Issue ICON_DIP_SIZE =
      Issue.create(
          "IconDipSize", //$NON-NLS-1$
          "Ensures that icons across densities provide roughly the same density-independent size",
          "Checks the all icons which are provided in multiple densities, all compute to "
              + "roughly the same density-independent pixel (dip) size. This catches errors where "
              + "images are either placed in the wrong folder, or icons are changed to new sizes "
              + "but some folders are forgotten.",
          Category.ICONS,
          5,
          Severity.WARNING,
          IconDetector.class,
          Scope.ALL_RESOURCES_SCOPE);

  /** Images in res/drawable folder */
  public static final Issue ICON_LOCATION =
      Issue.create(
              "IconLocation", //$NON-NLS-1$
              "Ensures that images are not defined in the density-independent drawable folder",
              "The res/drawable folder is intended for density-independent graphics such as "
                  + "shapes defined in XML. For bitmaps, move it to drawable-mdpi and consider "
                  + "providing higher and lower resolution versions in drawable-ldpi, drawable-hdpi "
                  + "and drawable-xhdpi. If the icon *really* is density independent (for example "
                  + "a solid color) you can place it in drawable-nodpi.",
              Category.ICONS,
              5,
              Severity.WARNING,
              IconDetector.class,
              Scope.ALL_RESOURCES_SCOPE)
          .setMoreInfo(
              "http://developer.android.com/guide/practices/screens_support.html"); //$NON-NLS-1$

  /** Missing density versions of image */
  public static final Issue ICON_DENSITIES =
      Issue.create(
              "IconDensities", //$NON-NLS-1$
              "Ensures that icons provide custom versions for all supported densities",
              "Icons will look best if a custom version is provided for each of the "
                  + "major screen density classes (low, medium, high, extra high). "
                  + "This lint check identifies icons which do not have complete coverage "
                  + "across the densities.\n"
                  + "\n"
                  + "Low density is not really used much anymore, so this check ignores "
                  + "the ldpi density. To force lint to include it, set the environment "
                  + "variable ANDROID_LINT_INCLUDE_LDPI=true. For more information on "
                  + "current density usage, see "
                  + "http://developer.android.com/resources/dashboard/screens.html",
              Category.ICONS,
              4,
              Severity.WARNING,
              IconDetector.class,
              Scope.ALL_RESOURCES_SCOPE)
          .setMoreInfo(
              "http://developer.android.com/guide/practices/screens_support.html"); //$NON-NLS-1$

  /** Missing density folders */
  public static final Issue ICON_MISSING_FOLDER =
      Issue.create(
              "IconMissingDensityFolder", //$NON-NLS-1$
              "Ensures that all the density folders are present",
              "Icons will look best if a custom version is provided for each of the "
                  + "major screen density classes (low, medium, high, extra high). "
                  + "This lint check identifies folders which are missing, such as drawable-hdpi."
                  + "\n"
                  + "Low density is not really used much anymore, so this check ignores "
                  + "the ldpi density. To force lint to include it, set the environment "
                  + "variable ANDROID_LINT_INCLUDE_LDPI=true. For more information on "
                  + "current density usage, see "
                  + "http://developer.android.com/resources/dashboard/screens.html",
              Category.ICONS,
              3,
              Severity.WARNING,
              IconDetector.class,
              Scope.ALL_RESOURCES_SCOPE)
          .setMoreInfo(
              "http://developer.android.com/guide/practices/screens_support.html"); //$NON-NLS-1$

  /** Using .gif bitmaps */
  public static final Issue GIF_USAGE =
      Issue.create(
              "GifUsage", //$NON-NLS-1$
              "Checks for images using the GIF file format which is discouraged",
              "The .gif file format is discouraged. Consider using .png (preferred) "
                  + "or .jpg (acceptable) instead.",
              Category.ICONS,
              5,
              Severity.WARNING,
              IconDetector.class,
              Scope.ALL_RESOURCES_SCOPE)
          .setMoreInfo(
              "http://developer.android.com/guide/topics/resources/drawable-resource.html#Bitmap"); //$NON-NLS-1$

  /** Duplicated icons across different names */
  public static final Issue DUPLICATES_NAMES =
      Issue.create(
          "IconDuplicates", //$NON-NLS-1$
          "Finds duplicated icons under different names",
          "If an icon is repeated under different names, you can consolidate and just "
              + "use one of the icons and delete the others to make your application smaller. "
              + "However, duplicated icons usually are not intentional and can sometimes point "
              + "to icons that were accidentally overwritten or accidentally not updated.",
          Category.ICONS,
          3,
          Severity.WARNING,
          IconDetector.class,
          Scope.ALL_RESOURCES_SCOPE);

  /** Duplicated contents across configurations for a given name */
  public static final Issue DUPLICATES_CONFIGURATIONS =
      Issue.create(
          "IconDuplicatesConfig", //$NON-NLS-1$
          "Finds icons that have identical bitmaps across various configuration parameters",
          "If an icon is provided under different configuration parameters such as "
              + "drawable-hdpi or -v11, they should typically be different. This detector "
              + "catches cases where the same icon is provided in different configuration folder "
              + "which is usually not intentional.",
          Category.ICONS,
          5,
          Severity.WARNING,
          IconDetector.class,
          Scope.ALL_RESOURCES_SCOPE);

  /** Icons appearing in both -nodpi and a -Ndpi folder */
  public static final Issue ICON_NODPI =
      Issue.create(
          "IconNoDpi", //$NON-NLS-1$
          "Finds icons that appear in both a -nodpi folder and a dpi folder",
          "Bitmaps that appear in drawable-nodpi folders will not be scaled by the "
              + "Android framework. If a drawable resource of the same name appears *both* in "
              + "a -nodpi folder as well as a dpi folder such as drawable-hdpi, then "
              + "the behavior is ambiguous and probably not intentional. Delete one or the "
              + "other, or use different names for the icons.",
          Category.ICONS,
          7,
          Severity.WARNING,
          IconDetector.class,
          Scope.ALL_RESOURCES_SCOPE);

  private String mApplicationIcon;

  /** Constructs a new accessibility check */
  public IconDetector() {}

  @Override
  public Speed getSpeed() {
    return Speed.SLOW;
  }

  @Override
  public void beforeCheckProject(Context context) {
    mApplicationIcon = null;
  }

  @Override
  public void afterCheckProject(Context context) {
    // Make sure no
    File res = new File(context.getProject().getDir(), RES_FOLDER);
    if (res.isDirectory()) {
      File[] folders = res.listFiles();
      if (folders != null) {
        boolean checkFolders =
            context.isEnabled(ICON_DENSITIES)
                || context.isEnabled(ICON_MISSING_FOLDER)
                || context.isEnabled(ICON_NODPI);
        boolean checkDipSizes = context.isEnabled(ICON_DIP_SIZE);
        boolean checkDuplicates =
            context.isEnabled(DUPLICATES_NAMES) || context.isEnabled(DUPLICATES_CONFIGURATIONS);

        Map<File, Dimension> pixelSizes = null;
        Map<File, Long> fileSizes = null;
        if (checkDipSizes || checkDuplicates) {
          pixelSizes = new HashMap<File, Dimension>();
          fileSizes = new HashMap<File, Long>();
        }
        Map<File, Set<String>> folderToNames = new HashMap<File, Set<String>>();
        for (File folder : folders) {
          String folderName = folder.getName();
          if (folderName.startsWith(DRAWABLE_FOLDER)) {
            File[] files = folder.listFiles();
            if (files != null) {
              checkDrawableDir(context, folder, files, pixelSizes, fileSizes);

              if (checkFolders && DENSITY_PATTERN.matcher(folderName).matches()) {
                Set<String> names = new HashSet<String>(files.length);
                for (File f : files) {
                  String name = f.getName();
                  if (hasBitmapExtension(name)) {
                    names.add(f.getName());
                  }
                }
                folderToNames.put(folder, names);
              }
            }
          }
        }

        if (checkDipSizes) {
          checkDipSizes(context, pixelSizes);
        }

        if (checkDuplicates) {
          checkDuplicates(context, pixelSizes, fileSizes);
        }

        if (checkFolders && folderToNames.size() > 0) {
          checkDensities(context, res, folderToNames);
        }
      }
    }
  }

  private static boolean hasBitmapExtension(String name) {
    // endsWith(name, DOT_PNG) is also true for endsWith(name, DOT_9PNG)
    return endsWith(name, DOT_PNG) || endsWith(name, DOT_JPG) || endsWith(name, DOT_GIF);
  }

  // This method looks for duplicates in the assets. This uses two pieces of information
  // (file sizes and image dimensions) to quickly reject candidates, such that it only
  // needs to check actual file contents on a small subset of the available files.
  private void checkDuplicates(
      Context context, Map<File, Dimension> pixelSizes, Map<File, Long> fileSizes) {
    Map<Long, Set<File>> sameSizes = new HashMap<Long, Set<File>>();
    Map<Long, File> seenSizes = new HashMap<Long, File>(fileSizes.size());
    for (Map.Entry<File, Long> entry : fileSizes.entrySet()) {
      File file = entry.getKey();
      Long size = entry.getValue();
      if (seenSizes.containsKey(size)) {
        Set<File> set = sameSizes.get(size);
        if (set == null) {
          set = new HashSet<File>();
          set.add(seenSizes.get(size));
          sameSizes.put(size, set);
        }
        set.add(file);
      } else {
        seenSizes.put(size, file);
      }
    }

    if (sameSizes.size() == 0) {
      return;
    }

    // Now go through the files that have the same size and check to see if we can
    // split them apart based on image dimensions
    // Note: we may not have file sizes on all the icons; in particular,
    // we don't have file sizes for ninepatch files.
    Collection<Set<File>> candidateLists = sameSizes.values();
    for (Set<File> candidates : candidateLists) {
      Map<Dimension, Set<File>> sameDimensions =
          new HashMap<Dimension, Set<File>>(candidates.size());
      List<File> noSize = new ArrayList<File>();
      for (File file : candidates) {
        Dimension dimension = pixelSizes.get(file);
        if (dimension != null) {
          Set<File> set = sameDimensions.get(dimension);
          if (set == null) {
            set = new HashSet<File>();
            sameDimensions.put(dimension, set);
          }
          set.add(file);
        } else {
          noSize.add(file);
        }
      }

      // Files that we have no dimensions for must be compared against everything
      Collection<Set<File>> sets = sameDimensions.values();
      if (noSize.size() > 0) {
        if (sets.size() > 0) {
          for (Set<File> set : sets) {
            set.addAll(noSize);
          }
        } else {
          // Must just test the noSize elements against themselves
          HashSet<File> noSizeSet = new HashSet<File>(noSize);
          sets = Collections.<Set<File>>singletonList(noSizeSet);
        }
      }

      // Map from file to actual byte contents of the file.
      // We store this in a map such that for repeated files, such as noSize files
      // which can appear in multiple buckets, we only need to read them once
      Map<File, byte[]> fileContents = new HashMap<File, byte[]>();

      // Now we're ready for the final check where we actually check the
      // bits. We have to partition the files into buckets of files that
      // are identical.
      for (Set<File> set : sets) {
        if (set.size() < 2) {
          continue;
        }

        // Read all files in this set and store in map
        for (File file : set) {
          byte[] bits = fileContents.get(file);
          if (bits == null) {
            try {
              bits = LintUtils.readBytes(file);
              fileContents.put(file, bits);
            } catch (IOException e) {
              context.log(e, null);
            }
          }
        }

        // Map where the key file is known to be equal to the value file.
        // After we check individual files for equality this will be used
        // to look for transitive equality.
        Map<File, File> equal = new HashMap<File, File>();

        // Now go and compare all the files. This isn't an efficient algorithm
        // but the number of candidates should be very small

        List<File> files = new ArrayList<File>(set);
        Collections.sort(files);
        for (int i = 0; i < files.size() - 1; i++) {
          for (int j = i + 1; j < files.size(); j++) {
            File file1 = files.get(i);
            File file2 = files.get(j);
            byte[] contents1 = fileContents.get(file1);
            byte[] contents2 = fileContents.get(file2);
            if (contents1 == null || contents2 == null) {
              // File couldn't be read: ignore
              continue;
            }
            if (contents1.length != contents2.length) {
              // Sizes differ: not identical.
              // This shouldn't happen since we've already partitioned based
              // on File.length(), but just make sure here since the file
              // system could have lied, or cached a value that has changed
              // if the file was just overwritten
              continue;
            }
            boolean same = true;
            for (int k = 0; k < contents1.length; k++) {
              if (contents1[k] != contents2[k]) {
                same = false;
                break;
              }
            }
            if (same) {
              equal.put(file1, file2);
            }
          }
        }

        if (equal.size() > 0) {
          Map<File, Set<File>> partitions = new HashMap<File, Set<File>>();
          List<Set<File>> sameSets = new ArrayList<Set<File>>();
          for (Map.Entry<File, File> entry : equal.entrySet()) {
            File file1 = entry.getKey();
            File file2 = entry.getValue();
            Set<File> set1 = partitions.get(file1);
            Set<File> set2 = partitions.get(file2);
            if (set1 != null) {
              set1.add(file2);
            } else if (set2 != null) {
              set2.add(file1);
            } else {
              set = new HashSet<File>();
              sameSets.add(set);
              set.add(file1);
              set.add(file2);
              partitions.put(file1, set);
              partitions.put(file2, set);
            }
          }

          // We've computed the partitions of equal files. Now sort them
          // for stable output.
          List<List<File>> lists = new ArrayList<List<File>>();
          for (Set<File> same : sameSets) {
            assert same.size() > 0;
            ArrayList<File> sorted = new ArrayList<File>(same);
            Collections.sort(sorted);
            lists.add(sorted);
          }
          // Sort overall partitions by the first item in each list
          Collections.sort(
              lists,
              new Comparator<List<File>>() {
                public int compare(List<File> list1, List<File> list2) {
                  return list1.get(0).compareTo(list2.get(0));
                }
              });

          for (List<File> sameFiles : lists) {
            Location location = null;
            boolean sameNames = true;
            String lastName = null;
            for (File file : sameFiles) {
              if (lastName != null && !lastName.equals(file.getName())) {
                sameNames = false;
              }
              lastName = file.getName();
              // Chain locations together
              Location linkedLocation = location;
              location = Location.create(file);
              location.setSecondary(linkedLocation);
            }

            if (sameNames) {
              StringBuilder sb = new StringBuilder();
              for (File file : sameFiles) {
                if (sb.length() > 0) {
                  sb.append(", "); // $NON-NLS-1$
                }
                sb.append(file.getParentFile().getName());
              }
              String message =
                  String.format(
                      "The %1$s icon has identical contents in the following configuration folders: %2$s",
                      lastName, sb.toString());
              context.report(DUPLICATES_CONFIGURATIONS, location, message, null);
            } else {
              StringBuilder sb = new StringBuilder();
              for (File file : sameFiles) {
                if (sb.length() > 0) {
                  sb.append(", "); // $NON-NLS-1$
                }
                sb.append(file.getName());
              }
              String message =
                  String.format(
                      "The following unrelated icon files have identical contents: %1$s",
                      sb.toString());
              context.report(DUPLICATES_NAMES, location, message, null);
            }
          }
        }
      }
    }
  }

  // This method checks the given map from resource file to pixel dimensions for each
  // such image and makes sure that the normalized dip sizes across all the densities
  // are mostly the same.
  private void checkDipSizes(Context context, Map<File, Dimension> pixelSizes) {
    // Partition up the files such that I can look at a series by name. This
    // creates a map from filename (such as foo.png) to a list of files
    // providing that icon in various folders: drawable-mdpi/foo.png, drawable-hdpi/foo.png
    // etc.
    Map<String, List<File>> nameToFiles = new HashMap<String, List<File>>();
    for (File file : pixelSizes.keySet()) {
      String name = file.getName();
      List<File> list = nameToFiles.get(name);
      if (list == null) {
        list = new ArrayList<File>();
        nameToFiles.put(name, list);
      }
      list.add(file);
    }

    ArrayList<String> names = new ArrayList<String>(nameToFiles.keySet());
    Collections.sort(names);

    // We have to partition the files further because it's possible for the project
    // to have different configurations for an icon, such as this:
    //   drawable-large-hdpi/foo.png, drawable-large-mdpi/foo.png,
    //   drawable-hdpi/foo.png, drawable-mdpi/foo.png,
    //    drawable-hdpi-v11/foo.png and drawable-mdpi-v11/foo.png.
    // In this case we don't want to compare across categories; we want to
    // ensure that the drawable-large-{density} icons are consistent,
    // that the drawable-{density}-v11 icons are consistent, and that
    // the drawable-{density} icons are consistent.

    // Map from name to list of map from parent folder to list of files
    Map<String, Map<String, List<File>>> configMap = new HashMap<String, Map<String, List<File>>>();
    for (Map.Entry<String, List<File>> entry : nameToFiles.entrySet()) {
      String name = entry.getKey();
      List<File> files = entry.getValue();
      for (File file : files) {
        String parentName = file.getParentFile().getName();
        // Strip out the density part
        int index = -1;
        for (String qualifier : DENSITY_QUALIFIERS) {
          index = parentName.indexOf(qualifier);
          if (index != -1) {
            parentName =
                parentName.substring(0, index) + parentName.substring(index + qualifier.length());
            break;
          }
        }
        if (index == -1) {
          // No relevant qualifier found in the parent directory name,
          // e.g. it's just "drawable" or something like "drawable-nodpi".
          continue;
        }

        Map<String, List<File>> folderMap = configMap.get(name);
        if (folderMap == null) {
          folderMap = new HashMap<String, List<File>>();
          configMap.put(name, folderMap);
        }
        // Map from name to a map from parent folder to files
        List<File> list = folderMap.get(parentName);
        if (list == null) {
          list = new ArrayList<File>();
          folderMap.put(parentName, list);
        }
        list.add(file);
      }
    }

    for (String name : names) {
      // List<File> files = nameToFiles.get(name);
      Map<String, List<File>> configurations = configMap.get(name);
      if (configurations == null) {
        // Nothing in this configuration: probably only found in drawable/ or
        // drawable-nodpi etc directories.
        continue;
      }

      for (Map.Entry<String, List<File>> entry : configurations.entrySet()) {
        List<File> files = entry.getValue();

        // Ensure that all the dip sizes are *roughly* the same
        Map<File, Dimension> dipSizes = new HashMap<File, Dimension>();
        int dipWidthSum = 0; // Incremental computation of average
        int dipHeightSum = 0; // Incremental computation of average
        int count = 0;
        for (File file : files) {
          float factor = getMdpiScalingFactor(file.getParentFile().getName());
          if (factor > 0) {
            Dimension size = pixelSizes.get(file);
            Dimension dip =
                new Dimension(Math.round(size.width / factor), Math.round(size.height / factor));
            dipWidthSum += dip.width;
            dipHeightSum += dip.height;
            dipSizes.put(file, dip);
            count++;
          }
        }
        if (count == 0) {
          // Icons in drawable/ and drawable-nodpi/
          continue;
        }
        int meanWidth = dipWidthSum / count;
        int meanHeight = dipHeightSum / count;

        // Compute standard deviation?
        int squareWidthSum = 0;
        int squareHeightSum = 0;
        for (Dimension size : dipSizes.values()) {
          squareWidthSum += (size.width - meanWidth) * (size.width - meanWidth);
          squareHeightSum += (size.height - meanHeight) * (size.height - meanHeight);
        }
        double widthStdDev = Math.sqrt(squareWidthSum / count);
        double heightStdDev = Math.sqrt(squareHeightSum / count);

        if (widthStdDev > meanWidth / 10 || heightStdDev > meanHeight) {
          Location location = null;
          StringBuilder sb = new StringBuilder();

          // Sort entries by decreasing dip size
          List<Map.Entry<File, Dimension>> entries = new ArrayList<Map.Entry<File, Dimension>>();
          for (Map.Entry<File, Dimension> entry2 : dipSizes.entrySet()) {
            entries.add(entry2);
          }
          Collections.sort(
              entries,
              new Comparator<Map.Entry<File, Dimension>>() {
                public int compare(Entry<File, Dimension> e1, Entry<File, Dimension> e2) {
                  Dimension d1 = e1.getValue();
                  Dimension d2 = e2.getValue();
                  if (d1.width != d2.width) {
                    return d2.width - d1.width;
                  }

                  return d2.height - d1.height;
                }
              });
          for (Map.Entry<File, Dimension> entry2 : entries) {
            if (sb.length() > 0) {
              sb.append(", ");
            }
            File file = entry2.getKey();

            // Chain locations together
            Location linkedLocation = location;
            location = Location.create(file);
            location.setSecondary(linkedLocation);
            Dimension dip = entry2.getValue();
            Dimension px = pixelSizes.get(file);
            String fileName = file.getParentFile().getName() + File.separator + file.getName();
            sb.append(
                String.format(
                    "%1$s: %2$dx%3$d dp (%4$dx%5$d px)",
                    fileName, dip.width, dip.height, px.width, px.height));
          }
          String message =
              String.format(
                  "The image %1$s varies significantly in its density-independent (dip) "
                      + "size across the various density versions: %2$s",
                  name, sb.toString());
          context.report(ICON_DIP_SIZE, location, message, null);
        }
      }
    }
  }

  private void checkDensities(Context context, File res, Map<File, Set<String>> folderToNames) {
    // TODO: Is there a way to look at the manifest and figure out whether
    // all densities are expected to be needed?
    // Note: ldpi is probably not needed; it has very little usage
    // (about 2%; http://developer.android.com/resources/dashboard/screens.html)
    // TODO: Use the matrix to check out if we can eliminate densities based
    // on the target screens?

    Set<String> definedDensities = new HashSet<String>();
    for (File f : folderToNames.keySet()) {
      definedDensities.add(f.getName());
    }

    // Look for missing folders -- if you define say drawable-mdpi then you
    // should also define -hdpi and -xhdpi.
    if (context.isEnabled(ICON_MISSING_FOLDER)) {
      List<String> missing = new ArrayList<String>();
      for (String density : REQUIRED_DENSITIES) {
        if (!definedDensities.contains(density)) {
          missing.add(density);
        }
      }
      if (missing.size() > 0) {
        context.report(
            ICON_MISSING_FOLDER,
            null /* location */,
            String.format(
                "Missing density variation folders in %1$s: %2$s",
                context.getProject().getDisplayPath(res),
                LintUtils.formatList(missing, missing.size())),
            null);
      }
    }

    if (context.isEnabled(ICON_NODPI)) {
      Set<String> noDpiNames = new HashSet<String>();
      for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) {
        if (isNoDpiFolder(entry.getKey())) {
          noDpiNames.addAll(entry.getValue());
        }
      }
      if (noDpiNames.size() > 0) {
        // Make sure that none of the nodpi names appear in a non-nodpi folder
        Set<String> inBoth = new HashSet<String>();
        List<File> files = new ArrayList<File>();
        for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) {
          File folder = entry.getKey();
          String folderName = folder.getName();
          if (!isNoDpiFolder(folder)) {
            assert DENSITY_PATTERN.matcher(folderName).matches();
            Set<String> overlap = intersection(noDpiNames, entry.getValue());
            inBoth.addAll(overlap);
            for (String name : overlap) {
              files.add(new File(folder, name));
            }
          }
        }

        if (inBoth.size() > 0) {
          List<String> list = new ArrayList<String>(inBoth);
          Collections.sort(list);

          // Chain locations together
          Collections.sort(files);
          Location location = null;
          for (File file : files) {
            Location linkedLocation = location;
            location = Location.create(file);
            location.setSecondary(linkedLocation);
          }

          context.report(
              ICON_DENSITIES,
              location,
              String.format(
                  "The following images appear in both -nodpi and in a density folder: %1$s",
                  LintUtils.formatList(list, 10)),
              null);
        }
      }
    }

    if (context.isEnabled(ICON_DENSITIES)) {
      // Look for folders missing some of the specific assets
      Set<String> allNames = new HashSet<String>();
      for (Entry<File, Set<String>> entry : folderToNames.entrySet()) {
        if (!isNoDpiFolder(entry.getKey())) {
          Set<String> names = entry.getValue();
          allNames.addAll(names);
        }
      }

      for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) {
        File file = entry.getKey();
        if (isNoDpiFolder(file)) {
          continue;
        }
        Set<String> names = entry.getValue();
        if (names.size() != allNames.size()) {
          List<String> delta = new ArrayList<String>(difference(allNames, names));
          Collections.sort(delta);
          String foundIn = "";
          if (delta.size() == 1) {
            // Produce list of where the icon is actually defined
            List<String> defined = new ArrayList<String>();
            String name = delta.get(0);
            for (Map.Entry<File, Set<String>> e : folderToNames.entrySet()) {
              if (e.getValue().contains(name)) {
                defined.add(e.getKey().getName());
              }
            }
            if (defined.size() > 0) {
              foundIn = String.format(" (found in %1$s)", LintUtils.formatList(defined, 5));
            }
          }

          context.report(
              ICON_DENSITIES,
              Location.create(file),
              String.format(
                  "Missing the following drawables in %1$s: %2$s%3$s",
                  file.getName(), LintUtils.formatList(delta, 5), foundIn),
              null);
        }
      }
    }
  }

  private static boolean isNoDpiFolder(File file) {
    return file.getName().contains("-nodpi");
  }

  private void checkDrawableDir(
      Context context,
      File folder,
      File[] files,
      Map<File, Dimension> pixelSizes,
      Map<File, Long> fileSizes) {
    if (folder.getName().equals(DRAWABLE_FOLDER) && context.isEnabled(ICON_LOCATION)) {
      for (File file : files) {
        String name = file.getName();
        if (name.endsWith(DOT_XML)) {
          // pass - most common case, avoids checking other extensions
        } else if (endsWith(name, DOT_PNG) || endsWith(name, DOT_JPG) || endsWith(name, DOT_GIF)) {
          context.report(
              ICON_LOCATION,
              Location.create(file),
              String.format(
                  "Found bitmap drawable res/drawable/%1$s in " + "densityless folder",
                  file.getName()),
              null);
        }
      }
    }

    if (context.isEnabled(GIF_USAGE)) {
      for (File file : files) {
        String name = file.getName();
        if (endsWith(name, DOT_GIF)) {
          context.report(
              GIF_USAGE,
              Location.create(file),
              "Using the .gif format for bitmaps is discouraged",
              null);
        }
      }
    }

    // Check icon sizes
    if (context.isEnabled(ICON_EXPECTED_SIZE)) {
      checkExpectedSizes(context, folder, files);
    }

    if (pixelSizes != null || fileSizes != null) {
      for (File file : files) {
        // TODO: Combine this check with the check for expected sizes such that
        // I don't check file sizes twice!
        String fileName = file.getName();

        if (endsWith(fileName, DOT_PNG) || endsWith(fileName, DOT_JPG)) {
          // Only scan .png files (except 9-patch png's) and jpg files for
          // dip sizes. Duplicate checks can also be performed on ninepatch files.
          if (pixelSizes != null && !endsWith(fileName, DOT_9PNG)) {
            Dimension size = getSize(file);
            pixelSizes.put(file, size);
          }
          if (fileSizes != null) {
            fileSizes.put(file, file.length());
          }
        }
      }
    }
  }

  private void checkExpectedSizes(Context context, File folder, File[] files) {
    String folderName = folder.getName();

    int folderVersion = -1;
    String[] qualifiers = folderName.split("-"); // $NON-NLS-1$
    for (String qualifier : qualifiers) {
      if (qualifier.startsWith("v")) {
        Matcher matcher = VERSION_PATTERN.matcher(qualifier);
        if (matcher.matches()) {
          folderVersion = Integer.parseInt(matcher.group(1));
        }
      }
    }

    for (File file : files) {
      String name = file.getName();

      // TODO: Look up exact app icon from the manifest rather than simply relying on
      // the naming conventions described here:
      //  http://developer.android.com/guide/practices/ui_guidelines/icon_design.html#design-tips
      // See if we can figure out other types of icons from usage too.

      String baseName = name;
      int index = baseName.indexOf('.');
      if (index != -1) {
        baseName = baseName.substring(0, index);
      }

      if (baseName.equals(mApplicationIcon) || name.startsWith("ic_launcher")) { // $NON-NLS-1$
        // Launcher icons
        checkSize(context, folderName, file, 48, 48, true /*exact*/);
      } else if (name.startsWith("ic_action_")) { // $NON-NLS-1$
        // Action Bar
        checkSize(context, folderName, file, 24, 24, true /*exact*/);
      } else if (name.startsWith("ic_dialog_")) { // $NON-NLS-1$
        // Action Bar
        checkSize(context, folderName, file, 32, 32, true /*exact*/);
      } else if (name.startsWith("ic_tab_")) { // $NON-NLS-1$
        // Tab icons
        checkSize(context, folderName, file, 32, 32, true /*exact*/);
      } else if (name.startsWith("ic_stat_")) { // $NON-NLS-1$
        // Notification icons

        if (isAndroid30(context, folderVersion)) {
          checkSize(context, folderName, file, 24, 24, true /*exact*/);
        } else if (isAndroid23(context, folderVersion)) {
          checkSize(context, folderName, file, 16, 25, false /*exact*/);
        } else {
          // Android 2.2 or earlier
          // TODO: Should this be done for each folder size?
          checkSize(context, folderName, file, 25, 25, true /*exact*/);
        }
      } else if (name.startsWith("ic_menu_")) { // $NON-NLS-1$
        // Menu icons (<=2.3 only: Replaced by action bar icons (ic_action_ in 3.0).
        if (isAndroid23(context, folderVersion)) {
          // The icon should be 32x32 inside the transparent image; should
          // we check that this is mostly the case (a few pixels are allowed to
          // overlap for anti-aliasing etc)
          checkSize(context, folderName, file, 48, 48, true /*exact*/);
        } else {
          // Android 2.2 or earlier
          // TODO: Should this be done for each folder size?
          checkSize(context, folderName, file, 48, 48, true /*exact*/);
        }
      }
      // TODO: ListView icons?
    }
  }

  /**
   * Is this drawable folder for an Android 3.0 drawable? This will be the case if it specifies
   * -v11+, or if the minimum SDK version declared in the manifest is at least 11.
   */
  private boolean isAndroid30(Context context, int folderVersion) {
    return folderVersion >= 11 || context.getProject().getMinSdk() >= 11;
  }

  /**
   * Is this drawable folder for an Android 2.3 drawable? This will be the case if it specifies -v9
   * or -v10, or if the minimum SDK version declared in the manifest is 9 or 10 (and it does not
   * specify some higher version like -v11
   */
  private boolean isAndroid23(Context context, int folderVersion) {
    if (isAndroid30(context, folderVersion)) {
      return false;
    }

    if (folderVersion == 9 || folderVersion == 10) {
      return true;
    }

    int minSdk = context.getProject().getMinSdk();

    return minSdk == 9 || minSdk == 10;
  }

  private float getMdpiScalingFactor(String folderName) {
    // Can't do startsWith(DRAWABLE_MDPI) because the folder could
    // be something like "drawable-sw600dp-mdpi".
    if (folderName.contains("-mdpi")) { // $NON-NLS-1$
      return 1.0f;
    } else if (folderName.contains("-hdpi")) { // $NON-NLS-1$
      return 1.5f;
    } else if (folderName.contains("-xhdpi")) { // $NON-NLS-1$
      return 2.0f;
    } else if (folderName.contains("-ldpi")) { // $NON-NLS-1$
      return 0.75f;
    } else {
      return 0f;
    }
  }

  private void checkSize(
      Context context,
      String folderName,
      File file,
      int mdpiWidth,
      int mdpiHeight,
      boolean exactMatch) {
    String fileName = file.getName();
    // Only scan .png files (except 9-patch png's) and jpg files
    if (!((endsWith(fileName, DOT_PNG) && !endsWith(fileName, DOT_9PNG))
        || endsWith(fileName, DOT_JPG))) {
      return;
    }

    int width = -1;
    int height = -1;
    // Use 3:4:6:8 scaling ratio to look up the other expected sizes
    if (folderName.startsWith(DRAWABLE_MDPI)) {
      width = mdpiWidth;
      height = mdpiHeight;
    } else if (folderName.startsWith(DRAWABLE_HDPI)) {
      // Perform math using floating point; if we just do
      //   width = mdpiWidth * 3 / 2;
      // then for mdpiWidth = 25 (as in notification icons on pre-GB) we end up
      // with width = 37, instead of 38 (with floating point rounding we get 37.5 = 38)
      width = Math.round(mdpiWidth * 3.f / 2);
      height = Math.round(mdpiHeight * 3f / 2);
    } else if (folderName.startsWith(DRAWABLE_XHDPI)) {
      width = mdpiWidth * 2;
      height = mdpiHeight * 2;
    } else if (folderName.startsWith(DRAWABLE_LDPI)) {
      width = Math.round(mdpiWidth * 3f / 4);
      height = Math.round(mdpiHeight * 3f / 4);
    } else {
      return;
    }

    Dimension size = getSize(file);
    if (size != null) {
      if (exactMatch && size.width != width || size.height != height) {
        context.report(
            ICON_EXPECTED_SIZE,
            Location.create(file),
            String.format(
                "Incorrect icon size for %1$s: expected %2$dx%3$d, but was %4$dx%5$d",
                folderName + File.separator + file.getName(),
                width,
                height,
                size.width,
                size.height),
            null);
      } else if (!exactMatch && size.width > width || size.height > height) {
        context.report(
            ICON_EXPECTED_SIZE,
            Location.create(file),
            String.format(
                "Incorrect icon size for %1$s: icon size should be at most %2$dx%3$d, but was %4$dx%5$d",
                folderName + File.separator + file.getName(),
                width,
                height,
                size.width,
                size.height),
            null);
      }
    }
  }

  private Dimension getSize(File file) {
    try {
      ImageInputStream input = ImageIO.createImageInputStream(file);
      if (input != null) {
        try {
          Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
          if (readers.hasNext()) {
            ImageReader reader = readers.next();
            try {
              reader.setInput(input);
              return new Dimension(reader.getWidth(0), reader.getHeight(0));
            } finally {
              reader.dispose();
            }
          }
        } finally {
          if (input != null) {
            input.close();
          }
        }
      }

      // Fallback: read the image using the normal means
      BufferedImage image = ImageIO.read(file);
      if (image != null) {
        return new Dimension(image.getWidth(), image.getHeight());
      } else {
        return null;
      }
    } catch (IOException e) {
      // Pass -- we can't handle all image types, warn about those we can
      return null;
    }
  }

  // XML detector: Skim manifest

  @Override
  public boolean appliesTo(Context context, File file) {
    return file.getName().equals(ANDROID_MANIFEST_XML);
  }

  @Override
  public Collection<String> getApplicableElements() {
    return Collections.singletonList(TAG_APPLICATION);
  }

  @Override
  public void visitElement(XmlContext context, Element element) {
    assert element.getTagName().equals(TAG_APPLICATION);
    mApplicationIcon = element.getAttributeNS(ANDROID_URI, ATTR_ICON);
    if (mApplicationIcon.startsWith(DRAWABLE_RESOURCE_PREFIX)) {
      mApplicationIcon = mApplicationIcon.substring(DRAWABLE_RESOURCE_PREFIX.length());
    }
  }
}