diff options
8 files changed, 862 insertions, 0 deletions
diff --git a/eclipse/dictionary.txt b/eclipse/dictionary.txt index d6c237f..e61a129 100644 --- a/eclipse/dictionary.txt +++ b/eclipse/dictionary.txt @@ -75,6 +75,7 @@ dir dirs discoverable ditto +div docs dp dpi @@ -96,6 +97,7 @@ foreach fqcn framelayout gen +gif git groovy guava @@ -129,8 +131,10 @@ interpolators iterable javac javadoc +jpg keystore layoutlib +ldpi leaky levenshtein lib @@ -149,6 +153,7 @@ macs malformed markup marquee +mdpi metadata min mipmap @@ -161,6 +166,7 @@ multimaps namespace namespaces newfound +nodpi num ok os @@ -172,6 +178,7 @@ placeholder placeholders plugin plugins +png popup popups pre @@ -241,6 +248,7 @@ stretchiness struct styleable styleables +stylesheet subclassed subclassing submenu @@ -254,6 +262,7 @@ textfield textfields thematically themed +timestamp tmp toolbar tooltip @@ -289,6 +298,7 @@ webtools whilst workflow xdpi +xhdpi xml xmlns ydpi diff --git a/lint/libs/lint_api/src/com/android/tools/lint/detector/api/LintUtils.java b/lint/libs/lint_api/src/com/android/tools/lint/detector/api/LintUtils.java index 4bc8503..2cf6c29 100644 --- a/lint/libs/lint_api/src/com/android/tools/lint/detector/api/LintUtils.java +++ b/lint/libs/lint_api/src/com/android/tools/lint/detector/api/LintUtils.java @@ -109,6 +109,20 @@ public class LintUtils { } /** + * 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(String string, String suffix) { + return string.regionMatches(true /* ignoreCase */, string.length() - suffix.length(), + suffix, 0, suffix.length()); + } + + /** * Returns the children elements of the given node * * @param node the parent node diff --git a/lint/libs/lint_checks/src/com/android/tools/lint/checks/BuiltinIssueRegistry.java b/lint/libs/lint_checks/src/com/android/tools/lint/checks/BuiltinIssueRegistry.java index 5e63caf..d050919 100644 --- a/lint/libs/lint_checks/src/com/android/tools/lint/checks/BuiltinIssueRegistry.java +++ b/lint/libs/lint_checks/src/com/android/tools/lint/checks/BuiltinIssueRegistry.java @@ -59,6 +59,11 @@ public class BuiltinIssueRegistry extends IssueRegistry { issues.add(ArraySizeDetector.INCONSISTENT); issues.add(ManifestOrderDetector.ISSUE); issues.add(ExportedServiceDetector.ISSUE); + issues.add(IconDetector.GIF_USAGE); + issues.add(IconDetector.ICON_DENSITIES); + issues.add(IconDetector.ICON_DIP_SIZE); + issues.add(IconDetector.ICON_EXPECTED_SIZE); + issues.add(IconDetector.ICON_LOCATION); // TODO: Populate dynamically somehow? diff --git a/lint/libs/lint_checks/src/com/android/tools/lint/checks/IconDetector.java b/lint/libs/lint_checks/src/com/android/tools/lint/checks/IconDetector.java new file mode 100644 index 0000000..27b4e01 --- /dev/null +++ b/lint/libs/lint_checks/src/com/android/tools/lint/checks/IconDetector.java @@ -0,0 +1,791 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tools.lint.checks; + +import static com.android.tools.lint.detector.api.LintConstants.ANDROID_MANIFEST_XML; +import static com.android.tools.lint.detector.api.LintConstants.ANDROID_URI; +import static com.android.tools.lint.detector.api.LintConstants.ATTR_ICON; +import static com.android.tools.lint.detector.api.LintConstants.ATTR_MIN_SDK_VERSION; +import static com.android.tools.lint.detector.api.LintConstants.DOT_9PNG; +import static com.android.tools.lint.detector.api.LintConstants.DOT_GIF; +import static com.android.tools.lint.detector.api.LintConstants.DOT_JPG; +import static com.android.tools.lint.detector.api.LintConstants.DOT_PNG; +import static com.android.tools.lint.detector.api.LintConstants.DOT_XML; +import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_FOLDER; +import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_HDPI; +import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_LDPI; +import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_MDPI; +import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_RESOURCE_PREFIX; +import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_XHDPI; +import static com.android.tools.lint.detector.api.LintConstants.RES_FOLDER; +import static com.android.tools.lint.detector.api.LintConstants.TAG_APPLICATION; +import static com.android.tools.lint.detector.api.LintConstants.TAG_USES_SDK; +import static com.android.tools.lint.detector.api.LintUtils.difference; +import static com.android.tools.lint.detector.api.LintUtils.endsWith; + +import com.android.tools.lint.detector.api.Category; +import com.android.tools.lint.detector.api.Context; +import com.android.tools.lint.detector.api.Detector; +import com.android.tools.lint.detector.api.Issue; +import com.android.tools.lint.detector.api.LintUtils; +import com.android.tools.lint.detector.api.Location; +import com.android.tools.lint.detector.api.Scope; +import com.android.tools.lint.detector.api.Severity; +import com.android.tools.lint.detector.api.Speed; + +import org.w3c.dom.Element; + +import java.awt.Dimension; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; + +/** + * Checks for common icon problems, such as wrong icon sizes, placing icons in the + * density independent drawable folder, etc. + */ +public class IconDetector extends Detector.XmlDetectorAdapter { + + 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-(xhdpi|hdpi|mdpi" //$NON-NLS-1$ + + (INCLUDE_LDPI ? "|ldpi" : "") + ")$"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + + /** Pattern for version qualifiers */ + private final static Pattern VERSION_PATTERN = Pattern.compile("^v(\\d+)$");//$NON-NLS-1$ + + private static final String[] REQUIRED_DENSITIES = INCLUDE_LDPI + ? new String[] { DRAWABLE_LDPI, DRAWABLE_MDPI, DRAWABLE_HDPI, DRAWABLE_XHDPI } + : new String[] { DRAWABLE_MDPI, DRAWABLE_HDPI, DRAWABLE_XHDPI }; + + private static final String[] DENSITY_QUALIFIERS = + new String[] { + "-ldpi", //$NON-NLS-1$ + "-mdpi", //$NON-NLS-1$ + "-hdpi", //$NON-NLS-1$ + "-xhdpi" //$NON-NLS-1$ + }; + + /** Wrong icon size according to published conventions */ + public static final Issue ICON_EXPECTED_SIZE = Issue.create( + "IconExpectedSize", //$NON-NLS-1$ + "Ensures that launcher icons, notification icons etc have the correct size", + "There are predefined sizes (for each density) for launcher icons. You " + + "should follow these conventions to make sure your icons fit in with the " + + "overall look of the platform.", + Category.ICONS, + 5, + Severity.WARNING, + IconDetector.class, + Scope.ALL_RESOURCES_SCOPE) + // Still some potential false positives: + .setEnabledByDefault(false) + .setMoreInfo( + "http://developer.android.com/guide/practices/ui_guidelines/icon_design_launcher.html#size"); //$NON-NLS-1$ + + /** Inconsistent dip size across densities */ + public static final Issue ICON_DIP_SIZE = Issue.create( + "IconDipSize", //$NON-NLS-1$ + "Ensures that icons across densities provide roughly the same density-independent size", + "Checks the all icons which are provided in multiple densities, all compute to " + + "roughly the same density-independent pixel (dip) size. This catches errors where " + + "images are either placed in the wrong folder, or icons are changed to new sizes " + + "but some folders are forgotten.", + Category.ICONS, + 5, + Severity.WARNING, + IconDetector.class, + Scope.ALL_RESOURCES_SCOPE); + + /** Images in res/drawable folder */ + public static final Issue ICON_LOCATION = Issue.create( + "IconLocation", //$NON-NLS-1$ + "Ensures that images are not defined in the density-independent drawable folder", + "The res/drawable folder is intended for density-independent graphics such as " + + "shapes defined in XML. For bitmaps, move it to drawable-mdpi and consider " + + "providing higher and lower resolution versions in drawable-ldpi, drawable-hdpi " + + "and drawable-xhdpi. If the icon *really* is density independent (for example " + + "a solid color) you can place it in drawable-nodpi.", + Category.ICONS, + 5, + Severity.WARNING, + IconDetector.class, + Scope.ALL_RESOURCES_SCOPE).setMoreInfo( + "http://developer.android.com/guide/practices/screens_support.html"); //$NON-NLS-1$ + + /** Missing density versions of image */ + public static final Issue ICON_DENSITIES = Issue.create( + "IconDensities", //$NON-NLS-1$ + "Ensures that icons provide custom versions for all supported densities", + "Icons will look best if a custom version is provided for each of the " + + "major screen density classes (low, medium, high, extra high). " + + "This lint check identifies icons which do not have complete coverage " + + "across the densities.\n" + + "\n" + + "Low density is not really used much anymore, so this check ignores " + + "the ldpi density. To force lint to include it, set the environment " + + "variable ANDROID_LINT_INCLUDE_LDPI=true. For more information on " + + "current density usage, see " + + "http://developer.android.com/resources/dashboard/screens.html", + Category.ICONS, + 4, + Severity.WARNING, + IconDetector.class, + Scope.ALL_RESOURCES_SCOPE).setMoreInfo( + "http://developer.android.com/guide/practices/screens_support.html"); //$NON-NLS-1$ + + /** Using .gif bitmaps */ + public static final Issue GIF_USAGE = Issue.create( + "GifUsage", //$NON-NLS-1$ + "Checks for images using the GIF file format which is discouraged", + "The .gif file format is discouraged. Consider using .png (preferred) " + + "or .jpg (acceptable) instead.", + Category.ICONS, + 5, + Severity.WARNING, + IconDetector.class, + Scope.ALL_RESOURCES_SCOPE).setMoreInfo( + "http://developer.android.com/guide/topics/resources/drawable-resource.html#Bitmap"); //$NON-NLS-1$ + + private int mMinSdk; + private String mApplicationIcon; + + /** Constructs a new accessibility check */ + public IconDetector() { + } + + @Override + public Speed getSpeed() { + return Speed.SLOW; + } + + @Override + public void beforeCheckProject(Context context) { + mMinSdk = -1; + mApplicationIcon = null; + } + + @Override + public void afterCheckProject(Context context) { + // Make sure no + File res = new File(context.project.getDir(), RES_FOLDER); + if (res.isDirectory()) { + File[] folders = res.listFiles(); + if (folders != null) { + boolean checkDensities = context.configuration.isEnabled(ICON_DENSITIES); + boolean checkDipSizes = context.configuration.isEnabled(ICON_DIP_SIZE); + + Map<File, Dimension> pixelSizes = null; + if (checkDipSizes) { + pixelSizes = new HashMap<File, Dimension>(); + } + Map<File, Set<String>> folderToNames = new HashMap<File, Set<String>>(); + for (File folder : folders) { + String folderName = folder.getName(); + if (folderName.startsWith(DRAWABLE_FOLDER)) { + File[] files = folder.listFiles(); + if (files != null) { + checkDrawableDir(context, folder, files, pixelSizes); + + if (checkDensities && DENSITY_PATTERN.matcher(folderName).matches()) { + Set<String> names = new HashSet<String>(files.length); + for (File f : files) { + String name = f.getName(); + if (!endsWith(name, DOT_XML)) { + names.add(name); + } + } + folderToNames.put(folder, names); + } + } + } + } + + if (pixelSizes != null) { + assert checkDipSizes; + checkDipSizes(context, pixelSizes); + } + + if (checkDensities && folderToNames.size() > 0) { + checkDensities(context, res, folderToNames); + } + } + } + } + + private void checkDipSizes(Context context, Map<File, Dimension> pixelSizes) { + // Partition up the files such that I can look at a series by name. This + // creates a map from filename (such as foo.png) to a list of files + // providing that icon in various folders: drawable-mdpi/foo.png, drawable-hdpi/foo.png + // etc. + Map<String, List<File>> nameToFiles = new HashMap<String, List<File>>(); + for (File file : pixelSizes.keySet()) { + String name = file.getName(); + List<File> list = nameToFiles.get(name); + if (list == null) { + list = new ArrayList<File>(); + nameToFiles.put(name, list); + } + list.add(file); + } + + ArrayList<String> names = new ArrayList<String>(nameToFiles.keySet()); + Collections.sort(names); + + // We have to partition the files further because it's possible for the project + // to have different configurations for an icon, such as this: + // drawable-large-hdpi/foo.png, drawable-large-mdpi/foo.png, + // drawable-hdpi/foo.png, drawable-mdpi/foo.png, + // drawable-hdpi-v11/foo.png and drawable-mdpi-v11/foo.png. + // In this case we don't want to compare across categories; we want to + // ensure that the drawable-large-{density} icons are consistent, + // that the drawable-{density}-v11 icons are consistent, and that + // the drawable-{density} icons are consistent. + + // Map from name to list of map from parent folder to list of files + Map<String, Map<String, List<File>>> configMap = + new HashMap<String, Map<String,List<File>>>(); + for (Map.Entry<String, List<File>> entry : nameToFiles.entrySet()) { + String name = entry.getKey(); + List<File> files = entry.getValue(); + for (File file : files) { + String parentName = file.getParentFile().getName(); + // Strip out the density part + int index = -1; + for (String qualifier : DENSITY_QUALIFIERS) { + index = parentName.indexOf(qualifier); + if (index != -1) { + parentName = parentName.substring(0, index) + + parentName.substring(index + qualifier.length()); + break; + } + } + if (index == -1) { + // No relevant qualifier found in the parent directory name, + // e.g. it's just "drawable" or something like "drawable-nodpi". + continue; + } + + Map<String, List<File>> folderMap = configMap.get(name); + if (folderMap == null) { + folderMap = new HashMap<String,List<File>>(); + configMap.put(name, folderMap); + } + // Map from name to a map from parent folder to files + List<File> list = folderMap.get(parentName); + if (list == null) { + list = new ArrayList<File>(); + folderMap.put(parentName, list); + } + list.add(file); + } + } + + for (String name : names) { + //List<File> files = nameToFiles.get(name); + Map<String, List<File>> configurations = configMap.get(name); + if (configurations == null) { + // Nothing in this configuration: probably only found in drawable/ or + // drawable-nodpi etc directories. + continue; + } + + for (Map.Entry<String, List<File>> entry : configurations.entrySet()) { + List<File> files = entry.getValue(); + + // Ensure that all the dip sizes are *roughly* the same + Map<File, Dimension> dipSizes = new HashMap<File, Dimension>(); + int dipWidthSum = 0; // Incremental computation of average + int dipHeightSum = 0; // Incremental computation of average + int count = 0; + for (File file : files) { + float factor = getMdpiScalingFactor(file.getParentFile().getName()); + if (factor > 0) { + Dimension size = pixelSizes.get(file); + Dimension dip = new Dimension( + Math.round(size.width / factor), + Math.round(size.height / factor)); + dipWidthSum += dip.width; + dipHeightSum += dip.height; + dipSizes.put(file, dip); + count++; + } + } + if (count == 0) { + // Icons in drawable/ and drawable-nodpi/ + continue; + } + int meanWidth = dipWidthSum / count; + int meanHeight = dipHeightSum / count; + + // Compute standard deviation? + int squareWidthSum = 0; + int squareHeightSum = 0; + for (Dimension size : dipSizes.values()) { + squareWidthSum += (size.width - meanWidth) * (size.width - meanWidth); + squareHeightSum += (size.height - meanHeight) * (size.height - meanHeight); + } + double widthStdDev = Math.sqrt(squareWidthSum / count); + double heightStdDev = Math.sqrt(squareHeightSum / count); + + if (widthStdDev > meanWidth / 10 || heightStdDev > meanHeight) { + Location location = null; + StringBuilder sb = new StringBuilder(); + + // Sort entries by decreasing dip size + List<Map.Entry<File, Dimension>> entries = + new ArrayList<Map.Entry<File,Dimension>>(); + for (Map.Entry<File, Dimension> entry2 : dipSizes.entrySet()) { + entries.add(entry2); + } + Collections.sort(entries, + new Comparator<Map.Entry<File, Dimension>>() { + public int compare(Entry<File, Dimension> e1, + Entry<File, Dimension> e2) { + Dimension d1 = e1.getValue(); + Dimension d2 = e2.getValue(); + if (d1.width != d2.width) { + return d2.width - d1.width; + } + + return d2.height - d1.height; + } + }); + for (Map.Entry<File, Dimension> entry2 : entries) { + if (sb.length() > 0) { + sb.append(", "); + } + File file = entry2.getKey(); + + // Chain locations together + Location linkedLocation = location; + location = new Location(file, null, null); + location.setSecondary(linkedLocation); + Dimension dip = entry2.getValue(); + Dimension px = pixelSizes.get(file); + String fileName = file.getParentFile().getName() + File.separator + + file.getName(); + sb.append(String.format("%1$s: %2$dx%3$d dp (%4$dx%5$d px)", + fileName, dip.width, dip.height, px.width, px.height)); + } + String message = String.format( + "The image %1$s varies significantly in its density-independent (dip) " + + "size across the various density versions: %2$s", + name, sb.toString()); + context.client.report(context, + ICON_DIP_SIZE, + location, + message, + null); + } + } + } + } + + private void checkDensities(Context context, File res, Map<File, Set<String>> folderToNames) { + // TODO: Is there a way to look at the manifest and figure out whether + // all densities are expected to be needed? + // Note: ldpi is probably not needed; it has very little usage + // (about 2%; http://developer.android.com/resources/dashboard/screens.html) + // TODO: Use the matrix to check out if we can eliminate densities based + // on the target screens? + + Set<String> definedDensities = new HashSet<String>(); + for (File f : folderToNames.keySet()) { + definedDensities.add(f.getName()); + } + + // Look for missing folders -- if you define say drawable-mdpi then you + // should also define -hdpi and -xhdpi. + + List<String> missing = new ArrayList<String>(); + for (String density : REQUIRED_DENSITIES) { + if (!definedDensities.contains(density)) { + missing.add(density); + } + } + if (missing.size() > 0) { + context.client.report(context, + ICON_DENSITIES, + null /* location */, + String.format("Missing density variation folders in %1$s: %2$s", + context.project.getDisplayPath(res), + LintUtils.formatList(missing, missing.size())), + null); + } + + // Look for folders missing some of the specific assets + Set<String> allNames = new HashSet<String>(); + for (Set<String> n : folderToNames.values()) { + allNames.addAll(n); + } + for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) { + Set<String> names = entry.getValue(); + if (names.size() != allNames.size()) { + List<String> delta = + new ArrayList<String>(difference(allNames, names)); + Collections.sort(delta); + File file = entry.getKey(); + String foundIn = ""; + if (delta.size() == 1) { + // Produce list of where the icon is actually defined + List<String> defined = new ArrayList<String>(); + String name = delta.get(0); + for (Map.Entry<File, Set<String>> e : folderToNames.entrySet()) { + if (e.getValue().contains(name)) { + defined.add(e.getKey().getName()); + } + } + if (defined.size() > 0) { + foundIn = String.format(" (found in %1$s)", + LintUtils.formatList(defined, 5)); + } + } + + context.client.report(context, + ICON_DENSITIES, + new Location(file, null, null), + String.format( + "Missing the following drawables in %1$s: %2$s%3$s", + file.getName(), + LintUtils.formatList(delta, 5), + foundIn), + null); + } + } + } + + private void checkDrawableDir(Context context, File folder, File[] files, + Map<File, Dimension> dipSizes) { + if (folder.getName().equals(DRAWABLE_FOLDER) + && context.configuration.isEnabled(ICON_LOCATION)) { + for (File file : files) { + String name = file.getName(); + if (name.endsWith(DOT_XML)) { + // pass - most common case, avoids checking other extensions + } else if (endsWith(name, DOT_PNG) + || endsWith(name, DOT_JPG) + || endsWith(name, DOT_GIF)) { + context.client.report(context, + ICON_LOCATION, + new Location(file, null, null), + String.format("Found bitmap drawable res/drawable/%1$s in " + + "densityless folder", + file.getName()), + null); + } + } + } + + if (context.configuration.isEnabled(GIF_USAGE)) { + for (File file : files) { + String name = file.getName(); + if (endsWith(name, DOT_GIF)) { + context.client.report(context, + GIF_USAGE, + new Location(file, null, null), + "Using the .gif format for bitmaps is discouraged", + null); + } + } + } + + // Check icon sizes + if (context.configuration.isEnabled(ICON_EXPECTED_SIZE)) { + checkExpectedSizes(context, folder, files); + } + + if (dipSizes != 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(); + // 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)) { + Dimension size = getSize(file); + dipSizes.put(file, size); + } + } + } + } + + private void checkExpectedSizes(Context context, File folder, File[] files) { + String folderName = folder.getName(); + + int folderVersion = -1; + String[] qualifiers = folderName.split("-"); //$NON-NLS-1$ + for (String qualifier : qualifiers) { + if (qualifier.startsWith("v")) { + Matcher matcher = VERSION_PATTERN.matcher(qualifier); + if (matcher.matches()) { + folderVersion = Integer.parseInt(matcher.group(1)); + } + } + } + + for (File file : files) { + String name = file.getName(); + + // TODO: Look up exact app icon from the manifest rather than simply relying on + // the naming conventions described here: + // http://developer.android.com/guide/practices/ui_guidelines/icon_design.html#design-tips + // See if we can figure out other types of icons from usage too. + + String baseName = name; + int index = baseName.indexOf('.'); + if (index != -1) { + baseName = baseName.substring(0, index); + } + + if (baseName.equals(mApplicationIcon) || name.startsWith("ic_launcher")) { //$NON-NLS-1$ + // Launcher icons + checkSize(context, folderName, file, 48, 48, true /*exact*/); + } else if (name.startsWith("ic_action_")) { //$NON-NLS-1$ + // Action Bar + checkSize(context, folderName, file, 24, 24, true /*exact*/); + } else if (name.startsWith("ic_dialog_")) { //$NON-NLS-1$ + // Action Bar + checkSize(context, folderName, file, 32, 32, true /*exact*/); + } else if (name.startsWith("ic_tab_")) { //$NON-NLS-1$ + // Tab icons + checkSize(context, folderName, file, 32, 32, true /*exact*/); + } else if (name.startsWith("ic_stat_")) { //$NON-NLS-1$ + // Notification icons + + if (isAndroid30(context, folderVersion)) { + checkSize(context, folderName, file, 24, 24, true /*exact*/); + } else if (isAndroid23(context, folderVersion)) { + checkSize(context, folderName, file, 16, 25, false /*exact*/); + } else { + // Android 2.2 or earlier + // TODO: Should this be done for each folder size? + checkSize(context, folderName, file, 25, 25, true /*exact*/); + } + } else if (name.startsWith("ic_menu_")) { //$NON-NLS-1$ + // Menu icons (<=2.3 only: Replaced by action bar icons (ic_action_ in 3.0). + if (isAndroid23(context, folderVersion)) { + // The icon should be 32x32 inside the transparent image; should + // we check that this is mostly the case (a few pixels are allowed to + // overlap for anti-aliasing etc) + checkSize(context, folderName, file, 48, 48, true /*exact*/); + } else { + // Android 2.2 or earlier + // TODO: Should this be done for each folder size? + checkSize(context, folderName, file, 48, 48, true /*exact*/); + } + } + // TODO: ListView icons? + } + } + + /** + * Is this drawable folder for an Android 3.0 drawable? This will be the + * case if it specifies -v11+, or if the minimum SDK version declared in the + * manifest is at least 11. + */ + private boolean isAndroid30(Context context, int folderVersion) { + return folderVersion >= 11 || mMinSdk >= 11; + } + + /** + * Is this drawable folder for an Android 2.3 drawable? This will be the + * case if it specifies -v9 or -v10, or if the minimum SDK version declared in the + * manifest is 9 or 10 (and it does not specify some higher version like -v11 + */ + private boolean isAndroid23(Context context, int folderVersion) { + if (isAndroid30(context, folderVersion)) { + return false; + } + + return folderVersion == 9 || folderVersion == 10 || mMinSdk == 9 || mMinSdk == 10; + } + + private float getMdpiScalingFactor(String folderName) { + // Can't do startsWith(DRAWABLE_MDPI) because the folder could + // be something like "drawable-sw600dp-mdpi". + if (folderName.contains("-mdpi")) { //$NON-NLS-1$ + return 1.0f; + } else if (folderName.contains("-hdpi")) { //$NON-NLS-1$ + return 1.5f; + } else if (folderName.contains("-xhdpi")) { //$NON-NLS-1$ + return 2.0f; + } else if (folderName.contains("-ldpi")) { //$NON-NLS-1$ + return 0.75f; + } else { + return 0f; + } + } + + private void checkSize(Context context, String folderName, File file, + int mdpiWidth, int mdpiHeight, boolean exactMatch) { + String fileName = file.getName(); + // Only scan .png files (except 9-patch png's) and jpg files + if (!((endsWith(fileName, DOT_PNG) && !endsWith(fileName, DOT_9PNG)) || + endsWith(fileName, DOT_JPG))) { + return; + } + + int width = -1; + int height = -1; + // Use 3:4:6:8 scaling ratio to look up the other expected sizes + if (folderName.startsWith(DRAWABLE_MDPI)) { + width = mdpiWidth; + height = mdpiHeight; + } else if (folderName.startsWith(DRAWABLE_HDPI)) { + // Perform math using floating point; if we just do + // width = mdpiWidth * 3 / 2; + // then for mdpiWidth = 25 (as in notification icons on pre-GB) we end up + // with width = 37, instead of 38 (with floating point rounding we get 37.5 = 38) + width = Math.round(mdpiWidth * 3.f / 2); + height = Math.round(mdpiHeight * 3f / 2); + } else if (folderName.startsWith(DRAWABLE_XHDPI)) { + width = mdpiWidth * 2; + height = mdpiHeight * 2; + } else if (folderName.startsWith(DRAWABLE_LDPI)) { + width = Math.round(mdpiWidth * 3f / 4); + height = Math.round(mdpiHeight * 3f / 4); + } else { + return; + } + + Dimension size = getSize(file); + if (size != null) { + if (exactMatch && size.width != width || size.height != height) { + context.client.report(context, + ICON_EXPECTED_SIZE, + new Location(file, null, null), + String.format( + "Incorrect icon size for %1$s: expected %2$dx%3$d, but was %4$dx%5$d", + folderName + File.separator + file.getName(), + width, height, size.width, size.height), + null); + } else if (!exactMatch && size.width > width || size.height > height) { + context.client.report(context, + ICON_EXPECTED_SIZE, + new Location(file, null, null), + String.format( + "Incorrect icon size for %1$s: icon size should be at most %2$dx%3$d, but was %4$dx%5$d", + folderName + File.separator + file.getName(), + width, height, size.width, size.height), + null); + } + } + } + + private Dimension getSize(File file) { + try { + ImageInputStream input = ImageIO.createImageInputStream(file); + if (input != null) { + try { + Iterator<ImageReader> readers = ImageIO.getImageReaders(input); + if (readers.hasNext()) { + ImageReader reader = readers.next(); + try { + reader.setInput(input); + return new Dimension(reader.getWidth(0), reader.getHeight(0)); + } finally { + reader.dispose(); + } + } + } finally { + if (input != null) { + input.close(); + } + } + } + + // Fallback: read the image using the normal means + BufferedImage image = ImageIO.read(file); + if (image != null) { + return new Dimension(image.getWidth(), image.getHeight()); + } else { + return null; + } + } catch (IOException e) { + // Pass -- we can't handle all image types, warn about those we can + return null; + } + } + + // XML detector: Skim manifest + + @Override + public boolean appliesTo(Context context, File file) { + return file.getName().equals(ANDROID_MANIFEST_XML); + } + + @Override + public Collection<String> getApplicableElements() { + return Arrays.asList(new String[] { + TAG_APPLICATION, + TAG_USES_SDK, + }); + } + + @Override + public void visitElement(Context context, Element element) { + if (element.getTagName().equals(TAG_USES_SDK)) { + String minSdk = null; + if (element.hasAttributeNS(ANDROID_URI, ATTR_MIN_SDK_VERSION)) { + minSdk = element.getAttributeNS(ANDROID_URI, ATTR_MIN_SDK_VERSION); + } + if (minSdk != null) { + try { + mMinSdk = Integer.valueOf(minSdk); + } catch (NumberFormatException e) { + mMinSdk = -1; + } + } + } else { + assert element.getTagName().equals(TAG_APPLICATION); + mApplicationIcon = element.getAttributeNS(ANDROID_URI, ATTR_ICON); + if (mApplicationIcon.startsWith(DRAWABLE_RESOURCE_PREFIX)) { + mApplicationIcon = mApplicationIcon.substring(DRAWABLE_RESOURCE_PREFIX.length()); + } + } + } +} diff --git a/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/IconDetectorTest.java b/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/IconDetectorTest.java new file mode 100644 index 0000000..e107485 --- /dev/null +++ b/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/IconDetectorTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.tools.lint.checks; + +import com.android.tools.lint.detector.api.Detector; + +@SuppressWarnings("javadoc") +public class IconDetectorTest extends AbstractCheckTest { + @Override + protected Detector getDetector() { + return new IconDetector(); + } + + public void test() throws Exception { + assertEquals( + "Warning: Missing density variation folders in res: drawable-xhdpi\n" + + "drawable-hdpi: Warning: Missing the following drawables in drawable-hdpi: " + + "sample_icon.gif (found in drawable-mdpi)\n" + + "ic_launcher.png: Warning: Found bitmap drawable res/drawable/ic_launcher.png " + + "in densityless folder\n" + + "sample_icon.gif: Warning: Using the .gif format for bitmaps is discouraged", + lintProject( + "res/drawable/ic_launcher.png", + "res/drawable/ic_launcher.png=>res/drawable-mdpi/ic_launcher.png", + "res/drawable-mdpi/sample_icon.gif", + "res/drawable-hdpi/ic_launcher.png")); + } +} diff --git a/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/data/res/drawable-hdpi/ic_launcher.png b/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/data/res/drawable-hdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000..8074c4c --- /dev/null +++ b/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/data/res/drawable-hdpi/ic_launcher.png diff --git a/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/data/res/drawable-mdpi/sample_icon.gif b/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/data/res/drawable-mdpi/sample_icon.gif Binary files differnew file mode 100644 index 0000000..1a0be94 --- /dev/null +++ b/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/data/res/drawable-mdpi/sample_icon.gif diff --git a/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/data/res/drawable/ic_launcher.png b/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/data/res/drawable/ic_launcher.png Binary files differnew file mode 100644 index 0000000..a07c69f --- /dev/null +++ b/lint/libs/lint_checks/tests/src/com/android/tools/lint/checks/data/res/drawable/ic_launcher.png |