blob: 92330fb11e1408e22020786cc5f8453401071df3 [file] [log] [blame]
/*
* 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.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_ICON;
import static com.android.SdkConstants.ATTR_ROUND_ICON;
import static com.android.SdkConstants.DOT_9PNG;
import static com.android.SdkConstants.DOT_GIF;
import static com.android.SdkConstants.DOT_JPEG;
import static com.android.SdkConstants.DOT_JPG;
import static com.android.SdkConstants.DOT_PNG;
import static com.android.SdkConstants.DOT_WEBP;
import static com.android.SdkConstants.DOT_XML;
import static com.android.SdkConstants.DRAWABLE_FOLDER;
import static com.android.SdkConstants.DRAWABLE_HDPI;
import static com.android.SdkConstants.DRAWABLE_LDPI;
import static com.android.SdkConstants.DRAWABLE_MDPI;
import static com.android.SdkConstants.DRAWABLE_PREFIX;
import static com.android.SdkConstants.DRAWABLE_XHDPI;
import static com.android.SdkConstants.DRAWABLE_XXHDPI;
import static com.android.SdkConstants.MIPMAP_FOLDER;
import static com.android.SdkConstants.MIPMAP_PREFIX;
import static com.android.SdkConstants.TAG_ACTIVITY;
import static com.android.SdkConstants.TAG_ACTIVITY_ALIAS;
import static com.android.SdkConstants.TAG_APPLICATION;
import static com.android.SdkConstants.TAG_ITEM;
import static com.android.SdkConstants.TAG_PROVIDER;
import static com.android.SdkConstants.TAG_RECEIVER;
import static com.android.SdkConstants.TAG_SERVICE;
import static com.android.tools.lint.detector.api.Constraints.minSdkAtLeast;
import static com.android.tools.lint.detector.api.Constraints.minSdkLessThan;
import static com.android.tools.lint.detector.api.Lint.endsWith;
import static com.android.tools.lint.detector.api.Lint.getMethodName;
import static com.android.utils.SdkUtils.endsWithIgnoreCase;
import static java.awt.image.BufferedImage.TYPE_INT_ARGB;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.resources.configuration.FolderConfiguration;
import com.android.resources.Density;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.resources.ResourceUrl;
import com.android.tools.lint.client.api.LintClient;
import com.android.tools.lint.client.api.UElementHandler;
import com.android.tools.lint.client.api.UastParser;
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.Implementation;
import com.android.tools.lint.detector.api.Incident;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.JavaContext;
import com.android.tools.lint.detector.api.Lint;
import com.android.tools.lint.detector.api.LintMap;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Project;
import com.android.tools.lint.detector.api.ResourceEvaluator;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.SourceCodeScanner;
import com.android.tools.lint.detector.api.XmlContext;
import com.android.tools.lint.detector.api.XmlScanner;
import com.android.utils.SdkUtils;
import com.google.common.base.Joiner;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.Sets;
import com.intellij.psi.PsiClass;
import com.intellij.psi.PsiElement;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.image.BufferedImage;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
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.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Map;
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;
import org.jetbrains.uast.UAnonymousClass;
import org.jetbrains.uast.UCallExpression;
import org.jetbrains.uast.UClass;
import org.jetbrains.uast.UElement;
import org.jetbrains.uast.UExpression;
import org.jetbrains.uast.UMethod;
import org.jetbrains.uast.UReferenceExpression;
import org.jetbrains.uast.USimpleNameReferenceExpression;
import org.jetbrains.uast.UastUtils;
import org.jetbrains.uast.util.UastExpressionUtils;
import org.jetbrains.uast.visitor.AbstractUastVisitor;
import org.w3c.dom.Element;
/**
* Checks for common icon problems, such as wrong icon sizes, placing icons in the density
* independent drawable folder, etc.
*/
public class IconDetector extends Detector implements XmlScanner, SourceCodeScanner {
// TODO: Use the new merged manifest model
private static final boolean INCLUDE_LDPI;
private static final String KEY_MIN_API = "minSdk";
static {
boolean includeLdpi = false;
String value = System.getenv("ANDROID_LINT_INCLUDE_LDPI");
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"
+ (INCLUDE_LDPI ? "|ldpi" : "")
+ ")$");
/** 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\\.[a-zA-Z]+");
/** Cache for {@link #getRequiredDensityFolders(Context)} */
private List<String> cachedRequiredDensities;
/** Cache key for {@link #getRequiredDensityFolders(Context)} */
private Project cachedDensitiesForProject;
// TODO: Convert this over to using the Density enum and FolderConfiguration
// for qualifier lookup
private static final String[] DENSITY_QUALIFIERS =
new String[] {
"-ldpi", "-mdpi", "-hdpi", "-xhdpi", "-xxhdpi", "-xxxhdpi",
};
/**
* 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<Scope> 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",
"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("https://material.io/design/iconography/");
/** Inconsistent dip size across densities */
public static final Issue ICON_DIP_SIZE =
Issue.create(
"IconDipSize",
"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",
"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(
"https://developer.android.com/guide/practices/screens_support.html");
/** Missing density versions of image */
public static final Issue ICON_DENSITIES =
Issue.create(
"IconDensities",
"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 "
+ "https://developer.android.com/about/dashboards",
Category.ICONS,
4,
Severity.WARNING,
IMPLEMENTATION_RES_ONLY)
.addMoreInfo(
"https://developer.android.com/guide/practices/screens_support.html");
/** Missing density folders */
public static final Issue ICON_MISSING_FOLDER =
Issue.create(
"IconMissingDensityFolder",
"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 "
+ "https://developer.android.com/about/dashboards",
Category.ICONS,
3,
Severity.WARNING,
IMPLEMENTATION_RES_ONLY)
.addMoreInfo(
"https://developer.android.com/guide/practices/screens_support.html");
/** Using .gif bitmaps */
public static final Issue GIF_USAGE =
Issue.create(
"GifUsage",
"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(
"https://developer.android.com/guide/topics/resources/drawable-resource.html#Bitmap");
/** Duplicated icons across different names */
public static final Issue DUPLICATES_NAMES =
Issue.create(
"IconDuplicates",
"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",
"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",
"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",
"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",
"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",
"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 color of notification icon. */
public static final Issue ICON_COLORS =
Issue.create(
"IconColors",
"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 "
+ "https://material.io/design/iconography/",
Category.ICONS,
6,
Severity.WARNING,
IMPLEMENTATION_JAVA);
/** Wrong launcher icon shape */
public static final Issue ICON_LAUNCHER_SHAPE =
Issue.create(
"IconLauncherShape",
"The launcher icon shape should use a distinct silhouette",
"According to the Android Design Guide "
+ "(https://material.io/design/iconography/) "
+ "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);
/** Raster image is required for notification icon at API < 21. */
public static final Issue NOTIFICATION_ICON_COMPATIBILITY =
Issue.create(
"NotificationIconCompatibility",
"Notification Icon Compatibility",
"Notification icons should define a raster image to support Android versions below 5.0 (API 21). "
+ "Note that the way Lint decides whether an icon is a notification icon is based on the filename prefix "
+ "`ic_stat_`. This corresponds to the naming convention documented in "
+ "https://material.io/design/iconography/",
Category.CORRECTNESS,
6,
Severity.WARNING,
IMPLEMENTATION_JAVA);
/** Switch to webp? */
public static final Issue WEBP_ELIGIBLE =
Issue.create(
"ConvertToWebp",
"Convert to WebP",
"The WebP format is typically more compact than PNG and JPEG. As of Android 4.2.1 "
+ "it supports transparency and lossless conversion as well. Note that there is a "
+ "quickfix in the IDE which lets you perform conversion.\n"
+ "\n"
+ "Previously, launcher icons were required to be in the PNG format but that "
+ "restriction is no longer there, so lint now flags these.",
Category.ICONS,
6,
Severity.WARNING,
IMPLEMENTATION_JAVA)
// Actual performance being benchmarked
.setEnabledByDefault(false);
/** Webp unsupported? */
public static final Issue WEBP_UNSUPPORTED =
Issue.create(
"WebpUnsupported",
"WebP Unsupported",
"The WebP format requires Android 4.0 (API 15). Certain features, such as lossless "
+ "encoding and transparency, requires Android 4.2.1 (API 18; API 17 is 4.2.0.)",
Category.ICONS,
6,
Severity.ERROR,
IMPLEMENTATION_JAVA);
/** Constructs a new {@link IconDetector} check */
public IconDetector() {}
@Override
public void beforeCheckRootProject(@NonNull Context context) {
launcherIcons = null;
actionBarIcons = null;
notificationIcons = null;
roundIcons = null;
}
@Override
public void afterCheckEachProject(@NonNull Context context) {
if (!context.getProject().getReportIssues()) {
// If this is a library project not being analyzed, ignore it
return;
}
checkResourceFolder(context, context.getProject());
}
private void checkResourceFolder(Context context, @NonNull Project project) {
// The resource files corresponding of the notification icons. The keys are icon names.
// The values are the icon files most compatible with old Android versions.
Map<String, File> notificationIconFiles = null;
List<File> 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);
boolean checkWebp = context.isEnabled(WEBP_ELIGIBLE);
Map<File, Dimension> pixelSizes = null;
Map<File, Long> fileSizes = null;
if (checkDipSizes || checkDuplicates || checkWebp) {
pixelSizes = new HashMap<>();
fileSizes = new HashMap<>();
}
if (context.isEnabled(NOTIFICATION_ICON_COMPATIBILITY)
&& context.getProject().getMinSdk() < 21) {
notificationIconFiles = new HashMap<>();
}
Map<File, Set<String>> folderToNames = new HashMap<>();
Map<File, Set<String>> nonDpiFolderNames = new HashMap<>();
for (File folder : folders) {
String folderName = folder.getName();
if (folderName.startsWith(DRAWABLE_FOLDER)
|| folderName.startsWith(MIPMAP_FOLDER)) {
File[] files = folder.listFiles();
if (files != null) {
checkDrawableDir(
context,
folder,
files,
pixelSizes,
fileSizes,
notificationIconFiles);
if (checkFolders && DENSITY_PATTERN.matcher(folderName).matches()) {
Set<String> 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<String> 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);
}
// Report webp-conversion-eligible images
// We only check for 18 here, which supports transparency and lossless
// encoding. We could also offer this for API 15, but in that case we'd need to
// (1) skip images with alpha, and (2) make it clear that only lossy conversion
// should be used.
// (It's Android 4.2.1 which starts supporting transparent WebP, whereas
// API level 17 corresponds to 4.2 and API level 18 corresponds to 4.3; we can't
// use API level 17 to determine whether WebP is safe since that also includes
// 4.2.0)
if (checkWebp) {
// (1) See if we have any png or jpeg images
// (2) Use the location of the largest such image
File largest = null;
long size = 0;
for (Map.Entry<File, Long> entry : fileSizes.entrySet()) {
File f = entry.getKey();
String name = f.getName();
if (endsWithIgnoreCase(name, DOT_PNG) && !endsWithIgnoreCase(name, DOT_9PNG)
|| endsWithIgnoreCase(name, DOT_JPG)
|| endsWithIgnoreCase(name, DOT_JPEG)) {
String baseName = getBaseName(name);
if (isAdaptiveIconLayer(baseName)) {
continue;
}
Long sizeLong = entry.getValue();
if (sizeLong != null && sizeLong > size) {
size = sizeLong;
largest = f;
}
}
}
if (largest != null) {
Location location = Location.create(largest);
String message =
"One or more images in this project can be converted to "
+ "the WebP format which typically results in smaller file sizes, "
+ "even for lossless conversion";
Incident incident = new Incident(WEBP_ELIGIBLE, location, message);
context.report(incident, minSdkAtLeast(18));
}
}
}
}
if (notificationIconFiles != null) {
for (Map.Entry<String, File> entry : notificationIconFiles.entrySet()) {
File file = entry.getValue();
if (!SdkUtils.isBitmapFile(file)) {
String message =
String.format(
"Notification icon %1$s has to have a raster image to support "
+ "Android versions below 5.0 (API 21)",
entry.getKey());
Location location = Location.create(file);
Incident incident =
new Incident(NOTIFICATION_ICON_COMPATIBILITY, location, message);
context.report(incident, minSdkLessThan(21));
}
}
}
}
/**
* Like {@link SdkUtils#isBitmapFile(File)} but (a) operates on Strings instead of files and (b)
* also considers XML drawables as images
*/
public 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<File, Dimension> pixelSizes, Map<File, Long> fileSizes) {
Map<Long, Set<File>> sameSizes = new HashMap<>();
Map<Long, File> seenSizes = new HashMap<>(fileSizes.size());
for (Map.Entry<File, Long> entry : fileSizes.entrySet()) {
File file = entry.getKey();
Long size = entry.getValue();
if (seenSizes.containsKey(size)) {
Set<File> set = sameSizes.get(size);
if (set == null) {
set = new HashSet<>();
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<Set<File>> candidateLists = sameSizes.values();
for (Set<File> candidates : candidateLists) {
Map<Dimension, Set<File>> sameDimensions = new HashMap<>(candidates.size());
List<File> noSize = new ArrayList<>();
for (File file : candidates) {
Dimension dimension = pixelSizes.get(file);
if (dimension != null) {
Set<File> 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<Set<File>> sets = sameDimensions.values();
if (!noSize.isEmpty()) {
if (!sets.isEmpty()) {
for (Set<File> set : sets) {
set.addAll(noSize);
}
} else {
// Must just test the noSize elements against themselves
HashSet<File> 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<File, byte[]> 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<File> set : sets) {
if (set.size() < 2) {
continue;
}
// Read all files in this set and store in map
for (File file : set) {
byte[] bits = fileContents.get(file);
if (bits == null) {
try {
bits = 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<File, File> 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<File> 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<File, Set<File>> partitions = new HashMap<>();
List<Set<File>> sameSets = new ArrayList<>();
for (Map.Entry<File, File> entry : equal.entrySet()) {
File file1 = entry.getKey();
File file2 = entry.getValue();
Set<File> set1 = partitions.get(file1);
Set<File> set2 = partitions.get(file2);
if (set1 != null) {
set1.add(file2);
} else if (set2 != null) {
set2.add(file1);
} else {
set = new HashSet<>();
sameSets.add(set);
set.add(file1);
set.add(file2);
partitions.put(file1, set);
partitions.put(file2, set);
}
}
// We've computed the partitions of equal files. Now sort them
// for stable output.
List<List<File>> lists = new ArrayList<>();
for (Set<File> same : sameSets) {
assert !same.isEmpty();
ArrayList<File> sorted = new ArrayList<>(same);
Collections.sort(sorted);
lists.add(sorted);
}
// Sort overall partitions by the first item in each list
lists.sort((list1, list2) -> 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_<N>dp.png. (See issue 74584 for more.)
ListIterator<List<File>> iterator = lists.listIterator();
while (iterator.hasNext()) {
List<File> 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<File> sameFiles : lists) {
Location location = null;
boolean sameNames = true;
String lastName = null;
for (File file : sameFiles) {
if (lastName != null && !lastName.equals(file.getName())) {
sameNames = false;
}
lastName = file.getName();
// Chain locations together
Location linkedLocation = location;
location = Location.create(file);
location.setSecondary(linkedLocation);
}
if (sameNames) {
StringBuilder sb = new StringBuilder(sameFiles.size() * 16);
for (File file : sameFiles) {
if (sb.length() > 0) {
sb.append(", ");
}
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) {
Location curr = location;
while (curr != null) {
// Suppressed? Check all alias paths
if (context.getConfiguration()
.isIgnored(
context,
DUPLICATES_CONFIGURATIONS,
curr,
message)) {
return;
}
curr = curr.getSecondary();
}
context.report(DUPLICATES_CONFIGURATIONS, location, message);
}
} else {
StringBuilder sb = new StringBuilder(sameFiles.size() * 16);
for (File file : sameFiles) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(file.getName());
}
String message =
String.format(
"The following unrelated icon files have identical contents: %1$s",
sb.toString());
Location curr = location;
while (curr != null) {
// Suppressed? Check all alias paths
if (context.getConfiguration()
.isIgnored(context, DUPLICATES_NAMES, curr, message)) {
return;
}
curr = curr.getSecondary();
}
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<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<>();
for (File file : pixelSizes.keySet()) {
String name = file.getName();
List<File> list = nameToFiles.get(name);
if (list == null) {
list = new ArrayList<>();
nameToFiles.put(name, list);
}
list.add(file);
}
ArrayList<String> 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<String, Map<String, List<File>>> configMap = new HashMap<>();
for (Map.Entry<String, List<File>> entry : nameToFiles.entrySet()) {
String name = entry.getKey();
List<File> 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<String, List<File>> 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<File> list = folderMap.get(parentName);
if (list == null) {
list = new ArrayList<>();
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<>();
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 / (double) count);
double heightStdDev = Math.sqrt(squareHeightSum / (double) count);
if ((widthStdDev > (meanWidth / 10)) || (heightStdDev > meanHeight)) {
StringBuilder sb = new StringBuilder(100);
sb.append("The image `")
.append(name)
.append(
"` varies significantly in its "
+ "density-independent (dip) size across the various density "
+ "versions: ");
Location location = null;
// Sort entries by decreasing dip size
List<Map.Entry<File, Dimension>> entries = new ArrayList<>();
for (Map.Entry<File, Dimension> entry2 : dipSizes.entrySet()) {
entries.add(entry2);
}
entries.sort(
(e1, e2) -> {
Dimension d1 = e1.getValue();
Dimension d2 = e2.getValue();
if (d1.width != d2.width) {
return d2.width - d1.width;
}
return d2.height - d1.height;
});
List<String> examples = Lists.newArrayList();
for (Map.Entry<File, Dimension> entry2 : entries) {
File file = entry2.getKey();
// Chain locations together
Location linkedLocation = location;
location = Location.create(file);
location.setSecondary(linkedLocation);
Dimension dip = entry2.getValue();
Dimension px = pixelSizes.get(file);
String example =
Lint.getFileNameWithParent(context.getClient(), file)
+ ": "
+ String.format(
"%1$dx%2$d dp (%3$dx%4$d px)",
dip.width, dip.height, px.width, px.height);
examples.add(example);
}
if (location != null) {
Collections.sort(examples);
sb.append(Joiner.on(", ").join(examples));
context.report(ICON_DIP_SIZE, location, sb.toString());
}
}
}
}
}
private void checkDensities(
Context context,
File res,
Map<File, Set<String>> folderToNames,
Map<File, Set<String>> 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<String> 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<String> missing = new ArrayList<>();
List<String> requiredDensityFolders = getRequiredDensityFolders(context);
boolean foundSome = false;
for (String density : requiredDensityFolders) {
if (!definedDensities.contains(density)) {
missing.add(density);
} else {
// Make sure the we have at least one of the folders required
// (e.g. we might only have mipmap folders as well as drawable/ or
// drawable-nodpi)
foundSome = true;
}
}
if (!missing.isEmpty() && foundSome) {
Collections.sort(missing);
context.report(
ICON_MISSING_FOLDER,
Location.create(res),
String.format(
"Missing density variation folders in `%1$s`: %2$s",
context.getProject().getDisplayPath(res),
Lint.formatList(missing, -1)));
}
}
if (context.isEnabled(ICON_NODPI)) {
Set<String> noDpiNames = new HashSet<>();
for (Map.Entry<File, Set<String>> 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<String> inBoth = new HashSet<>();
List<File> files = new ArrayList<>();
for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) {
File folder = entry.getKey();
String folderName = folder.getName();
if (!isNoDpiFolder(folder)) {
assert DENSITY_PATTERN.matcher(folderName).matches();
Set<String> overlap = nameIntersection(noDpiNames, entry.getValue());
inBoth.addAll(overlap);
for (String name : overlap) {
files.add(new File(folder, name));
}
}
}
if (!inBoth.isEmpty()) {
List<String> 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",
Lint.formatList(
list, context.getDriver().isAbbreviating() ? 10 : -1)));
}
}
}
if (context.isEnabled(ICON_MIX_9PNG)) {
checkMixedNinePatches(context, folderToNames);
}
if (context.isEnabled(ICON_XML_AND_PNG)) {
Map<File, Set<String>> folderMap = Maps.newHashMap(folderToNames);
folderMap.putAll(nonDpiFolderNames);
Set<String> xmlNames = Sets.newHashSetWithExpectedSize(100);
Set<String> bitmapNames = Sets.newHashSetWithExpectedSize(100);
for (Map.Entry<File, Set<String>> entry : folderMap.entrySet()) {
Set<String> names = entry.getValue();
String folderName = entry.getKey().getName();
for (String name : names) {
if (endsWith(name, DOT_XML)) {
// Ignore .xml files in version qualifier folders
if (!folderName.contains("-v")) {
xmlNames.add(name);
}
} else if (isDrawableFile(name)) {
bitmapNames.add(name);
}
}
}
if (!xmlNames.isEmpty() && !bitmapNames.isEmpty()) {
// Make sure that none of the density-independent names appear in a density folder
Set<String> overlap = nameIntersection(xmlNames, bitmapNames);
if (!overlap.isEmpty()) {
Multimap<String, File> map = ArrayListMultimap.create();
Set<String> bases = Sets.newHashSetWithExpectedSize(overlap.size());
for (String name : overlap) {
bases.add(Lint.getBaseName(name));
}
for (String base : bases) {
for (Map.Entry<File, Set<String>> entry : folderMap.entrySet()) {
File folder = entry.getKey();
for (String n : entry.getValue()) {
if (base.equals(Lint.getBaseName(n))) {
map.put(base, new File(folder, n));
}
}
}
}
List<String> sorted = new ArrayList<>(map.keySet());
Collections.sort(sorted);
for (String name : sorted) {
List<File> lists = Lists.newArrayList(map.get(name));
Location location = chainLocations(lists);
List<String> 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;
}
}
Collections.sort(fileNames);
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",
Lint.formatList(
fileNames,
context.getDriver().isAbbreviating() ? 10 : -1)));
}
}
}
}
if (context.isEnabled(ICON_DENSITIES)) {
// Look for folders missing some of the specific assets
Set<String> allNames = new HashSet<>();
for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) {
if (!isNoDpiFolder(entry.getKey())) {
Set<String> names = entry.getValue();
allNames.addAll(names);
}
}
for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) {
File file = entry.getKey();
if (isNoDpiFolder(file)) {
continue;
}
Set<String> names = entry.getValue();
if (names.size() != allNames.size()) {
List<String> delta = new ArrayList<>(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<String> defined = new ArrayList<>();
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.isEmpty()) {
Collections.sort(defined);
foundIn =
String.format(
" (found in %1$s)",
Lint.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,
Lint.formatList(
delta, context.getDriver().isAbbreviating() ? 5 : -1),
foundIn));
}
}
}
}
private List<String> getRequiredDensityFolders(@NonNull Context context) {
if (cachedRequiredDensities == null || context.getProject() != cachedDensitiesForProject) {
cachedDensitiesForProject = context.getProject();
cachedRequiredDensities = Lists.newArrayListWithExpectedSize(10);
List<String> applicableDensities = context.getProject().getApplicableDensities();
if (applicableDensities != null) {
cachedRequiredDensities.addAll(applicableDensities);
} else {
if (INCLUDE_LDPI) {
cachedRequiredDensities.add(DRAWABLE_LDPI);
}
cachedRequiredDensities.add(DRAWABLE_MDPI);
cachedRequiredDensities.add(DRAWABLE_HDPI);
cachedRequiredDensities.add(DRAWABLE_XHDPI);
cachedRequiredDensities.add(DRAWABLE_XXHDPI);
// xxxhdpi is not required - only required for launchers
// From http://developer.android.com/guide/practices/screens_support.html:
// "Note: the drawable-xxxhdpi qualifier is necessary only to provide a launcher
// icon that can appear larger than usual on an xxhdpi device. You do not need
// to provide xxxhdpi assets for all your app's images."
}
}
return cachedRequiredDensities;
}
/**
* Compute the difference in names between a and b. This is not just Sets.difference(a, b)
* because we want to make the comparisons <b>without file extensions</b> and return the result
* <b>with</b>..
*/
private static Set<String> nameDifferences(Set<String> a, Set<String> b) {
Set<String> names1 = new HashSet<>(a.size());
for (String s : a) {
names1.add(Lint.getBaseName(s));
}
Set<String> names2 = new HashSet<>(b.size());
for (String s : b) {
names2.add(Lint.getBaseName(s));
}
names1.removeAll(names2);
if (!names1.isEmpty()) {
// Map filenames back to original filenames with extensions
Set<String> result = new HashSet<>(names1.size());
for (String s : a) {
if (names1.contains(Lint.getBaseName(s))) {
result.add(s);
}
}
for (String s : b) {
if (names1.contains(Lint.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 <b>without file extensions</b> and return the result
* <b>with</b>.
*/
private static Set<String> nameIntersection(Set<String> a, Set<String> b) {
Set<String> names1 = new HashSet<>(a.size());
for (String s : a) {
names1.add(Lint.getBaseName(s));
}
Set<String> names2 = new HashSet<>(b.size());
for (String s : b) {
names2.add(Lint.getBaseName(s));
}
names1.retainAll(names2);
if (!names1.isEmpty()) {
// Map filenames back to original filenames with extensions
Set<String> result = new HashSet<>(names1.size());
for (String s : a) {
if (names1.contains(Lint.getBaseName(s))) {
result.add(s);
}
}
for (String s : b) {
if (names1.contains(Lint.getBaseName(s))) {
result.add(s);
}
}
return result;
}
return Collections.emptySet();
}
private static boolean isNoDpiFolder(File file) {
return file.getName().contains("-nodpi");
}
private Map<File, BufferedImage> imageCache;
@Nullable
private BufferedImage getImage(@Nullable File file) throws IOException {
if (file == null) {
return null;
}
if (imageCache == null) {
imageCache = Maps.newHashMap();
} else {
BufferedImage image = imageCache.get(file);
if (image != null) {
return image;
}
}
BufferedImage image = ImageIO.read(file);
imageCache.put(file, image);
return image;
}
private void checkDrawableDir(
Context context,
File folder,
File[] files,
Map<File, Dimension> pixelSizes,
Map<File, Long> fileSizes,
@Nullable Map<String, File> notificationIconsCompatibility) {
String folderName = folder.getName();
if (folderName.equals(DRAWABLE_FOLDER)) {
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_WEBP)
|| 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, folderName, 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)) {
for (File file : files) {
String name = file.getName();
if (isLauncherIcon(folderName, getBaseName(name))) {
if (!endsWith(name, DOT_XML) && !endsWith(name, DOT_9PNG)) {
checkLauncherShape(context, folderName, 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)
|| endsWith(fileName, DOT_WEBP)) {
// 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());
}
}
}
}
// Check version compatibility of notification icons.
if (notificationIconsCompatibility != null) {
checkNotificationIconsCompatibility(files, notificationIconsCompatibility);
}
if (context.isEnabled(WEBP_UNSUPPORTED) && files.length > 0) {
checkWebpSupported(context, files);
}
imageCache = null;
}
private void checkNotificationIconsCompatibility(
@Nullable File[] files, @NonNull Map<String, File> notificationIconsCompatibility) {
if (files == null || files.length == 0) {
return;
}
for (File file : files) {
String name = file.getName();
String baseName = getBaseName(name);
if (isNotificationIcon(baseName)) {
File mostCompatibleFile = notificationIconsCompatibility.get(baseName);
if (mostCompatibleFile == null || !SdkUtils.isBitmapFile(mostCompatibleFile)) {
notificationIconsCompatibility.put(baseName, file);
}
}
}
}
private void checkWebpSupported(@NonNull Context context, @NonNull File[] files) {
// all files in this folder have the same folder minSdkVersion
int minSdk =
Math.max(
context.getProject().getMinSdk(),
context.getDriver().getResourceFolderVersion(files[0]));
if (minSdk >= 18) {
return;
}
for (File file : files) {
String path = file.getPath();
if (!endsWithIgnoreCase(path, DOT_WEBP)) {
continue;
}
String name = file.getName();
String baseName = getBaseName(name);
if (isAdaptiveIconLayer(baseName)) {
Location location = Location.create(file);
String message = "Adaptive icon bitmaps must be in PNG format";
Incident incident = new Incident(WEBP_UNSUPPORTED, location, message);
context.report(incident, minSdkLessThan(18));
continue;
}
WebpHeader header = WebpHeader.getWebpHeader(file);
if (header != null && header.format != null) {
boolean simpleFormat = "VP8".equals(header.format);
int required = simpleFormat ? 15 : 18;
if (required > minSdk) {
Location location = Location.create(file);
String message =
simpleFormat
? "WebP requires Android 4.0 (API 15)"
: "WebP extended or lossless format requires Android 4.2.1 (API 18)";
message += "; current minSdkVersion is %1$s";
Incident incident = new Incident(WEBP_UNSUPPORTED, location, message);
// With folder versions, could be higher than the app minSdkVersion
context.report(incident, map().put(KEY_MIN_API, minSdk));
}
}
}
}
@Override
public boolean filterIncident(
@NonNull Context context, @NonNull Incident incident, @NonNull LintMap map) {
assert incident.getIssue() == WEBP_UNSUPPORTED;
int minSdk = context.getMainProject().getMinSdk();
if (minSdk < 18) {
// include minSdkVersion in the message
String message = incident.getMessage();
assert message.contains("%");
int fileMin = map.getInt(KEY_MIN_API, 1);
int actualMinSdk = Math.max(minSdk, fileMin);
incident.setMessage(String.format(message, actualMinSdk));
return true;
}
return false;
}
/** Check that launcher icons do not fill every pixel in the image */
private void checkLauncherShape(Context context, String folderName, File file) {
try {
BufferedImage image = getImage(file);
if (image != null) {
if (isRoundIcon(folderName, getBaseName(file.getName()))) {
if (!isRound(image)) {
String message =
"Launcher icon used as round icon did not have a "
+ "circular shape";
context.report(ICON_LAUNCHER_SHAPE, Location.create(file), message);
return;
}
}
// 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
}
}
private static boolean isRound(@NonNull BufferedImage image) {
// Simple algorithm: compute radius and center of the launcher icon;
// then compute a mask for it and then diff it with a drawing of a circle
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
int maxX = 0;
int maxY = 0;
int imageHeight = image.getHeight();
int imageWidth = image.getWidth();
for (int y = 0; y < imageHeight; y++) {
for (int x = 0; x < imageWidth; x++) {
int rgb = image.getRGB(x, y);
if ((rgb & 0xFF000000) != 0) {
if (x > maxX) {
maxX = x;
}
if (y > maxY) {
maxY = y;
}
if (x < minX) {
minX = x;
}
if (y < minY) {
minY = y;
}
}
}
}
int shapeWidth = maxX - minX + 1;
int shapeHeight = maxY - minY + 1;
// The shape width and height should be roughly equal; it's supposed to be
// a circle, not an oval
if (Math.abs(shapeWidth - shapeHeight) > imageWidth / 10) {
return false;
}
BufferedImage circle = new BufferedImage(imageWidth, imageHeight, TYPE_INT_ARGB);
Graphics graphics = circle.getGraphics();
graphics.fillOval(minX, minY, shapeWidth, shapeHeight);
graphics.dispose();
final int threshold = 64;
int different = 0;
for (int y = 0; y < imageHeight; y++) {
for (int x = 0; x < imageWidth; x++) {
int sourceAlpha = (image.getRGB(x, y) & 0xFF000000) >>> 24;
int circleAlpha = (circle.getRGB(x, y) & 0xFF000000) >>> 24;
if (sourceAlpha > 0 && sourceAlpha < threshold) {
// Don't compare pixels in the alpha area
continue;
}
boolean original = sourceAlpha < threshold;
boolean cir = circleAlpha < threshold;
if (original != cir) {
different++;
}
}
}
long total = (long) imageHeight * imageWidth;
double percentDifferent = 100 * different / (double) total;
// Allow 4% difference or less -- mainly to account for anti-aliasing edge differences
return percentDifferent < 4;
}
/**
* 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) {
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 {
// else: transparent
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";
Location location = Location.create(file);
String name = getBaseName(file.getName());
UElement usage =
notificationIcons != null
? notificationIcons.get(name)
: null;
if (usage != null) {
LintClient client = context.getClient();
Project project = context.getProject();
UastParser parser = client.getUastParser(project);
Location secondary = parser.createLocation(usage);
secondary.setMessage("Icon used in notification here");
location.setSecondary(secondary);
}
context.report(ICON_COLORS, location, 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<ImageReader> readers = ImageIO.getImageReaders(input);
if (!readers.hasNext() && !endsWithIgnoreCase(file.getPath(), DOT_WEBP)) {
// Check WEBP: No decoder available outside of Android Studio
// See if it's a WEBP file without a webp extension
//noinspection VariableNotUsedInsideIf
if (WebpHeader.getWebpHeader(file) != null) {
String extension = file.getName();
extension = extension.substring(extension.lastIndexOf('.') + 1);
String message =
String.format(
"Misleading file extension; named `.%1$s` but the "
+ "file format is `%2$s`",
extension, "webp");
Location location = Location.create(file);
context.report(ICON_EXTENSION, location, message);
}
}
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")) {
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
System.out.println("foo");
}
}
// Like SdkUtils.fileNameToResourceName, 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<File, Set<String>> folderToNames) {
Set<String> conflictSet = null;
for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) {
Set<String> baseNames = new HashSet<>();
Set<String> 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 = new HashSet<>();
}
conflictSet.add(base);
}
} else {
baseNames.add(base);
}
}
}
if (conflictSet == null || conflictSet.isEmpty()) {
return;
}
Map<String, List<File>> conflicts = null;
for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) {
File dir = entry.getKey();
Set<String> 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<File> 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<String> names = new ArrayList<>(conflicts.keySet());
Collections.sort(names);
for (String name : names) {
List<File> 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<File> 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]);
FolderConfiguration folderConfig = FolderConfiguration.getConfigForFolder(folderName);
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(folderName, baseName)) {
// TODO: Update according to
// https://developer.android.com/guide/practices/ui_guidelines/icon_design_adaptive
// Launcher icons
checkSize(context, folderName, file, 48, 48, true, folderConfig);
} else if (isActionBarIcon(folderName, baseName)) {
checkSize(context, folderName, file, 32, 32, true, folderConfig);
} else if (name.startsWith("ic_dialog_")) {
// Dialog
checkSize(context, folderName, file, 32, 32, true, folderConfig);
} else if (name.startsWith("ic_tab_")) {
// Tab icons
checkSize(context, folderName, file, 32, 32, true, folderConfig);
} else if (isNotificationIcon(baseName)) {
// Notification icons
checkSize(context, folderName, file, 24, 24, true, folderConfig);
} else if (isAdaptiveIconLayer(baseName)) {
checkSize(context, folderName, file, 108, 108, true, folderConfig);
} else if (name.startsWith("ic_menu_")) {
// 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, folderConfig);
}
// TODO: ListView icons?
}
}
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")) {
return 1.0f;
} else if (folderName.contains("-hdpi")) {
return 1.5f;
} else if (folderName.contains("-xhdpi")) {
return 2.0f;
} else if (folderName.contains("-xxhdpi")) {
return 3.0f;
} else if (folderName.contains("-xxxhdpi")) {
return 4.0f;
} else if (folderName.contains("-ldpi")) {
return 0.75f;
} else {
return 0f;
}
}
private static void checkSize(
Context context,
String folderName,
File file,
int mdpiWidth,
int mdpiHeight,
boolean exactMatch,
@Nullable FolderConfiguration folderConfig) {
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_WEBP)
|| 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 if (folderConfig != null
&& folderConfig.getDensityQualifier() != null
&& !folderConfig.getDensityQualifier().hasFakeValue()) {
Density density = folderConfig.getDensityQualifier().getValue();
if (density == null) {
return;
}
width = mdpiWidth * density.getDpiValue() / Density.DEFAULT_DENSITY;
height = mdpiHeight * density.getDpiValue() / Density.DEFAULT_DENSITY;
} 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),
"Incorrect icon size for `"
+ Lint.getFileNameWithParent(context.getClient(), file)
+ "`: "
+ String.format(
"expected %1$dx%2$d, but was %3$dx%4$d",
width, height, size.width, size.height));
} else if (!exactMatch && (size.width > width || size.height > height)) {
context.report(
ICON_EXPECTED_SIZE,
Location.create(file),
"Incorrect icon size for `"
+ Lint.getFileNameWithParent(context.getClient(), file)
+ "`: "
+ String.format(
"icon size should be at most %1$dx%2$d, but "
+ "was %3$dx%4$d",
width, height, size.width, size.height));
}
}
}
@Nullable
public static Dimension getSize(@NonNull File file) {
try {
ImageInputStream input = ImageIO.createImageInputStream(file);
if (input != null) {
// Apparently there are concurrency issues inside this class, so
// don't try to access it in parallel
synchronized (ImageIO.class) {
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 {
input.close();
}
}
}
// WEBP custom file header decoding, not available via ImageReaders outside
// of Android Studio since that requires native code around libwebp
if (endsWithIgnoreCase(file.getPath(), DOT_WEBP)) {
WebpHeader header = WebpHeader.getWebpHeader(file);
if (header != null && header.width > 0) {
return new Dimension(header.width, header.height);
}
}
// 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 static class WebpHeader {
public String format;
int width;
int height;
@Nullable
private static WebpHeader getWebpHeader(@NonNull File file) {
try (InputStream is = new BufferedInputStream(new FileInputStream(file))) {
// WEBP header:
//
// 0 1 2 3
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | 'R' | 'I' | 'F' | 'F' |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | File Size |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | 'W' | 'E' | 'B' | 'P' |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//noinspection DuplicateCondition
if (is.read() != 'R'
|| is.read() != 'I'
|| is.read() != 'F'
|| is.read() != 'F'
|| is.read() == -1
|| is.read() == -1
|| is.read() == -1
|| is.read() == -1
|| is.read() != 'W'
|| is.read() != 'E'
|| is.read() != 'B'
|| is.read() != 'P') {
return null;
}
// See https://developers.google.com/speed/webp/docs/riff_container
if (is.read() != 'V' || is.read() != 'P' || is.read() != '8') {
return null;
}
// Found WEBP header. API level 15 is required for the simple format; extended
// or lossless formats require API 18.
int format = is.read();
WebpHeader data = new WebpHeader();
if (format == 'L') {
data.format = "VP8L";
// https://developers.google.com/speed/webp/docs/webp_lossless_bitstream_specification#2_riff_header
// So far we've read up to number 5.
// "5. A little-endian 32-bit value of the number of bytes in the lossless
// stream."
for (int i = 0; i < 4; i++) {
//noinspection ResultOfMethodCallIgnored
is.read();
}
// "6. One byte signature 0x2f."
if (is.read() == 0x2f) { // signature
// "The first 28 bits of the bitstream specify the width and height of the
// image. Width and height are decoded as 14-bit integers as follows:
// int image_width = ReadBits(14) + 1;
// int image_height = ReadBits(14) + 1;"
int byte1 = is.read();
int byte2 = is.read();
int byte3 = is.read();
int byte4 = is.read();
if (is.read() != -1) {
data.width = ((byte2 & 0b111111) << 8 | byte1) + 1;
data.height =
((byte4 & 0b1111) << 10
| byte3 << 2
| (byte2 & 0b11000000) >> 6)
+ 1;
} // else already reached end somehow: invalid file
}
return data;
} else if (format == 'X') {
// VP8X - extended file format
data.format = "VP8X";
// 0 1 2 3
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | WebP file header (12 bytes) |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | ChunkHeader('VP8X') |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// |Rsv|I|L|E|X|A|R| Reserved |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | Canvas Width Minus One | ...
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// ... Canvas Height Minus One |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// Skip flags
for (int i = 0; i < 8; i++) {
//noinspection ResultOfMethodCallIgnored
is.read();
}
int byte1 = is.read();
int byte2 = is.read();
int byte3 = is.read();
int byte4 = is.read();
int byte5 = is.read();
int byte6 = is.read();
if (is.read() != -1) {
data.width = (byte1 | byte2 << 8 | byte3 << 16) + 1;
data.height = (byte4 | byte5 << 8 | byte6 << 16) + 1;
} // else already reached end somehow: invalid file
return data;
} else if (format == ' ') {
data.format = "VP8";
// https://tools.ietf.org/html/rfc6386#section-9
for (int i = 0; i < 7; i++) {
//noinspection ResultOfMethodCallIgnored
is.read();
}
// ---- Begin code block --------------------------------------
//
// Start code byte 0 0x9d
// Start code byte 1 0x01
// Start code byte 2 0x2a
//
// 16 bits : (2 bits Horizontal Scale << 14) | Width (14 bits)
// 16 bits : (2 bits Vertical Scale << 14) | Height (14 bits)
//
// ---- End code block ----------------------------------------
if (is.read() == 0x9d && is.read() == 0x01 && is.read() == 0x2a) {
int byte1 = is.read();
int byte2 = is.read();
int byte3 = is.read();
int byte4 = is.read();
if (is.read() != -1) {
data.width = byte1 | ((byte2 & 0b111111) << 8);
data.height = byte3 | ((byte4 & 0b111111) << 8);
} // else already reached end somehow: invalid file
}
return data;
} else {
return null;
}
} catch (IOException ignore) {
}
return null;
}
}
private Map<String, UElement> notificationIcons;
/**
* Set of names of @drawable resources that represent action bar icons, <b>or</b>, if the icons
* is a @mipmap icon, the resource url (@mipmap/name).
*/
private Set<String> actionBarIcons;
/**
* Set of names of @drawable resources that represent launcher icons, <b>or</b>, if the icons is
* a @mipmap icon, the resource url (@mipmap/name).
*/
private Set<String> launcherIcons;
private Multimap<String, String> menuToIcons;
/**
* Set of names of @drawable resources that represent round icons, <b>or</b>, if the icons is
* a @mipmap icon, the resource url (@mipmap/name).
*/
private Set<Object> roundIcons;
private boolean isLauncherIcon(@NonNull String folderName, @NonNull String name) {
assert name.indexOf('.') == -1 : name; // Should supply base name
// Naming convention
//noinspection SimplifiableIfStatement
if (name.startsWith("ic_launcher") && !isAdaptiveIconLayer(name)) {
return true;
}
if (launcherIcons != null) {
if (folderName.startsWith(MIPMAP_FOLDER)) {
name = MIPMAP_PREFIX + name;
}
return launcherIcons.contains(name);
}
return false;
}
private boolean isRoundIcon(@NonNull String folderName, @NonNull String name) {
assert name.indexOf('.') == -1 : name; // Should supply base name
// Naming convention
//noinspection SimplifiableIfStatement
if (name.endsWith("_round")) {
return true;
}
if (roundIcons != null) {
if (folderName.startsWith(MIPMAP_FOLDER)) {
name = MIPMAP_PREFIX + name;
}
return roundIcons.contains(name);
}
return false;
}
private boolean isNotificationIcon(String name) {
assert name.indexOf('.') == -1; // Should supply base name
// Naming convention
//noinspection SimplifiableIfStatement
if (name.startsWith("ic_stat_")) {
return true;
}
return notificationIcons != null && notificationIcons.containsKey(name);
}
private boolean isActionBarIcon(@NonNull String folderName, @NonNull String name) {
assert name.indexOf('.') == -1; // Should supply base name
// Naming convention
//noinspection SimplifiableIfStatement
if (name.startsWith("ic_action_")) {
return true;
}
if (actionBarIcons != null) {
if (folderName.startsWith(MIPMAP_FOLDER)) {
name = MIPMAP_PREFIX + name;
}
return actionBarIcons.contains(name);
}
return false;
}
private boolean isActionBarIcon(Context context, String folderName, String name, File file) {
if (isActionBarIcon(folderName, name)) {
return true;
}
// As of Android 3.0 ic_menu_ are action icons
//noinspection SimplifiableIfStatement,RedundantIfStatement
if (file != null && name.startsWith("ic_menu_")) {
// Naming convention
return true;
}
return false;
}
/**
* Checks if the file looks like a foreground or background layer of an adaptive launcher icon.
*/
private static boolean isAdaptiveIconLayer(@NonNull String name) {
return name.startsWith("ic_launcher")
&& (name.endsWith("_foreground") || name.endsWith("_background"));
}
// XML detector: Skim manifest and menu files
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return folderType == ResourceFolderType.MENU;
}
@Override
public Collection<String> getApplicableElements() {
return Arrays.asList(
// Manifest
TAG_APPLICATION,
TAG_ACTIVITY,
TAG_ACTIVITY_ALIAS,
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);
addIcon(context, element, icon);
icon = element.getAttributeNS(ANDROID_URI, ATTR_ROUND_ICON);
String key = addIcon(context, element, icon);
if (key != null) {
if (roundIcons == null) {
roundIcons = Sets.newHashSet();
}
roundIcons.add(key);
}
}
@Nullable
private String addIcon(
@NonNull XmlContext context, @NonNull Element element, @Nullable String icon) {
if (icon == null || icon.isEmpty()) {
return null;
}
if (icon.startsWith(DRAWABLE_PREFIX)) {
icon = icon.substring(DRAWABLE_PREFIX.length());
} else if (!icon.startsWith(MIPMAP_PREFIX)) {
// Store mipmaps as @mipmap/name instead of just name to avoid
// confusing @drawable/foo and @mipmap/foo as representing the same icon
return null;
}
String tagName = element.getTagName();
if (tagName.equals(TAG_ITEM)) {
if (menuToIcons == null) {
menuToIcons = ArrayListMultimap.create();
}
String menu = getBaseName(context.file.getName());
menuToIcons.put(menu, icon);
} else if (tagName.equals(TAG_ACTIVITY)
|| tagName.equals(TAG_ACTIVITY_ALIAS)
|| tagName.equals(TAG_APPLICATION)) {
// Manifest tags: launcher icons
if (launcherIcons == null) {
launcherIcons = Sets.newHashSet();
}
launcherIcons.add(icon);
}
return icon;
}
// ---- implements SourceCodeScanner ----
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";
@Override
public List<Class<? extends UElement>> getApplicableUastTypes() {
List<Class<? extends UElement>> types = new ArrayList<>(2);
types.add(UCallExpression.class);
types.add(UMethod.class);
return types;
}
@Nullable
@Override
public UElementHandler createUastHandler(@NonNull JavaContext context) {
return new NotificationFinder(context);
}
private final class NotificationFinder extends UElementHandler {
private final JavaContext context;
private NotificationFinder(JavaContext context) {
this.context = context;
}
@Override
public void visitMethod(@NonNull UMethod method) {
if (ON_CREATE_OPTIONS_MENU.equals(method.getName())) {
// Gather any R.menu references found in this method
method.accept(new MenuFinder());
}
}
@Override
public void visitCallExpression(@NonNull UCallExpression node) {
if (UastExpressionUtils.isConstructorCall(node)) {
visitConstructorCall(node);
}
}
private void visitConstructorCall(@NonNull 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<UExpression> args = node.getValueArguments();
if (args.size() == 3) {
if (args.get(0) instanceof UReferenceExpression && handleSelect(args.get(0))) {
return;
}
ResourceUrl url =
ResourceEvaluator.getResource(context.getEvaluator(), args.get(0));
if (url != null
&& (url.type == ResourceType.DRAWABLE
|| url.type == ResourceType.COLOR
|| url.type == ResourceType.MIPMAP)) {
if (notificationIcons == null) {
notificationIcons = Maps.newHashMap();
}
notificationIcons.put(url.name, node);
}
}
} 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.isFramework()) {
if (notificationIcons == null) {
notificationIcons = Maps.newHashMap();
}
notificationIcons.put(url.name, select);
return true;
}
return false;
}
private final class SetIconFinder extends AbstractUastVisitor {
@Override
public boolean visitCallExpression(@NonNull UCallExpression expression) {
if (UastExpressionUtils.isMethodCall(expression)) {
if (SET_SMALL_ICON.equals(getMethodName(expression))) {
List<UExpression> 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) {
// Skip anonymous inner classes
return node instanceof UAnonymousClass || 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.isFramework()) {
// Reclassify icons in the given menu as action bar icons
if (menuToIcons != null) {
Collection<String> icons = menuToIcons.get(url.name);
if (icons != null) {
if (actionBarIcons == null) {
actionBarIcons = Sets.newHashSet();
}
actionBarIcons.addAll(icons);
}
}
}
return super.visitSimpleNameReferenceExpression(node);
}
@Override
public boolean visitClass(UClass node) {
// Skip anonymous inner classes
return node instanceof UAnonymousClass || super.visitClass(node);
}
}
}