diff --git a/plugins/lint/lint-api/src/com/android/tools/klint/detector/api/LintUtils.java.as31 b/plugins/lint/lint-api/src/com/android/tools/klint/detector/api/LintUtils.java.as31
new file mode 100644
index 00000000000..a0ced7f40e4
--- /dev/null
+++ b/plugins/lint/lint-api/src/com/android/tools/klint/detector/api/LintUtils.java.as31
@@ -0,0 +1,1343 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tools.klint.detector.api;
+
+import com.android.annotations.NonNull;
+import com.android.annotations.Nullable;
+import com.android.builder.model.AndroidProject;
+import com.android.builder.model.ApiVersion;
+import com.android.ide.common.rendering.api.ItemResourceValue;
+import com.android.ide.common.rendering.api.ResourceValue;
+import com.android.ide.common.rendering.api.StyleResourceValue;
+import com.android.ide.common.res2.AbstractResourceRepository;
+import com.android.ide.common.res2.ResourceItem;
+import com.android.resources.ResourceUrl;
+import com.android.ide.common.resources.configuration.FolderConfiguration;
+import com.android.ide.common.resources.configuration.LocaleQualifier;
+import com.android.resources.FolderTypeRelationship;
+import com.android.resources.ResourceFolderType;
+import com.android.resources.ResourceType;
+import com.android.sdklib.AndroidVersion;
+import com.android.sdklib.IAndroidTarget;
+import com.android.sdklib.SdkVersionInfo;
+import com.android.tools.klint.client.api.LintClient;
+import com.android.utils.PositionXmlParser;
+import com.android.utils.SdkUtils;
+import com.google.common.annotations.Beta;
+import com.google.common.base.Objects;
+import com.google.common.base.Splitter;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.intellij.psi.*;
+import lombok.ast.ImportDeclaration;
+import org.jetbrains.org.objectweb.asm.Opcodes;
+import org.jetbrains.org.objectweb.asm.tree.AbstractInsnNode;
+import org.jetbrains.org.objectweb.asm.tree.ClassNode;
+import org.jetbrains.org.objectweb.asm.tree.FieldNode;
+import org.jetbrains.uast.UElement;
+import org.jetbrains.uast.UParenthesizedExpression;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+import static com.android.SdkConstants.*;
+import static com.android.ide.common.resources.configuration.FolderConfiguration.QUALIFIER_SPLITTER;
+import static com.android.ide.common.resources.configuration.LocaleQualifier.BCP_47_PREFIX;
+import static com.android.tools.klint.client.api.JavaParser.*;
+
+
+/**
+ * Useful utility methods related to lint.
+ *
+ * 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.
+ */
+@Beta
+public class LintUtils {
+ // Utility class, do not instantiate
+ private LintUtils() {
+ }
+
+ /**
+ * Format a list of strings, and cut of the list at {@code maxItems} if the
+ * number of items are greater.
+ *
+ * @param strings the list of strings to print out as a comma separated list
+ * @param maxItems the maximum number of items to print
+ * @return a comma separated list
+ */
+ @NonNull
+ public static String formatList(@NonNull List strings, int maxItems) {
+ StringBuilder sb = new StringBuilder(20 * strings.size());
+
+ for (int i = 0, n = strings.size(); i < n; i++) {
+ if (sb.length() > 0) {
+ sb.append(", "); //$NON-NLS-1$
+ }
+ sb.append(strings.get(i));
+
+ if (maxItems > 0 && i == maxItems - 1 && n > maxItems) {
+ sb.append(String.format("... (%1$d more)", n - i - 1));
+ break;
+ }
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Determine if the given type corresponds to a resource that has a unique
+ * file
+ *
+ * @param type the resource type to check
+ * @return true if the given type corresponds to a file-type resource
+ */
+ public static boolean isFileBasedResourceType(@NonNull ResourceType type) {
+ List folderTypes = FolderTypeRelationship.getRelatedFolders(type);
+ for (ResourceFolderType folderType : folderTypes) {
+ if (folderType != ResourceFolderType.VALUES) {
+ return type != ResourceType.ID;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if the given file represents an XML file
+ *
+ * @param file the file to be checked
+ * @return true if the given file is an xml file
+ */
+ public static boolean isXmlFile(@NonNull File file) {
+ return SdkUtils.endsWithIgnoreCase(file.getPath(), DOT_XML);
+ }
+
+ /**
+ * Returns true if the given file represents a bitmap drawable file
+ *
+ * @param file the file to be checked
+ * @return true if the given file is an xml file
+ */
+ public static boolean isBitmapFile(@NonNull File file) {
+ String path = file.getPath();
+ // endsWith(name, DOT_PNG) is also true for endsWith(name, DOT_9PNG)
+ return endsWith(path, DOT_PNG)
+ || endsWith(path, DOT_JPG)
+ || endsWith(path, DOT_GIF)
+ || endsWith(path, DOT_JPEG)
+ || endsWith(path, DOT_WEBP);
+ }
+
+ /**
+ * Case insensitive ends with
+ *
+ * @param string the string to be tested whether it ends with the given
+ * suffix
+ * @param suffix the suffix to check
+ * @return true if {@code string} ends with {@code suffix},
+ * case-insensitively.
+ */
+ public static boolean endsWith(@NonNull String string, @NonNull String suffix) {
+ return string.regionMatches(true /* ignoreCase */, string.length() - suffix.length(),
+ suffix, 0, suffix.length());
+ }
+
+ /**
+ * Case insensitive starts with
+ *
+ * @param string the string to be tested whether it starts with the given prefix
+ * @param prefix the prefix to check
+ * @param offset the offset to start checking with
+ * @return true if {@code string} starts with {@code prefix},
+ * case-insensitively.
+ */
+ public static boolean startsWith(@NonNull String string, @NonNull String prefix, int offset) {
+ return string.regionMatches(true /* ignoreCase */, offset, prefix, 0, prefix.length());
+ }
+
+ /**
+ * Returns the basename of the given filename, unless it's a dot-file such as ".svn".
+ *
+ * @param fileName the file name to extract the basename from
+ * @return the basename (the filename without the file extension)
+ */
+ public static String getBaseName(@NonNull String fileName) {
+ int extension = fileName.indexOf('.');
+ if (extension > 0) {
+ return fileName.substring(0, extension);
+ } else {
+ return fileName;
+ }
+ }
+
+ /**
+ * Returns the children elements of the given node
+ *
+ * @param node the parent node
+ * @return a list of element children, never null
+ */
+ @NonNull
+ public static List getChildren(@NonNull Node node) {
+ NodeList childNodes = node.getChildNodes();
+ List children = new ArrayList(childNodes.getLength());
+ for (int i = 0, n = childNodes.getLength(); i < n; i++) {
+ Node child = childNodes.item(i);
+ if (child.getNodeType() == Node.ELEMENT_NODE) {
+ children.add((Element) child);
+ }
+ }
+
+ return children;
+ }
+
+ /**
+ * Returns the number of children of the given node
+ *
+ * @param node the parent node
+ * @return the count of element children
+ */
+ public static int getChildCount(@NonNull Node node) {
+ NodeList childNodes = node.getChildNodes();
+ int childCount = 0;
+ for (int i = 0, n = childNodes.getLength(); i < n; i++) {
+ Node child = childNodes.item(i);
+ if (child.getNodeType() == Node.ELEMENT_NODE) {
+ childCount++;
+ }
+ }
+
+ return childCount;
+ }
+
+ /**
+ * Returns true if the given element is the root element of its document
+ *
+ * @param element the element to test
+ * @return true if the element is the root element
+ */
+ public static boolean isRootElement(Element element) {
+ return element == element.getOwnerDocument().getDocumentElement();
+ }
+
+ /**
+ * Returns the corresponding R field name for the given XML resource name
+ * @param styleName the XML name
+ * @return the corresponding R field name
+ */
+ public static String getFieldName(@NonNull String styleName) {
+ for (int i = 0, n = styleName.length(); i < n; i++) {
+ char c = styleName.charAt(i);
+ if (c == '.' || c == '-' || c == ':') {
+ return styleName.replace('.', '_').replace('-', '_').replace(':', '_');
+ }
+ }
+
+ return styleName;
+ }
+
+ /**
+ * Returns the given id without an {@code @id/} or {@code @+id} prefix
+ *
+ * @param id the id to strip
+ * @return the stripped id, never null
+ */
+ @NonNull
+ public static String stripIdPrefix(@Nullable String id) {
+ if (id == null) {
+ return "";
+ } else if (id.startsWith(NEW_ID_PREFIX)) {
+ return id.substring(NEW_ID_PREFIX.length());
+ } else if (id.startsWith(ID_PREFIX)) {
+ return id.substring(ID_PREFIX.length());
+ }
+
+ return id;
+ }
+
+ /**
+ * Returns true if the given two id references match. This is similar to
+ * String equality, but it also considers "{@code @+id/foo == @id/foo}.
+ *
+ * @param id1 the first id to compare
+ * @param id2 the second id to compare
+ * @return true if the two id references refer to the same id
+ */
+ public static boolean idReferencesMatch(@Nullable String id1, @Nullable String id2) {
+ if (id1 == null || id2 == null || id1.isEmpty() || id2.isEmpty()) {
+ return false;
+ }
+ if (id1.startsWith(NEW_ID_PREFIX)) {
+ if (id2.startsWith(NEW_ID_PREFIX)) {
+ return id1.equals(id2);
+ } else {
+ assert id2.startsWith(ID_PREFIX) : id2;
+ return ((id1.length() - id2.length())
+ == (NEW_ID_PREFIX.length() - ID_PREFIX.length()))
+ && id1.regionMatches(NEW_ID_PREFIX.length(), id2,
+ ID_PREFIX.length(),
+ id2.length() - ID_PREFIX.length());
+ }
+ } else {
+ assert id1.startsWith(ID_PREFIX) : id1;
+ if (id2.startsWith(ID_PREFIX)) {
+ return id1.equals(id2);
+ } else {
+ assert id2.startsWith(NEW_ID_PREFIX);
+ return (id2.length() - id1.length()
+ == (NEW_ID_PREFIX.length() - ID_PREFIX.length()))
+ && id2.regionMatches(NEW_ID_PREFIX.length(), id1,
+ ID_PREFIX.length(),
+ id1.length() - ID_PREFIX.length());
+ }
+ }
+ }
+
+ /**
+ * Computes the edit distance (number of insertions, deletions or substitutions
+ * to edit one string into the other) between two strings. In particular,
+ * this will compute the Levenshtein distance.
+ *
+ * See http://en.wikipedia.org/wiki/Levenshtein_distance for details.
+ *
+ * @param s the first string to compare
+ * @param t the second string to compare
+ * @return the edit distance between the two strings
+ */
+ public static int editDistance(@NonNull String s, @NonNull String t) {
+ int m = s.length();
+ int n = t.length();
+ int[][] d = new int[m + 1][n + 1];
+ for (int i = 0; i <= m; i++) {
+ d[i][0] = i;
+ }
+ for (int j = 0; j <= n; j++) {
+ d[0][j] = j;
+ }
+ for (int j = 1; j <= n; j++) {
+ for (int i = 1; i <= m; i++) {
+ if (s.charAt(i - 1) == t.charAt(j - 1)) {
+ d[i][j] = d[i - 1][j - 1];
+ } else {
+ int deletion = d[i - 1][j] + 1;
+ int insertion = d[i][j - 1] + 1;
+ int substitution = d[i - 1][j - 1] + 1;
+ d[i][j] = Math.min(deletion, Math.min(insertion, substitution));
+ }
+ }
+ }
+
+ return d[m][n];
+ }
+
+ /**
+ * Returns true if assertions are enabled
+ *
+ * @return true if assertions are enabled
+ */
+ @SuppressWarnings("all")
+ public static boolean assertionsEnabled() {
+ boolean assertionsEnabled = false;
+ assert assertionsEnabled = true; // Intentional side-effect
+ return assertionsEnabled;
+ }
+
+ /**
+ * Returns the layout resource name for the given layout file
+ *
+ * @param layoutFile the file pointing to the layout
+ * @return the layout resource name, not including the {@code @layout}
+ * prefix
+ */
+ public static String getLayoutName(File layoutFile) {
+ String name = layoutFile.getName();
+ int dotIndex = name.indexOf('.');
+ if (dotIndex != -1) {
+ name = name.substring(0, dotIndex);
+ }
+ return name;
+ }
+
+ /**
+ * Splits the given path into its individual parts, attempting to be
+ * tolerant about path separators (: or ;). It can handle possibly ambiguous
+ * paths, such as {@code c:\foo\bar:\other}, though of course these are to
+ * be avoided if possible.
+ *
+ * @param path the path variable to split, which can use both : and ; as
+ * path separators.
+ * @return the individual path components as an Iterable of strings
+ */
+ public static Iterable splitPath(@NonNull String path) {
+ if (path.indexOf(';') != -1) {
+ return Splitter.on(';').omitEmptyStrings().trimResults().split(path);
+ }
+
+ List combined = new ArrayList();
+ Iterables.addAll(combined, Splitter.on(':').omitEmptyStrings().trimResults().split(path));
+ for (int i = 0, n = combined.size(); i < n; i++) {
+ String p = combined.get(i);
+ if (p.length() == 1 && i < n - 1 && Character.isLetter(p.charAt(0))
+ // Technically, Windows paths do not have to have a \ after the :,
+ // which means it would be using the current directory on that drive,
+ // but that's unlikely to be the case in a path since it would have
+ // unpredictable results
+ && !combined.get(i+1).isEmpty() && combined.get(i+1).charAt(0) == '\\') {
+ combined.set(i, p + ':' + combined.get(i+1));
+ combined.remove(i+1);
+ n--;
+ continue;
+ }
+ }
+
+ return combined;
+ }
+
+ /**
+ * Computes the shared parent among a set of files (which may be null).
+ *
+ * @param files the set of files to be checked
+ * @return the closest common ancestor file, or null if none was found
+ */
+ @Nullable
+ public static File getCommonParent(@NonNull List files) {
+ int fileCount = files.size();
+ if (fileCount == 0) {
+ return null;
+ } else if (fileCount == 1) {
+ return files.get(0);
+ } else if (fileCount == 2) {
+ return getCommonParent(files.get(0), files.get(1));
+ } else {
+ File common = files.get(0);
+ for (int i = 1; i < fileCount; i++) {
+ common = getCommonParent(common, files.get(i));
+ if (common == null) {
+ return null;
+ }
+ }
+
+ return common;
+ }
+ }
+
+ /**
+ * Computes the closest common parent path between two files.
+ *
+ * @param file1 the first file to be compared
+ * @param file2 the second file to be compared
+ * @return the closest common ancestor file, or null if the two files have
+ * no common parent
+ */
+ @Nullable
+ public static File getCommonParent(@NonNull File file1, @NonNull File file2) {
+ if (file1.equals(file2)) {
+ return file1;
+ } else if (file1.getPath().startsWith(file2.getPath())) {
+ return file2;
+ } else if (file2.getPath().startsWith(file1.getPath())) {
+ return file1;
+ } else {
+ // Dumb and simple implementation
+ File first = file1.getParentFile();
+ while (first != null) {
+ File second = file2.getParentFile();
+ while (second != null) {
+ if (first.equals(second)) {
+ return first;
+ }
+ second = second.getParentFile();
+ }
+
+ first = first.getParentFile();
+ }
+ }
+ return null;
+ }
+
+ private static final String UTF_16 = "UTF_16"; //$NON-NLS-1$
+ private static final String UTF_16LE = "UTF_16LE"; //$NON-NLS-1$
+
+ /**
+ * Returns the encoded String for the given file. This is usually the
+ * same as {@code Files.toString(file, Charsets.UTF8}, but if there's a UTF byte order mark
+ * (for UTF8, UTF_16 or UTF_16LE), use that instead.
+ *
+ * @param client the client to use for I/O operations
+ * @param file the file to read from
+ * @return the string
+ * @throws IOException if the file cannot be read properly
+ */
+ @NonNull
+ public static String getEncodedString(
+ @NonNull LintClient client,
+ @NonNull File file) throws IOException {
+ byte[] bytes = client.readBytes(file);
+ if (endsWith(file.getName(), DOT_XML)) {
+ return PositionXmlParser.getXmlString(bytes);
+ }
+
+ return getEncodedString(bytes);
+ }
+
+ /**
+ * Returns the String corresponding to the given data. This is usually the
+ * same as {@code new String(data)}, but if there's a UTF byte order mark
+ * (for UTF8, UTF_16 or UTF_16LE), use that instead.
+ *
+ * NOTE: For XML files, there is the additional complication that there
+ * could be a {@code encoding=} attribute in the prologue. For those files,
+ * use {@link PositionXmlParser#getXmlString(byte[])} instead.
+ *
+ * @param data the byte array to construct the string from
+ * @return the string
+ */
+ @NonNull
+ public static String getEncodedString(@Nullable byte[] data) {
+ if (data == null) {
+ return "";
+ }
+
+ int offset = 0;
+ String defaultCharset = UTF_8;
+ String charset = null;
+ // Look for the byte order mark, to see if we need to remove bytes from
+ // the input stream (and to determine whether files are big endian or little endian) etc
+ // for files which do not specify the encoding.
+ // See http://unicode.org/faq/utf_bom.html#BOM for more.
+ if (data.length > 4) {
+ if (data[0] == (byte)0xef && data[1] == (byte)0xbb && data[2] == (byte)0xbf) {
+ // UTF-8
+ defaultCharset = charset = UTF_8;
+ offset += 3;
+ } else if (data[0] == (byte)0xfe && data[1] == (byte)0xff) {
+ // UTF-16, big-endian
+ defaultCharset = charset = UTF_16;
+ offset += 2;
+ } else if (data[0] == (byte)0x0 && data[1] == (byte)0x0
+ && data[2] == (byte)0xfe && data[3] == (byte)0xff) {
+ // UTF-32, big-endian
+ defaultCharset = charset = "UTF_32"; //$NON-NLS-1$
+ offset += 4;
+ } else if (data[0] == (byte)0xff && data[1] == (byte)0xfe
+ && data[2] == (byte)0x0 && data[3] == (byte)0x0) {
+ // UTF-32, little-endian. We must check for this *before* looking for
+ // UTF_16LE since UTF_32LE has the same prefix!
+ defaultCharset = charset = "UTF_32LE"; //$NON-NLS-1$
+ offset += 4;
+ } else if (data[0] == (byte)0xff && data[1] == (byte)0xfe) {
+ // UTF-16, little-endian
+ defaultCharset = charset = UTF_16LE;
+ offset += 2;
+ }
+ }
+ int length = data.length - offset;
+
+ // Guess encoding by searching for an encoding= entry in the first line.
+ boolean seenOddZero = false;
+ boolean seenEvenZero = false;
+ for (int lineEnd = offset; lineEnd < data.length; lineEnd++) {
+ if (data[lineEnd] == 0) {
+ if ((lineEnd - offset) % 2 == 0) {
+ seenEvenZero = true;
+ } else {
+ seenOddZero = true;
+ }
+ } else if (data[lineEnd] == '\n' || data[lineEnd] == '\r') {
+ break;
+ }
+ }
+
+ if (charset == null) {
+ charset = seenOddZero ? UTF_16LE : seenEvenZero ? UTF_16 : UTF_8;
+ }
+
+ String text = null;
+ try {
+ text = new String(data, offset, length, charset);
+ } catch (UnsupportedEncodingException e) {
+ try {
+ if (!charset.equals(defaultCharset)) {
+ text = new String(data, offset, length, defaultCharset);
+ }
+ } catch (UnsupportedEncodingException u) {
+ // Just use the default encoding below
+ }
+ }
+ if (text == null) {
+ text = new String(data, offset, length);
+ }
+ return text;
+ }
+
+ /**
+ * Returns true if the given class node represents a static inner class.
+ *
+ * @param classNode the inner class to be checked
+ * @return true if the class node represents an inner class that is static
+ */
+ public static boolean isStaticInnerClass(@NonNull ClassNode classNode) {
+ // Note: We can't just filter out static inner classes like this:
+ // (classNode.access & Opcodes.ACC_STATIC) != 0
+ // because the static flag only appears on methods and fields in the class
+ // file. Instead, look for the synthetic this pointer.
+
+ @SuppressWarnings("rawtypes") // ASM API
+ List fieldList = classNode.fields;
+ for (Object f : fieldList) {
+ FieldNode field = (FieldNode) f;
+ if (field.name.startsWith("this$") && (field.access & Opcodes.ACC_SYNTHETIC) != 0) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns true if the given class node represents an anonymous inner class
+ *
+ * @param classNode the class to be checked
+ * @return true if the class appears to be an anonymous class
+ */
+ public static boolean isAnonymousClass(@NonNull ClassNode classNode) {
+ if (classNode.outerClass == null) {
+ return false;
+ }
+
+ String name = classNode.name;
+ int index = name.lastIndexOf('$');
+ if (index == -1 || index == name.length() - 1) {
+ return false;
+ }
+
+ return Character.isDigit(name.charAt(index + 1));
+ }
+
+ /**
+ * Returns the previous opcode prior to the given node, ignoring label and
+ * line number nodes
+ *
+ * @param node the node to look up the previous opcode for
+ * @return the previous opcode, or {@link Opcodes#NOP} if no previous node
+ * was found
+ */
+ public static int getPrevOpcode(@NonNull AbstractInsnNode node) {
+ AbstractInsnNode prev = getPrevInstruction(node);
+ if (prev != null) {
+ return prev.getOpcode();
+ } else {
+ return Opcodes.NOP;
+ }
+ }
+
+ /**
+ * Returns the previous instruction prior to the given node, ignoring label
+ * and line number nodes.
+ *
+ * @param node the node to look up the previous instruction for
+ * @return the previous instruction, or null if no previous node was found
+ */
+ @Nullable
+ public static AbstractInsnNode getPrevInstruction(@NonNull AbstractInsnNode node) {
+ AbstractInsnNode prev = node;
+ while (true) {
+ prev = prev.getPrevious();
+ if (prev == null) {
+ return null;
+ } else {
+ int type = prev.getType();
+ if (type != AbstractInsnNode.LINE && type != AbstractInsnNode.LABEL
+ && type != AbstractInsnNode.FRAME) {
+ return prev;
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns the next opcode after to the given node, ignoring label and line
+ * number nodes
+ *
+ * @param node the node to look up the next opcode for
+ * @return the next opcode, or {@link Opcodes#NOP} if no next node was found
+ */
+ public static int getNextOpcode(@NonNull AbstractInsnNode node) {
+ AbstractInsnNode next = getNextInstruction(node);
+ if (next != null) {
+ return next.getOpcode();
+ } else {
+ return Opcodes.NOP;
+ }
+ }
+
+ /**
+ * Returns the next instruction after to the given node, ignoring label and
+ * line number nodes.
+ *
+ * @param node the node to look up the next node for
+ * @return the next instruction, or null if no next node was found
+ */
+ @Nullable
+ public static AbstractInsnNode getNextInstruction(@NonNull AbstractInsnNode node) {
+ AbstractInsnNode next = node;
+ while (true) {
+ next = next.getNext();
+ if (next == null) {
+ return null;
+ } else {
+ int type = next.getType();
+ if (type != AbstractInsnNode.LINE && type != AbstractInsnNode.LABEL
+ && type != AbstractInsnNode.FRAME) {
+ return next;
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns true if the given directory is a lint manifest file directory.
+ *
+ * @param dir the directory to check
+ * @return true if the directory contains a manifest file
+ */
+ public static boolean isManifestFolder(File dir) {
+ boolean hasManifest = new File(dir, ANDROID_MANIFEST_XML).exists();
+ if (hasManifest) {
+ // Special case: the bin/ folder can also contain a copy of the
+ // manifest file, but this is *not* a project directory
+ if (dir.getName().equals(BIN_FOLDER)) {
+ // ...unless of course it just *happens* to be a project named bin, in
+ // which case we peek at its parent to see if this is the case
+ dir = dir.getParentFile();
+ //noinspection ConstantConditions
+ if (dir != null && isManifestFolder(dir)) {
+ // Yes, it's a bin/ directory inside a real project: ignore this dir
+ return false;
+ }
+ }
+ }
+
+ return hasManifest;
+ }
+
+ /**
+ * Look up the locale and region from the given parent folder name and
+ * return it as a combined string, such as "en", "en-rUS", b+eng-US, etc, or null if
+ * no language is specified.
+ *
+ * @param folderName the folder name
+ * @return the locale+region string or null
+ */
+ @Nullable
+ public static String getLocaleAndRegion(@NonNull String folderName) {
+ if (folderName.indexOf('-') == -1) {
+ return null;
+ }
+
+ String locale = null;
+
+ for (String qualifier : QUALIFIER_SPLITTER.split(folderName)) {
+ int qualifierLength = qualifier.length();
+ if (qualifierLength == 2) {
+ char first = qualifier.charAt(0);
+ char second = qualifier.charAt(1);
+ if (first >= 'a' && first <= 'z' && second >= 'a' && second <= 'z') {
+ locale = qualifier;
+ }
+ } else if (qualifierLength == 3 && qualifier.charAt(0) == 'r' && locale != null) {
+ char first = qualifier.charAt(1);
+ char second = qualifier.charAt(2);
+ if (first >= 'A' && first <= 'Z' && second >= 'A' && second <= 'Z') {
+ return locale + '-' + qualifier;
+ }
+ break;
+ } else if (qualifier.startsWith(BCP_47_PREFIX)) {
+ return qualifier;
+ }
+ }
+
+ return locale;
+ }
+
+ /**
+ * Returns true if the given class (specified by a fully qualified class
+ * name) name is imported in the given compilation unit either through a fully qualified
+ * import or by a wildcard import.
+ *
+ * @param compilationUnit the compilation unit
+ * @param fullyQualifiedName the fully qualified class name
+ * @return true if the given imported name refers to the given fully
+ * qualified name
+ * @deprecated Use PSI element hierarchies instead where type resolution is more directly
+ * available (call {@link PsiImportStatement#resolve()})
+ */
+ @Deprecated
+ public static boolean isImported(
+ @Nullable lombok.ast.Node compilationUnit,
+ @NonNull String fullyQualifiedName) {
+ if (compilationUnit == null) {
+ return false;
+ }
+ int dotIndex = fullyQualifiedName.lastIndexOf('.');
+ int dotLength = fullyQualifiedName.length() - dotIndex;
+
+ boolean imported = false;
+ for (lombok.ast.Node rootNode : compilationUnit.getChildren()) {
+ if (rootNode instanceof ImportDeclaration) {
+ ImportDeclaration importDeclaration = (ImportDeclaration) rootNode;
+ String fqn = importDeclaration.asFullyQualifiedName();
+ if (fqn.equals(fullyQualifiedName)) {
+ return true;
+ } else if (fullyQualifiedName.regionMatches(dotIndex, fqn,
+ fqn.length() - dotLength, dotLength)) {
+ // This import is importing the class name using some other prefix, so there
+ // fully qualified class name cannot be imported under that name
+ return false;
+ } else if (importDeclaration.astStarImport()
+ && fqn.regionMatches(0, fqn, 0, dotIndex + 1)) {
+ imported = true;
+ // but don't break -- keep searching in case there's a non-wildcard
+ // import of the specific class name, e.g. if we're looking for
+ // android.content.SharedPreferences.Editor, don't match on the following:
+ // import android.content.SharedPreferences.*;
+ // import foo.bar.Editor;
+ }
+ }
+ }
+
+ return imported;
+ }
+
+ /**
+ * Looks up the resource values for the given attribute given a style. Note that
+ * this only looks project-level style values, it does not resume into the framework
+ * styles.
+ */
+ @Nullable
+ public static List getStyleAttributes(
+ @NonNull Project project, @NonNull LintClient client,
+ @NonNull String styleUrl, @NonNull String namespace, @NonNull String attribute) {
+ if (!client.supportsProjectResources()) {
+ return null;
+ }
+
+ AbstractResourceRepository resources = client.getProjectResources(project, true);
+ if (resources == null) {
+ return null;
+ }
+
+ ResourceUrl style = ResourceUrl.parse(styleUrl);
+ if (style == null || style.framework) {
+ return null;
+ }
+
+ List result = null;
+
+ Queue queue = new ArrayDeque();
+ queue.add(new ResourceValue(ResourceUrl.create(style.type, style.name, false), null));
+ Set seen = Sets.newHashSet();
+ int count = 0;
+ boolean isFrameworkAttribute = ANDROID_URI.equals(namespace);
+ while (count < 30 && !queue.isEmpty()) {
+ ResourceValue front = queue.remove();
+ String name = front.getName();
+ seen.add(name);
+ List items = resources.getResourceItem(front.getResourceType(), name);
+ if (items != null) {
+ for (ResourceItem item : items) {
+ ResourceValue rv = item.getResourceValue(false);
+ if (rv instanceof StyleResourceValue) {
+ StyleResourceValue srv = (StyleResourceValue) rv;
+ ItemResourceValue value = srv.getItem(attribute, isFrameworkAttribute);
+ if (value != null) {
+ if (result == null) {
+ result = Lists.newArrayList();
+ }
+ if (!result.contains(value)) {
+ result.add(value);
+ }
+ }
+
+ String parent = srv.getParentStyle();
+ if (parent != null && !parent.startsWith(ANDROID_PREFIX)) {
+ ResourceUrl p = ResourceUrl.parse(parent);
+ if (p != null && !p.framework && !seen.contains(p.name)) {
+ seen.add(p.name);
+ queue.add(new ResourceValue(ResourceUrl.create(ResourceType.STYLE, p.name,
+ false), null));
+ }
+ }
+
+ int index = name.lastIndexOf('.');
+ if (index > 0) {
+ String parentName = name.substring(0, index);
+ if (!seen.contains(parentName)) {
+ seen.add(parentName);
+ queue.add(new ResourceValue(ResourceUrl.create(ResourceType.STYLE, parentName,
+ false), null));
+ }
+ }
+ }
+ }
+ }
+
+ count++;
+ }
+
+ return result;
+ }
+
+ @Nullable
+ public static List getInheritedStyles(
+ @NonNull Project project, @NonNull LintClient client,
+ @NonNull String styleUrl) {
+ if (!client.supportsProjectResources()) {
+ return null;
+ }
+
+ AbstractResourceRepository resources = client.getProjectResources(project, true);
+ if (resources == null) {
+ return null;
+ }
+
+ ResourceUrl style = ResourceUrl.parse(styleUrl);
+ if (style == null || style.framework) {
+ return null;
+ }
+
+ List result = null;
+
+ Queue queue = new ArrayDeque();
+ queue.add(new ResourceValue(ResourceUrl.create(style.type, style.name, false), null));
+ Set seen = Sets.newHashSet();
+ int count = 0;
+ while (count < 30 && !queue.isEmpty()) {
+ ResourceValue front = queue.remove();
+ String name = front.getName();
+ seen.add(name);
+ List items = resources.getResourceItem(front.getResourceType(), name);
+ if (items != null) {
+ for (ResourceItem item : items) {
+ ResourceValue rv = item.getResourceValue(false);
+ if (rv instanceof StyleResourceValue) {
+ StyleResourceValue srv = (StyleResourceValue) rv;
+ if (result == null) {
+ result = Lists.newArrayList();
+ }
+ result.add(srv);
+
+ String parent = srv.getParentStyle();
+ if (parent != null && !parent.startsWith(ANDROID_PREFIX)) {
+ ResourceUrl p = ResourceUrl.parse(parent);
+ if (p != null && !p.framework && !seen.contains(p.name)) {
+ seen.add(p.name);
+ queue.add(new ResourceValue(ResourceUrl.create(ResourceType.STYLE, p.name,
+ false), null));
+ }
+ }
+
+ int index = name.lastIndexOf('.');
+ if (index > 0) {
+ String parentName = name.substring(0, index);
+ if (!seen.contains(parentName)) {
+ seen.add(parentName);
+ queue.add(new ResourceValue(ResourceUrl.create(ResourceType.STYLE, parentName,
+ false), null));
+ }
+ }
+ }
+ }
+ }
+
+ count++;
+ }
+
+ return result;
+ }
+
+ /** Returns true if the given two paths point to the same logical resource file within
+ * a source set. This means that it only checks the parent folder name and individual
+ * file name, not the path outside the parent folder.
+ *
+ * @param file1 the first file to compare
+ * @param file2 the second file to compare
+ * @return true if the two files have the same parent and file names
+ */
+ public static boolean isSameResourceFile(@Nullable File file1, @Nullable File file2) {
+ if (file1 != null && file2 != null
+ && file1.getName().equals(file2.getName())) {
+ File parent1 = file1.getParentFile();
+ File parent2 = file2.getParentFile();
+ if (parent1 != null && parent2 != null &&
+ parent1.getName().equals(parent2.getName())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Whether we should attempt to look up the prefix from the model. Set to false
+ * if we encounter a model which is too old.
+ *
+ * This is public such that code which for example syncs to a new gradle model
+ * can reset it.
+ */
+ public static boolean sTryPrefixLookup = true;
+
+ /** Looks up the resource prefix for the given Gradle project, if possible */
+ @Nullable
+ public static String computeResourcePrefix(@Nullable AndroidProject project) {
+ try {
+ if (sTryPrefixLookup && project != null) {
+ return project.getResourcePrefix();
+ }
+ } catch (Exception e) {
+ // This happens if we're talking to an older model than 0.10
+ // Ignore; fall through to normal handling and never try again.
+ //noinspection AssignmentToStaticFieldFromInstanceMethod
+ sTryPrefixLookup = false;
+ }
+
+ return null;
+ }
+
+ /** Computes a suggested name given a resource prefix and resource name */
+ public static String computeResourceName(@NonNull String prefix, @NonNull String name) {
+ if (prefix.isEmpty()) {
+ return name;
+ } else if (name.isEmpty()) {
+ return prefix;
+ } else if (prefix.endsWith("_")) {
+ return prefix + name;
+ } else {
+ return prefix + Character.toUpperCase(name.charAt(0)) + name.substring(1);
+ }
+ }
+
+
+ /**
+ * Convert an {@link com.android.builder.model.ApiVersion} to a {@link
+ * com.android.sdklib.AndroidVersion}. The chief problem here is that the {@link
+ * com.android.builder.model.ApiVersion}, when using a codename, will not encode the
+ * corresponding API level (it just reflects the string entered by the user in the gradle file)
+ * so we perform a search here (since lint really wants to know the actual numeric API level)
+ *
+ * @param api the api version to convert
+ * @param targets if known, the installed targets (used to resolve platform codenames, only
+ * needed to resolve platforms newer than the tools since {@link
+ * com.android.sdklib.SdkVersionInfo} knows the rest)
+ * @return the corresponding version
+ */
+ @NonNull
+ public static AndroidVersion convertVersion(
+ @NonNull ApiVersion api,
+ @Nullable IAndroidTarget[] targets) {
+ String codename = api.getCodename();
+ if (codename != null) {
+ AndroidVersion version = SdkVersionInfo.getVersion(codename, targets);
+ if (version != null) {
+ return version;
+ }
+ return new AndroidVersion(api.getApiLevel(), codename);
+ }
+ return new AndroidVersion(api.getApiLevel(), null);
+ }
+
+ /**
+ * Looks for a certain string within a larger string, which should immediately follow
+ * the given prefix and immediately precede the given suffix.
+ *
+ * @param string the full string to search
+ * @param prefix the optional prefix to follow
+ * @param suffix the optional suffix to precede
+ * @return the corresponding substring, if present
+ */
+ @Nullable
+ public static String findSubstring(@NonNull String string, @Nullable String prefix,
+ @Nullable String suffix) {
+ int start = 0;
+ if (prefix != null) {
+ start = string.indexOf(prefix);
+ if (start == -1) {
+ return null;
+ }
+ start += prefix.length();
+ }
+
+ if (suffix != null) {
+ int end = string.indexOf(suffix, start);
+ if (end == -1) {
+ return null;
+ }
+ return string.substring(start, end);
+ }
+
+ return string.substring(start);
+ }
+
+ /**
+ * Splits up the given message coming from a given string format (where the string
+ * format follows the very specific convention of having only strings formatted exactly
+ * with the format %n$s where n is between 1 and 9 inclusive, and each formatting parameter
+ * appears exactly once, and in increasing order.
+ *
+ * @param format the format string responsible for creating the error message
+ * @param errorMessage an error message formatted with the format string
+ * @return the specific values inserted into the format
+ */
+ @NonNull
+ public static List getFormattedParameters(
+ @NonNull String format,
+ @NonNull String errorMessage) {
+ StringBuilder pattern = new StringBuilder(format.length());
+ int parameter = 1;
+ for (int i = 0, n = format.length(); i < n; i++) {
+ char c = format.charAt(i);
+ if (c == '%') {
+ // Only support formats of the form %n$s where n is 1 <= n <=9
+ assert i < format.length() - 4 : format;
+ assert format.charAt(i + 1) == ('0' + parameter) : format;
+ assert Character.isDigit(format.charAt(i + 1)) : format;
+ assert format.charAt(i + 2) == '$' : format;
+ assert format.charAt(i + 3) == 's' : format;
+ parameter++;
+ i += 3;
+ pattern.append("(.*)");
+ } else {
+ pattern.append(c);
+ }
+ }
+ try {
+ Pattern compile = Pattern.compile(pattern.toString());
+ Matcher matcher = compile.matcher(errorMessage);
+ if (matcher.find()) {
+ int groupCount = matcher.groupCount();
+ List parameters = Lists.newArrayListWithExpectedSize(groupCount);
+ for (int i = 1; i <= groupCount; i++) {
+ parameters.add(matcher.group(i));
+ }
+
+ return parameters;
+ }
+
+ } catch (PatternSyntaxException pse) {
+ // Internal error: string format is not valid. Should be caught by unit tests
+ // as a failure to return the formatted parameters.
+ }
+ return Collections.emptyList();
+ }
+
+ /**
+ * Returns the locale for the given parent folder.
+ *
+ * @param parent the name of the parent folder
+ * @return null if the locale is not known, or a locale qualifier providing the language
+ * and possibly region
+ */
+ @Nullable
+ public static LocaleQualifier getLocale(@NonNull String parent) {
+ if (parent.indexOf('-') != -1) {
+ FolderConfiguration config = FolderConfiguration.getConfigForFolder(parent);
+ if (config != null) {
+ return config.getLocaleQualifier();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the locale for the given context.
+ *
+ * @param context the context to look up the locale for
+ * @return null if the locale is not known, or a locale qualifier providing the language
+ * and possibly region
+ */
+ @Nullable
+ public static LocaleQualifier getLocale(@NonNull XmlContext context) {
+ Element root = context.document.getDocumentElement();
+ if (root != null) {
+ String locale = root.getAttributeNS(TOOLS_URI, ATTR_LOCALE);
+ if (locale != null && !locale.isEmpty()) {
+ return getLocale(locale);
+ }
+ }
+
+ return getLocale(context.file.getParentFile().getName());
+ }
+
+ /**
+ * Check whether the given resource file is in an English locale
+ * @param context the XML context for the resource file
+ * @param assumeForBase whether the base folder (e.g. no locale specified) should be
+ * treated as English
+ */
+ public static boolean isEnglishResource(@NonNull XmlContext context, boolean assumeForBase) {
+ LocaleQualifier locale = LintUtils.getLocale(context);
+ if (locale == null) {
+ return assumeForBase;
+ } else {
+ return "en".equals(locale.getLanguage()); //$NON-NLS-1$
+ }
+ }
+
+ /**
+ * Create a {@link Location} for an error in the top level build.gradle file.
+ * This is necessary when we're doing an analysis based on the Gradle interpreted model,
+ * not from parsing Gradle files - and the model doesn't provide source positions.
+ * @param project the project containing the gradle file being analyzed
+ * @return location for the top level gradle file if it exists, otherwise fall back to
+ * the project directory.
+ */
+ public static Location guessGradleLocation(@NonNull Project project) {
+ File dir = project.getDir();
+ Location location;
+ File topLevel = new File(dir, FN_BUILD_GRADLE);
+ if (topLevel.exists()) {
+ location = Location.create(topLevel);
+ } else {
+ location = Location.create(dir);
+ }
+ return location;
+ }
+
+ /**
+ * Returns true if the given element is the null literal
+ *
+ * @param element the element to check
+ * @return true if the element is "null"
+ */
+ public static boolean isNullLiteral(@Nullable PsiElement element) {
+ return element instanceof PsiLiteral && "null".equals(element.getText());
+ }
+
+ public static boolean isTrueLiteral(@Nullable PsiElement element) {
+ return element instanceof PsiLiteral && "true".equals(element.getText());
+ }
+
+ public static boolean isFalseLiteral(@Nullable PsiElement element) {
+ return element instanceof PsiLiteral && "false".equals(element.getText());
+ }
+
+ @Nullable
+ public static PsiElement skipParentheses(@Nullable PsiElement element) {
+ while (element instanceof PsiParenthesizedExpression) {
+ element = element.getParent();
+ }
+
+ return element;
+ }
+
+ @Nullable
+ public static UElement skipParentheses(@Nullable UElement element) {
+ while (element instanceof UParenthesizedExpression) {
+ element = element.getUastParent();
+ }
+
+ return element;
+ }
+
+ @Nullable
+ public static PsiElement nextNonWhitespace(@Nullable PsiElement element) {
+ if (element != null) {
+ element = element.getNextSibling();
+ while (element instanceof PsiWhiteSpace) {
+ element = element.getNextSibling();
+ }
+ }
+
+ return element;
+ }
+
+ @Nullable
+ public static PsiElement prevNonWhitespace(@Nullable PsiElement element) {
+ if (element != null) {
+ element = element.getPrevSibling();
+ while (element instanceof PsiWhiteSpace) {
+ element = element.getPrevSibling();
+ }
+ }
+
+ return element;
+ }
+
+ public static boolean isString(@NonNull PsiType type) {
+ if (type instanceof PsiClassType) {
+ final String shortName = ((PsiClassType)type).getClassName();
+ if (!Objects.equal(shortName, CommonClassNames.JAVA_LANG_STRING_SHORT)) {
+ return false;
+ }
+ }
+ return CommonClassNames.JAVA_LANG_STRING.equals(type.getCanonicalText());
+ }
+
+ @Nullable
+ public static String getAutoBoxedType(@NonNull String primitive) {
+ if (TYPE_INT.equals(primitive)) {
+ return TYPE_INTEGER_WRAPPER;
+ } else if (TYPE_LONG.equals(primitive)) {
+ return TYPE_LONG_WRAPPER;
+ } else if (TYPE_CHAR.equals(primitive)) {
+ return TYPE_CHARACTER_WRAPPER;
+ } else if (TYPE_FLOAT.equals(primitive)) {
+ return TYPE_FLOAT_WRAPPER;
+ } else if (TYPE_DOUBLE.equals(primitive)) {
+ return TYPE_DOUBLE_WRAPPER;
+ } else if (TYPE_BOOLEAN.equals(primitive)) {
+ return TYPE_BOOLEAN_WRAPPER;
+ } else if (TYPE_SHORT.equals(primitive)) {
+ return TYPE_SHORT_WRAPPER;
+ } else if (TYPE_BYTE.equals(primitive)) {
+ return TYPE_BYTE_WRAPPER;
+ }
+
+ return null;
+ }
+
+ @Nullable
+ public static String getPrimitiveType(@NonNull String autoBoxedType) {
+ if (TYPE_INTEGER_WRAPPER.equals(autoBoxedType)) {
+ return TYPE_INT;
+ } else if (TYPE_LONG_WRAPPER.equals(autoBoxedType)) {
+ return TYPE_LONG;
+ } else if (TYPE_CHARACTER_WRAPPER.equals(autoBoxedType)) {
+ return TYPE_CHAR;
+ } else if (TYPE_FLOAT_WRAPPER.equals(autoBoxedType)) {
+ return TYPE_FLOAT;
+ } else if (TYPE_DOUBLE_WRAPPER.equals(autoBoxedType)) {
+ return TYPE_DOUBLE;
+ } else if (TYPE_BOOLEAN_WRAPPER.equals(autoBoxedType)) {
+ return TYPE_BOOLEAN;
+ } else if (TYPE_SHORT_WRAPPER.equals(autoBoxedType)) {
+ return TYPE_SHORT;
+ } else if (TYPE_BYTE_WRAPPER.equals(autoBoxedType)) {
+ return TYPE_BYTE;
+ }
+
+ return null;
+ }
+}
diff --git a/plugins/lint/lint-api/src/com/android/tools/klint/detector/api/ResourceEvaluator.java.as31 b/plugins/lint/lint-api/src/com/android/tools/klint/detector/api/ResourceEvaluator.java.as31
new file mode 100644
index 00000000000..9b3368e9a47
--- /dev/null
+++ b/plugins/lint/lint-api/src/com/android/tools/klint/detector/api/ResourceEvaluator.java.as31
@@ -0,0 +1,690 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.klint.detector.api;
+
+import static com.android.SdkConstants.ANDROID_PKG;
+import static com.android.SdkConstants.ANDROID_PKG_PREFIX;
+import static com.android.SdkConstants.CLASS_CONTEXT;
+import static com.android.SdkConstants.CLASS_FRAGMENT;
+import static com.android.SdkConstants.CLASS_RESOURCES;
+import static com.android.SdkConstants.CLASS_V4_FRAGMENT;
+import static com.android.SdkConstants.R_CLASS;
+import static com.android.SdkConstants.SUPPORT_ANNOTATIONS_PREFIX;
+import static com.android.tools.klint.client.api.UastLintUtils.toAndroidReferenceViaResolve;
+
+import com.android.annotations.NonNull;
+import com.android.annotations.Nullable;
+import com.android.resources.ResourceUrl;
+import com.android.resources.ResourceType;
+import com.android.tools.klint.client.api.AndroidReference;
+import com.android.tools.klint.client.api.JavaEvaluator;
+import com.android.tools.klint.client.api.UastLintUtils;
+import com.intellij.psi.PsiAnnotation;
+import com.intellij.psi.PsiAssignmentExpression;
+import com.intellij.psi.PsiClass;
+import com.intellij.psi.PsiConditionalExpression;
+import com.intellij.psi.PsiDeclarationStatement;
+import com.intellij.psi.PsiElement;
+import com.intellij.psi.PsiExpression;
+import com.intellij.psi.PsiExpressionStatement;
+import com.intellij.psi.PsiField;
+import com.intellij.psi.PsiLocalVariable;
+import com.intellij.psi.PsiMethod;
+import com.intellij.psi.PsiMethodCallExpression;
+import com.intellij.psi.PsiModifierListOwner;
+import com.intellij.psi.PsiParameter;
+import com.intellij.psi.PsiParenthesizedExpression;
+import com.intellij.psi.PsiReference;
+import com.intellij.psi.PsiReferenceExpression;
+import com.intellij.psi.PsiStatement;
+import com.intellij.psi.PsiVariable;
+import com.intellij.psi.util.PsiTreeUtil;
+
+import org.jetbrains.uast.UCallExpression;
+import org.jetbrains.uast.UElement;
+import org.jetbrains.uast.UExpression;
+import org.jetbrains.uast.UIfExpression;
+import org.jetbrains.uast.UParenthesizedExpression;
+import org.jetbrains.uast.UQualifiedReferenceExpression;
+import org.jetbrains.uast.UastUtils;
+import org.jetbrains.uast.UReferenceExpression;
+
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Locale;
+
+/** Evaluates constant expressions */
+public class ResourceEvaluator {
+
+ /**
+ * Marker ResourceType used to signify that an expression is of type {@code @ColorInt},
+ * which isn't actually a ResourceType but one we want to specifically compare with.
+ * We're using {@link ResourceType#PUBLIC} because that one won't appear in the R
+ * class (and ResourceType is an enum we can't just create new constants for.)
+ */
+ public static final ResourceType COLOR_INT_MARKER_TYPE = ResourceType.PUBLIC;
+ /**
+ * Marker ResourceType used to signify that an expression is of type {@code @Px},
+ * which isn't actually a ResourceType but one we want to specifically compare with.
+ * We're using {@link ResourceType#DECLARE_STYLEABLE} because that one doesn't
+ * have a corresponding {@code *Res} constant (and ResourceType is an enum we can't
+ * just create new constants for.)
+ */
+ public static final ResourceType PX_MARKER_TYPE = ResourceType.DECLARE_STYLEABLE;
+
+ public static final String COLOR_INT_ANNOTATION = SUPPORT_ANNOTATIONS_PREFIX + "ColorInt"; //$NON-NLS-1$
+ public static final String PX_ANNOTATION = SUPPORT_ANNOTATIONS_PREFIX + "Px"; //$NON-NLS-1$
+ public static final String RES_SUFFIX = "Res";
+
+ public static final String CLS_TYPED_ARRAY = "android.content.res.TypedArray";
+
+ private final JavaContext mContext;
+ private final JavaEvaluator mEvaluator;
+
+ private boolean mAllowDereference = true;
+
+ /**
+ * Creates a new resource evaluator
+ *
+ * @param context Java context
+ */
+ public ResourceEvaluator(JavaContext context) {
+ mContext = context;
+ mEvaluator = context.getEvaluator();
+ }
+
+ /**
+ * Whether we allow dereferencing resources when computing constants;
+ * e.g. if we ask for the resource for {@code x} when the code is
+ * {@code x = getString(R.string.name)}, if {@code allowDereference} is
+ * true we'll return R.string.name, otherwise we'll return null.
+ *
+ * @return this for constructor chaining
+ */
+ public ResourceEvaluator allowDereference(boolean allow) {
+ mAllowDereference = allow;
+ return this;
+ }
+
+ /**
+ * Evaluates the given node and returns the resource reference (type and name) it
+ * points to, if any
+ *
+ * @param context Java context
+ * @param element the node to compute the constant value for
+ * @return the corresponding resource url (type and name)
+ */
+ @Nullable
+ public static ResourceUrl getResource(
+ @NonNull JavaContext context,
+ @NonNull PsiElement element) {
+ return new ResourceEvaluator(context).getResource(element);
+ }
+
+ /**
+ * Evaluates the given node and returns the resource reference (type and name) it
+ * points to, if any
+ *
+ * @param context Java context
+ * @param element the node to compute the constant value for
+ * @return the corresponding resource url (type and name)
+ */
+ @Nullable
+ public static ResourceUrl getResource(
+ @NonNull JavaContext context,
+ @NonNull UElement element) {
+ return new ResourceEvaluator(context).getResource(element);
+ }
+
+ /**
+ * Evaluates the given node and returns the resource types implied by the given element,
+ * if any.
+ *
+ * @param context Java context
+ * @param element the node to compute the constant value for
+ * @return the corresponding resource types
+ */
+ @Nullable
+ public static EnumSet getResourceTypes(
+ @NonNull JavaContext context,
+ @NonNull PsiElement element) {
+ return new ResourceEvaluator(context).getResourceTypes(element);
+ }
+
+ /**
+ * Evaluates the given node and returns the resource types implied by the given element,
+ * if any.
+ *
+ * @param context Java context
+ * @param element the node to compute the constant value for
+ * @return the corresponding resource types
+ */
+ @Nullable
+ public static EnumSet getResourceTypes(
+ @NonNull JavaContext context,
+ @NonNull UElement element) {
+ return new ResourceEvaluator(context).getResourceTypes(element);
+ }
+
+ /**
+ * Evaluates the given node and returns the resource reference (type and name) it
+ * points to, if any
+ *
+ * @param element the node to compute the constant value for
+ * @return the corresponding constant value - a String, an Integer, a Float, and so on
+ */
+ @Nullable
+ public ResourceUrl getResource(@Nullable UElement element) {
+ if (element == null) {
+ return null;
+ }
+
+ if (element instanceof UIfExpression) {
+ UIfExpression expression = (UIfExpression) element;
+ Object known = ConstantEvaluator.evaluate(null, expression.getCondition());
+ if (known == Boolean.TRUE && expression.getThenExpression() != null) {
+ return getResource(expression.getThenExpression());
+ } else if (known == Boolean.FALSE && expression.getElseExpression() != null) {
+ return getResource(expression.getElseExpression());
+ }
+ } else if (element instanceof UParenthesizedExpression) {
+ UParenthesizedExpression parenthesizedExpression = (UParenthesizedExpression) element;
+ return getResource(parenthesizedExpression.getExpression());
+ } else if (mAllowDereference && element instanceof UQualifiedReferenceExpression) {
+ UQualifiedReferenceExpression qualifiedExpression = (UQualifiedReferenceExpression) element;
+ UExpression selector = qualifiedExpression.getSelector();
+ if ((selector instanceof UCallExpression)) {
+ UCallExpression call = (UCallExpression) selector;
+ PsiMethod function = call.resolve();
+ PsiClass containingClass = UastUtils.getContainingClass(function);
+ if (function != null && containingClass != null) {
+ String qualifiedName = containingClass.getQualifiedName();
+ String name = call.getMethodName();
+ if ((CLASS_RESOURCES.equals(qualifiedName)
+ || CLASS_CONTEXT.equals(qualifiedName)
+ || CLASS_FRAGMENT.equals(qualifiedName)
+ || CLASS_V4_FRAGMENT.equals(qualifiedName)
+ || CLS_TYPED_ARRAY.equals(qualifiedName))
+ && name != null
+ && name.startsWith("get")) {
+ List args = call.getValueArguments();
+ if (!args.isEmpty()) {
+ return getResource(args.get(0));
+ }
+ }
+ }
+ }
+ }
+
+ if (element instanceof UReferenceExpression) {
+ ResourceUrl url = getResourceConstant(element);
+ if (url != null) {
+ return url;
+ }
+ PsiElement resolved = ((UReferenceExpression) element).resolve();
+ if (resolved instanceof PsiVariable) {
+ PsiVariable variable = (PsiVariable) resolved;
+ UElement lastAssignment =
+ UastLintUtils.findLastAssignment(
+ variable, element, mContext);
+
+ if (lastAssignment != null) {
+ return getResource(lastAssignment);
+ }
+
+ return null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Evaluates the given node and returns the resource reference (type and name) it
+ * points to, if any
+ *
+ * @param element the node to compute the constant value for
+ * @return the corresponding constant value - a String, an Integer, a Float, and so on
+ */
+
+ @Nullable
+ public ResourceUrl getResource(@Nullable PsiElement element) {
+ if (element == null) {
+ return null;
+ }
+ if (element instanceof PsiConditionalExpression) {
+ PsiConditionalExpression expression = (PsiConditionalExpression) element;
+ Object known = ConstantEvaluator.evaluate(null, expression.getCondition());
+ if (known == Boolean.TRUE && expression.getThenExpression() != null) {
+ return getResource(expression.getThenExpression());
+ } else if (known == Boolean.FALSE && expression.getElseExpression() != null) {
+ return getResource(expression.getElseExpression());
+ }
+ } else if (element instanceof PsiParenthesizedExpression) {
+ PsiParenthesizedExpression parenthesizedExpression = (PsiParenthesizedExpression) element;
+ return getResource(parenthesizedExpression.getExpression());
+ } else if (element instanceof PsiMethodCallExpression && mAllowDereference) {
+ PsiMethodCallExpression call = (PsiMethodCallExpression) element;
+ PsiReferenceExpression expression = call.getMethodExpression();
+ PsiMethod method = call.resolveMethod();
+ if (method != null && method.getContainingClass() != null) {
+ String qualifiedName = method.getContainingClass().getQualifiedName();
+ String name = expression.getReferenceName();
+ if ((CLASS_RESOURCES.equals(qualifiedName)
+ || CLASS_CONTEXT.equals(qualifiedName)
+ || CLASS_FRAGMENT.equals(qualifiedName)
+ || CLASS_V4_FRAGMENT.equals(qualifiedName)
+ || CLS_TYPED_ARRAY.equals(qualifiedName))
+ && name != null
+ && name.startsWith("get")) {
+ PsiExpression[] args = call.getArgumentList().getExpressions();
+ if (args.length > 0) {
+ return getResource(args[0]);
+ }
+ }
+ }
+ } else if (element instanceof PsiReference) {
+ ResourceUrl url = getResourceConstant(element);
+ if (url != null) {
+ return url;
+ }
+ PsiElement resolved = ((PsiReference) element).resolve();
+ if (resolved instanceof PsiField) {
+ url = getResourceConstant(resolved);
+ if (url != null) {
+ return url;
+ }
+ PsiField field = (PsiField) resolved;
+ if (field.getInitializer() != null) {
+ return getResource(field.getInitializer());
+ }
+ return null;
+ } else if (resolved instanceof PsiLocalVariable) {
+ PsiLocalVariable variable = (PsiLocalVariable) resolved;
+ PsiStatement statement = PsiTreeUtil.getParentOfType(element, PsiStatement.class,
+ false);
+ if (statement != null) {
+ PsiStatement prev = PsiTreeUtil.getPrevSiblingOfType(statement,
+ PsiStatement.class);
+ String targetName = variable.getName();
+ if (targetName == null) {
+ return null;
+ }
+ while (prev != null) {
+ if (prev instanceof PsiDeclarationStatement) {
+ PsiDeclarationStatement prevStatement = (PsiDeclarationStatement) prev;
+ for (PsiElement e : prevStatement.getDeclaredElements()) {
+ if (variable.equals(e)) {
+ return getResource(variable.getInitializer());
+ }
+ }
+ } else if (prev instanceof PsiExpressionStatement) {
+ PsiExpression expression = ((PsiExpressionStatement) prev)
+ .getExpression();
+ if (expression instanceof PsiAssignmentExpression) {
+ PsiAssignmentExpression assign
+ = (PsiAssignmentExpression) expression;
+ PsiExpression lhs = assign.getLExpression();
+ if (lhs instanceof PsiReferenceExpression) {
+ PsiReferenceExpression reference = (PsiReferenceExpression) lhs;
+ if (targetName.equals(reference.getReferenceName()) &&
+ reference.getQualifier() == null) {
+ return getResource(assign.getRExpression());
+ }
+ }
+ }
+ }
+ prev = PsiTreeUtil.getPrevSiblingOfType(prev,
+ PsiStatement.class);
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Evaluates the given node and returns the resource types applicable to the
+ * node, if any.
+ *
+ * @param element the element to compute the types for
+ * @return the corresponding resource types
+ */
+ @Nullable
+ public EnumSet getResourceTypes(@Nullable UElement element) {
+ if (element == null) {
+ return null;
+ }
+ if (element instanceof UIfExpression) {
+ UIfExpression expression = (UIfExpression) element;
+ Object known = ConstantEvaluator.evaluate(null, expression.getCondition());
+ if (known == Boolean.TRUE && expression.getThenExpression() != null) {
+ return getResourceTypes(expression.getThenExpression());
+ } else if (known == Boolean.FALSE && expression.getElseExpression() != null) {
+ return getResourceTypes(expression.getElseExpression());
+ } else {
+ EnumSet left = getResourceTypes(
+ expression.getThenExpression());
+ EnumSet right = getResourceTypes(
+ expression.getElseExpression());
+ if (left == null) {
+ return right;
+ } else if (right == null) {
+ return left;
+ } else {
+ EnumSet copy = EnumSet.copyOf(left);
+ copy.addAll(right);
+ return copy;
+ }
+ }
+ } else if (element instanceof UParenthesizedExpression) {
+ UParenthesizedExpression parenthesizedExpression = (UParenthesizedExpression) element;
+ return getResourceTypes(parenthesizedExpression.getExpression());
+ } else if ((element instanceof UQualifiedReferenceExpression && mAllowDereference)
+ || element instanceof UCallExpression) {
+ UElement probablyCallExpression = element;
+ if (element instanceof UQualifiedReferenceExpression) {
+ UQualifiedReferenceExpression qualifiedExpression =
+ (UQualifiedReferenceExpression) element;
+ probablyCallExpression = qualifiedExpression.getSelector();
+ }
+ if ((probablyCallExpression instanceof UCallExpression)) {
+ UCallExpression call = (UCallExpression) probablyCallExpression;
+ PsiMethod method = call.resolve();
+ PsiClass containingClass = UastUtils.getContainingClass(method);
+ if (method != null && containingClass != null) {
+ EnumSet types = getTypesFromAnnotations(method);
+ if (types != null) {
+ return types;
+ }
+
+ String qualifiedName = containingClass.getQualifiedName();
+ String name = call.getMethodName();
+ if ((CLASS_RESOURCES.equals(qualifiedName)
+ || CLASS_CONTEXT.equals(qualifiedName)
+ || CLASS_FRAGMENT.equals(qualifiedName)
+ || CLASS_V4_FRAGMENT.equals(qualifiedName)
+ || CLS_TYPED_ARRAY.equals(qualifiedName))
+ && name != null
+ && name.startsWith("get")) {
+ List args = call.getValueArguments();
+ if (!args.isEmpty()) {
+ types = getResourceTypes(args.get(0));
+ if (types != null) {
+ return types;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (element instanceof UReferenceExpression) {
+ ResourceUrl url = getResourceConstant(element);
+ if (url != null) {
+ return EnumSet.of(url.type);
+ }
+
+ PsiElement resolved = ((UReferenceExpression) element).resolve();
+ if (resolved instanceof PsiVariable) {
+ PsiVariable variable = (PsiVariable) resolved;
+ UElement lastAssignment =
+ UastLintUtils.findLastAssignment(variable, element, mContext);
+
+ if (lastAssignment != null) {
+ return getResourceTypes(lastAssignment);
+ }
+
+ return null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Evaluates the given node and returns the resource types applicable to the
+ * node, if any.
+ *
+ * @param element the element to compute the types for
+ * @return the corresponding resource types
+ */
+ @Nullable
+ public EnumSet getResourceTypes(@Nullable PsiElement element) {
+ if (element == null) {
+ return null;
+ }
+ if (element instanceof PsiConditionalExpression) {
+ PsiConditionalExpression expression = (PsiConditionalExpression) element;
+ Object known = ConstantEvaluator.evaluate(null, expression.getCondition());
+ if (known == Boolean.TRUE && expression.getThenExpression() != null) {
+ return getResourceTypes(expression.getThenExpression());
+ } else if (known == Boolean.FALSE && expression.getElseExpression() != null) {
+ return getResourceTypes(expression.getElseExpression());
+ } else {
+ EnumSet left = getResourceTypes(
+ expression.getThenExpression());
+ EnumSet right = getResourceTypes(
+ expression.getElseExpression());
+ if (left == null) {
+ return right;
+ } else if (right == null) {
+ return left;
+ } else {
+ EnumSet copy = EnumSet.copyOf(left);
+ copy.addAll(right);
+ return copy;
+ }
+ }
+ } else if (element instanceof PsiParenthesizedExpression) {
+ PsiParenthesizedExpression parenthesizedExpression = (PsiParenthesizedExpression) element;
+ return getResourceTypes(parenthesizedExpression.getExpression());
+ } else if (element instanceof PsiMethodCallExpression && mAllowDereference) {
+ PsiMethodCallExpression call = (PsiMethodCallExpression) element;
+ PsiReferenceExpression expression = call.getMethodExpression();
+ PsiMethod method = call.resolveMethod();
+ if (method != null && method.getContainingClass() != null) {
+ EnumSet types = getTypesFromAnnotations(method);
+ if (types != null) {
+ return types;
+ }
+
+ String qualifiedName = method.getContainingClass().getQualifiedName();
+ String name = expression.getReferenceName();
+ if ((CLASS_RESOURCES.equals(qualifiedName)
+ || CLASS_CONTEXT.equals(qualifiedName)
+ || CLASS_FRAGMENT.equals(qualifiedName)
+ || CLASS_V4_FRAGMENT.equals(qualifiedName)
+ || CLS_TYPED_ARRAY.equals(qualifiedName))
+ && name != null
+ && name.startsWith("get")) {
+ PsiExpression[] args = call.getArgumentList().getExpressions();
+ if (args.length > 0) {
+ types = getResourceTypes(args[0]);
+ if (types != null) {
+ return types;
+ }
+ }
+ }
+ }
+ } else if (element instanceof PsiReference) {
+ ResourceUrl url = getResourceConstant(element);
+ if (url != null) {
+ return EnumSet.of(url.type);
+ }
+ PsiElement resolved = ((PsiReference) element).resolve();
+ if (resolved instanceof PsiField) {
+ url = getResourceConstant(resolved);
+ if (url != null) {
+ return EnumSet.of(url.type);
+ }
+ PsiField field = (PsiField) resolved;
+ if (field.getInitializer() != null) {
+ return getResourceTypes(field.getInitializer());
+ }
+ return null;
+ } else if (resolved instanceof PsiParameter) {
+ return getTypesFromAnnotations((PsiParameter)resolved);
+ } else if (resolved instanceof PsiLocalVariable) {
+ PsiLocalVariable variable = (PsiLocalVariable) resolved;
+ PsiStatement statement = PsiTreeUtil.getParentOfType(element, PsiStatement.class,
+ false);
+ if (statement != null) {
+ PsiStatement prev = PsiTreeUtil.getPrevSiblingOfType(statement,
+ PsiStatement.class);
+ String targetName = variable.getName();
+ if (targetName == null) {
+ return null;
+ }
+ while (prev != null) {
+ if (prev instanceof PsiDeclarationStatement) {
+ PsiDeclarationStatement prevStatement = (PsiDeclarationStatement) prev;
+ for (PsiElement e : prevStatement.getDeclaredElements()) {
+ if (variable.equals(e)) {
+ return getResourceTypes(variable.getInitializer());
+ }
+ }
+ } else if (prev instanceof PsiExpressionStatement) {
+ PsiExpression expression = ((PsiExpressionStatement) prev)
+ .getExpression();
+ if (expression instanceof PsiAssignmentExpression) {
+ PsiAssignmentExpression assign
+ = (PsiAssignmentExpression) expression;
+ PsiExpression lhs = assign.getLExpression();
+ if (lhs instanceof PsiReferenceExpression) {
+ PsiReferenceExpression reference = (PsiReferenceExpression) lhs;
+ if (targetName.equals(reference.getReferenceName()) &&
+ reference.getQualifier() == null) {
+ return getResourceTypes(assign.getRExpression());
+ }
+ }
+ }
+ }
+ prev = PsiTreeUtil.getPrevSiblingOfType(prev,
+ PsiStatement.class);
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ @Nullable
+ private EnumSet getTypesFromAnnotations(PsiModifierListOwner owner) {
+ if (mEvaluator == null) {
+ return null;
+ }
+ for (PsiAnnotation annotation : mEvaluator.getAllAnnotations(owner)) {
+ String signature = annotation.getQualifiedName();
+ if (signature == null) {
+ continue;
+ }
+ if (signature.equals(COLOR_INT_ANNOTATION)) {
+ return EnumSet.of(COLOR_INT_MARKER_TYPE);
+ }
+ if (signature.equals(PX_ANNOTATION)) {
+ return EnumSet.of(PX_MARKER_TYPE);
+ }
+ if (signature.endsWith(RES_SUFFIX)
+ && signature.startsWith(SUPPORT_ANNOTATIONS_PREFIX)) {
+ String typeString = signature
+ .substring(SUPPORT_ANNOTATIONS_PREFIX.length(),
+ signature.length() - RES_SUFFIX.length())
+ .toLowerCase(Locale.US);
+ ResourceType type = ResourceType.getEnum(typeString);
+ if (type != null) {
+ return EnumSet.of(type);
+ } else if (typeString.equals("any")) { // @AnyRes
+ return getAnyRes();
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /** Returns a resource URL based on the field reference in the code */
+ @Nullable
+ public static ResourceUrl getResourceConstant(@NonNull PsiElement node) {
+ // R.type.name
+ if (node instanceof PsiReferenceExpression) {
+ PsiReferenceExpression expression = (PsiReferenceExpression) node;
+ if (expression.getQualifier() instanceof PsiReferenceExpression) {
+ PsiReferenceExpression select = (PsiReferenceExpression) expression.getQualifier();
+ if (select.getQualifier() instanceof PsiReferenceExpression) {
+ PsiReferenceExpression reference = (PsiReferenceExpression) select
+ .getQualifier();
+ if (R_CLASS.equals(reference.getReferenceName())) {
+ String typeName = select.getReferenceName();
+ String name = expression.getReferenceName();
+
+ ResourceType type = ResourceType.getEnum(typeName);
+ if (type != null && name != null) {
+ boolean isFramework =
+ reference.getQualifier() instanceof PsiReferenceExpression
+ && ANDROID_PKG
+ .equals(((PsiReferenceExpression) reference.
+ getQualifier()).getReferenceName());
+
+ return ResourceUrl.create(type, name, isFramework);
+ }
+ }
+ }
+ }
+ } else if (node instanceof PsiField) {
+ PsiField field = (PsiField) node;
+ PsiClass typeClass = field.getContainingClass();
+ if (typeClass != null) {
+ PsiClass rClass = typeClass.getContainingClass();
+ if (rClass != null && R_CLASS.equals(rClass.getName())) {
+ String name = field.getName();
+ ResourceType type = ResourceType.getEnum(typeClass.getName());
+ if (type != null && name != null) {
+ String qualifiedName = rClass.getQualifiedName();
+ boolean isFramework = qualifiedName != null
+ && qualifiedName.startsWith(ANDROID_PKG_PREFIX);
+ return ResourceUrl.create(type, name, isFramework);
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ /** Returns a resource URL based on the field reference in the code */
+ @Nullable
+ public static ResourceUrl getResourceConstant(@NonNull UElement node) {
+ AndroidReference androidReference = toAndroidReferenceViaResolve(node);
+ if (androidReference == null) {
+ return null;
+ }
+
+ String name = androidReference.getName();
+ ResourceType type = androidReference.getType();
+ boolean isFramework = androidReference.getPackage().equals("android");
+
+ return ResourceUrl.create(type, name, isFramework);
+ }
+
+ private static EnumSet getAnyRes() {
+ EnumSet types = EnumSet.allOf(ResourceType.class);
+ types.remove(ResourceEvaluator.COLOR_INT_MARKER_TYPE);
+ types.remove(ResourceEvaluator.PX_MARKER_TYPE);
+ return types;
+ }
+}
diff --git a/plugins/lint/lint-checks/src/com/android/tools/klint/checks/AppIndexingApiDetector.java.as31 b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/AppIndexingApiDetector.java.as31
new file mode 100644
index 00000000000..5989552db15
--- /dev/null
+++ b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/AppIndexingApiDetector.java.as31
@@ -0,0 +1,744 @@
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.klint.checks;
+
+import static com.android.SdkConstants.ANDROID_URI;
+import static com.android.SdkConstants.ATTR_EXPORTED;
+import static com.android.SdkConstants.ATTR_HOST;
+import static com.android.SdkConstants.ATTR_PATH;
+import static com.android.SdkConstants.ATTR_PATH_PREFIX;
+import static com.android.SdkConstants.ATTR_SCHEME;
+import static com.android.SdkConstants.CLASS_ACTIVITY;
+import static com.android.xml.AndroidManifest.ATTRIBUTE_MIME_TYPE;
+import static com.android.xml.AndroidManifest.ATTRIBUTE_NAME;
+import static com.android.xml.AndroidManifest.ATTRIBUTE_PORT;
+import static com.android.xml.AndroidManifest.NODE_ACTION;
+import static com.android.xml.AndroidManifest.NODE_ACTIVITY;
+import static com.android.xml.AndroidManifest.NODE_APPLICATION;
+import static com.android.xml.AndroidManifest.NODE_CATEGORY;
+import static com.android.xml.AndroidManifest.NODE_DATA;
+import static com.android.xml.AndroidManifest.NODE_INTENT;
+import static com.android.xml.AndroidManifest.NODE_MANIFEST;
+
+import com.android.SdkConstants;
+import com.android.annotations.NonNull;
+import com.android.annotations.Nullable;
+import com.android.ide.common.rendering.api.ResourceValue;
+import com.android.ide.common.res2.AbstractResourceRepository;
+import com.android.ide.common.res2.ResourceItem;
+import com.android.resources.ResourceUrl;
+import com.android.resources.ResourceType;
+import com.android.tools.klint.client.api.JavaEvaluator;
+import com.android.tools.klint.client.api.LintClient;
+import com.android.tools.klint.client.api.XmlParser;
+import com.android.tools.klint.detector.api.Category;
+import com.android.tools.klint.detector.api.Context;
+import com.android.tools.klint.detector.api.Detector;
+import com.android.tools.klint.detector.api.Detector.XmlScanner;
+import com.android.tools.klint.detector.api.Implementation;
+import com.android.tools.klint.detector.api.Issue;
+import com.android.tools.klint.detector.api.JavaContext;
+import com.android.tools.klint.detector.api.LintUtils;
+import com.android.tools.klint.detector.api.Project;
+import com.android.tools.klint.detector.api.Scope;
+import com.android.tools.klint.detector.api.Severity;
+import com.android.tools.klint.detector.api.XmlContext;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.intellij.psi.PsiElement;
+import com.intellij.psi.PsiField;
+
+import com.intellij.psi.util.InheritanceUtil;
+import org.jetbrains.uast.UCallExpression;
+import org.jetbrains.uast.UClass;
+import org.jetbrains.uast.UElement;
+import org.jetbrains.uast.UExpression;
+import org.jetbrains.uast.UastUtils;
+import org.jetbrains.uast.util.UastExpressionUtils;
+import org.jetbrains.uast.visitor.AbstractUastVisitor;
+import org.w3c.dom.Attr;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+
+import java.io.File;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+
+/**
+ * Check if the usage of App Indexing is correct.
+ */
+public class AppIndexingApiDetector extends Detector implements XmlScanner, Detector.UastScanner {
+
+ private static final Implementation URL_IMPLEMENTATION = new Implementation(
+ AppIndexingApiDetector.class, Scope.MANIFEST_SCOPE);
+
+ @SuppressWarnings("unchecked")
+ private static final Implementation APP_INDEXING_API_IMPLEMENTATION =
+ new Implementation(
+ AppIndexingApiDetector.class,
+ EnumSet.of(Scope.JAVA_FILE, Scope.MANIFEST),
+ Scope.JAVA_FILE_SCOPE, Scope.MANIFEST_SCOPE);
+
+ public static final Issue ISSUE_URL_ERROR = Issue.create(
+ "GoogleAppIndexingUrlError", //$NON-NLS-1$
+ "URL not supported by app for Google App Indexing",
+ "Ensure the URL is supported by your app, to get installs and traffic to your"
+ + " app from Google Search.",
+ Category.USABILITY, 5, Severity.ERROR, URL_IMPLEMENTATION)
+ .addMoreInfo("https://g.co/AppIndexing/AndroidStudio");
+
+ public static final Issue ISSUE_APP_INDEXING =
+ Issue.create(
+ "GoogleAppIndexingWarning", //$NON-NLS-1$
+ "Missing support for Google App Indexing",
+ "Adds URLs to get your app into the Google index, to get installs"
+ + " and traffic to your app from Google Search.",
+ Category.USABILITY, 5, Severity.WARNING, URL_IMPLEMENTATION)
+ .addMoreInfo("https://g.co/AppIndexing/AndroidStudio");
+
+ public static final Issue ISSUE_APP_INDEXING_API =
+ Issue.create(
+ "GoogleAppIndexingApiWarning", //$NON-NLS-1$
+ "Missing support for Google App Indexing Api",
+ "Adds URLs to get your app into the Google index, to get installs"
+ + " and traffic to your app from Google Search.",
+ Category.USABILITY, 5, Severity.WARNING, APP_INDEXING_API_IMPLEMENTATION)
+ .addMoreInfo("https://g.co/AppIndexing/AndroidStudio")
+ .setEnabledByDefault(false);
+
+ private static final String[] PATH_ATTR_LIST = new String[]{ATTR_PATH_PREFIX, ATTR_PATH};
+ private static final String SCHEME_MISSING = "android:scheme is missing";
+ private static final String HOST_MISSING = "android:host is missing";
+ private static final String DATA_MISSING = "Missing data element";
+ private static final String URL_MISSING = "Missing URL for the intent filter";
+ private static final String NOT_BROWSABLE
+ = "Activity supporting ACTION_VIEW is not set as BROWSABLE";
+ private static final String ILLEGAL_NUMBER = "android:port is not a legal number";
+
+ private static final String APP_INDEX_START = "start"; //$NON-NLS-1$
+ private static final String APP_INDEX_END = "end"; //$NON-NLS-1$
+ private static final String APP_INDEX_VIEW = "view"; //$NON-NLS-1$
+ private static final String APP_INDEX_VIEW_END = "viewEnd"; //$NON-NLS-1$
+ private static final String CLIENT_CONNECT = "connect"; //$NON-NLS-1$
+ private static final String CLIENT_DISCONNECT = "disconnect"; //$NON-NLS-1$
+ private static final String ADD_API = "addApi"; //$NON-NLS-1$
+
+ private static final String APP_INDEXING_API_CLASS
+ = "com.google.android.gms.appindexing.AppIndexApi";
+ private static final String GOOGLE_API_CLIENT_CLASS
+ = "com.google.android.gms.common.api.GoogleApiClient";
+ private static final String GOOGLE_API_CLIENT_BUILDER_CLASS
+ = "com.google.android.gms.common.api.GoogleApiClient.Builder";
+ private static final String API_CLASS = "com.google.android.gms.appindexing.AppIndex";
+
+ public enum IssueType {
+ SCHEME_MISSING(AppIndexingApiDetector.SCHEME_MISSING),
+ HOST_MISSING(AppIndexingApiDetector.HOST_MISSING),
+ DATA_MISSING(AppIndexingApiDetector.DATA_MISSING),
+ URL_MISSING(AppIndexingApiDetector.URL_MISSING),
+ NOT_BROWSABLE(AppIndexingApiDetector.NOT_BROWSABLE),
+ ILLEGAL_NUMBER(AppIndexingApiDetector.ILLEGAL_NUMBER),
+ EMPTY_FIELD("cannot be empty"),
+ MISSING_SLASH("attribute should start with '/'"),
+ UNKNOWN("unknown error type");
+
+ private final String message;
+
+ IssueType(String str) {
+ this.message = str;
+ }
+
+ public static IssueType parse(String str) {
+ for (IssueType type : IssueType.values()) {
+ if (str.contains(type.message)) {
+ return type;
+ }
+ }
+ return UNKNOWN;
+ }
+ }
+
+ // ---- Implements XmlScanner ----
+ @Override
+ @Nullable
+ public Collection getApplicableElements() {
+ return Collections.singletonList(NODE_APPLICATION);
+ }
+
+ @Override
+ public void visitElement(@NonNull XmlContext context, @NonNull Element application) {
+ List activities = extractChildrenByName(application, NODE_ACTIVITY);
+ boolean applicationHasActionView = false;
+ for (Element activity : activities) {
+ List intents = extractChildrenByName(activity, NODE_INTENT);
+ boolean activityHasActionView = false;
+ for (Element intent : intents) {
+ boolean actionView = hasActionView(intent);
+ if (actionView) {
+ activityHasActionView = true;
+ }
+ visitIntent(context, intent);
+ }
+ if (activityHasActionView) {
+ applicationHasActionView = true;
+ if (activity.hasAttributeNS(ANDROID_URI, ATTR_EXPORTED)) {
+ Attr exported = activity.getAttributeNodeNS(ANDROID_URI, ATTR_EXPORTED);
+ if (!exported.getValue().equals("true")) {
+ // Report error if the activity supporting action view is not exported.
+ context.report(ISSUE_URL_ERROR, activity,
+ context.getLocation(activity),
+ "Activity supporting ACTION_VIEW is not exported");
+ }
+ }
+ }
+ }
+ if (!applicationHasActionView && !context.getProject().isLibrary()) {
+ // Report warning if there is no activity that supports action view.
+ context.report(ISSUE_APP_INDEXING, application, context.getLocation(application),
+ // This error message is more verbose than the other app indexing lint warnings, because it
+ // shows up on a blank project, and we want to make it obvious by just looking at the error
+ // message what this is
+ "App is not indexable by Google Search; consider adding at least one Activity with an ACTION-VIEW " +
+ "intent filter. See issue explanation for more details.");
+ }
+ }
+
+ @Nullable
+ @Override
+ public List applicableSuperClasses() {
+ return Collections.singletonList(CLASS_ACTIVITY);
+ }
+
+ @Override
+ public void checkClass(@NonNull JavaContext context, @NonNull UClass declaration) {
+ if (declaration.getName() == null) {
+ return;
+ }
+
+ // In case linting the base class itself.
+ if (!InheritanceUtil.isInheritor(declaration, true, CLASS_ACTIVITY)) {
+ return;
+ }
+
+ declaration.accept(new MethodVisitor(context, declaration));
+ }
+
+ static class MethodVisitor extends AbstractUastVisitor {
+ private final JavaContext mContext;
+ private final UClass mCls;
+
+ private final List mStartMethods;
+ private final List mEndMethods;
+ private final List mConnectMethods;
+ private final List mDisconnectMethods;
+ private boolean mHasAddAppIndexApi;
+
+ private MethodVisitor(JavaContext context, UClass cls) {
+ mCls = cls;
+ mContext = context;
+ mStartMethods = Lists.newArrayListWithExpectedSize(2);
+ mEndMethods = Lists.newArrayListWithExpectedSize(2);
+ mConnectMethods = Lists.newArrayListWithExpectedSize(2);
+ mDisconnectMethods = Lists.newArrayListWithExpectedSize(2);
+ }
+
+ @Override
+ public boolean visitClass(UClass aClass) {
+ if (aClass.getPsi().equals(mCls.getPsi())) {
+ return super.visitClass(aClass);
+ } else {
+ // Don't go into inner classes
+ return true;
+ }
+ }
+
+ @Override
+ public void afterVisitClass(UClass node) {
+ report();
+ }
+
+ @Override
+ public boolean visitCallExpression(UCallExpression node) {
+ if (UastExpressionUtils.isMethodCall(node)) {
+ visitMethodCallExpression(node);
+ }
+ return super.visitCallExpression(node);
+ }
+
+ private void visitMethodCallExpression(UCallExpression node) {
+ String methodName = node.getMethodName();
+ if (methodName == null) {
+ return;
+ }
+
+ if (methodName.equals(APP_INDEX_START)) {
+ if (JavaEvaluator.isMemberInClass(node.resolve(), APP_INDEXING_API_CLASS)) {
+ mStartMethods.add(node);
+ }
+ }
+ else if (methodName.equals(APP_INDEX_END)) {
+ if (JavaEvaluator.isMemberInClass(node.resolve(), APP_INDEXING_API_CLASS)) {
+ mEndMethods.add(node);
+ }
+ }
+ else if (methodName.equals(APP_INDEX_VIEW)) {
+ if (JavaEvaluator.isMemberInClass(node.resolve(), APP_INDEXING_API_CLASS)) {
+ mStartMethods.add(node);
+ }
+ }
+ else if (methodName.equals(APP_INDEX_VIEW_END)) {
+ if (JavaEvaluator.isMemberInClass(node.resolve(), APP_INDEXING_API_CLASS)) {
+ mEndMethods.add(node);
+ }
+ }
+ else if (methodName.equals(CLIENT_CONNECT)) {
+ if (JavaEvaluator.isMemberInClass(node.resolve(), GOOGLE_API_CLIENT_CLASS)) {
+ mConnectMethods.add(node);
+ }
+ }
+ else if (methodName.equals(CLIENT_DISCONNECT)) {
+ if (JavaEvaluator.isMemberInClass(node.resolve(), GOOGLE_API_CLIENT_CLASS)) {
+ mDisconnectMethods.add(node);
+ }
+ }
+ else if (methodName.equals(ADD_API)) {
+ if (JavaEvaluator
+ .isMemberInClass(node.resolve(), GOOGLE_API_CLIENT_BUILDER_CLASS)) {
+ List args = node.getValueArguments();
+ if (!args.isEmpty()) {
+ PsiElement resolved = UastUtils.tryResolve(args.get(0));
+ if (resolved instanceof PsiField &&
+ JavaEvaluator.isMemberInClass((PsiField) resolved, API_CLASS)) {
+ mHasAddAppIndexApi = true;
+ }
+ }
+ }
+ }
+ }
+
+ private void report() {
+ // finds the activity classes that need app activity annotation
+ Set activitiesToCheck = getActivitiesToCheck(mContext);
+
+ // app indexing API used but no support in manifest
+ boolean hasIntent = activitiesToCheck.contains(mCls.getQualifiedName());
+ if (!hasIntent) {
+ for (UCallExpression call : mStartMethods) {
+ mContext.report(ISSUE_APP_INDEXING_API, call,
+ mContext.getUastNameLocation(call),
+ "Missing support for Google App Indexing in the manifest");
+ }
+ for (UCallExpression call : mEndMethods) {
+ mContext.report(ISSUE_APP_INDEXING_API, call,
+ mContext.getUastNameLocation(call),
+ "Missing support for Google App Indexing in the manifest");
+ }
+ return;
+ }
+
+ // `AppIndex.AppIndexApi.start / end / view / viewEnd` should exist
+ if (mStartMethods.isEmpty() && mEndMethods.isEmpty()) {
+ mContext.reportUast(ISSUE_APP_INDEXING_API, mCls,
+ mContext.getUastNameLocation(mCls),
+ "Missing support for Google App Indexing API");
+ return;
+ }
+
+ for (UCallExpression startNode : mStartMethods) {
+ List expressions = startNode.getValueArguments();
+ if (expressions.isEmpty()) {
+ continue;
+ }
+ UExpression startClient = expressions.get(0);
+
+ // GoogleApiClient should `addApi(AppIndex.APP_INDEX_API)`
+ if (!mHasAddAppIndexApi) {
+ String message = String.format(
+ "GoogleApiClient `%1$s` has not added support for App Indexing API",
+ startClient.asSourceString());
+ mContext.report(ISSUE_APP_INDEXING_API, startClient,
+ mContext.getUastLocation(startClient), message);
+ }
+
+ // GoogleApiClient `connect` should exist
+ if (!hasOperand(startClient, mConnectMethods)) {
+ String message = String.format("GoogleApiClient `%1$s` is not connected",
+ startClient.asSourceString());
+ mContext.report(ISSUE_APP_INDEXING_API, startClient,
+ mContext.getUastLocation(startClient), message);
+ }
+
+ // `AppIndex.AppIndexApi.end` should pair with `AppIndex.AppIndexApi.start`
+ if (!hasFirstArgument(startClient, mEndMethods)) {
+ mContext.report(ISSUE_APP_INDEXING_API, startNode,
+ mContext.getUastNameLocation(startNode),
+ "Missing corresponding `AppIndex.AppIndexApi.end` method");
+ }
+ }
+
+ for (UCallExpression endNode : mEndMethods) {
+ List expressions = endNode.getValueArguments();
+ if (expressions.isEmpty()) {
+ continue;
+ }
+ UExpression endClient = expressions.get(0);
+
+ // GoogleApiClient should `addApi(AppIndex.APP_INDEX_API)`
+ if (!mHasAddAppIndexApi) {
+ String message = String.format(
+ "GoogleApiClient `%1$s` has not added support for App Indexing API",
+ endClient.asSourceString());
+ mContext.report(ISSUE_APP_INDEXING_API, endClient,
+ mContext.getUastLocation(endClient), message);
+ }
+
+ // GoogleApiClient `disconnect` should exist
+ if (!hasOperand(endClient, mDisconnectMethods)) {
+ String message = String.format("GoogleApiClient `%1$s`"
+ + " is not disconnected", endClient.asSourceString());
+ mContext.report(ISSUE_APP_INDEXING_API, endClient,
+ mContext.getUastLocation(endClient), message);
+ }
+
+ // `AppIndex.AppIndexApi.start` should pair with `AppIndex.AppIndexApi.end`
+ if (!hasFirstArgument(endClient, mStartMethods)) {
+ mContext.report(ISSUE_APP_INDEXING_API, endNode,
+ mContext.getUastNameLocation(endNode),
+ "Missing corresponding `AppIndex.AppIndexApi.start` method");
+ }
+ }
+ }
+ }
+
+ /**
+ * Gets names of activities which needs app indexing. i.e. the activities have data tag in their
+ * intent filters.
+ * TODO: Cache the activities to speed up batch lint.
+ *
+ * @param context The context to check in.
+ */
+ private static Set getActivitiesToCheck(Context context) {
+ Set activitiesToCheck = Sets.newHashSet();
+ List manifestFiles = context.getProject().getManifestFiles();
+ XmlParser xmlParser = context.getDriver().getClient().getXmlParser();
+ if (xmlParser != null) {
+ // TODO: Avoid visit all manifest files before enable this check by default.
+ for (File manifest : manifestFiles) {
+ XmlContext xmlContext =
+ new XmlContext(context.getDriver(), context.getProject(),
+ null, manifest, null, xmlParser);
+ Document doc = xmlParser.parseXml(xmlContext);
+ if (doc != null) {
+ List children = LintUtils.getChildren(doc);
+ for (Element child : children) {
+ if (child.getNodeName().equals(NODE_MANIFEST)) {
+ List apps = extractChildrenByName(child, NODE_APPLICATION);
+ for (Element app : apps) {
+ List acts = extractChildrenByName(app, NODE_ACTIVITY);
+ for (Element act : acts) {
+ List intents = extractChildrenByName(act, NODE_INTENT);
+ for (Element intent : intents) {
+ List data = extractChildrenByName(intent,
+ NODE_DATA);
+ if (!data.isEmpty() && act.hasAttributeNS(
+ ANDROID_URI, ATTRIBUTE_NAME)) {
+ Attr attr = act.getAttributeNodeNS(
+ ANDROID_URI, ATTRIBUTE_NAME);
+ String activityName = attr.getValue();
+ int dotIndex = activityName.indexOf('.');
+ if (dotIndex <= 0) {
+ String pkg = context.getMainProject().getPackage();
+ if (pkg != null) {
+ if (dotIndex == 0) {
+ activityName = pkg + activityName;
+ }
+ else {
+ activityName = pkg + '.' + activityName;
+ }
+ }
+ }
+ activitiesToCheck.add(activityName);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ return activitiesToCheck;
+ }
+
+ private static void visitIntent(@NonNull XmlContext context, @NonNull Element intent) {
+ boolean actionView = hasActionView(intent);
+ boolean browsable = isBrowsable(intent);
+ boolean isHttp = false;
+ boolean hasScheme = false;
+ boolean hasHost = false;
+ boolean hasPort = false;
+ boolean hasPath = false;
+ boolean hasMimeType = false;
+ Element firstData = null;
+ List children = extractChildrenByName(intent, NODE_DATA);
+ for (Element data : children) {
+ if (firstData == null) {
+ firstData = data;
+ }
+ if (isHttpSchema(data)) {
+ isHttp = true;
+ }
+ checkSingleData(context, data);
+
+ for (String name : PATH_ATTR_LIST) {
+ if (data.hasAttributeNS(ANDROID_URI, name)) {
+ hasPath = true;
+ }
+ }
+
+ if (data.hasAttributeNS(ANDROID_URI, ATTR_SCHEME)) {
+ hasScheme = true;
+ }
+
+ if (data.hasAttributeNS(ANDROID_URI, ATTR_HOST)) {
+ hasHost = true;
+ }
+
+ if (data.hasAttributeNS(ANDROID_URI, ATTRIBUTE_PORT)) {
+ hasPort = true;
+ }
+
+ if (data.hasAttributeNS(ANDROID_URI, ATTRIBUTE_MIME_TYPE)) {
+ hasMimeType = true;
+ }
+ }
+
+ // In data field, a URL is consisted by
+ // ://:[||]
+ // Each part of the URL should not have illegal character.
+ if ((hasPath || hasHost || hasPort) && !hasScheme) {
+ context.report(ISSUE_URL_ERROR, firstData, context.getLocation(firstData),
+ SCHEME_MISSING);
+ }
+
+ if ((hasPath || hasPort) && !hasHost) {
+ context.report(ISSUE_URL_ERROR, firstData, context.getLocation(firstData),
+ HOST_MISSING);
+ }
+
+ if (actionView && browsable) {
+ if (firstData == null) {
+ // If this activity is an ACTION_VIEW action with category BROWSABLE, but doesn't
+ // have data node, it may be a mistake and we will report error.
+ context.report(ISSUE_URL_ERROR, intent, context.getLocation(intent),
+ DATA_MISSING);
+ } else if (!hasScheme && !hasMimeType) {
+ // If this activity is an action view, is browsable, but has neither a
+ // URL nor mimeType, it may be a mistake and we will report error.
+ context.report(ISSUE_URL_ERROR, firstData, context.getLocation(firstData),
+ URL_MISSING);
+ }
+ }
+
+ // If this activity is an ACTION_VIEW action, has a http URL but doesn't have
+ // BROWSABLE, it may be a mistake and and we will report warning.
+ if (actionView && isHttp && !browsable) {
+ context.report(ISSUE_APP_INDEXING, intent, context.getLocation(intent),
+ NOT_BROWSABLE);
+ }
+
+ if (actionView && !hasScheme) {
+ context.report(ISSUE_APP_INDEXING, intent, context.getLocation(intent),
+ "Missing URL");
+ }
+ }
+
+ /**
+ * Check if the intent filter supports action view.
+ *
+ * @param intent the intent filter
+ * @return true if it does
+ */
+ private static boolean hasActionView(@NonNull Element intent) {
+ List children = extractChildrenByName(intent, NODE_ACTION);
+ for (Element action : children) {
+ if (action.hasAttributeNS(ANDROID_URI, ATTRIBUTE_NAME)) {
+ Attr attr = action.getAttributeNodeNS(ANDROID_URI, ATTRIBUTE_NAME);
+ if (attr.getValue().equals("android.intent.action.VIEW")) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Check if the intent filter is browsable.
+ *
+ * @param intent the intent filter
+ * @return true if it does
+ */
+ private static boolean isBrowsable(@NonNull Element intent) {
+ List children = extractChildrenByName(intent, NODE_CATEGORY);
+ for (Element e : children) {
+ if (e.hasAttributeNS(ANDROID_URI, ATTRIBUTE_NAME)) {
+ Attr attr = e.getAttributeNodeNS(ANDROID_URI, ATTRIBUTE_NAME);
+ if (attr.getNodeValue().equals("android.intent.category.BROWSABLE")) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Check if the data node contains http schema
+ *
+ * @param data the data node
+ * @return true if it does
+ */
+ private static boolean isHttpSchema(@NonNull Element data) {
+ if (data.hasAttributeNS(ANDROID_URI, ATTR_SCHEME)) {
+ String value = data.getAttributeNodeNS(ANDROID_URI, ATTR_SCHEME).getValue();
+ if (value.equalsIgnoreCase("http") || value.equalsIgnoreCase("https")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static void checkSingleData(@NonNull XmlContext context, @NonNull Element data) {
+ // path, pathPrefix and pathPattern should starts with /.
+ for (String name : PATH_ATTR_LIST) {
+ if (data.hasAttributeNS(ANDROID_URI, name)) {
+ Attr attr = data.getAttributeNodeNS(ANDROID_URI, name);
+ String path = replaceUrlWithValue(context, attr.getValue());
+ if (!path.startsWith("/") && !path.startsWith(SdkConstants.PREFIX_RESOURCE_REF)) {
+ context.report(ISSUE_URL_ERROR, attr, context.getLocation(attr),
+ "android:" + name + " attribute should start with '/', but it is : "
+ + path);
+ }
+ }
+ }
+
+ // port should be a legal number.
+ if (data.hasAttributeNS(ANDROID_URI, ATTRIBUTE_PORT)) {
+ Attr attr = data.getAttributeNodeNS(ANDROID_URI, ATTRIBUTE_PORT);
+ try {
+ String port = replaceUrlWithValue(context, attr.getValue());
+ //noinspection ResultOfMethodCallIgnored
+ Integer.parseInt(port);
+ } catch (NumberFormatException e) {
+ context.report(ISSUE_URL_ERROR, attr, context.getLocation(attr),
+ ILLEGAL_NUMBER);
+ }
+ }
+
+ // Each field should be non empty.
+ NamedNodeMap attrs = data.getAttributes();
+ for (int i = 0; i < attrs.getLength(); i++) {
+ Node item = attrs.item(i);
+ if (item.getNodeType() == Node.ATTRIBUTE_NODE) {
+ Attr attr = (Attr) attrs.item(i);
+ if (attr.getValue().isEmpty()) {
+ context.report(ISSUE_URL_ERROR, attr, context.getLocation(attr),
+ attr.getName() + " cannot be empty");
+ }
+ }
+ }
+ }
+
+ private static String replaceUrlWithValue(@NonNull XmlContext context,
+ @NonNull String str) {
+ Project project = context.getProject();
+ LintClient client = context.getClient();
+ if (!client.supportsProjectResources()) {
+ return str;
+ }
+ ResourceUrl style = ResourceUrl.parse(str);
+ if (style == null || style.type != ResourceType.STRING || style.framework) {
+ return str;
+ }
+ AbstractResourceRepository resources = client.getProjectResources(project, true);
+ if (resources == null) {
+ return str;
+ }
+ List items = resources.getResourceItem(ResourceType.STRING, style.name);
+ if (items == null || items.isEmpty()) {
+ return str;
+ }
+ ResourceValue resourceValue = items.get(0).getResourceValue(false);
+ if (resourceValue == null) {
+ return str;
+ }
+ return resourceValue.getValue() == null ? str : resourceValue.getValue();
+ }
+
+ /**
+ * If a method with a certain argument exists in the list of methods.
+ *
+ * @param argument The first argument of the method.
+ * @param list The methods list.
+ * @return If such a method exists in the list.
+ */
+ private static boolean hasFirstArgument(UExpression argument, List list) {
+ for (UCallExpression call : list) {
+ List expressions = call.getValueArguments();
+ if (!expressions.isEmpty()) {
+ UExpression argument2 = expressions.get(0);
+ if (argument.asSourceString().equals(argument2.asSourceString())) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * If a method with a certain operand exists in the list of methods.
+ *
+ * @param operand The operand of the method.
+ * @param list The methods list.
+ * @return If such a method exists in the list.
+ */
+ private static boolean hasOperand(UExpression operand, List list) {
+ for (UCallExpression method : list) {
+ UElement operand2 = method.getReceiver();
+ if (operand2 != null && operand.asSourceString().equals(operand2.asSourceString())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static List extractChildrenByName(@NonNull Element node,
+ @NonNull String name) {
+ List result = Lists.newArrayList();
+ List children = LintUtils.getChildren(node);
+ for (Element child : children) {
+ if (child.getNodeName().equals(name)) {
+ result.add(child);
+ }
+ }
+ return result;
+ }
+}
diff --git a/plugins/lint/lint-checks/src/com/android/tools/klint/checks/IconDetector.java.as31 b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/IconDetector.java.as31
new file mode 100644
index 00000000000..12f6a267892
--- /dev/null
+++ b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/IconDetector.java.as31
@@ -0,0 +1,2049 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.tools.klint.checks;
+
+import com.android.annotations.NonNull;
+import com.android.annotations.Nullable;
+import com.android.builder.model.ProductFlavor;
+import com.android.builder.model.ProductFlavorContainer;
+import com.android.resources.ResourceUrl;
+import com.android.resources.Density;
+import com.android.resources.ResourceFolderType;
+import com.android.resources.ResourceType;
+import com.android.tools.klint.detector.api.*;
+import com.google.common.collect.*;
+import com.intellij.psi.PsiClass;
+import com.intellij.psi.PsiElement;
+import org.jetbrains.uast.*;
+import org.jetbrains.uast.util.UastExpressionUtils;
+import org.jetbrains.uast.visitor.AbstractUastVisitor;
+import org.jetbrains.uast.visitor.UastVisitor;
+import org.w3c.dom.Element;
+
+import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.stream.ImageInputStream;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.util.*;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static com.android.SdkConstants.*;
+import static com.android.tools.klint.detector.api.LintUtils.endsWith;
+
+/**
+ * Checks for common icon problems, such as wrong icon sizes, placing icons in the
+ * density independent drawable folder, etc.
+ */
+public class IconDetector extends ResourceXmlDetector implements Detector.UastScanner {
+
+ 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|xxxhdpi|xxhdpi|xhdpi|hdpi|mdpi" //$NON-NLS-1$
+ + (INCLUDE_LDPI ? "|ldpi" : "") + ")$"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
+
+ /** Pattern for icon names that include their dp size as part of the name */
+ private static final Pattern DP_NAME_PATTERN = Pattern.compile(".+_(\\d+)dp\\.png"); //$NON-NLS-1$
+
+ /** Cache for {@link #getRequiredDensityFolders(Context)} */
+ private List mCachedRequiredDensities;
+ /** Cache key for {@link #getRequiredDensityFolders(Context)} */
+ private Project mCachedDensitiesForProject;
+
+ // TODO: Convert this over to using the Density enum and FolderConfiguration
+ // for qualifier lookup
+ private static final String[] DENSITY_QUALIFIERS =
+ new String[] {
+ "-ldpi", //$NON-NLS-1$
+ "-mdpi", //$NON-NLS-1$
+ "-hdpi", //$NON-NLS-1$
+ "-xhdpi", //$NON-NLS-1$
+ "-xxhdpi",//$NON-NLS-1$
+ "-xxxhdpi",//$NON-NLS-1$
+ };
+
+ /** Scope needed to detect the types of icons (which involves scanning .java files,
+ * the manifest, menu files etc to see how icons are used
+ */
+ private static final EnumSet ICON_TYPE_SCOPE = EnumSet.of(Scope.ALL_RESOURCE_FILES,
+ Scope.JAVA_FILE, Scope.MANIFEST);
+
+ private static final Implementation IMPLEMENTATION_JAVA = new Implementation(
+ IconDetector.class,
+ ICON_TYPE_SCOPE);
+
+ private static final Implementation IMPLEMENTATION_RES_ONLY = new Implementation(
+ IconDetector.class,
+ Scope.ALL_RESOURCES_SCOPE);
+
+ /** Wrong icon size according to published conventions */
+ public static final Issue ICON_EXPECTED_SIZE = Issue.create(
+ "IconExpectedSize", //$NON-NLS-1$
+ "Icon has incorrect 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,
+ IMPLEMENTATION_JAVA)
+ // Still some potential false positives:
+ .setEnabledByDefault(false)
+ .addMoreInfo(
+ "http://developer.android.com/design/style/iconography.html"); //$NON-NLS-1$
+
+ /** Inconsistent dip size across densities */
+ public static final Issue ICON_DIP_SIZE = Issue.create(
+ "IconDipSize", //$NON-NLS-1$
+ "Icon density-independent size validation",
+ "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,
+ IMPLEMENTATION_RES_ONLY);
+
+ /** Images in res/drawable folder */
+ public static final Issue ICON_LOCATION = Issue.create(
+ "IconLocation", //$NON-NLS-1$
+ "Image defined in 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,
+ IMPLEMENTATION_RES_ONLY).addMoreInfo(
+ "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$
+ "Icon densities validation",
+ "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,
+ IMPLEMENTATION_RES_ONLY).addMoreInfo(
+ "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$
+ "Missing density folder",
+ "Icons will look best if a custom version is provided for each of the " +
+ "major screen density classes (low, medium, high, extra-high, extra-extra-high). " +
+ "This lint check identifies folders which are missing, such as `drawable-hdpi`.\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,
+ 3,
+ Severity.WARNING,
+ IMPLEMENTATION_RES_ONLY).addMoreInfo(
+ "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$
+ "Using `.gif` format for bitmaps is discouraged",
+ "The `.gif` file format is discouraged. Consider using `.png` (preferred) " +
+ "or `.jpg` (acceptable) instead.",
+ Category.ICONS,
+ 5,
+ Severity.WARNING,
+ IMPLEMENTATION_RES_ONLY).addMoreInfo(
+ "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$
+ "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,
+ IMPLEMENTATION_RES_ONLY);
+
+ /** Duplicated contents across configurations for a given name */
+ public static final Issue DUPLICATES_CONFIGURATIONS = Issue.create(
+ "IconDuplicatesConfig", //$NON-NLS-1$
+ "Identical bitmaps across various configurations",
+ "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,
+ IMPLEMENTATION_RES_ONLY);
+
+ /** Icons appearing in both -nodpi and a -Ndpi folder */
+ public static final Issue ICON_NODPI = Issue.create(
+ "IconNoDpi", //$NON-NLS-1$
+ "Icon appears in both `-nodpi` and dpi folders",
+ "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,
+ IMPLEMENTATION_RES_ONLY);
+
+ /** Drawables provided as both .9.png and .png files */
+ public static final Issue ICON_MIX_9PNG = Issue.create(
+ "IconMixedNinePatch", //$NON-NLS-1$
+ "Clashing PNG and 9-PNG files",
+
+ "If you accidentally name two separate resources `file.png` and `file.9.png`, " +
+ "the image file and the nine patch file will both map to the same drawable " +
+ "resource, `@drawable/file`, which is probably not what was intended.",
+ Category.ICONS,
+ 5,
+ Severity.WARNING,
+ IMPLEMENTATION_RES_ONLY);
+
+ /** Icons appearing as both drawable xml files and bitmaps */
+ public static final Issue ICON_XML_AND_PNG = Issue.create(
+ "IconXmlAndPng", //$NON-NLS-1$
+ "Icon is specified both as `.xml` file and as a bitmap",
+ "If a drawable resource appears as an `.xml` file in the `drawable/` folder, " +
+ "it's usually not intentional for it to also appear as a bitmap using the " +
+ "same name; generally you expect the drawable XML file to define states " +
+ "and each state has a corresponding drawable bitmap.",
+ Category.ICONS,
+ 7,
+ Severity.WARNING,
+ IMPLEMENTATION_RES_ONLY);
+
+ /** Wrong filename according to the format */
+ public static final Issue ICON_EXTENSION = Issue.create(
+ "IconExtension", //$NON-NLS-1$
+ "Icon format does not match the file extension",
+
+ "Ensures that icons have the correct file extension (e.g. a `.png` file is " +
+ "really in the PNG format and not for example a GIF file named `.png`.)",
+ Category.ICONS,
+ 3,
+ Severity.WARNING,
+ IMPLEMENTATION_RES_ONLY);
+
+ /** Wrong filename according to the format */
+ public static final Issue ICON_COLORS = Issue.create(
+ "IconColors", //$NON-NLS-1$
+ "Icon colors do not follow the recommended visual style",
+
+ "Notification icons and Action Bar icons should only white and shades of gray. " +
+ "See the Android Design Guide for more details. " +
+ "Note that the way Lint decides whether an icon is an action bar icon or " +
+ "a notification icon is based on the filename prefix: `ic_menu_` for " +
+ "action bar icons, `ic_stat_` for notification icons etc. These correspond " +
+ "to the naming conventions documented in " +
+ "http://developer.android.com/guide/practices/ui_guidelines/icon_design.html",
+ Category.ICONS,
+ 6,
+ Severity.WARNING,
+ IMPLEMENTATION_JAVA).addMoreInfo(
+ "http://developer.android.com/design/style/iconography.html"); //$NON-NLS-1$
+
+ /** Wrong launcher icon shape */
+ public static final Issue ICON_LAUNCHER_SHAPE = Issue.create(
+ "IconLauncherShape", //$NON-NLS-1$
+ "The launcher icon shape should use a distinct silhouette",
+
+ "According to the Android Design Guide " +
+ "(http://developer.android.com/design/style/iconography.html) " +
+ "your launcher icons should \"use a distinct silhouette\", " +
+ "a \"three-dimensional, front view, with a slight perspective as if viewed " +
+ "from above, so that users perceive some depth.\"\n" +
+ "\n" +
+ "The unique silhouette implies that your launcher icon should not be a filled " +
+ "square.",
+ Category.ICONS,
+ 6,
+ Severity.WARNING,
+ IMPLEMENTATION_JAVA).addMoreInfo(
+ "http://developer.android.com/design/style/iconography.html"); //$NON-NLS-1$
+
+ /** Constructs a new {@link IconDetector} check */
+ public IconDetector() {
+ }
+
+ @Override
+ public void beforeCheckProject(@NonNull Context context) {
+ mLauncherIcons = null;
+ mActionBarIcons = null;
+ mNotificationIcons = null;
+ }
+
+ @Override
+ public void afterCheckLibraryProject(@NonNull Context context) {
+ if (!context.getProject().getReportIssues()) {
+ // If this is a library project not being analyzed, ignore it
+ return;
+ }
+
+ checkResourceFolder(context, context.getProject());
+ }
+
+ @Override
+ public void afterCheckProject(@NonNull Context context) {
+ checkResourceFolder(context, context.getProject());
+ }
+
+ private void checkResourceFolder(Context context, @NonNull Project project) {
+ List resourceFolders = project.getResourceFolders();
+ for (File res : resourceFolders) {
+ File[] folders = res.listFiles();
+ if (folders != null) {
+ boolean checkFolders = context.isEnabled(ICON_DENSITIES)
+ || context.isEnabled(ICON_MISSING_FOLDER)
+ || context.isEnabled(ICON_NODPI)
+ || context.isEnabled(ICON_MIX_9PNG)
+ || context.isEnabled(ICON_XML_AND_PNG);
+ boolean checkDipSizes = context.isEnabled(ICON_DIP_SIZE);
+ boolean checkDuplicates = context.isEnabled(DUPLICATES_NAMES)
+ || context.isEnabled(DUPLICATES_CONFIGURATIONS);
+
+ Map pixelSizes = null;
+ Map fileSizes = null;
+ if (checkDipSizes || checkDuplicates) {
+ pixelSizes = new HashMap();
+ fileSizes = new HashMap();
+ }
+ Map> folderToNames = new HashMap>();
+ Map> nonDpiFolderNames = new HashMap>();
+ 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 names = new HashSet(files.length);
+ for (File f : files) {
+ String name = f.getName();
+ if (isDrawableFile(name)) {
+ names.add(name);
+ }
+ }
+ folderToNames.put(folder, names);
+ } else if (checkFolders) {
+ Set names = new HashSet(files.length);
+ for (File f : files) {
+ String name = f.getName();
+ if (isDrawableFile(name)) {
+ names.add(name);
+ }
+ }
+ nonDpiFolderNames.put(folder, names);
+ }
+ }
+ }
+ }
+
+ if (checkDipSizes) {
+ checkDipSizes(context, pixelSizes);
+ }
+
+ if (checkDuplicates) {
+ checkDuplicates(context, pixelSizes, fileSizes);
+ }
+
+ if (checkFolders && !folderToNames.isEmpty()) {
+ checkDensities(context, res, folderToNames, nonDpiFolderNames);
+ }
+ }
+ }
+ }
+
+ /** Like {@link LintUtils#isBitmapFile(File)} but (a) operates on Strings instead
+ * of files and (b) also considers XML drawables as images */
+ private static boolean isDrawableFile(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)
+ || endsWith(name, DOT_XML) || endsWith(name, DOT_JPEG) || endsWith(name, DOT_WEBP);
+ }
+
+ // 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 static void checkDuplicates(Context context, Map pixelSizes,
+ Map fileSizes) {
+ Map> sameSizes = new HashMap>();
+ Map seenSizes = new HashMap(fileSizes.size());
+ for (Map.Entry entry : fileSizes.entrySet()) {
+ File file = entry.getKey();
+ Long size = entry.getValue();
+ if (seenSizes.containsKey(size)) {
+ Set set = sameSizes.get(size);
+ if (set == null) {
+ set = new HashSet();
+ set.add(seenSizes.get(size));
+ sameSizes.put(size, set);
+ }
+ set.add(file);
+ } else {
+ seenSizes.put(size, file);
+ }
+ }
+
+ if (sameSizes.isEmpty()) {
+ 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> candidateLists = sameSizes.values();
+ for (Set candidates : candidateLists) {
+ Map> sameDimensions = new HashMap>(
+ candidates.size());
+ List noSize = new ArrayList();
+ for (File file : candidates) {
+ Dimension dimension = pixelSizes.get(file);
+ if (dimension != null) {
+ Set set = sameDimensions.get(dimension);
+ if (set == null) {
+ set = new HashSet();
+ sameDimensions.put(dimension, set);
+ }
+ set.add(file);
+ } else {
+ noSize.add(file);
+ }
+ }
+
+
+ // Files that we have no dimensions for must be compared against everything
+ Collection> sets = sameDimensions.values();
+ if (!noSize.isEmpty()) {
+ if (!sets.isEmpty()) {
+ for (Set set : sets) {
+ set.addAll(noSize);
+ }
+ } else {
+ // Must just test the noSize elements against themselves
+ HashSet noSizeSet = new HashSet(noSize);
+ sets = Collections.>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 fileContents = new HashMap();
+
+ // 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 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 = context.getClient().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 equal = new HashMap();
+
+ // Now go and compare all the files. This isn't an efficient algorithm
+ // but the number of candidates should be very small
+
+ List files = new ArrayList(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.isEmpty()) {
+ Map> partitions = new HashMap>();
+ List> sameSets = new ArrayList>();
+ for (Map.Entry entry : equal.entrySet()) {
+ File file1 = entry.getKey();
+ File file2 = entry.getValue();
+ Set set1 = partitions.get(file1);
+ Set set2 = partitions.get(file2);
+ if (set1 != null) {
+ set1.add(file2);
+ } else if (set2 != null) {
+ set2.add(file1);
+ } else {
+ set = new HashSet();
+ 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> lists = new ArrayList>();
+ for (Set same : sameSets) {
+ assert !same.isEmpty();
+ ArrayList sorted = new ArrayList(same);
+ Collections.sort(sorted);
+ lists.add(sorted);
+ }
+ // Sort overall partitions by the first item in each list
+ Collections.sort(lists, new Comparator>() {
+ @Override
+ public int compare(List list1, List list2) {
+ return list1.get(0).compareTo(list2.get(0));
+ }
+ });
+
+ // Allow one specific scenario of duplicated icon contents:
+ // Checking in different size icons (within a single density
+ // folder). For now the only pattern we recognize is the
+ // one advocated by the material design icons:
+ // https://github.com/google/material-design-icons
+ // where the pattern is foo_dp.png. (See issue 74584 for more.)
+ ListIterator> iterator = lists.listIterator();
+ while (iterator.hasNext()) {
+ List list = iterator.next();
+ boolean remove = true;
+ for (File file : list) {
+ String name = file.getName();
+ if (!DP_NAME_PATTERN.matcher(name).matches()) {
+ // One or more pattern in this list does not
+ // conform to the dp naming pattern, so
+ remove = false;
+ break;
+ }
+ }
+ if (remove) {
+ iterator.remove();
+ }
+ }
+
+ for (List 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(sameFiles.size() * 16);
+ 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());
+ if (location != null) {
+ context.report(DUPLICATES_CONFIGURATIONS, location, message);
+ }
+ } else {
+ StringBuilder sb = new StringBuilder(sameFiles.size() * 16);
+ 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);
+ }
+ }
+ }
+ }
+ }
+
+ }
+
+ // 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 static void checkDipSizes(Context context, Map 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> nameToFiles = new HashMap>();
+ for (File file : pixelSizes.keySet()) {
+ String name = file.getName();
+ List list = nameToFiles.get(name);
+ if (list == null) {
+ list = new ArrayList();
+ nameToFiles.put(name, list);
+ }
+ list.add(file);
+ }
+
+ ArrayList names = new ArrayList(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>> configMap =
+ new HashMap>>();
+ for (Map.Entry> entry : nameToFiles.entrySet()) {
+ String name = entry.getKey();
+ List files = entry.getValue();
+ for (File file : files) {
+ //noinspection ConstantConditions
+ 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> folderMap = configMap.get(name);
+ if (folderMap == null) {
+ folderMap = new HashMap>();
+ configMap.put(name, folderMap);
+ }
+ // Map from name to a map from parent folder to files
+ List list = folderMap.get(parentName);
+ if (list == null) {
+ list = new ArrayList();
+ folderMap.put(parentName, list);
+ }
+ list.add(file);
+ }
+ }
+
+ for (String name : names) {
+ //List files = nameToFiles.get(name);
+ Map> 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> entry : configurations.entrySet()) {
+ List files = entry.getValue();
+
+ // Ensure that all the dip sizes are *roughly* the same
+ Map dipSizes = new HashMap();
+ int dipWidthSum = 0; // Incremental computation of average
+ int dipHeightSum = 0; // Incremental computation of average
+ int count = 0;
+ for (File file : files) {
+ //noinspection ConstantConditions
+ String folderName = file.getParentFile().getName();
+ float factor = getMdpiScalingFactor(folderName);
+ if (factor > 0) {
+ Dimension size = pixelSizes.get(file);
+ if (size == null) {
+ continue;
+ }
+ 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++;
+
+ String fileName = file.getName();
+ Matcher matcher = DP_NAME_PATTERN.matcher(fileName);
+ if (matcher.matches()) {
+ String dpString = matcher.group(1);
+ int dp = Integer.parseInt(dpString);
+ // We're not sure whether the dp size refers to the width
+ // or the height, so check both. Allow a little bit of rounding
+ // slop.
+ if (Math.abs(dip.width - dp) > 2 || Math.abs(dip.height - dp) > 2) {
+ // Unicode 00D7 is the multiplication sign
+ String message = String.format(""
+ + "Suspicious file name `%1$s`: The implied %2$s `dp` "
+ + "size does not match the actual `dp` size "
+ + "(pixel size %3$d\u00D7%4$d in a `%5$s` folder "
+ + "computes to %6$d\u00D7%7$d `dp`)",
+ fileName, dpString,
+ size.width, size.height,
+ folderName,
+ dip.width, dip.height);
+ context.report(ICON_DIP_SIZE, Location.create(file), message);
+ }
+ }
+ }
+ }
+ 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(100);
+
+ // Sort entries by decreasing dip size
+ List> entries =
+ new ArrayList>();
+ for (Map.Entry entry2 : dipSizes.entrySet()) {
+ entries.add(entry2);
+ }
+ Collections.sort(entries,
+ new Comparator>() {
+ @Override
+ public int compare(Entry e1,
+ Entry 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 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);
+ //noinspection ConstantConditions
+ 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());
+ if (location != null) {
+ context.report(ICON_DIP_SIZE, location, message);
+ }
+ }
+ }
+ }
+ }
+
+ private void checkDensities(Context context, File res,
+ Map> folderToNames,
+ Map> nonDpiFolderNames) {
+ // 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 definedDensities = new HashSet();
+ 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 missing = new ArrayList();
+ for (String density : getRequiredDensityFolders(context)) {
+ if (!definedDensities.contains(density)) {
+ missing.add(density);
+ }
+ }
+ if (!missing.isEmpty()) {
+ context.report(
+ ICON_MISSING_FOLDER,
+ Location.create(res),
+ String.format("Missing density variation folders in `%1$s`: %2$s",
+ context.getProject().getDisplayPath(res),
+ LintUtils.formatList(missing, -1)));
+ }
+ }
+
+ if (context.isEnabled(ICON_NODPI)) {
+ Set noDpiNames = new HashSet();
+ for (Map.Entry> entry : folderToNames.entrySet()) {
+ if (isNoDpiFolder(entry.getKey())) {
+ noDpiNames.addAll(entry.getValue());
+ }
+ }
+ if (!noDpiNames.isEmpty()) {
+ // Make sure that none of the nodpi names appear in a non-nodpi folder
+ Set inBoth = new HashSet();
+ List files = new ArrayList();
+ for (Map.Entry> entry : folderToNames.entrySet()) {
+ File folder = entry.getKey();
+ String folderName = folder.getName();
+ if (!isNoDpiFolder(folder)) {
+ assert DENSITY_PATTERN.matcher(folderName).matches();
+ Set overlap = nameIntersection(noDpiNames, entry.getValue());
+ inBoth.addAll(overlap);
+ for (String name : overlap) {
+ files.add(new File(folder, name));
+ }
+ }
+ }
+
+ if (!inBoth.isEmpty()) {
+ List list = new ArrayList(inBoth);
+ Collections.sort(list);
+
+ // Chain locations together
+ Location location = chainLocations(files);
+
+ context.report(ICON_NODPI, location,
+ String.format(
+ "The following images appear in both `-nodpi` and in a density folder: %1$s",
+ LintUtils.formatList(list,
+ context.getDriver().isAbbreviating() ? 10 : -1)));
+ }
+ }
+ }
+
+ if (context.isEnabled(ICON_MIX_9PNG)) {
+ checkMixedNinePatches(context, folderToNames);
+ }
+
+ if (context.isEnabled(ICON_XML_AND_PNG)) {
+ Map> folderMap = Maps.newHashMap(folderToNames);
+ folderMap.putAll(nonDpiFolderNames);
+ Set xmlNames = Sets.newHashSetWithExpectedSize(100);
+ Set bitmapNames = Sets.newHashSetWithExpectedSize(100);
+
+ for (Map.Entry> entry : folderMap.entrySet()) {
+ Set names = entry.getValue();
+ for (String name : names) {
+ if (endsWith(name, DOT_XML)) {
+ xmlNames.add(name);
+ } else if (isDrawableFile(name)) {
+ bitmapNames.add(name);
+ }
+ }
+ }
+ if (!xmlNames.isEmpty() && !bitmapNames.isEmpty()) {
+ // Make sure that none of the nodpi names appear in a non-nodpi folder
+ Set overlap = nameIntersection(xmlNames, bitmapNames);
+ if (!overlap.isEmpty()) {
+ Multimap map = ArrayListMultimap.create();
+ Set bases = Sets.newHashSetWithExpectedSize(overlap.size());
+ for (String name : overlap) {
+ bases.add(LintUtils.getBaseName(name));
+ }
+
+ for (String base : bases) {
+ for (Map.Entry> entry : folderMap.entrySet()) {
+ File folder = entry.getKey();
+ for (String n : entry.getValue()) {
+ if (base.equals(LintUtils.getBaseName(n))) {
+ map.put(base, new File(folder, n));
+ }
+ }
+ }
+ }
+ List sorted = new ArrayList(map.keySet());
+ Collections.sort(sorted);
+ for (String name : sorted) {
+ List lists = Lists.newArrayList(map.get(name));
+ Location location = chainLocations(lists);
+
+ List fileNames = Lists.newArrayList();
+ boolean seenXml = false;
+ boolean seenNonXml = false;
+ for (File f : lists) {
+ boolean isXml = endsWith(f.getPath(), DOT_XML);
+ if (isXml && !seenXml) {
+ fileNames.add(context.getProject().getDisplayPath(f));
+ seenXml = true;
+ } else if (!isXml && !seenNonXml) {
+ fileNames.add(context.getProject().getDisplayPath(f));
+ seenNonXml = true;
+ }
+ }
+
+ context.report(ICON_XML_AND_PNG, location,
+ String.format(
+ "The following images appear both as density independent `.xml` files and as bitmap files: %1$s",
+ LintUtils.formatList(fileNames,
+ context.getDriver().isAbbreviating() ? 10 : -1)));
+ }
+ }
+ }
+ }
+
+ if (context.isEnabled(ICON_DENSITIES)) {
+ // Look for folders missing some of the specific assets
+ Set allNames = new HashSet();
+ for (Entry> entry : folderToNames.entrySet()) {
+ if (!isNoDpiFolder(entry.getKey())) {
+ Set names = entry.getValue();
+ allNames.addAll(names);
+ }
+ }
+
+ for (Map.Entry> entry : folderToNames.entrySet()) {
+ File file = entry.getKey();
+ if (isNoDpiFolder(file)) {
+ continue;
+ }
+ Set names = entry.getValue();
+ if (names.size() != allNames.size()) {
+ List delta = new ArrayList(nameDifferences(allNames, names));
+ if (delta.isEmpty()) {
+ continue;
+ }
+ Collections.sort(delta);
+ String foundIn = "";
+ if (delta.size() == 1) {
+ // Produce list of where the icon is actually defined
+ List defined = new ArrayList();
+ String name = delta.get(0);
+ for (Map.Entry> e : folderToNames.entrySet()) {
+ if (e.getValue().contains(name)) {
+ defined.add(e.getKey().getName());
+ }
+ }
+ if (!defined.isEmpty()) {
+ foundIn = String.format(" (found in %1$s)",
+ LintUtils.formatList(defined,
+ context.getDriver().isAbbreviating() ? 5 : -1));
+ }
+ }
+
+ // Irrelevant folder?
+ String folder = file.getName();
+ if (!getRequiredDensityFolders(context).contains(folder)) {
+ continue;
+ }
+
+ context.report(ICON_DENSITIES, Location.create(file),
+ String.format(
+ "Missing the following drawables in `%1$s`: %2$s%3$s",
+ folder,
+ LintUtils.formatList(delta,
+ context.getDriver().isAbbreviating() ? 5 : -1),
+ foundIn));
+ }
+ }
+ }
+ }
+
+ private List getRequiredDensityFolders(@NonNull Context context) {
+ if (mCachedRequiredDensities == null
+ || context.getProject() != mCachedDensitiesForProject) {
+ mCachedDensitiesForProject = context.getProject();
+ mCachedRequiredDensities = Lists.newArrayListWithExpectedSize(10);
+
+ List applicableDensities = context.getProject().getApplicableDensities();
+ if (applicableDensities != null) {
+ mCachedRequiredDensities.addAll(applicableDensities);
+ } else {
+ if (INCLUDE_LDPI) {
+ mCachedRequiredDensities.add(DRAWABLE_LDPI);
+ }
+ mCachedRequiredDensities.add(DRAWABLE_MDPI);
+ mCachedRequiredDensities.add(DRAWABLE_HDPI);
+ mCachedRequiredDensities.add(DRAWABLE_XHDPI);
+ mCachedRequiredDensities.add(DRAWABLE_XXHDPI);
+ mCachedRequiredDensities.add(DRAWABLE_XXXHDPI);
+ }
+ }
+
+ return mCachedRequiredDensities;
+ }
+
+ /**
+ * Adds in the resConfig values specified by the given flavor container, assuming
+ * it's in one of the relevant variantFlavors, into the given set
+ */
+ private static void addResConfigsFromFlavor(@NonNull Set relevantDensities,
+ @Nullable List variantFlavors,
+ @NonNull ProductFlavorContainer container) {
+ ProductFlavor flavor = container.getProductFlavor();
+ if (variantFlavors == null || variantFlavors.contains(flavor.getName())) {
+ if (!flavor.getResourceConfigurations().isEmpty()) {
+ for (String densityName : flavor.getResourceConfigurations()) {
+ Density density = Density.getEnum(densityName);
+ if (density != null && density.isRecommended()
+ && density != Density.NODPI && density != Density.ANYDPI) {
+ relevantDensities.add(densityName);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Compute the difference in names between a and b. This is not just
+ * Sets.difference(a, b) because we want to make the comparisons without
+ * file extensions and return the result with..
+ */
+ private static Set nameDifferences(Set a, Set b) {
+ Set names1 = new HashSet(a.size());
+ for (String s : a) {
+ names1.add(LintUtils.getBaseName(s));
+ }
+ Set names2 = new HashSet(b.size());
+ for (String s : b) {
+ names2.add(LintUtils.getBaseName(s));
+ }
+
+ names1.removeAll(names2);
+
+ if (!names1.isEmpty()) {
+ // Map filenames back to original filenames with extensions
+ Set result = new HashSet(names1.size());
+ for (String s : a) {
+ if (names1.contains(LintUtils.getBaseName(s))) {
+ result.add(s);
+ }
+ }
+ for (String s : b) {
+ if (names1.contains(LintUtils.getBaseName(s))) {
+ result.add(s);
+ }
+ }
+
+ return result;
+ }
+
+ return Collections.emptySet();
+ }
+
+ /**
+ * Compute the intersection in names between a and b. This is not just
+ * Sets.intersection(a, b) because we want to make the comparisons without
+ * file extensions and return the result with.
+ */
+ private static Set nameIntersection(Set a, Set b) {
+ Set names1 = new HashSet(a.size());
+ for (String s : a) {
+ names1.add(LintUtils.getBaseName(s));
+ }
+ Set names2 = new HashSet(b.size());
+ for (String s : b) {
+ names2.add(LintUtils.getBaseName(s));
+ }
+
+ names1.retainAll(names2);
+
+ if (!names1.isEmpty()) {
+ // Map filenames back to original filenames with extensions
+ Set result = new HashSet(names1.size());
+ for (String s : a) {
+ if (names1.contains(LintUtils.getBaseName(s))) {
+ result.add(s);
+ }
+ }
+ for (String s : b) {
+ if (names1.contains(LintUtils.getBaseName(s))) {
+ result.add(s);
+ }
+ }
+
+ return result;
+ }
+
+ return Collections.emptySet();
+ }
+
+ private static boolean isNoDpiFolder(File file) {
+ return file.getName().contains("-nodpi");
+ }
+
+ private Map mImageCache;
+
+ @Nullable
+ private BufferedImage getImage(@Nullable File file) throws IOException {
+ if (file == null) {
+ return null;
+ }
+ if (mImageCache == null) {
+ mImageCache = Maps.newHashMap();
+ } else {
+ BufferedImage image = mImageCache.get(file);
+ if (image != null) {
+ return image;
+ }
+ }
+
+ BufferedImage image = ImageIO.read(file);
+ mImageCache.put(file, image);
+
+ return image;
+ }
+
+ private void checkDrawableDir(Context context, File folder, File[] files,
+ Map pixelSizes, Map fileSizes) {
+ if (folder.getName().equals(DRAWABLE_FOLDER)
+ && context.isEnabled(ICON_LOCATION) &&
+ // If supporting older versions than Android 1.6, it's not an error
+ // to include bitmaps in drawable/
+ context.getProject().getMinSdk() >= 4) {
+ for (File file : files) {
+ String name = file.getName();
+ //noinspection StatementWithEmptyBody
+ 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_JPEG)
+ || 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()));
+ }
+ }
+ }
+
+ 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");
+ }
+ }
+ }
+
+ if (context.isEnabled(ICON_EXTENSION)) {
+ for (File file : files) {
+ String path = file.getPath();
+ if (isDrawableFile(path) && !endsWith(path, DOT_XML)) {
+ checkExtension(context, file);
+ }
+ }
+ }
+
+ if (context.isEnabled(ICON_COLORS)) {
+ for (File file : files) {
+ String name = file.getName();
+
+ if (isDrawableFile(name)
+ && !endsWith(name, DOT_XML)
+ && !endsWith(name, DOT_9PNG)) {
+ String baseName = getBaseName(name);
+ boolean isActionBarIcon = isActionBarIcon(context, baseName, file);
+ if (isActionBarIcon || isNotificationIcon(baseName)) {
+ Dimension size = checkColor(context, file, isActionBarIcon);
+
+ // Store dimension for size check if we went to the trouble of reading image
+ if (size != null && pixelSizes != null) {
+ pixelSizes.put(file, size);
+ }
+ }
+ }
+ }
+ }
+
+ if (context.isEnabled(ICON_LAUNCHER_SHAPE)) {
+ // Look up launcher icon name
+ for (File file : files) {
+ String name = file.getName();
+ if (isLauncherIcon(getBaseName(name))
+ && !endsWith(name, DOT_XML)
+ && !endsWith(name, DOT_9PNG)) {
+ checkLauncherShape(context, file);
+ }
+ }
+ }
+
+ // 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)
+ || endsWith(fileName, DOT_JPEG)) {
+ // 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)
+ && !pixelSizes.containsKey(file)) { // already read by checkColor?
+ Dimension size = getSize(file);
+ pixelSizes.put(file, size);
+ }
+ if (fileSizes != null) {
+ fileSizes.put(file, file.length());
+ }
+ }
+ }
+ }
+
+ mImageCache = null;
+ }
+
+ /**
+ * Check that launcher icons do not fill every pixel in the image
+ */
+ private void checkLauncherShape(Context context, File file) {
+ try {
+ BufferedImage image = getImage(file);
+ if (image != null) {
+ // TODO: see if the shape is rectangular but inset from outer rectangle; if so
+ // that's probably not right either!
+ for (int y = 0, height = image.getHeight(); y < height; y++) {
+ for (int x = 0, width = image.getWidth(); x < width; x++) {
+ int rgb = image.getRGB(x, y);
+ if ((rgb & 0xFF000000) == 0) {
+ return;
+ }
+ }
+ }
+
+ String message = "Launcher icons should not fill every pixel of their square " +
+ "region; see the design guide for details";
+ context.report(ICON_LAUNCHER_SHAPE, Location.create(file),
+ message);
+ }
+ } catch (IOException e) {
+ // Pass: ignore files we can't read
+ }
+ }
+
+ /**
+ * Check whether the icons in the file are okay. Also return the image size
+ * if known (for use by other checks)
+ */
+ private Dimension checkColor(Context context, File file, boolean isActionBarIcon) {
+ int folderVersion = context.getDriver().getResourceFolderVersion(file);
+ if (isActionBarIcon) {
+ if (folderVersion != -1 && folderVersion < 11
+ || !isAndroid30(context, folderVersion)) {
+ return null;
+ }
+ } else {
+ if (folderVersion != -1 && folderVersion < 9
+ || !isAndroid23(context, folderVersion)
+ && !isAndroid30(context, folderVersion)) {
+ return null;
+ }
+ }
+
+ // TODO: This only checks icons that are known to be using the Holo style.
+ // However, if the user has minSdk < 11 as well as targetSdk > 11, we should
+ // also check that they actually include a -v11 or -v14 folder with proper
+ // icons, since the below won't flag the older icons.
+ try {
+ BufferedImage image = getImage(file);
+ if (image != null) {
+ if (isActionBarIcon) {
+ checkPixels:
+ for (int y = 0, height = image.getHeight(); y < height; y++) {
+ for (int x = 0, width = image.getWidth(); x < width; x++) {
+ int rgb = image.getRGB(x, y);
+ if ((rgb & 0xFF000000) != 0) { // else: transparent
+ int r = (rgb & 0xFF0000) >>> 16;
+ int g = (rgb & 0x00FF00) >>> 8;
+ int b = (rgb & 0x0000FF);
+ if (r != g || r != b) {
+ String message = "Action Bar icons should use a single gray "
+ + "color (`#333333` for light themes (with 60%/30% "
+ + "opacity for enabled/disabled), and `#FFFFFF` with "
+ + "opacity 80%/30% for dark themes";
+ context.report(ICON_COLORS, Location.create(file),
+ message);
+ break checkPixels;
+ }
+ }
+ }
+ }
+ } else {
+ if (folderVersion >= 11 || isAndroid30(context, folderVersion)) {
+ // Notification icons. Should be white as of API 14
+ checkPixels:
+ for (int y = 0, height = image.getHeight(); y < height; y++) {
+ for (int x = 0, width = image.getWidth(); x < width; x++) {
+ int rgb = image.getRGB(x, y);
+ // If the pixel is not completely transparent, insist that
+ // its RGB channel must be white (with any alpha value)
+ if ((rgb & 0xFF000000) != 0 && (rgb & 0xFFFFFF) != 0xFFFFFF) {
+ int r = (rgb & 0xFF0000) >>> 16;
+ int g = (rgb & 0x00FF00) >>> 8;
+ int b = (rgb & 0x0000FF);
+ if (r == g && r == b) {
+ // If the pixel is not white, it might be because of
+ // anti-aliasing. In that case, at least one neighbor
+ // should be of a different color
+ if (x < width - 1 && rgb != image.getRGB(x + 1, y)) {
+ continue;
+ }
+ if (x > 0 && rgb != image.getRGB(x - 1, y)) {
+ continue;
+ }
+ if (y < height - 1 && rgb != image.getRGB(x, y + 1)) {
+ continue;
+ }
+ if (y > 0 && rgb != image.getRGB(x, y - 1)) {
+ continue;
+ }
+ }
+
+ String message = "Notification icons must be entirely white";
+ context.report(ICON_COLORS, Location.create(file),
+ message);
+ break checkPixels;
+ }
+ }
+ }
+ } else {
+ // As of API 9, should be gray.
+ checkPixels:
+ for (int y = 0, height = image.getHeight(); y < height; y++) {
+ for (int x = 0, width = image.getWidth(); x < width; x++) {
+ int rgb = image.getRGB(x, y);
+ if ((rgb & 0xFF000000) != 0) { // else: transparent
+ int r = (rgb & 0xFF0000) >>> 16;
+ int g = (rgb & 0x00FF00) >>> 8;
+ int b = (rgb & 0x0000FF);
+ if (r != g || r != b) {
+ String message = "Notification icons should not use "
+ + "colors";
+ context.report(ICON_COLORS, Location.create(file),
+ message);
+ break checkPixels;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return new Dimension(image.getWidth(), image.getHeight());
+ }
+ } catch (IOException e) {
+ // Pass: ignore files we can't read
+ }
+
+ return null;
+ }
+
+ private static void checkExtension(Context context, File file) {
+ try {
+ ImageInputStream input = ImageIO.createImageInputStream(file);
+ if (input != null) {
+ try {
+ Iterator readers = ImageIO.getImageReaders(input);
+ while (readers.hasNext()) {
+ ImageReader reader = readers.next();
+ try {
+ reader.setInput(input);
+
+ // Check file extension
+ String formatName = reader.getFormatName();
+ if (formatName != null && !formatName.isEmpty()) {
+ String path = file.getPath();
+ int index = path.lastIndexOf('.');
+ String extension = path.substring(index+1).toLowerCase(Locale.US);
+
+ if (!formatName.equalsIgnoreCase(extension)) {
+ if (endsWith(path, DOT_JPG)
+ && formatName.equals("JPEG")) { //$NON-NLS-1$
+ return;
+ }
+ String message = String.format(
+ "Misleading file extension; named `.%1$s` but the " +
+ "file format is `%2$s`", extension, formatName);
+ Location location = Location.create(file);
+ context.report(ICON_EXTENSION, location, message);
+ }
+ break;
+ }
+ } finally {
+ reader.dispose();
+ }
+ }
+ } finally {
+ input.close();
+ }
+ }
+ } catch (IOException e) {
+ // Pass -- we can't handle all image types, warn about those we can
+ }
+ }
+
+ // Like LintUtils.getBaseName, but for files like .svn it returns "" rather than ".svn"
+ private static String getBaseName(String name) {
+ String baseName = name;
+ int index = baseName.indexOf('.');
+ if (index != -1) {
+ baseName = baseName.substring(0, index);
+ }
+
+ return baseName;
+ }
+
+ private static void checkMixedNinePatches(Context context,
+ Map> folderToNames) {
+ Set conflictSet = null;
+
+ for (Entry> entry : folderToNames.entrySet()) {
+ Set baseNames = new HashSet();
+ Set names = entry.getValue();
+ for (String name : names) {
+ assert isDrawableFile(name) : name;
+ String base = getBaseName(name);
+ if (baseNames.contains(base)) {
+ String ninepatch = base + DOT_9PNG;
+ String png = base + DOT_PNG;
+ if (names.contains(ninepatch) && names.contains(png)) {
+ if (conflictSet == null) {
+ conflictSet = Sets.newHashSet();
+ }
+ conflictSet.add(base);
+ }
+ } else {
+ baseNames.add(base);
+ }
+ }
+ }
+
+ if (conflictSet == null || conflictSet.isEmpty()) {
+ return;
+ }
+
+ Map> conflicts = null;
+ for (Entry> entry : folderToNames.entrySet()) {
+ File dir = entry.getKey();
+ Set names = entry.getValue();
+ for (String name : names) {
+ assert isDrawableFile(name) : name;
+ String base = getBaseName(name);
+ if (conflictSet.contains(base)) {
+ if (conflicts == null) {
+ conflicts = Maps.newHashMap();
+ }
+ List files = conflicts.get(base);
+ if (files == null) {
+ files = Lists.newArrayList();
+ conflicts.put(base, files);
+ }
+ files.add(new File(dir, name));
+ }
+ }
+ }
+
+ assert conflicts != null && !conflicts.isEmpty() : conflictSet;
+ List names = new ArrayList(conflicts.keySet());
+ Collections.sort(names);
+ for (String name : names) {
+ List files = conflicts.get(name);
+ assert files != null : name;
+ Location location = chainLocations(files);
+
+ String message = String.format(
+ "The files `%1$s.png` and `%1$s.9.png` clash; both "
+ + "will map to `@drawable/%1$s`", name);
+ context.report(ICON_MIX_9PNG, location, message);
+ }
+ }
+
+ private static Location chainLocations(List files) {
+ // Chain locations together
+ Collections.sort(files);
+ Location location = null;
+ for (File file : files) {
+ Location linkedLocation = location;
+ location = Location.create(file);
+ location.setSecondary(linkedLocation);
+ }
+ return location;
+ }
+
+ private void checkExpectedSizes(Context context, File folder, File[] files) {
+ if (files == null || files.length == 0) {
+ return;
+ }
+
+ String folderName = folder.getName();
+ int folderVersion = context.getDriver().getResourceFolderVersion(files[0]);
+
+ 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 = getBaseName(name);
+
+ if (isLauncherIcon(baseName)) {
+ // Launcher icons
+ checkSize(context, folderName, file, 48, 48, true /*exact*/);
+ } else if (isActionBarIcon(baseName)) {
+ checkSize(context, folderName, file, 32, 32, true /*exact*/);
+ } else if (name.startsWith("ic_dialog_")) { //$NON-NLS-1$
+ // Dialog
+ 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 (isNotificationIcon(baseName)) {
+ // 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$
+ if (isAndroid30(context, folderVersion)) {
+ // Menu icons (<=2.3 only: Replaced by action bar icons (ic_action_ in 3.0).
+ // However the table halfway down the page on
+ // http://developer.android.com/guide/practices/ui_guidelines/icon_design.html
+ // and the README in the icon template download says that convention is ic_menu
+ checkSize(context, folderName, file, 32, 32, true);
+ } else 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 static boolean isAndroid30(Context context, int folderVersion) {
+ return folderVersion >= 11 || context.getMainProject().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 static boolean isAndroid23(Context context, int folderVersion) {
+ if (isAndroid30(context, folderVersion)) {
+ return false;
+ }
+
+ if (folderVersion == 9 || folderVersion == 10) {
+ return true;
+ }
+
+ int minSdk = context.getMainProject().getMinSdk();
+
+ return minSdk == 9 || minSdk == 10;
+ }
+
+ private static 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("-xxhdpi")) { //$NON-NLS-1$
+ return 3.0f;
+ } else if (folderName.contains("-xxxhdpi")) { //$NON-NLS-1$
+ return 4.0f;
+ } else if (folderName.contains("-ldpi")) { //$NON-NLS-1$
+ return 0.75f;
+ } else {
+ return 0f;
+ }
+ }
+
+ private static 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) || endsWith(fileName, DOT_JPEG))) {
+ return;
+ }
+
+ int width;
+ int height;
+ // 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_XXHDPI)) {
+ width = mdpiWidth * 3;
+ height = mdpiWidth * 3;
+ } 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));
+ } 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));
+ }
+ }
+ }
+
+ private static Dimension getSize(File file) {
+ try {
+ ImageInputStream input = ImageIO.createImageInputStream(file);
+ if (input != null) {
+ try {
+ Iterator