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 readers = ImageIO.getImageReaders(input); + if (readers.hasNext()) { + ImageReader reader = readers.next(); + try { + reader.setInput(input); + return new Dimension(reader.getWidth(0), reader.getHeight(0)); + } finally { + reader.dispose(); + } + } + } finally { + input.close(); + } + } + + // Fallback: read the image using the normal means + BufferedImage image = ImageIO.read(file); + if (image != null) { + return new Dimension(image.getWidth(), image.getHeight()); + } else { + return null; + } + } catch (IOException e) { + // Pass -- we can't handle all image types, warn about those we can + return null; + } + } + + + private Set mActionBarIcons; + private Set mNotificationIcons; + private Set mLauncherIcons; + private Multimap mMenuToIcons; + + private boolean isLauncherIcon(String name) { + assert name.indexOf('.') == -1 : name; // Should supply base name + + // Naming convention + //noinspection SimplifiableIfStatement + if (name.startsWith("ic_launcher")) { //$NON-NLS-1$ + return true; + } + return mLauncherIcons != null && mLauncherIcons.contains(name); + } + + private boolean isNotificationIcon(String name) { + assert name.indexOf('.') == -1; // Should supply base name + + // Naming convention + //noinspection SimplifiableIfStatement + if (name.startsWith("ic_stat_")) { //$NON-NLS-1$ + return true; + } + + return mNotificationIcons != null && mNotificationIcons.contains(name); + } + + private boolean isActionBarIcon(String name) { + assert name.indexOf('.') == -1; // Should supply base name + + // Naming convention + //noinspection SimplifiableIfStatement + if (name.startsWith("ic_action_")) { //$NON-NLS-1$ + return true; + } + + // Naming convention + + return mActionBarIcons != null && mActionBarIcons.contains(name); + } + + private boolean isActionBarIcon(Context context, String name, File file) { + if (isActionBarIcon(name)) { + return true; + } + + // As of Android 3.0 ic_menu_ are action icons + //noinspection SimplifiableIfStatement,RedundantIfStatement + if (file != null && name.startsWith("ic_menu_") //$NON-NLS-1$ + && isAndroid30(context, context.getDriver().getResourceFolderVersion(file))) { + // Naming convention + return true; + } + + return false; + } + + // XML detector: Skim manifest and menu files + + @Override + public boolean appliesTo(@NonNull Context context, @NonNull File file) { + return file.getName().equals(ANDROID_MANIFEST_XML); + } + + @Override + public boolean appliesTo(@NonNull ResourceFolderType folderType) { + return folderType == ResourceFolderType.MENU; + } + + @Override + public Collection getApplicableElements() { + return Arrays.asList( + // Manifest + TAG_APPLICATION, + TAG_ACTIVITY, + TAG_SERVICE, + TAG_PROVIDER, + TAG_RECEIVER, + + // Menu + TAG_ITEM + ); + } + + @Override + public void visitElement(@NonNull XmlContext context, @NonNull Element element) { + String icon = element.getAttributeNS(ANDROID_URI, ATTR_ICON); + if (icon != null && icon.startsWith(DRAWABLE_PREFIX)) { + icon = icon.substring(DRAWABLE_PREFIX.length()); + + String tagName = element.getTagName(); + if (tagName.equals(TAG_ITEM)) { + if (mMenuToIcons == null) { + mMenuToIcons = ArrayListMultimap.create(); + } + String menu = getBaseName(context.file.getName()); + mMenuToIcons.put(menu, icon); + } else { + // Manifest tags: launcher icons + if (mLauncherIcons == null) { + mLauncherIcons = Sets.newHashSet(); + } + mLauncherIcons.add(icon); + } + } + } + + // ---- Implements UastScanner ---- + + private static final String NOTIFICATION_CLASS = "android.app.Notification"; + private static final String NOTIFICATION_BUILDER_CLASS = "android.app.Notification.Builder"; + private static final String NOTIFICATION_COMPAT_BUILDER_CLASS = + "android.support.v4.app.NotificationCompat.Builder"; + private static final String SET_SMALL_ICON = "setSmallIcon"; + private static final String ON_CREATE_OPTIONS_MENU = "onCreateOptionsMenu"; + + @Nullable + @Override + public List> getApplicableUastTypes() { + List> types = new ArrayList>(2); + types.add(UCallExpression.class); + types.add(UMethod.class); + return types; + } + + @Nullable + @Override + public UastVisitor createUastVisitor(@NonNull JavaContext context) { + return new NotificationFinder(context); + } + + private final class NotificationFinder extends AbstractUastVisitor { + private final JavaContext mContext; + + private NotificationFinder(JavaContext context) { + mContext = context; + } + + @Override + public boolean visitMethod(UMethod method) { + if (ON_CREATE_OPTIONS_MENU.equals(method.getName())) { + // Gather any R.menu references found in this method + method.accept(new MenuFinder()); + } + return super.visitMethod(method); + } + + @Override + public boolean visitCallExpression(UCallExpression node) { + if (UastExpressionUtils.isConstructorCall(node)) { + visitConstructorCall(node); + } + return super.visitCallExpression(node); + } + + private void visitConstructorCall(UCallExpression node) { + UReferenceExpression classReference = node.getClassReference(); + if (classReference == null) { + return; + } + PsiElement resolved = classReference.resolve(); + if (!(resolved instanceof PsiClass)) { + return; + } + String typeName = ((PsiClass) resolved).getQualifiedName(); + if (NOTIFICATION_CLASS.equals(typeName)) { + List args = node.getValueArguments(); + if (args.size() == 3) { + if (args.get(0) instanceof UReferenceExpression && handleSelect(args.get(0))) { + return; + } + + ResourceUrl url = ResourceEvaluator.getResource(mContext, args.get(0)); + if (url != null + && (url.type == ResourceType.DRAWABLE + || url.type == ResourceType.COLOR + || url.type == ResourceType.MIPMAP)) { + if (mNotificationIcons == null) { + mNotificationIcons = Sets.newHashSet(); + } + mNotificationIcons.add(url.name); + } + } + } else if (NOTIFICATION_BUILDER_CLASS.equals(typeName) + || NOTIFICATION_COMPAT_BUILDER_CLASS.equals(typeName)) { + UMethod method = UastUtils.getParentOfType(node, UMethod.class, true); + if (method != null) { + SetIconFinder finder = new SetIconFinder(); + method.accept(finder); + } + } + } + } + + private boolean handleSelect(UElement select) { + ResourceUrl url = ResourceEvaluator.getResourceConstant(select); + if (url != null && url.type == ResourceType.DRAWABLE && !url.framework) { + if (mNotificationIcons == null) { + mNotificationIcons = Sets.newHashSet(); + } + mNotificationIcons.add(url.name); + + return true; + } + + return false; + } + + private final class SetIconFinder extends AbstractUastVisitor { + + @Override + public boolean visitCallExpression(UCallExpression expression) { + if (UastExpressionUtils.isMethodCall(expression)) { + if (SET_SMALL_ICON.equals(expression.getMethodName())) { + List arguments = expression.getValueArguments(); + if (arguments.size() == 1 && arguments.get(0) instanceof UReferenceExpression) { + handleSelect(arguments.get(0)); + } + } + } + return super.visitCallExpression(expression); + } + + @Override + public boolean visitClass(UClass node) { + if (node instanceof UAnonymousClass) { + return true; + } + return super.visitClass(node); + } + } + + private final class MenuFinder extends AbstractUastVisitor { + @Override + public boolean visitSimpleNameReferenceExpression(USimpleNameReferenceExpression node) { + ResourceUrl url = ResourceEvaluator.getResourceConstant(node); + if (url != null && url.type == ResourceType.MENU && !url.framework) { + // Reclassify icons in the given menu as action bar icons + if (mMenuToIcons != null) { + Collection icons = mMenuToIcons.get(url.name); + if (icons != null) { + if (mActionBarIcons == null) { + mActionBarIcons = Sets.newHashSet(); + } + mActionBarIcons.addAll(icons); + } + } + } + + return super.visitSimpleNameReferenceExpression(node); + } + } +} diff --git a/plugins/lint/lint-checks/src/com/android/tools/klint/checks/PrivateResourceDetector.java.as31 b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/PrivateResourceDetector.java.as31 new file mode 100644 index 00000000000..cac223b63f1 --- /dev/null +++ b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/PrivateResourceDetector.java.as31 @@ -0,0 +1,321 @@ +/* + * Copyright (C) 2012 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.ATTR_NAME; +import static com.android.SdkConstants.ATTR_REF_PREFIX; +import static com.android.SdkConstants.ATTR_TYPE; +import static com.android.SdkConstants.FD_RES_VALUES; +import static com.android.SdkConstants.RESOURCE_CLR_STYLEABLE; +import static com.android.SdkConstants.RESOURCE_CLZ_ARRAY; +import static com.android.SdkConstants.RESOURCE_CLZ_ID; +import static com.android.SdkConstants.TAG_ARRAY; +import static com.android.SdkConstants.TAG_INTEGER_ARRAY; +import static com.android.SdkConstants.TAG_ITEM; +import static com.android.SdkConstants.TAG_PLURALS; +import static com.android.SdkConstants.TAG_RESOURCES; +import static com.android.SdkConstants.TAG_STRING_ARRAY; +import static com.android.SdkConstants.TAG_STYLE; +import static com.android.SdkConstants.TOOLS_URI; +import static com.android.SdkConstants.VALUE_TRUE; +import static com.android.tools.klint.detector.api.LintUtils.getBaseName; +import static com.android.utils.SdkUtils.getResourceFieldName; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.builder.model.AndroidLibrary; +import com.android.builder.model.MavenCoordinates; +import com.android.ide.common.repository.ResourceVisibilityLookup; +import com.android.resources.ResourceUrl; +import com.android.resources.FolderTypeRelationship; +import com.android.resources.ResourceFolderType; +import com.android.resources.ResourceType; +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.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.Location; +import com.android.tools.klint.detector.api.Project; +import com.android.tools.klint.detector.api.ResourceXmlDetector; +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 org.jetbrains.uast.UElement; +import org.jetbrains.uast.visitor.UastVisitor; +import org.w3c.dom.Attr; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.io.File; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +/** + * Check which looks for access of private resources. + */ +public class PrivateResourceDetector extends ResourceXmlDetector implements + Detector.UastScanner { + /** Attribute for overriding a resource */ + private static final String ATTR_OVERRIDE = "override"; + + @SuppressWarnings("unchecked") + private static final Implementation IMPLEMENTATION = new Implementation( + PrivateResourceDetector.class, + Scope.JAVA_AND_RESOURCE_FILES, + Scope.JAVA_FILE_SCOPE, + Scope.RESOURCE_FILE_SCOPE); + + /** The main issue discovered by this detector */ + public static final Issue ISSUE = Issue.create( + "PrivateResource", //$NON-NLS-1$ + "Using private resources", + + "Private resources should not be referenced; the may not be present everywhere, and " + + "even where they are they may disappear without notice.\n" + + "\n" + + "To fix this, copy the resource into your own project instead.", + + Category.CORRECTNESS, + 3, + Severity.WARNING, + IMPLEMENTATION); + + /** Constructs a new detector */ + public PrivateResourceDetector() { + } + + // ---- Implements UastScanner ---- + + @Override + public boolean appliesToResourceRefs() { + return true; + } + + @Override + public void visitResourceReference(@NonNull JavaContext context, @Nullable UastVisitor visitor, + @NonNull UElement node, @NonNull ResourceType resourceType, @NonNull String name, + boolean isFramework) { + if (context.getProject().isGradleProject() && !isFramework) { + Project project = context.getProject(); + if (project.getGradleProjectModel() != null && project.getCurrentVariant() != null) { + if (isPrivate(context, resourceType, name)) { + String message = createUsageErrorMessage(context, resourceType, name); + context.report(ISSUE, node, context.getUastLocation(node), message); + } + } + } + } + + // ---- Implements XmlScanner ---- + + @Override + public Collection getApplicableAttributes() { + return ALL; + } + + /** Check resource references: accessing a private resource from an upstream library? */ + @Override + public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) { + String value = attribute.getNodeValue(); + if (context.getProject().isGradleProject()) { + ResourceUrl url = ResourceUrl.parse(value); + if (isPrivate(context, url)) { + String message = createUsageErrorMessage(context, url.type, url.name); + context.report(ISSUE, attribute, context.getValueLocation(attribute), message); + } + } + } + + /** Check resource definitions: overriding a private resource from an upstream library? */ + @Override + public Collection getApplicableElements() { + return Arrays.asList( + TAG_STYLE, + TAG_RESOURCES, + TAG_ARRAY, + TAG_STRING_ARRAY, + TAG_INTEGER_ARRAY, + TAG_PLURALS + ); + } + + @Override + public void visitElement(@NonNull XmlContext context, @NonNull Element element) { + if (TAG_RESOURCES.equals(element.getTagName())) { + for (Element item : LintUtils.getChildren(element)) { + Attr nameAttribute = item.getAttributeNode(ATTR_NAME); + if (nameAttribute != null) { + String name = getResourceFieldName(nameAttribute.getValue()); + String type = item.getTagName(); + if (type.equals(TAG_ITEM)) { + type = item.getAttribute(ATTR_TYPE); + if (type == null || type.isEmpty()) { + type = RESOURCE_CLZ_ID; + } + } else if (type.equals("declare-styleable")) { //$NON-NLS-1$ + type = RESOURCE_CLR_STYLEABLE; + } else if (type.contains("array")) { //$NON-NLS-1$ + // etc + type = RESOURCE_CLZ_ARRAY; + } + ResourceType t = ResourceType.getEnum(type); + if (t != null && isPrivate(context, t, name) && + !VALUE_TRUE.equals(item.getAttributeNS(TOOLS_URI, ATTR_OVERRIDE))) { + String message = createOverrideErrorMessage(context, t, name); + Location location = context.getValueLocation(nameAttribute); + context.report(ISSUE, nameAttribute, location, message); + } + } + } + } else { + assert TAG_STYLE.equals(element.getTagName()) + || TAG_ARRAY.equals(element.getTagName()) + || TAG_PLURALS.equals(element.getTagName()) + || TAG_INTEGER_ARRAY.equals(element.getTagName()) + || TAG_STRING_ARRAY.equals(element.getTagName()); + for (Element item : LintUtils.getChildren(element)) { + checkChildRefs(context, item); + } + } + } + + private static boolean isPrivate(Context context, ResourceType type, String name) { + if (type == ResourceType.ID) { + // No need to complain about "overriding" id's. There's no harm + // in doing so. (This avoids warning about cases like for example + // appcompat's (private) @id/title resource, which would otherwise + // flag any attempt to create a resource named title in the user's + // project. + return false; + } + + if (context.getProject().isGradleProject()) { + ResourceVisibilityLookup lookup = context.getProject().getResourceVisibility(); + return lookup.isPrivate(type, name); + } + + return false; + } + + private static boolean isPrivate(@NonNull Context context, @Nullable ResourceUrl url) { + return url != null && !url.framework && isPrivate(context, url.type, url.name); + } + + private static void checkChildRefs(@NonNull XmlContext context, Element item) { + // Look for ?attr/ and @dimen/foo etc references in the item children + NodeList childNodes = item.getChildNodes(); + for (int i = 0, n = childNodes.getLength(); i < n; i++) { + Node child = childNodes.item(i); + if (child.getNodeType() == Node.TEXT_NODE) { + String text = child.getNodeValue(); + + int index = text.indexOf(ATTR_REF_PREFIX); + if (index != -1) { + String name = text.substring(index + ATTR_REF_PREFIX.length()).trim(); + if (isPrivate(context, ResourceType.ATTR, name)) { + String message = createUsageErrorMessage(context, ResourceType.ATTR, name); + context.report(ISSUE, item, context.getLocation(child), message); + } + } else { + for (int j = 0, m = text.length(); j < m; j++) { + char c = text.charAt(j); + if (c == '@') { + ResourceUrl url = ResourceUrl.parse(text.trim()); + if (isPrivate(context, url)) { + String message = createUsageErrorMessage(context, url.type, + url.name); + context.report(ISSUE, item, context.getLocation(child), message); + } + break; + } else if (!Character.isWhitespace(c)) { + break; + } + } + } + } + } + } + + @Override + public void beforeCheckFile(@NonNull Context context) { + File file = context.file; + boolean isXmlFile = LintUtils.isXmlFile(file); + if (!isXmlFile && !LintUtils.isBitmapFile(file)) { + return; + } + String parentName = file.getParentFile().getName(); + int dash = parentName.indexOf('-'); + if (dash != -1 || FD_RES_VALUES.equals(parentName)) { + return; + } + ResourceFolderType folderType = ResourceFolderType.getFolderType(parentName); + if (folderType == null) { + return; + } + List types = FolderTypeRelationship.getRelatedResourceTypes(folderType); + if (types.isEmpty()) { + return; + } + ResourceType type = types.get(0); + String resourceName = getResourceFieldName(getBaseName(file.getName())); + if (isPrivate(context, type, resourceName)) { + String message = createOverrideErrorMessage(context, type, resourceName); + Location location = Location.create(file); + context.report(ISSUE, location, message); + } + } + + private static String createOverrideErrorMessage(@NonNull Context context, + @NonNull ResourceType type, @NonNull String name) { + String libraryName = getLibraryName(context, type, name); + return String.format("Overriding `@%1$s/%2$s` which is marked as private in %3$s. If " + + "deliberate, use tools:override=\"true\", otherwise pick a " + + "different name.", type, name, libraryName); + } + + private static String createUsageErrorMessage(@NonNull Context context, + @NonNull ResourceType type, @NonNull String name) { + String libraryName = getLibraryName(context, type, name); + return String.format("The resource `@%1$s/%2$s` is marked as private in %3$s", type, + name, libraryName); + } + + /** Pick a suitable name to describe the library defining the private resource */ + @Nullable + private static String getLibraryName(@NonNull Context context, @NonNull ResourceType type, + @NonNull String name) { + ResourceVisibilityLookup lookup = context.getProject().getResourceVisibility(); + AndroidLibrary library = lookup.getPrivateIn(type, name); + if (library != null) { + String libraryName = library.getProject(); + if (libraryName != null) { + return libraryName; + } + MavenCoordinates coordinates = library.getResolvedCoordinates(); + if (coordinates != null) { + return coordinates.getGroupId() + ':' + coordinates.getArtifactId(); + } + } + return "the library"; + } +} diff --git a/plugins/lint/lint-checks/src/com/android/tools/klint/checks/RequiredAttributeDetector.java.as31 b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/RequiredAttributeDetector.java.as31 new file mode 100644 index 00000000000..98305d27828 --- /dev/null +++ b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/RequiredAttributeDetector.java.as31 @@ -0,0 +1,621 @@ +/* + * Copyright (C) 2012 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_NS_NAME_PREFIX; +import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX; +import static com.android.SdkConstants.ANDROID_URI; +import static com.android.SdkConstants.ATTR_LAYOUT; +import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; +import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; +import static com.android.SdkConstants.ATTR_NAME; +import static com.android.SdkConstants.ATTR_PARENT; +import static com.android.SdkConstants.ATTR_STYLE; +import static com.android.SdkConstants.AUTO_URI; +import static com.android.SdkConstants.FD_RES_LAYOUT; +import static com.android.SdkConstants.FQCN_GRID_LAYOUT_V7; +import static com.android.SdkConstants.GRID_LAYOUT; +import static com.android.SdkConstants.LAYOUT_RESOURCE_PREFIX; +import static com.android.SdkConstants.REQUEST_FOCUS; +import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX; +import static com.android.SdkConstants.TABLE_LAYOUT; +import static com.android.SdkConstants.TABLE_ROW; +import static com.android.SdkConstants.TAG_DATA; +import static com.android.SdkConstants.TAG_IMPORT; +import static com.android.SdkConstants.TAG_ITEM; +import static com.android.SdkConstants.TAG_LAYOUT; +import static com.android.SdkConstants.TAG_STYLE; +import static com.android.SdkConstants.TAG_VARIABLE; +import static com.android.SdkConstants.VIEW_INCLUDE; +import static com.android.SdkConstants.VIEW_MERGE; +import static com.android.resources.ResourceFolderType.LAYOUT; +import static com.android.resources.ResourceFolderType.VALUES; +import static com.android.tools.klint.detector.api.LintUtils.getLayoutName; +import static com.android.tools.klint.detector.api.LintUtils.isNullLiteral; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.annotations.VisibleForTesting; +import com.android.resources.ResourceUrl; +import com.android.resources.ResourceFolderType; +import com.android.resources.ResourceType; +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.JavaPsiScanner; +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.LayoutDetector; +import com.android.tools.klint.detector.api.ResourceEvaluator; +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.Maps; +import com.google.common.collect.Sets; +import com.intellij.psi.JavaElementVisitor; +import com.intellij.psi.PsiExpression; +import com.intellij.psi.PsiMethod; +import com.intellij.psi.PsiMethodCallExpression; + +import org.jetbrains.uast.UCallExpression; +import org.jetbrains.uast.UExpression; +import org.jetbrains.uast.UMethod; +import org.jetbrains.uast.UastLiteralUtils; +import org.jetbrains.uast.visitor.UastVisitor; +import org.w3c.dom.Element; +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.Map; +import java.util.Set; + +/** + * Ensures that layout width and height attributes are specified + */ +public class RequiredAttributeDetector extends LayoutDetector implements Detector.UastScanner { + /** The main issue discovered by this detector */ + public static final Issue ISSUE = Issue.create( + "RequiredSize", //$NON-NLS-1$ + "Missing `layout_width` or `layout_height` attributes", + + "All views must specify an explicit `layout_width` and `layout_height` attribute. " + + "There is a runtime check for this, so if you fail to specify a size, an exception " + + "is thrown at runtime.\n" + + "\n" + + "It's possible to specify these widths via styles as well. GridLayout, as a special " + + "case, does not require you to specify a size.", + Category.CORRECTNESS, + 4, + Severity.ERROR, + new Implementation( + RequiredAttributeDetector.class, + EnumSet.of(Scope.JAVA_FILE, Scope.ALL_RESOURCE_FILES))); + + public static final String PERCENT_RELATIVE_LAYOUT + = "android.support.percent.PercentRelativeLayout"; + public static final String ATTR_LAYOUT_WIDTH_PERCENT = "layout_widthPercent"; + public static final String ATTR_LAYOUT_HEIGHT_PERCENT = "layout_heightPercent"; + + /** Map from each style name to parent style */ + @Nullable private Map mStyleParents; + + /** Set of style names where the style sets the layout width */ + @Nullable private Set mWidthStyles; + + /** Set of style names where the style sets the layout height */ + @Nullable private Set mHeightStyles; + + /** Set of layout names for layouts that are included by an {@code } tag + * where the width is set on the include */ + @Nullable private Set mIncludedWidths; + + /** Set of layout names for layouts that are included by an {@code } tag + * where the height is set on the include */ + @Nullable private Set mIncludedHeights; + + /** Set of layout names for layouts that are included by an {@code } tag + * where the width is not set on the include */ + @Nullable private Set mNotIncludedWidths; + + /** Set of layout names for layouts that are included by an {@code } tag + * where the height is not set on the include */ + @Nullable private Set mNotIncludedHeights; + + /** Whether the width was set in a theme definition */ + private boolean mSetWidthInTheme; + + /** Whether the height was set in a theme definition */ + private boolean mSetHeightInTheme; + + /** Constructs a new {@link RequiredAttributeDetector} */ + public RequiredAttributeDetector() { + } + + @Override + public boolean appliesTo(@NonNull ResourceFolderType folderType) { + return folderType == LAYOUT || folderType == VALUES; + } + + @Override + public void afterCheckProject(@NonNull Context context) { + // Process checks in two phases: + // Phase 1: Gather styles and includes (styles are encountered after the layouts + // so we can't do it in a single phase, and includes can be affected by includes from + // layouts we haven't seen yet) + // Phase 2: Process layouts, using gathered style and include data, and mark layouts + // not known. + // + if (context.getPhase() == 1) { + checkSizeSetInTheme(); + + context.requestRepeat(this, Scope.RESOURCE_FILE_SCOPE); + } + } + + private boolean isWidthStyle(String style) { + return isSizeStyle(style, mWidthStyles); + } + + private boolean isHeightStyle(String style) { + return isSizeStyle(style, mHeightStyles); + } + + private boolean isSizeStyle(String style, Set sizeStyles) { + if (isFrameworkSizeStyle(style)) { + return true; + } + if (sizeStyles == null) { + return false; + } + return isSizeStyle(stripStylePrefix(style), sizeStyles, 0); + } + + private static boolean isFrameworkSizeStyle(String style) { + // The styles Widget.TextView.ListSeparator (and several theme variations, such as + // Widget.Holo.TextView.ListSeparator, Widget.Holo.Light.TextView.ListSeparator, etc) + // define layout_width and layout_height. + // These are exposed through the listSeparatorTextViewStyle style. + if (style.equals("?android:attr/listSeparatorTextViewStyle") //$NON-NLS-1$ + || style.equals("?android/listSeparatorTextViewStyle")) { //$NON-NLS-1$ + return true; + } + + // It's also set on Widget.QuickContactBadge and Widget.QuickContactBadgeSmall + // These are exposed via a handful of attributes with a common prefix + if (style.startsWith("?android:attr/quickContactBadgeStyle")) { //$NON-NLS-1$ + return true; + } + + // Finally, the styles are set on MediaButton and Widget.Holo.Tab (and + // Widget.Holo.Light.Tab) but these are not exposed via attributes. + + return false; + } + + private boolean isSizeStyle( + @NonNull String style, + @NonNull Set sizeStyles, int depth) { + if (depth == 30) { + // Cycle between local and framework attribute style missed + // by the fact that we're stripping the distinction between framework + // and local styles here + return false; + } + + assert !style.startsWith(STYLE_RESOURCE_PREFIX) + && !style.startsWith(ANDROID_STYLE_RESOURCE_PREFIX); + + if (sizeStyles.contains(style)) { + return true; + } + + if (mStyleParents != null) { + String parentStyle = mStyleParents.get(style); + if (parentStyle != null) { + parentStyle = stripStylePrefix(parentStyle); + if (isSizeStyle(parentStyle, sizeStyles, depth + 1)) { + return true; + } + } + } + + int index = style.lastIndexOf('.'); + if (index > 0) { + return isSizeStyle(style.substring(0, index), sizeStyles, depth + 1); + } + + return false; + } + + private void checkSizeSetInTheme() { + // Look through the styles and determine whether each style is a theme + if (mStyleParents == null) { + return; + } + + Map isTheme = Maps.newHashMap(); + for (String style : mStyleParents.keySet()) { + if (isTheme(stripStylePrefix(style), isTheme, 0)) { + mSetWidthInTheme = true; + mSetHeightInTheme = true; + break; + } + } + } + + private boolean isTheme(String style, Map isTheme, int depth) { + if (depth == 30) { + // Cycle between local and framework attribute style missed + // by the fact that we're stripping the distinction between framework + // and local styles here + return false; + } + + assert !style.startsWith(STYLE_RESOURCE_PREFIX) + && !style.startsWith(ANDROID_STYLE_RESOURCE_PREFIX); + + Boolean known = isTheme.get(style); + if (known != null) { + return known; + } + + if (style.contains("Theme")) { //$NON-NLS-1$ + isTheme.put(style, true); + return true; + } + + if (mStyleParents != null) { + String parentStyle = mStyleParents.get(style); + if (parentStyle != null) { + parentStyle = stripStylePrefix(parentStyle); + if (isTheme(parentStyle, isTheme, depth + 1)) { + isTheme.put(style, true); + return true; + } + } + } + + int index = style.lastIndexOf('.'); + if (index > 0) { + String parentStyle = style.substring(0, index); + boolean result = isTheme(parentStyle, isTheme, depth + 1); + isTheme.put(style, result); + return result; + } + + return false; + } + + @VisibleForTesting + static boolean hasLayoutVariations(File file) { + File parent = file.getParentFile(); + if (parent == null) { + return false; + } + File res = parent.getParentFile(); + if (res == null) { + return false; + } + String name = file.getName(); + File[] folders = res.listFiles(); + if (folders == null) { + return false; + } + for (File folder : folders) { + if (!folder.getName().startsWith(FD_RES_LAYOUT)) { + continue; + } + if (folder.equals(parent)) { + continue; + } + File other = new File(folder, name); + if (other.exists()) { + return true; + } + } + + return false; + } + + private static String stripStylePrefix(@NonNull String style) { + if (style.startsWith(STYLE_RESOURCE_PREFIX)) { + style = style.substring(STYLE_RESOURCE_PREFIX.length()); + } else if (style.startsWith(ANDROID_STYLE_RESOURCE_PREFIX)) { + style = style.substring(ANDROID_STYLE_RESOURCE_PREFIX.length()); + } + + return style; + } + + private static boolean isRootElement(@NonNull Node node) { + return node == node.getOwnerDocument().getDocumentElement(); + } + + // ---- Implements XmlScanner ---- + + @Override + public Collection getApplicableElements() { + return ALL; + } + + @Override + public void visitElement(@NonNull XmlContext context, @NonNull Element element) { + ResourceFolderType folderType = context.getResourceFolderType(); + int phase = context.getPhase(); + if (phase == 1 && folderType == VALUES) { + String tag = element.getTagName(); + if (TAG_STYLE.equals(tag)) { + String parent = element.getAttribute(ATTR_PARENT); + if (parent != null && !parent.isEmpty()) { + String name = element.getAttribute(ATTR_NAME); + if (name != null && !name.isEmpty()) { + if (mStyleParents == null) { + mStyleParents = Maps.newHashMap(); + } + mStyleParents.put(name, parent); + } + } + } else if (TAG_ITEM.equals(tag) + && TAG_STYLE.equals(element.getParentNode().getNodeName())) { + String name = element.getAttribute(ATTR_NAME); + if (name.endsWith(ATTR_LAYOUT_WIDTH) && + name.equals(ANDROID_NS_NAME_PREFIX + ATTR_LAYOUT_WIDTH)) { + if (mWidthStyles == null) { + mWidthStyles = Sets.newHashSet(); + } + String styleName = ((Element) element.getParentNode()).getAttribute(ATTR_NAME); + mWidthStyles.add(styleName); + } + if (name.endsWith(ATTR_LAYOUT_HEIGHT) && + name.equals(ANDROID_NS_NAME_PREFIX + ATTR_LAYOUT_HEIGHT)) { + if (mHeightStyles == null) { + mHeightStyles = Sets.newHashSet(); + } + String styleName = ((Element) element.getParentNode()).getAttribute(ATTR_NAME); + mHeightStyles.add(styleName); + } + } + } else if (folderType == LAYOUT) { + if (phase == 1) { + // Gather includes + if (element.getTagName().equals(VIEW_INCLUDE)) { + String layout = element.getAttribute(ATTR_LAYOUT); + if (layout != null && !layout.isEmpty()) { + recordIncludeWidth(layout, + element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH)); + recordIncludeHeight(layout, + element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT)); + } + } + } else { + assert phase == 2; // Check everything using style data and include data + boolean hasWidth = element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH); + boolean hasHeight = element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT); + + if (mSetWidthInTheme) { + hasWidth = true; + } + + if (mSetHeightInTheme) { + hasHeight = true; + } + + if (hasWidth && hasHeight) { + return; + } + + String tag = element.getTagName(); + if (VIEW_MERGE.equals(tag) + || VIEW_INCLUDE.equals(tag) + || REQUEST_FOCUS.equals(tag)) { + return; + } + + // Data binding: these tags shouldn't specify width/height + if (tag.equals(TAG_LAYOUT) + || tag.equals(TAG_VARIABLE) + || tag.equals(TAG_DATA) + || tag.equals(TAG_IMPORT)) { + return; + } + + String parentTag = element.getParentNode() != null + ? element.getParentNode().getNodeName() : ""; + if (TABLE_LAYOUT.equals(parentTag) + || TABLE_ROW.equals(parentTag) + || GRID_LAYOUT.equals(parentTag) + || FQCN_GRID_LAYOUT_V7.equals(parentTag)) { + return; + } + + // PercentRelativeLayout or PercentFrameLayout? + boolean isPercent = parentTag.startsWith("android.support.percent.Percent"); + if (isPercent) { + hasWidth |= element.hasAttributeNS(AUTO_URI, ATTR_LAYOUT_WIDTH_PERCENT); + hasHeight |= element.hasAttributeNS(AUTO_URI, ATTR_LAYOUT_HEIGHT_PERCENT); + if (hasWidth && hasHeight) { + return; + } + } + + if (!context.getProject().getReportIssues()) { + // If this is a library project not being analyzed, ignore it + return; + } + + boolean certain = true; + boolean isRoot = isRootElement(element); + if (isRoot || isRootElement(element.getParentNode()) + && VIEW_MERGE.equals(parentTag)) { + String name = LAYOUT_RESOURCE_PREFIX + getLayoutName(context.file); + if (!hasWidth && mIncludedWidths != null) { + hasWidth = mIncludedWidths.contains(name); + // If the layout is *also* included in a context where the width + // was not set, we're not certain; it's possible that + if (mNotIncludedWidths != null && mNotIncludedWidths.contains(name)) { + hasWidth = false; + // If we only have a single layout we know that this layout isn't + // always included with layout_width or layout_height set, but + // if there are multiple layouts, it's possible that at runtime + // we only load the size-less layout by the tag which includes + // the size + certain = !hasLayoutVariations(context.file); + } + } + if (!hasHeight && mIncludedHeights != null) { + hasHeight = mIncludedHeights.contains(name); + if (mNotIncludedHeights != null && mNotIncludedHeights.contains(name)) { + hasHeight = false; + certain = !hasLayoutVariations(context.file); + } + } + if (hasWidth && hasHeight) { + return; + } + } + + if (!hasWidth || !hasHeight) { + String style = element.getAttribute(ATTR_STYLE); + if (style != null && !style.isEmpty()) { + if (!hasWidth) { + hasWidth = isWidthStyle(style); + } + if (!hasHeight) { + hasHeight = isHeightStyle(style); + } + } + if (hasWidth && hasHeight) { + return; + } + } + + String message; + if (!(hasWidth || hasHeight)) { + if (certain) { + message = "The required `layout_width` and `layout_height` attributes " + + "are missing"; + } else { + message = "The required `layout_width` and `layout_height` attributes " + + "*may* be missing"; + } + } else { + String attribute = hasWidth ? ATTR_LAYOUT_HEIGHT : ATTR_LAYOUT_WIDTH; + if (certain) { + message = String.format("The required `%1$s` attribute is missing", + attribute); + } else { + message = String.format("The required `%1$s` attribute *may* be missing", + attribute); + } + } + if (isPercent) { + String escapedLayoutWidth = '`' + ATTR_LAYOUT_WIDTH + '`'; + String escapedLayoutHeight = '`' + ATTR_LAYOUT_HEIGHT + '`'; + String escapedLayoutWidthPercent = '`' + ATTR_LAYOUT_WIDTH_PERCENT + '`'; + String escapedLayoutHeightPercent = '`' + ATTR_LAYOUT_HEIGHT_PERCENT + '`'; + message = message.replace(escapedLayoutWidth, escapedLayoutWidth + " or " + + escapedLayoutWidthPercent).replace(escapedLayoutHeight, + escapedLayoutHeight + " or " + escapedLayoutHeightPercent); + } + context.report(ISSUE, element, context.getLocation(element), + message); + } + } + } + + private void recordIncludeWidth(String layout, boolean providesWidth) { + if (providesWidth) { + if (mIncludedWidths == null) { + mIncludedWidths = Sets.newHashSet(); + } + mIncludedWidths.add(layout); + } else { + if (mNotIncludedWidths == null) { + mNotIncludedWidths = Sets.newHashSet(); + } + mNotIncludedWidths.add(layout); + } + } + + private void recordIncludeHeight(String layout, boolean providesHeight) { + if (providesHeight) { + if (mIncludedHeights == null) { + mIncludedHeights = Sets.newHashSet(); + } + mIncludedHeights.add(layout); + } else { + if (mNotIncludedHeights == null) { + mNotIncludedHeights = Sets.newHashSet(); + } + mNotIncludedHeights.add(layout); + } + } + + // ---- Implements UastScanner ---- + + @Override + @Nullable + public List getApplicableMethodNames() { + return Collections.singletonList("inflate"); //$NON-NLS-1$ + } + + @Override + public void visitMethod(@NonNull JavaContext context, @Nullable UastVisitor visitor, + @NonNull UCallExpression call, @NonNull UMethod method) { + // Handle + // View#inflate(Context context, int resource, ViewGroup root) + // LayoutInflater#inflate(int resource, ViewGroup root) + // LayoutInflater#inflate(int resource, ViewGroup root, boolean attachToRoot) + List args = call.getValueArguments(); + + String layout = null; + int index = 0; + ResourceEvaluator evaluator = new ResourceEvaluator(context); + for (UExpression expression : args) { + ResourceUrl url = evaluator.getResource(expression); + if (url != null && url.type == ResourceType.LAYOUT) { + layout = url.toString(); + break; + } + index++; + } + + if (layout == null) { + // Flow analysis didn't succeed + return; + } + + // In all the applicable signatures, the view root argument is immediately after + // the layout resource id. + int viewRootPos = index + 1; + if (viewRootPos < args.size()) { + UExpression viewRoot = args.get(viewRootPos); + if (UastLiteralUtils.isNullLiteral(viewRoot)) { + // Yep, this one inflates the given view with a null parent: + // Tag it as such. For now just use the include data structure since + // it has the same net effect + recordIncludeWidth(layout, true); + recordIncludeHeight(layout, true); + } + } + } +} diff --git a/plugins/lint/lint-checks/src/com/android/tools/klint/checks/StringFormatDetector.java.as31 b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/StringFormatDetector.java.as31 new file mode 100644 index 00000000000..d2529c4aebb --- /dev/null +++ b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/StringFormatDetector.java.as31 @@ -0,0 +1,1494 @@ +/* + * 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 static com.android.SdkConstants.ATTR_NAME; +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.DOT_JAVA; +import static com.android.SdkConstants.FORMAT_METHOD; +import static com.android.SdkConstants.GET_STRING_METHOD; +import static com.android.SdkConstants.TAG_STRING; +import static com.android.tools.klint.client.api.JavaParser.TYPE_BOOLEAN_WRAPPER; +import static com.android.tools.klint.client.api.JavaParser.TYPE_BYTE_WRAPPER; +import static com.android.tools.klint.client.api.JavaParser.TYPE_CHARACTER_WRAPPER; +import static com.android.tools.klint.client.api.JavaParser.TYPE_DOUBLE_WRAPPER; +import static com.android.tools.klint.client.api.JavaParser.TYPE_FLOAT_WRAPPER; +import static com.android.tools.klint.client.api.JavaParser.TYPE_INTEGER_WRAPPER; +import static com.android.tools.klint.client.api.JavaParser.TYPE_LONG_WRAPPER; +import static com.android.tools.klint.client.api.JavaParser.TYPE_OBJECT; +import static com.android.tools.klint.client.api.JavaParser.TYPE_SHORT_WRAPPER; +import static com.android.tools.klint.client.api.JavaParser.TYPE_STRING; + +import com.android.annotations.NonNull; +import com.android.annotations.Nullable; +import com.android.annotations.VisibleForTesting; +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.ResourceFolderType; +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.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.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.Location; +import com.android.tools.klint.detector.api.Location.Handle; +import com.android.tools.klint.detector.api.Position; +import com.android.tools.klint.detector.api.ResourceEvaluator; +import com.android.tools.klint.detector.api.ResourceXmlDetector; +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.android.utils.Pair; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiMethod; +import com.intellij.psi.PsiParameterList; +import com.intellij.psi.PsiType; +import com.intellij.psi.PsiVariable; + +import org.jetbrains.uast.UCallExpression; +import org.jetbrains.uast.UExpression; +import org.jetbrains.uast.ULiteralExpression; +import org.jetbrains.uast.UMethod; +import org.jetbrains.uast.UReferenceExpression; +import org.jetbrains.uast.util.UastExpressionUtils; +import org.jetbrains.uast.visitor.UastVisitor; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Check which looks for problems with formatting strings such as inconsistencies between + * translations or between string declaration and string usage in Java. + *

+ * TODO: Handle Resources.getQuantityString as well + */ +public class StringFormatDetector extends ResourceXmlDetector implements Detector.UastScanner { + private static final Implementation IMPLEMENTATION_XML = new Implementation( + StringFormatDetector.class, + Scope.ALL_RESOURCES_SCOPE); + + @SuppressWarnings("unchecked") + private static final Implementation IMPLEMENTATION_XML_AND_JAVA = new Implementation( + StringFormatDetector.class, + EnumSet.of(Scope.ALL_RESOURCE_FILES, Scope.JAVA_FILE), + Scope.JAVA_FILE_SCOPE); + + + /** Whether formatting strings are invalid */ + public static final Issue INVALID = Issue.create( + "StringFormatInvalid", //$NON-NLS-1$ + "Invalid format string", + + "If a string contains a '%' character, then the string may be a formatting string " + + "which will be passed to `String.format` from Java code to replace each '%' " + + "occurrence with specific values.\n" + + "\n" + + "This lint warning checks for two related problems:\n" + + "(1) Formatting strings that are invalid, meaning that `String.format` will throw " + + "exceptions at runtime when attempting to use the format string.\n" + + "(2) Strings containing '%' that are not formatting strings getting passed to " + + "a `String.format` call. In this case the '%' will need to be escaped as '%%'.\n" + + "\n" + + "NOTE: Not all Strings which look like formatting strings are intended for " + + "use by `String.format`; for example, they may contain date formats intended " + + "for `android.text.format.Time#format()`. Lint cannot always figure out that " + + "a String is a date format, so you may get false warnings in those scenarios. " + + "See the suppress help topic for information on how to suppress errors in " + + "that case.", + + Category.MESSAGES, + 9, + Severity.ERROR, + IMPLEMENTATION_XML); + + /** Whether formatting argument types are consistent across translations */ + public static final Issue ARG_COUNT = Issue.create( + "StringFormatCount", //$NON-NLS-1$ + "Formatting argument types incomplete or inconsistent", + + "When a formatted string takes arguments, it usually needs to reference the " + + "same arguments in all translations (or all arguments if there are no " + + "translations.\n" + + "\n" + + "There are cases where this is not the case, so this issue is a warning rather " + + "than an error by default. However, this usually happens when a language is not " + + "translated or updated correctly.", + Category.MESSAGES, + 5, + Severity.WARNING, + IMPLEMENTATION_XML); + + /** Whether the string format supplied in a call to String.format matches the format string */ + public static final Issue ARG_TYPES = Issue.create( + "StringFormatMatches", //$NON-NLS-1$ + "`String.format` string doesn't match the XML format string", + + "This lint check ensures the following:\n" + + "(1) If there are multiple translations of the format string, then all translations " + + "use the same type for the same numbered arguments\n" + + "(2) The usage of the format string in Java is consistent with the format string, " + + "meaning that the parameter types passed to String.format matches those in the " + + "format string.", + Category.MESSAGES, + 9, + Severity.ERROR, + IMPLEMENTATION_XML_AND_JAVA); + + /** This plural does not use the quantity value */ + public static final Issue POTENTIAL_PLURAL = Issue.create( + "PluralsCandidate", //$NON-NLS-1$ + "Potential Plurals", + + "This lint check looks for potential errors in internationalization where you have " + + "translated a message which involves a quantity and it looks like other parts of " + + "the string may need grammatical changes.\n" + + "\n" + + "For example, rather than something like this:\n" + + " Try again in %d seconds.\n" + + "you should be using a plural:\n" + + " \n" + + " Try again in %d second\n" + + " Try again in %d seconds\n" + + " \n" + + "This will ensure that in other languages the right set of translations are " + + "provided for the different quantity classes.\n" + + "\n" + + "(This check depends on some heuristics, so it may not accurately determine whether " + + "a string really should be a quantity. You can use tools:ignore to filter out false " + + "positives.", + + Category.MESSAGES, + 5, + Severity.WARNING, + IMPLEMENTATION_XML).addMoreInfo( + "http://developer.android.com/guide/topics/resources/string-resource.html#Plurals"); + + /** + * Map from a format string name to a list of declaration file and actual + * formatting string content. We're using a list since a format string can be + * defined multiple times, usually for different translations. + */ + private Map>> mFormatStrings; + + /** + * Map of strings that do not contain any formatting. + */ + private final Map mNotFormatStrings = new HashMap(); + + /** + * Set of strings that have an unknown format such as date formatting; we should not + * flag these as invalid when used from a String#format call + */ + private Set mIgnoreStrings; + + /** Constructs a new {@link StringFormatDetector} check */ + public StringFormatDetector() { + } + + @Override + public boolean appliesTo(@NonNull ResourceFolderType folderType) { + return folderType == ResourceFolderType.VALUES; + } + + @Override + public boolean appliesTo(@NonNull Context context, @NonNull File file) { + if (LintUtils.endsWith(file.getName(), DOT_JAVA)) { + return mFormatStrings != null; + } + + return super.appliesTo(context, file); + } + + @Override + public Collection getApplicableElements() { + return Collections.singletonList(TAG_STRING); + } + + @Override + public void visitElement(@NonNull XmlContext context, @NonNull Element element) { + NodeList childNodes = element.getChildNodes(); + if (childNodes.getLength() > 0) { + if (childNodes.getLength() == 1) { + Node child = childNodes.item(0); + if (child.getNodeType() == Node.TEXT_NODE) { + checkTextNode(context, element, stripQuotes(child.getNodeValue())); + } + } else { + // Concatenate children and build up a plain string. + // This is needed to handle xliff localization documents, + // but this needs more work so ignore compound XML documents as + // string values for now: + StringBuilder sb = new StringBuilder(); + addText(sb, element); + if (sb.length() > 0) { + checkTextNode(context, element, sb.toString()); + } + } + } + } + + private static void addText(StringBuilder sb, Node node) { + if (node.getNodeType() == Node.TEXT_NODE) { + sb.append(stripQuotes(node.getNodeValue().trim())); + } else { + NodeList childNodes = node.getChildNodes(); + for (int i = 0, n = childNodes.getLength(); i < n; i++) { + addText(sb, childNodes.item(i)); + } + } + } + + /** + * Removes all the unescaped quotes. See + * Escaping apostrophes and quotes + */ + @VisibleForTesting + static String stripQuotes(String s) { + StringBuilder sb = new StringBuilder(); + boolean isEscaped = false; + boolean isQuotedBlock = false; + for (int i = 0, len = s.length(); i < len; i++) { + char current = s.charAt(i); + if (isEscaped) { + sb.append(current); + isEscaped = false; + } else { + isEscaped = current == '\\'; // Next char will be escaped so we will just copy it + if (current == '"') { + isQuotedBlock = !isQuotedBlock; + } else if (current == '\'') { + if (isQuotedBlock) { + // We only add single quotes when they are within a quoted block + sb.append(current); + } + } else { + sb.append(current); + } + } + } + + return sb.toString(); + } + + private void checkTextNode(XmlContext context, Element element, String text) { + String name = element.getAttribute(ATTR_NAME); + boolean found = false; + boolean foundPlural = false; + + // Look at the String and see if it's a format string (contains + // positional %'s) + for (int j = 0, m = text.length(); j < m; j++) { + char c = text.charAt(j); + if (c == '\\') { + j++; + } + if (c == '%') { + // Also make sure this String isn't an unformatted String + String formatted = element.getAttribute("formatted"); //$NON-NLS-1$ + if (!formatted.isEmpty() && !Boolean.parseBoolean(formatted)) { + if (!mNotFormatStrings.containsKey(name)) { + Handle handle = context.createLocationHandle(element); + handle.setClientData(element); + mNotFormatStrings.put(name, handle); + } + return; + } + + // See if it's not a format string, e.g. "Battery charge is 100%!". + // If so we want to record this name in a special list such that we can + // make sure you don't attempt to reference this string from a String.format + // call. + Matcher matcher = FORMAT.matcher(text); + if (!matcher.find(j)) { + if (!mNotFormatStrings.containsKey(name)) { + Handle handle = context.createLocationHandle(element); + handle.setClientData(element); + mNotFormatStrings.put(name, handle); + } + return; + } + + String conversion = matcher.group(6); + int conversionClass = getConversionClass(conversion.charAt(0)); + if (conversionClass == CONVERSION_CLASS_UNKNOWN || matcher.group(5) != null) { + if (mIgnoreStrings == null) { + mIgnoreStrings = new HashSet(); + } + mIgnoreStrings.add(name); + + // Don't process any other strings here; some of them could + // accidentally look like a string, e.g. "%H" is a hash code conversion + // in String.format (and hour in Time formatting). + return; + } + + if (conversionClass == CONVERSION_CLASS_INTEGER && !foundPlural) { + // See if there appears to be further text content here. + // Look for whitespace followed by a letter, with no punctuation in between + for (int k = matcher.end(); k < m; k++) { + char nc = text.charAt(k); + if (!Character.isWhitespace(nc)) { + if (Character.isLetter(nc)) { + foundPlural = checkPotentialPlural(context, element, text, k); + } + break; + } + } + } + + found = true; + j++; // Ensure that when we process a "%%" we don't separately check the second % + } + } + + if (!context.getProject().getReportIssues()) { + // If this is a library project not being analyzed, ignore it + return; + } + + if (name != null) { + Handle handle = context.createLocationHandle(element); + handle.setClientData(element); + if (found) { + // Record it for analysis when seen in Java code + if (mFormatStrings == null) { + mFormatStrings = new HashMap>>(); + } + + List> list = mFormatStrings.get(name); + if (list == null) { + list = new ArrayList>(); + mFormatStrings.put(name, list); + } + list.add(Pair.of(handle, text)); + } else { + if (!isReference(text)) { + mNotFormatStrings.put(name, handle); + } + } + } + } + + private static boolean isReference(@NonNull String text) { + for (int i = 0, n = text.length(); i < n; i++) { + char c = text.charAt(i); + if (!Character.isWhitespace(c)) { + return c == '@' || c == '?'; + } + } + return false; + } + + /** + * Checks whether the text begins with a non-unit word, pointing to a string + * that should probably be a plural instead. This + */ + private static boolean checkPotentialPlural(XmlContext context, Element element, String text, + int wordBegin) { + // This method should only be called if the text is known to start with a word + assert Character.isLetter(text.charAt(wordBegin)); + + int wordEnd = wordBegin; + while (wordEnd < text.length()) { + if (!Character.isLetter(text.charAt(wordEnd))) { + break; + } + wordEnd++; + } + + // Eliminate units, since those are not sentences you need to use plurals for, e.g. + // "Elevation gain: %1$d m (%2$d ft)" + // We'll determine whether something is a unit by looking for + // (1) Multiple uppercase characters (e.g. KB, or MiB), or better yet, uppercase characters + // anywhere but as the first letter + // (2) No vowels (e.g. ft) + // (3) Adjacent consonants (e.g. ft); this one can eliminate some legitimate + // English words as well (e.g. "the") so we should really limit this to + // letter pairs that are not common in English. This is probably overkill + // so not handled yet. Instead we use a simpler heuristic: + // (4) Very short "words" (1-2 letters) + if (wordEnd - wordBegin <= 2) { + // Very short word (1-2 chars): possible unit, e.g. "m", "ft", "kb", etc + return false; + } + boolean hasVowel = false; + for (int i = wordBegin; i < wordEnd; i++) { + // Uppercase character anywhere but first character: probably a unit (e.g. KB) + char c = text.charAt(i); + if (i > wordBegin && Character.isUpperCase(c)) { + return false; + } + if (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u' || c == 'y') { + hasVowel = true; + } + } + if (!hasVowel) { + // No vowels: likely unit + return false; + } + + String word = text.substring(wordBegin, wordEnd); + + // Some other known abbreviations that we don't want to count: + if (word.equals("min")) { + return false; + } + + // This heuristic only works in English! + if (LintUtils.isEnglishResource(context, true)) { + String message = String.format("Formatting %%d followed by words (\"%1$s\"): " + + "This should probably be a plural rather than a string", word); + context.report(POTENTIAL_PLURAL, element, + context.getLocation(element), + message); + // Avoid reporting multiple errors on the same string + // (if it contains more than one %d) + return true; + } + + return false; + } + + @Override + public void afterCheckProject(@NonNull Context context) { + if (mFormatStrings != null) { + boolean checkCount = context.isEnabled(ARG_COUNT); + boolean checkValid = context.isEnabled(INVALID); + boolean checkTypes = context.isEnabled(ARG_TYPES); + + // Ensure that all the format strings are consistent with respect to each other; + // e.g. they all have the same number of arguments, they all use all the + // arguments, and they all use the same types for all the numbered arguments + for (Map.Entry>> entry : mFormatStrings.entrySet()) { + String name = entry.getKey(); + List> list = entry.getValue(); + + // Check argument counts + if (checkCount) { + Handle notFormatted = mNotFormatStrings.get(name); + if (notFormatted != null) { + list = ImmutableList.>builder() + .add(Pair.of(notFormatted, name)).addAll(list).build(); + } + checkArity(context, name, list); + } + + // Check argument types (and also make sure that the formatting strings are valid) + if (checkValid || checkTypes) { + checkTypes(context, checkValid, checkTypes, name, list); + } + } + } + } + + private static void checkTypes(Context context, boolean checkValid, + boolean checkTypes, String name, List> list) { + Map types = new HashMap(); + Map typeDefinition = new HashMap(); + for (Pair pair : list) { + Handle handle = pair.getFirst(); + String formatString = pair.getSecond(); + + //boolean warned = false; + Matcher matcher = FORMAT.matcher(formatString); + int index = 0; + int prevIndex = 0; + int nextNumber = 1; + while (true) { + if (matcher.find(index)) { + int matchStart = matcher.start(); + // Make sure this is not an escaped '%' + for (; prevIndex < matchStart; prevIndex++) { + char c = formatString.charAt(prevIndex); + if (c == '\\') { + prevIndex++; + } + } + if (prevIndex > matchStart) { + // We're in an escape, ignore this result + index = prevIndex; + continue; + } + + index = matcher.end(); // Ensure loop proceeds + String str = formatString.substring(matchStart, matcher.end()); + if (str.equals("%%") || str.equals("%n")) { //$NON-NLS-1$ //$NON-NLS-2$ + // Just an escaped % + continue; + } + + if (checkValid) { + // Make sure it's a valid format string + if (str.length() > 2 && str.charAt(str.length() - 2) == ' ') { + char last = str.charAt(str.length() - 1); + // If you forget to include the conversion character, e.g. + // "Weight=%1$ g" instead of "Weight=%1$d g", then + // you're going to end up with a format string interpreted as + // "%1$ g". This means that the space character is interpreted + // as a flag character, but it can only be a flag character + // when used in conjunction with the numeric conversion + // formats (d, o, x, X). If that's not the case, make a + // dedicated error message + if (last != 'd' && last != 'o' && last != 'x' && last != 'X') { + Object clientData = handle.getClientData(); + if (clientData instanceof Node) { + if (context.getDriver().isSuppressed(null, INVALID, + (Node) clientData)) { + return; + } + } + + Location location = handle.resolve(); + String message = String.format( + "Incorrect formatting string `%1$s`; missing conversion " + + "character in '`%2$s`' ?", name, str); + context.report(INVALID, location, message); + //warned = true; + continue; + } + } + } + + if (!checkTypes) { + continue; + } + + // Shouldn't throw a number format exception since we've already + // matched the pattern in the regexp + int number; + String numberString = matcher.group(1); + if (numberString != null) { + // Strip off trailing $ + numberString = numberString.substring(0, numberString.length() - 1); + number = Integer.parseInt(numberString); + nextNumber = number + 1; + } else { + number = nextNumber++; + } + String format = matcher.group(6); + String currentFormat = types.get(number); + if (currentFormat == null) { + types.put(number, format); + typeDefinition.put(number, handle); + } else if (!currentFormat.equals(format) + && isIncompatible(currentFormat.charAt(0), format.charAt(0))) { + + Object clientData = handle.getClientData(); + if (clientData instanceof Node) { + if (context.getDriver().isSuppressed(null, ARG_TYPES, + (Node) clientData)) { + return; + } + } + + Location location = handle.resolve(); + // Attempt to limit the location range to just the formatting + // string in question + location = refineLocation(context, location, formatString, + matcher.start(), matcher.end()); + Location otherLocation = typeDefinition.get(number).resolve(); + otherLocation.setMessage("Conflicting argument type here"); + location.setSecondary(otherLocation); + File f = otherLocation.getFile(); + String message = String.format( + "Inconsistent formatting types for argument #%1$d in " + + "format string `%2$s` ('%3$s'): Found both '`%4$s`' and '`%5$s`' " + + "(in %6$s)", + number, name, + str, + currentFormat, format, + f.getParentFile().getName() + File.separator + f.getName()); + //warned = true; + context.report(ARG_TYPES, location, message); + break; + } + } else { + break; + } + } + + // Check that the format string is valid by actually attempting to instantiate + // it. We only do this if we haven't already complained about this string + // for other reasons. + /* Check disabled for now: it had many false reports due to conversion + * errors (which is expected since we just pass in strings), but once those + * are eliminated there aren't really any other valid error messages returned + * (for example, calling the formatter with bogus formatting flags always just + * returns a "conversion" error. It looks like we'd need to actually pass compatible + * arguments to trigger other types of formatting errors such as precision errors. + if (!warned && checkValid) { + try { + formatter.format(formatString, "", "", "", "", "", "", "", + "", "", "", "", "", "", ""); + + } catch (IllegalFormatException t) { // TODO: UnknownFormatConversionException + if (!t.getLocalizedMessage().contains(" != ") + && !t.getLocalizedMessage().contains("Conversion")) { + Location location = handle.resolve(); + context.report(INVALID, location, + String.format("Wrong format for %1$s: %2$s", + name, t.getLocalizedMessage()), null); + } + } + } + */ + } + } + + /** + * Returns true if two String.format conversions are "incompatible" (meaning + * that using these two for the same argument across different translations + * is more likely an error than intentional. Some conversions are + * incompatible, e.g. "d" and "s" where one is a number and string, whereas + * others may work (e.g. float versus integer) but are probably not + * intentional. + */ + private static boolean isIncompatible(char conversion1, char conversion2) { + int class1 = getConversionClass(conversion1); + int class2 = getConversionClass(conversion2); + return class1 != class2 + && class1 != CONVERSION_CLASS_UNKNOWN + && class2 != CONVERSION_CLASS_UNKNOWN; + } + + private static final int CONVERSION_CLASS_UNKNOWN = 0; + private static final int CONVERSION_CLASS_STRING = 1; + private static final int CONVERSION_CLASS_CHARACTER = 2; + private static final int CONVERSION_CLASS_INTEGER = 3; + private static final int CONVERSION_CLASS_FLOAT = 4; + private static final int CONVERSION_CLASS_BOOLEAN = 5; + private static final int CONVERSION_CLASS_HASHCODE = 6; + private static final int CONVERSION_CLASS_PERCENT = 7; + private static final int CONVERSION_CLASS_NEWLINE = 8; + private static final int CONVERSION_CLASS_DATETIME = 9; + + private static int getConversionClass(char conversion) { + // See http://developer.android.com/reference/java/util/Formatter.html + switch (conversion) { + case 't': // Time/date conversion + case 'T': + return CONVERSION_CLASS_DATETIME; + case 's': // string + case 'S': // Uppercase string + return CONVERSION_CLASS_STRING; + case 'c': // character + case 'C': // Uppercase character + return CONVERSION_CLASS_CHARACTER; + case 'd': // decimal + case 'o': // octal + case 'x': // hex + case 'X': + return CONVERSION_CLASS_INTEGER; + case 'f': // decimal float + case 'e': // exponential float + case 'E': + case 'g': // decimal or exponential depending on size + case 'G': + case 'a': // hex float + case 'A': + return CONVERSION_CLASS_FLOAT; + case 'b': // boolean + case 'B': + return CONVERSION_CLASS_BOOLEAN; + case 'h': // boolean + case 'H': + return CONVERSION_CLASS_HASHCODE; + case '%': // literal + return CONVERSION_CLASS_PERCENT; + case 'n': // literal + return CONVERSION_CLASS_NEWLINE; + } + + return CONVERSION_CLASS_UNKNOWN; + } + + private static Location refineLocation(Context context, Location location, + String formatString, int substringStart, int substringEnd) { + Position startLocation = location.getStart(); + Position endLocation = location.getEnd(); + if (startLocation != null && endLocation != null) { + int startOffset = startLocation.getOffset(); + int endOffset = endLocation.getOffset(); + if (startOffset >= 0) { + String contents = context.getClient().readFile(location.getFile()); + if (endOffset <= contents.length() && startOffset < endOffset) { + int formatOffset = contents.indexOf(formatString, startOffset); + if (formatOffset != -1 && formatOffset <= endOffset) { + return Location.create(location.getFile(), contents, + formatOffset + substringStart, formatOffset + substringEnd); + } + } + } + } + + return location; + } + + /** + * Check that the number of arguments in the format string is consistent + * across translations, and that all arguments are used + */ + private static void checkArity(Context context, String name, List> list) { + // Check to make sure that the argument counts and types are consistent + int prevCount = -1; + for (Pair pair : list) { + Set indices = new HashSet(); + int count = getFormatArgumentCount(pair.getSecond(), indices); + Handle handle = pair.getFirst(); + if (prevCount != -1 && prevCount != count) { + Object clientData = handle.getClientData(); + if (clientData instanceof Node) { + if (context.getDriver().isSuppressed(null, ARG_COUNT, (Node) clientData)) { + return; + } + } + Location location = handle.resolve(); + Location secondary = list.get(0).getFirst().resolve(); + secondary.setMessage("Conflicting number of arguments here"); + location.setSecondary(secondary); + String message = String.format( + "Inconsistent number of arguments in formatting string `%1$s`; " + + "found both %2$d and %3$d", name, prevCount, count); + context.report(ARG_COUNT, location, message); + break; + } + + for (int i = 1; i <= count; i++) { + if (!indices.contains(i)) { + Object clientData = handle.getClientData(); + if (clientData instanceof Node) { + if (context.getDriver().isSuppressed(null, ARG_COUNT, + (Node) clientData)) { + return; + } + } + + Set all = new HashSet(); + for (int j = 1; j < count; j++) { + all.add(j); + } + all.removeAll(indices); + List sorted = new ArrayList(all); + Collections.sort(sorted); + Location location = handle.resolve(); + String message = String.format( + "Formatting string '`%1$s`' is not referencing numbered arguments %2$s", + name, sorted); + context.report(ARG_COUNT, location, message); + break; + } + } + + prevCount = count; + } + } + + // See java.util.Formatter docs + public static final Pattern FORMAT = Pattern.compile( + // Generic format: + // %[argument_index$][flags][width][.precision]conversion + // + "%" + //$NON-NLS-1$ + // Argument Index + "(\\d+\\$)?" + //$NON-NLS-1$ + // Flags + "([-+#, 0(<]*)?" + //$NON-NLS-1$ + // Width + "(\\d+)?" + //$NON-NLS-1$ + // Precision + "(\\.\\d+)?" + //$NON-NLS-1$ + // Conversion. These are all a single character, except date/time conversions + // which take a prefix of t/T: + "([tT])?" + //$NON-NLS-1$ + // The current set of conversion characters are + // b,h,s,c,d,o,x,e,f,g,a,t (as well as all those as upper-case characters), plus + // n for newlines and % as a literal %. And then there are all the time/date + // characters: HIKLm etc. Just match on all characters here since there should + // be at least one. + "([a-zA-Z%])"); //$NON-NLS-1$ + + /** Given a format string returns the format type of the given argument */ + @VisibleForTesting + @Nullable + static String getFormatArgumentType(String s, int argument) { + Matcher matcher = FORMAT.matcher(s); + int index = 0; + int prevIndex = 0; + int nextNumber = 1; + while (true) { + if (matcher.find(index)) { + String value = matcher.group(6); + if ("%".equals(value) || "n".equals(value)) { //$NON-NLS-1$ //$NON-NLS-2$ + index = matcher.end(); + continue; + } + int matchStart = matcher.start(); + // Make sure this is not an escaped '%' + for (; prevIndex < matchStart; prevIndex++) { + char c = s.charAt(prevIndex); + if (c == '\\') { + prevIndex++; + } + } + if (prevIndex > matchStart) { + // We're in an escape, ignore this result + index = prevIndex; + continue; + } + + // Shouldn't throw a number format exception since we've already + // matched the pattern in the regexp + int number; + String numberString = matcher.group(1); + if (numberString != null) { + // Strip off trailing $ + numberString = numberString.substring(0, numberString.length() - 1); + number = Integer.parseInt(numberString); + nextNumber = number + 1; + } else { + number = nextNumber++; + } + + if (number == argument) { + return matcher.group(6); + } + index = matcher.end(); + } else { + break; + } + } + + return null; + } + + /** + * Given a format string returns the number of required arguments. If the + * {@code seenArguments} parameter is not null, put the indices of any + * observed arguments into it. + */ + static int getFormatArgumentCount(@NonNull String s, @Nullable Set seenArguments) { + Matcher matcher = FORMAT.matcher(s); + int index = 0; + int prevIndex = 0; + int nextNumber = 1; + int max = 0; + while (true) { + if (matcher.find(index)) { + String value = matcher.group(6); + if ("%".equals(value) || "n".equals(value)) { //$NON-NLS-1$ //$NON-NLS-2$ + index = matcher.end(); + continue; + } + int matchStart = matcher.start(); + // Make sure this is not an escaped '%' + for (; prevIndex < matchStart; prevIndex++) { + char c = s.charAt(prevIndex); + if (c == '\\') { + prevIndex++; + } + } + if (prevIndex > matchStart) { + // We're in an escape, ignore this result + index = prevIndex; + continue; + } + + // Shouldn't throw a number format exception since we've already + // matched the pattern in the regexp + int number; + String numberString = matcher.group(1); + if (numberString != null) { + // Strip off trailing $ + numberString = numberString.substring(0, numberString.length() - 1); + number = Integer.parseInt(numberString); + nextNumber = number + 1; + } else { + number = nextNumber++; + } + + if (number > max) { + max = number; + } + if (seenArguments != null) { + seenArguments.add(number); + } + + index = matcher.end(); + } else { + break; + } + } + + return max; + } + + /** + * Determines whether the given {@link String#format(String, Object...)} + * formatting string is "locale dependent", meaning that its output depends + * on the locale. This is the case if it for example references decimal + * numbers of dates and times. + * + * @param format the format string + * @return true if the format is locale sensitive, false otherwise + */ + public static boolean isLocaleSpecific(@NonNull String format) { + if (format.indexOf('%') == -1) { + return false; + } + + Matcher matcher = FORMAT.matcher(format); + int index = 0; + int prevIndex = 0; + while (true) { + if (matcher.find(index)) { + int matchStart = matcher.start(); + // Make sure this is not an escaped '%' + for (; prevIndex < matchStart; prevIndex++) { + char c = format.charAt(prevIndex); + if (c == '\\') { + prevIndex++; + } + } + if (prevIndex > matchStart) { + // We're in an escape, ignore this result + index = prevIndex; + continue; + } + + String type = matcher.group(6); + if (!type.isEmpty()) { + char t = type.charAt(0); + + // The following formatting characters are locale sensitive: + switch (t) { + case 'd': // decimal integer + case 'e': // scientific + case 'E': + case 'f': // decimal float + case 'g': // general + case 'G': + case 't': // date/time + case 'T': + return true; + } + } + index = matcher.end(); + } else { + break; + } + } + + return false; + } + + @Override + public List getApplicableMethodNames() { + return Arrays.asList(FORMAT_METHOD, GET_STRING_METHOD); + } + + @Override + public void visitMethod(@NonNull JavaContext context, @Nullable UastVisitor visitor, + @NonNull UCallExpression node, @NonNull UMethod method) { + if (mFormatStrings == null && !context.getClient().supportsProjectResources()) { + return; + } + + JavaEvaluator evaluator = context.getEvaluator(); + String methodName = method.getName(); + if (methodName.equals(FORMAT_METHOD)) { + if (JavaEvaluator.isMemberInClass(method, TYPE_STRING)) { + // Check formatting parameters for + // java.lang.String#format(String format, Object... formatArgs) + // java.lang.String#format(Locale locale, String format, Object... formatArgs) + checkStringFormatCall(context, method, node, + method.getParameterList().getParametersCount() == 3); + + // TODO: Consider also enforcing + // java.util.Formatter#format(String string, Object... formatArgs) + } + } else { + // Look up any of these string formatting methods: + // android.content.res.Resources#getString(@StringRes int resId, Object... formatArgs) + // android.content.Context#getString(@StringRes int resId, Object... formatArgs) + // android.app.Fragment#getString(@StringRes int resId, Object... formatArgs) + // android.support.v4.app.Fragment#getString(@StringRes int resId, Object... formatArgs) + + // Many of these also define a plain getString method: + // android.content.res.Resources#getString(@StringRes int resId) + // However, while it's possible that these contain formatting strings) it's + // also possible that they're looking up strings that are not intended to be used + // for formatting so while we may want to warn about this it's not necessarily + // an error. + if (method.getParameterList().getParametersCount() < 2) { + return; + } + + if (evaluator.isMemberInSubClassOf(method, CLASS_RESOURCES, false) || + evaluator.isMemberInSubClassOf(method, CLASS_CONTEXT, false) || + evaluator.isMemberInSubClassOf(method, CLASS_FRAGMENT, false) || + evaluator.isMemberInSubClassOf(method, CLASS_V4_FRAGMENT, false)) { + checkStringFormatCall(context, method, node, false); + } + + // TODO: Consider also looking up + // android.content.res.Resources#getQuantityString(@PluralsRes int id, int quantity, + // Object... formatArgs) + // though this will require being smarter about cross referencing formatting + // strings since we'll need to go via the quantity string definitions + } + } + + /** + * Checks a String.format call that is using a string that doesn't contain format placeholders. + * @param context the context to report errors to + * @param call the AST node for the {@link String#format} + * @param name the string name + * @param handle the string location + */ + private static void checkNotFormattedHandle( + JavaContext context, + UCallExpression call, + String name, + Handle handle) { + Object clientData = handle.getClientData(); + if (clientData instanceof Node) { + if (context.getDriver().isSuppressed(null, INVALID, (Node) clientData)) { + return; + } + } + Location location = context.getUastLocation(call); + Location secondary = handle.resolve(); + secondary.setMessage("This definition does not require arguments"); + location.setSecondary(secondary); + String message = String.format( + "Format string '`%1$s`' is not a valid format string so it should not be " + + "passed to `String.format`", + name); + context.report(INVALID, call, location, message); + } + + /** + * Check the given String.format call (with the given arguments) to see if the string format is + * being used correctly + * @param context the context to report errors to + * @param calledMethod the method being called + * @param call the AST node for the {@link String#format} + * @param specifiesLocale whether the first parameter is a locale string, shifting the + */ + private void checkStringFormatCall( + JavaContext context, + PsiMethod calledMethod, + UCallExpression call, + boolean specifiesLocale) { + + int argIndex = specifiesLocale ? 1 : 0; + List args = call.getValueArguments(); + + if (args.size() <= argIndex) { + return; + } + + UExpression argument = args.get(argIndex); + ResourceUrl resource = ResourceEvaluator.getResource(context, argument); + if (resource == null || resource.framework || resource.type != ResourceType.STRING) { + return; + } + + String name = resource.name; + if (mIgnoreStrings != null && mIgnoreStrings.contains(name)) { + return; + } + + boolean passingVarArgsArray = false; + int callCount = args.size() - 1 - argIndex; + + if (callCount == 1) { + // If instead of a varargs call like + // getString(R.string.foo, arg1, arg2, arg3) + // the code is calling the varargs method with a packed Object array, as in + // getString(R.string.foo, new Object[] { arg1, arg2, arg3 }) + // we'll need to handle that such that we don't think this is a single + // argument + + UExpression lastArg = args.get(args.size() - 1); + PsiParameterList parameterList = calledMethod.getParameterList(); + int parameterCount = parameterList.getParametersCount(); + if (parameterCount > 0 && parameterList.getParameters()[parameterCount - 1].isVarArgs()) { + boolean knownArity = false; + + boolean argWasReference = false; + if (lastArg instanceof UReferenceExpression) { + PsiElement resolved = ((UReferenceExpression) lastArg).resolve(); + if (resolved instanceof PsiVariable) { + UExpression initializer = context.getUastContext().getInitializerBody ( + (PsiVariable) resolved); + if (initializer != null && + (UastExpressionUtils.isNewArray(initializer) || + UastExpressionUtils.isArrayInitializer(initializer))) { + argWasReference = true; + // Now handled by check below + lastArg = initializer; + } + } + } + + if (UastExpressionUtils.isNewArray(lastArg) || + UastExpressionUtils.isArrayInitializer(lastArg)) { + UCallExpression arrayInitializer = (UCallExpression) lastArg; + + if (UastExpressionUtils.isNewArrayWithInitializer(lastArg) || + UastExpressionUtils.isArrayInitializer(lastArg)) { + callCount = arrayInitializer.getValueArgumentCount(); + knownArity = true; + } else if (UastExpressionUtils.isNewArrayWithDimensions(lastArg)) { + List arrayDimensions = arrayInitializer.getValueArguments(); + if (arrayDimensions.size() == 1) { + UExpression first = arrayDimensions.get(0); + if (first instanceof ULiteralExpression) { + Object o = ((ULiteralExpression) first).getValue(); + if (o instanceof Integer) { + callCount = (Integer)o; + knownArity = true; + } + } + } + } + + if (!knownArity) { + if (!argWasReference) { + return; + } + } else { + passingVarArgsArray = true; + } + } + } + } + + if (callCount > 0 && mNotFormatStrings.containsKey(name)) { + checkNotFormattedHandle(context, call, name, mNotFormatStrings.get(name)); + return; + } + + List> list = mFormatStrings != null ? mFormatStrings.get(name) : null; + if (list == null) { + LintClient client = context.getClient(); + if (client.supportsProjectResources() && + !context.getScope().contains(Scope.RESOURCE_FILE)) { + AbstractResourceRepository resources = client + .getProjectResources(context.getMainProject(), true); + List items; + if (resources != null) { + items = resources.getResourceItem(ResourceType.STRING, name); + } else { + // Must be a non-Android module + items = null; + } + if (items != null) { + for (final ResourceItem item : items) { + ResourceValue v = item.getResourceValue(false); + if (v != null) { + String value = v.getRawXmlValue(); + if (value != null) { + // Make sure it's really a formatting string, + // not for example "Battery remaining: 90%" + boolean isFormattingString = value.indexOf('%') != -1; + for (int j = 0, m = value.length(); + j < m && isFormattingString; + j++) { + char c = value.charAt(j); + if (c == '\\') { + j++; + } else if (c == '%') { + Matcher matcher = FORMAT.matcher(value); + if (!matcher.find(j)) { + isFormattingString = false; + } else { + String conversion = matcher.group(6); + int conversionClass = getConversionClass( + conversion.charAt(0)); + if (conversionClass == CONVERSION_CLASS_UNKNOWN + || matcher.group(5) != null) { + // Some date format etc - don't process + return; + } + } + j++; // Don't process second % in a %% + } + // If the user marked the string with + } + + Handle handle = client.createResourceItemHandle(item); + if (isFormattingString) { + if (list == null) { + list = Lists.newArrayList(); + if (mFormatStrings == null) { + mFormatStrings = Maps.newHashMap(); + } + mFormatStrings.put(name, list); + } + list.add(Pair.of(handle, value)); + } else if (callCount > 0) { + checkNotFormattedHandle(context, call, name, handle); + } + } + } + } + } + } else { + return; + } + } + + if (list != null) { + Set reported = null; + for (Pair pair : list) { + String s = pair.getSecond(); + if (reported != null && reported.contains(s)) { + continue; + } + int count = getFormatArgumentCount(s, null); + Handle handle = pair.getFirst(); + if (count != callCount) { + Location location = context.getUastLocation(call); + Location secondary = handle.resolve(); + secondary.setMessage(String.format("This definition requires %1$d arguments", + count)); + location.setSecondary(secondary); + String message = String.format( + "Wrong argument count, format string `%1$s` requires `%2$d` but format " + + "call supplies `%3$d`", + name, count, callCount); + context.report(ARG_TYPES, call, location, message); + if (reported == null) { + reported = Sets.newHashSet(); + } + reported.add(s); + } else { + if (passingVarArgsArray) { + // Can't currently check these: make sure we don't incorrectly + // flag parameters on the Object[] instead of the wrapped parameters + return; + } + for (int i = 1; i <= count; i++) { + int argumentIndex = i + argIndex; + PsiType type = args.get(argumentIndex).getExpressionType(); + if (type != null) { + boolean valid = true; + String formatType = getFormatArgumentType(s, i); + if (formatType == null) { + continue; + } + char last = formatType.charAt(formatType.length() - 1); + if (formatType.length() >= 2 && + Character.toLowerCase( + formatType.charAt(formatType.length() - 2)) == 't') { + // Date time conversion. + // TODO + continue; + } + switch (last) { + // Booleans. It's okay to pass objects to these; + // it will print "true" if non-null, but it's + // unusual and probably not intended. + case 'b': + case 'B': + valid = isBooleanType(type); + break; + + // Numeric: integer and floats in various formats + case 'x': + case 'X': + case 'd': + case 'o': + case 'e': + case 'E': + case 'f': + case 'g': + case 'G': + case 'a': + case 'A': + valid = isNumericType(type, true); + break; + case 'c': + case 'C': + // Unicode character + valid = isCharacterType(type); + break; + case 'h': + case 'H': // Hex print of hash code of objects + case 's': + case 'S': + // String. Can pass anything, but warn about + // numbers since you may have meant more + // specific formatting. Use special issue + // explanation for this? + valid = !isBooleanType(type) && + !isNumericType(type, false); + break; + } + + if (!valid) { + Location location = context.getUastLocation(args.get(argumentIndex)); + Location secondary = handle.resolve(); + secondary.setMessage("Conflicting argument declaration here"); + location.setSecondary(secondary); + String suggestion = null; + if (isBooleanType(type)) { + suggestion = "`b`"; + } else if (isCharacterType(type)) { + suggestion = "'c'"; + } else if (PsiType.INT.equals(type) + || PsiType.LONG.equals(type) + || PsiType.BYTE.equals(type) + || PsiType.SHORT.equals(type)) { + suggestion = "`d`, 'o' or `x`"; + } else if (PsiType.FLOAT.equals(type) + || PsiType.DOUBLE.equals(type)) { + suggestion = "`e`, 'f', 'g' or `a`"; + } else if (type instanceof PsiClassType) { + String fqn = type.getCanonicalText(); + if (TYPE_INTEGER_WRAPPER.equals(fqn) + || TYPE_LONG_WRAPPER.equals(fqn) + || TYPE_BYTE_WRAPPER.equals(fqn) + || TYPE_SHORT_WRAPPER.equals(fqn)) { + suggestion = "`d`, 'o' or `x`"; + } else if (TYPE_FLOAT_WRAPPER.equals(fqn) + || TYPE_DOUBLE_WRAPPER.equals(fqn)) { + suggestion = "`d`, 'o' or `x`"; + } else if (TYPE_OBJECT.equals(fqn)) { + suggestion = "'s' or 'h'"; + } + } + + if (suggestion != null) { + suggestion = " (Did you mean formatting character " + + suggestion + "?)"; + } else { + suggestion = ""; + } + + String canonicalText = type.getCanonicalText(); + canonicalText = canonicalText.substring( + canonicalText.lastIndexOf('.') + 1); + + String message = String.format( + "Wrong argument type for formatting argument '#%1$d' " + + "in `%2$s`: conversion is '`%3$s`', received `%4$s` " + + "(argument #%5$d in method call)%6$s", + i, name, formatType, canonicalText, + argumentIndex + 1, suggestion); + context.report(ARG_TYPES, call, location, message); + if (reported == null) { + reported = Sets.newHashSet(); + } + reported.add(s); + } + } + } + } + } + } + } + + private static boolean isCharacterType(PsiType type) { + //return PsiType.CHAR.isAssignableFrom(type); + if (type == PsiType.CHAR) { + return true; + } + if (type instanceof PsiClassType) { + String fqn = type.getCanonicalText(); + return TYPE_CHARACTER_WRAPPER.equals(fqn); + } + + return false; + } + + private static boolean isBooleanType(PsiType type) { + //return PsiType.BOOLEAN.isAssignableFrom(type); + if (type == PsiType.BOOLEAN) { + return true; + } + if (type instanceof PsiClassType) { + String fqn = type.getCanonicalText(); + return TYPE_BOOLEAN_WRAPPER.equals(fqn); + } + + return false; + } + + //PsiType:java.lang.Boolean + private static boolean isNumericType(@NonNull PsiType type, boolean allowBigNumbers) { + if (PsiType.INT.equals(type) + || PsiType.FLOAT.equals(type) + || PsiType.DOUBLE.equals(type) + || PsiType.LONG.equals(type) + || PsiType.BYTE.equals(type) + || PsiType.SHORT.equals(type)) { + return true; + } + + if (type instanceof PsiClassType) { + String fqn = type.getCanonicalText(); + if (TYPE_INTEGER_WRAPPER.equals(fqn) + || TYPE_FLOAT_WRAPPER.equals(fqn) + || TYPE_DOUBLE_WRAPPER.equals(fqn) + || TYPE_LONG_WRAPPER.equals(fqn) + || TYPE_BYTE_WRAPPER.equals(fqn) + || TYPE_SHORT_WRAPPER.equals(fqn)) { + return true; + } + if (allowBigNumbers) { + if ("java.math.BigInteger".equals(fqn) || + "java.math.BigDecimal".equals(fqn)) { + return true; + } + } + } + + return false; + } +} diff --git a/plugins/lint/lint-checks/src/com/android/tools/klint/checks/ViewTypeDetector.java.as31 b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/ViewTypeDetector.java.as31 new file mode 100644 index 00000000000..d29ca095bca --- /dev/null +++ b/plugins/lint/lint-checks/src/com/android/tools/klint/checks/ViewTypeDetector.java.as31 @@ -0,0 +1,308 @@ +/* + * 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.ide.common.res2.AbstractResourceRepository; +import com.android.ide.common.res2.ResourceFile; +import com.android.ide.common.res2.ResourceItem; +import com.android.resources.ResourceUrl; +import com.android.resources.ResourceFolderType; +import com.android.resources.ResourceType; +import com.android.tools.klint.client.api.LintClient; +import com.android.tools.klint.detector.api.*; +import com.android.utils.XmlUtils; +import com.google.common.base.Joiner; +import com.google.common.collect.*; +import com.intellij.psi.PsiClassType; +import com.intellij.psi.PsiType; +import org.jetbrains.uast.*; +import org.jetbrains.uast.visitor.UastVisitor; +import org.w3c.dom.*; + +import java.io.File; +import java.util.*; + +import static com.android.SdkConstants.*; + +/** Detector for finding inconsistent usage of views and casts + *

+ * TODO: Check findFragmentById + *

+ * ((ItemListFragment) getSupportFragmentManager()
+ *   .findFragmentById(R.id.item_list))
+ *   .setActivateOnItemClick(true);
+ * 
+ * Here we should check the {@code } tag pointed to by the id, and + * check its name or class attributes to make sure the cast is compatible with + * the named fragment class! + */ +public class ViewTypeDetector extends ResourceXmlDetector implements Detector.UastScanner { + /** Mismatched view types */ + @SuppressWarnings("unchecked") + public static final Issue ISSUE = Issue.create( + "WrongViewCast", //$NON-NLS-1$ + "Mismatched view type", + "Keeps track of the view types associated with ids and if it finds a usage of " + + "the id in the Java code it ensures that it is treated as the same type.", + Category.CORRECTNESS, + 9, + Severity.FATAL, + new Implementation( + ViewTypeDetector.class, + EnumSet.of(Scope.ALL_RESOURCE_FILES, Scope.ALL_JAVA_FILES), + Scope.JAVA_FILE_SCOPE)); + + /** Flag used to do no work if we're running in incremental mode in a .java file without + * a client supporting project resources */ + private Boolean mIgnore = null; + + private final Map mIdToViewTag = new HashMap(50); + + @NonNull + @Override + public Speed getSpeed() { + return Speed.SLOW; + } + + @Override + public boolean appliesTo(@NonNull ResourceFolderType folderType) { + return folderType == ResourceFolderType.LAYOUT; + } + + @Override + public Collection getApplicableAttributes() { + return Collections.singletonList(ATTR_ID); + } + + @Override + public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) { + String view = attribute.getOwnerElement().getTagName(); + String value = attribute.getValue(); + String id = null; + if (value.startsWith(ID_PREFIX)) { + id = value.substring(ID_PREFIX.length()); + } else if (value.startsWith(NEW_ID_PREFIX)) { + id = value.substring(NEW_ID_PREFIX.length()); + } // else: could be @android id + + if (id != null) { + if (view.equals(VIEW_TAG)) { + view = attribute.getOwnerElement().getAttribute(ATTR_CLASS); + } + + Object existing = mIdToViewTag.get(id); + if (existing == null) { + mIdToViewTag.put(id, view); + } else if (existing instanceof String) { + String existingString = (String) existing; + if (!existingString.equals(view)) { + // Convert to list + List list = new ArrayList(2); + list.add((String) existing); + list.add(view); + mIdToViewTag.put(id, list); + } + } else if (existing instanceof List) { + @SuppressWarnings("unchecked") + List list = (List) existing; + if (!list.contains(view)) { + list.add(view); + } + } + } + } + + // ---- Implements Detector.JavaScanner ---- + + @Override + public List getApplicableMethodNames() { + return Collections.singletonList("findViewById"); //$NON-NLS-1$ + } + + @Override + public void visitMethod(@NonNull JavaContext context, @Nullable UastVisitor visitor, + @NonNull UCallExpression call, @NonNull UMethod method) { + LintClient client = context.getClient(); + if (mIgnore == Boolean.TRUE) { + return; + } else if (mIgnore == null) { + mIgnore = !context.getScope().contains(Scope.ALL_RESOURCE_FILES) && + !client.supportsProjectResources(); + if (mIgnore) { + return; + } + } + assert method.getName().equals("findViewById"); + UElement node = LintUtils.skipParentheses(call); + while (node != null && node.getUastParent() instanceof UParenthesizedExpression) { + node = node.getUastParent(); + } + if (node.getUastParent() instanceof UBinaryExpressionWithType) { + UBinaryExpressionWithType cast = (UBinaryExpressionWithType) node.getUastParent(); + PsiType type = cast.getType(); + String castType = null; + if (type instanceof PsiClassType) { + castType = type.getCanonicalText(); + } + if (castType == null) { + return; + } + + List args = call.getValueArguments(); + if (args.size() == 1) { + UExpression first = args.get(0); + ResourceUrl resourceUrl = ResourceEvaluator.getResource(context, first); + if (resourceUrl != null && resourceUrl.type == ResourceType.ID && + !resourceUrl.framework) { + String id = resourceUrl.name; + + if (client.supportsProjectResources()) { + AbstractResourceRepository resources = client + .getProjectResources(context.getMainProject(), true); + if (resources == null) { + return; + } + + List items = resources.getResourceItem(ResourceType.ID, + id); + if (items != null && !items.isEmpty()) { + Set compatible = Sets.newHashSet(); + for (ResourceItem item : items) { + Collection tags = getViewTags(context, item); + if (tags != null) { + compatible.addAll(tags); + } + } + if (!compatible.isEmpty()) { + ArrayList layoutTypes = Lists.newArrayList(compatible); + checkCompatible(context, castType, null, layoutTypes, cast); + } + } + } else { + Object types = mIdToViewTag.get(id); + if (types instanceof String) { + String layoutType = (String) types; + checkCompatible(context, castType, layoutType, null, cast); + } else if (types instanceof List) { + @SuppressWarnings("unchecked") + List layoutTypes = (List) types; + checkCompatible(context, castType, null, layoutTypes, cast); + } + } + } + } + } + } + + @Nullable + protected Collection getViewTags( + @NonNull Context context, + @NonNull ResourceItem item) { + // Check view tag in this file. Can I do it cheaply? Try with + // an XML pull parser. Or DOM if we have multiple resources looked + // up? + ResourceFile source = item.getSource(); + if (source != null) { + File file = source.getFile(); + Multimap map = getIdToTagsIn(context, file); + if (map != null) { + return map.get(item.getName()); + } + } + + return null; + } + + + private Map> mFileIdMap; + + @Nullable + private Multimap getIdToTagsIn(@NonNull Context context, @NonNull File file) { + if (!file.getPath().endsWith(DOT_XML)) { + return null; + } + if (mFileIdMap == null) { + mFileIdMap = Maps.newHashMap(); + } + Multimap map = mFileIdMap.get(file); + if (map == null) { + map = ArrayListMultimap.create(); + mFileIdMap.put(file, map); + + String xml = context.getClient().readFile(file); + // TODO: Use pull parser instead for better performance! + Document document = XmlUtils.parseDocumentSilently(xml, true); + if (document != null && document.getDocumentElement() != null) { + addViewTags(map, document.getDocumentElement()); + } + } + return map; + } + + private static void addViewTags(Multimap map, Element element) { + String id = element.getAttributeNS(ANDROID_URI, ATTR_ID); + if (id != null && !id.isEmpty()) { + id = LintUtils.stripIdPrefix(id); + if (!map.containsEntry(id, element.getTagName())) { + map.put(id, element.getTagName()); + } + } + + NodeList children = element.getChildNodes(); + for (int i = 0, n = children.getLength(); i < n; i++) { + Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE) { + addViewTags(map, (Element) child); + } + } + } + + /** Check if the view and cast type are compatible */ + private static void checkCompatible(JavaContext context, String castType, String layoutType, + List layoutTypes, UBinaryExpressionWithType node) { + assert layoutType == null || layoutTypes == null; // Should only specify one or the other + boolean compatible = true; + if (layoutType != null) { + if (!layoutType.equals(castType) + && !context.getSdkInfo().isSubViewOf(castType, layoutType)) { + compatible = false; + } + } else { + compatible = false; + assert layoutTypes != null; + for (String type : layoutTypes) { + if (type.equals(castType) + || context.getSdkInfo().isSubViewOf(castType, type)) { + compatible = true; + break; + } + } + } + + if (!compatible) { + if (layoutType == null) { + layoutType = Joiner.on("|").join(layoutTypes); + } + String message = String.format( + "Unexpected cast to `%1$s`: layout tag was `%2$s`", + castType.substring(castType.lastIndexOf('.') + 1), layoutType); + context.report(ISSUE, node, context.getUastLocation(node), message); + } + } +}