1 /* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.tools.lint.checks; 18 19 import static com.android.tools.lint.detector.api.LintConstants.ANDROID_MANIFEST_XML; 20 import static com.android.tools.lint.detector.api.LintConstants.ANDROID_URI; 21 import static com.android.tools.lint.detector.api.LintConstants.ATTR_ICON; 22 import static com.android.tools.lint.detector.api.LintConstants.DOT_9PNG; 23 import static com.android.tools.lint.detector.api.LintConstants.DOT_GIF; 24 import static com.android.tools.lint.detector.api.LintConstants.DOT_JPG; 25 import static com.android.tools.lint.detector.api.LintConstants.DOT_PNG; 26 import static com.android.tools.lint.detector.api.LintConstants.DOT_XML; 27 import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_FOLDER; 28 import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_HDPI; 29 import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_LDPI; 30 import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_MDPI; 31 import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_RESOURCE_PREFIX; 32 import static com.android.tools.lint.detector.api.LintConstants.DRAWABLE_XHDPI; 33 import static com.android.tools.lint.detector.api.LintConstants.RES_FOLDER; 34 import static com.android.tools.lint.detector.api.LintConstants.TAG_APPLICATION; 35 import static com.android.tools.lint.detector.api.LintUtils.endsWith; 36 37 import com.android.tools.lint.detector.api.Category; 38 import com.android.tools.lint.detector.api.Context; 39 import com.android.tools.lint.detector.api.Detector; 40 import com.android.tools.lint.detector.api.Issue; 41 import com.android.tools.lint.detector.api.LintUtils; 42 import com.android.tools.lint.detector.api.Location; 43 import com.android.tools.lint.detector.api.Scope; 44 import com.android.tools.lint.detector.api.Severity; 45 import com.android.tools.lint.detector.api.Speed; 46 import com.android.tools.lint.detector.api.XmlContext; 47 import com.google.common.io.Files; 48 49 import org.w3c.dom.Element; 50 51 import java.awt.Dimension; 52 import java.awt.image.BufferedImage; 53 import java.io.File; 54 import java.io.IOException; 55 import java.util.ArrayList; 56 import java.util.Collection; 57 import java.util.Collections; 58 import java.util.Comparator; 59 import java.util.HashMap; 60 import java.util.HashSet; 61 import java.util.Iterator; 62 import java.util.List; 63 import java.util.Map; 64 import java.util.Map.Entry; 65 import java.util.Set; 66 import java.util.regex.Matcher; 67 import java.util.regex.Pattern; 68 69 import javax.imageio.ImageIO; 70 import javax.imageio.ImageReader; 71 import javax.imageio.stream.ImageInputStream; 72 73 /** 74 * Checks for common icon problems, such as wrong icon sizes, placing icons in the 75 * density independent drawable folder, etc. 76 */ 77 public class IconDetector extends Detector implements Detector.XmlScanner { 78 79 private static final boolean INCLUDE_LDPI; 80 static { 81 boolean includeLdpi = false; 82 83 String value = System.getenv("ANDROID_LINT_INCLUDE_LDPI"); //$NON-NLS-1$ 84 if (value != null) { 85 includeLdpi = Boolean.valueOf(value); 86 } 87 INCLUDE_LDPI = includeLdpi; 88 } 89 90 /** Pattern for the expected density folders to be found in the project */ 91 private static final Pattern DENSITY_PATTERN = Pattern.compile( 92 "^drawable-(nodpi|xhdpi|hdpi|mdpi" //$NON-NLS-1$ 93 + (INCLUDE_LDPI ? "|ldpi" : "") + ")$"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ 94 95 /** Pattern for version qualifiers */ 96 private final static Pattern VERSION_PATTERN = Pattern.compile("^v(\\d+)$");//$NON-NLS-1$ 97 98 private static final String[] REQUIRED_DENSITIES = INCLUDE_LDPI 99 ? new String[] { DRAWABLE_LDPI, DRAWABLE_MDPI, DRAWABLE_HDPI, DRAWABLE_XHDPI } 100 : new String[] { DRAWABLE_MDPI, DRAWABLE_HDPI, DRAWABLE_XHDPI }; 101 102 private static final String[] DENSITY_QUALIFIERS = 103 new String[] { 104 "-ldpi", //$NON-NLS-1$ 105 "-mdpi", //$NON-NLS-1$ 106 "-hdpi", //$NON-NLS-1$ 107 "-xhdpi" //$NON-NLS-1$ 108 }; 109 110 /** Wrong icon size according to published conventions */ 111 public static final Issue ICON_EXPECTED_SIZE = Issue.create( 112 "IconExpectedSize", //$NON-NLS-1$ 113 "Ensures that launcher icons, notification icons etc have the correct size", 114 "There are predefined sizes (for each density) for launcher icons. You " + 115 "should follow these conventions to make sure your icons fit in with the " + 116 "overall look of the platform.", 117 Category.ICONS, 118 5, 119 Severity.WARNING, 120 IconDetector.class, 121 Scope.ALL_RESOURCES_SCOPE) 122 // Still some potential false positives: 123 .setEnabledByDefault(false) 124 .setMoreInfo( 125 "http://developer.android.com/design/style/iconography.html"); //$NON-NLS-1$ 126 127 /** Inconsistent dip size across densities */ 128 public static final Issue ICON_DIP_SIZE = Issue.create( 129 "IconDipSize", //$NON-NLS-1$ 130 "Ensures that icons across densities provide roughly the same density-independent size", 131 "Checks the all icons which are provided in multiple densities, all compute to " + 132 "roughly the same density-independent pixel (dip) size. This catches errors where " + 133 "images are either placed in the wrong folder, or icons are changed to new sizes " + 134 "but some folders are forgotten.", 135 Category.ICONS, 136 5, 137 Severity.WARNING, 138 IconDetector.class, 139 Scope.ALL_RESOURCES_SCOPE); 140 141 /** Images in res/drawable folder */ 142 public static final Issue ICON_LOCATION = Issue.create( 143 "IconLocation", //$NON-NLS-1$ 144 "Ensures that images are not defined in the density-independent drawable folder", 145 "The res/drawable folder is intended for density-independent graphics such as " + 146 "shapes defined in XML. For bitmaps, move it to drawable-mdpi and consider " + 147 "providing higher and lower resolution versions in drawable-ldpi, drawable-hdpi " + 148 "and drawable-xhdpi. If the icon *really* is density independent (for example " + 149 "a solid color) you can place it in drawable-nodpi.", 150 Category.ICONS, 151 5, 152 Severity.WARNING, 153 IconDetector.class, 154 Scope.ALL_RESOURCES_SCOPE).setMoreInfo( 155 "http://developer.android.com/guide/practices/screens_support.html"); //$NON-NLS-1$ 156 157 /** Missing density versions of image */ 158 public static final Issue ICON_DENSITIES = Issue.create( 159 "IconDensities", //$NON-NLS-1$ 160 "Ensures that icons provide custom versions for all supported densities", 161 "Icons will look best if a custom version is provided for each of the " + 162 "major screen density classes (low, medium, high, extra high). " + 163 "This lint check identifies icons which do not have complete coverage " + 164 "across the densities.\n" + 165 "\n" + 166 "Low density is not really used much anymore, so this check ignores " + 167 "the ldpi density. To force lint to include it, set the environment " + 168 "variable ANDROID_LINT_INCLUDE_LDPI=true. For more information on " + 169 "current density usage, see " + 170 "http://developer.android.com/resources/dashboard/screens.html", 171 Category.ICONS, 172 4, 173 Severity.WARNING, 174 IconDetector.class, 175 Scope.ALL_RESOURCES_SCOPE).setMoreInfo( 176 "http://developer.android.com/guide/practices/screens_support.html"); //$NON-NLS-1$ 177 178 /** Missing density folders */ 179 public static final Issue ICON_MISSING_FOLDER = Issue.create( 180 "IconMissingDensityFolder", //$NON-NLS-1$ 181 "Ensures that all the density folders are present", 182 "Icons will look best if a custom version is provided for each of the " + 183 "major screen density classes (low, medium, high, extra high). " + 184 "This lint check identifies folders which are missing, such as drawable-hdpi." + 185 "\n" + 186 "Low density is not really used much anymore, so this check ignores " + 187 "the ldpi density. To force lint to include it, set the environment " + 188 "variable ANDROID_LINT_INCLUDE_LDPI=true. For more information on " + 189 "current density usage, see " + 190 "http://developer.android.com/resources/dashboard/screens.html", 191 Category.ICONS, 192 3, 193 Severity.WARNING, 194 IconDetector.class, 195 Scope.ALL_RESOURCES_SCOPE).setMoreInfo( 196 "http://developer.android.com/guide/practices/screens_support.html"); //$NON-NLS-1$ 197 198 /** Using .gif bitmaps */ 199 public static final Issue GIF_USAGE = Issue.create( 200 "GifUsage", //$NON-NLS-1$ 201 "Checks for images using the GIF file format which is discouraged", 202 "The .gif file format is discouraged. Consider using .png (preferred) " + 203 "or .jpg (acceptable) instead.", 204 Category.ICONS, 205 5, 206 Severity.WARNING, 207 IconDetector.class, 208 Scope.ALL_RESOURCES_SCOPE).setMoreInfo( 209 "http://developer.android.com/guide/topics/resources/drawable-resource.html#Bitmap"); //$NON-NLS-1$ 210 211 /** Duplicated icons across different names */ 212 public static final Issue DUPLICATES_NAMES = Issue.create( 213 "IconDuplicates", //$NON-NLS-1$ 214 "Finds duplicated icons under different names", 215 "If an icon is repeated under different names, you can consolidate and just " + 216 "use one of the icons and delete the others to make your application smaller. " + 217 "However, duplicated icons usually are not intentional and can sometimes point " + 218 "to icons that were accidentally overwritten or accidentally not updated.", 219 Category.ICONS, 220 3, 221 Severity.WARNING, 222 IconDetector.class, 223 Scope.ALL_RESOURCES_SCOPE); 224 225 /** Duplicated contents across configurations for a given name */ 226 public static final Issue DUPLICATES_CONFIGURATIONS = Issue.create( 227 "IconDuplicatesConfig", //$NON-NLS-1$ 228 "Finds icons that have identical bitmaps across various configuration parameters", 229 "If an icon is provided under different configuration parameters such as " + 230 "drawable-hdpi or -v11, they should typically be different. This detector " + 231 "catches cases where the same icon is provided in different configuration folder " + 232 "which is usually not intentional.", 233 Category.ICONS, 234 5, 235 Severity.WARNING, 236 IconDetector.class, 237 Scope.ALL_RESOURCES_SCOPE); 238 239 /** Icons appearing in both -nodpi and a -Ndpi folder */ 240 public static final Issue ICON_NODPI = Issue.create( 241 "IconNoDpi", //$NON-NLS-1$ 242 "Finds icons that appear in both a -nodpi folder and a dpi folder", 243 "Bitmaps that appear in drawable-nodpi folders will not be scaled by the " + 244 "Android framework. If a drawable resource of the same name appears *both* in " + 245 "a -nodpi folder as well as a dpi folder such as drawable-hdpi, then " + 246 "the behavior is ambiguous and probably not intentional. Delete one or the " + 247 "other, or use different names for the icons.", 248 Category.ICONS, 249 7, 250 Severity.WARNING, 251 IconDetector.class, 252 Scope.ALL_RESOURCES_SCOPE); 253 254 private String mApplicationIcon; 255 256 /** Constructs a new {@link IconDetector} check */ 257 public IconDetector() { 258 } 259 260 @Override 261 public Speed getSpeed() { 262 return Speed.SLOW; 263 } 264 265 @Override 266 public void beforeCheckProject(Context context) { 267 mApplicationIcon = null; 268 } 269 270 @Override 271 public void afterCheckLibraryProject(Context context) { 272 checkResourceFolder(context, context.getProject().getDir()); 273 } 274 275 @Override 276 public void afterCheckProject(Context context) { 277 checkResourceFolder(context, context.getProject().getDir()); 278 } 279 280 private void checkResourceFolder(Context context, File dir) { 281 File res = new File(dir, RES_FOLDER); 282 if (res.isDirectory()) { 283 File[] folders = res.listFiles(); 284 if (folders != null) { 285 boolean checkFolders = context.isEnabled(ICON_DENSITIES) 286 || context.isEnabled(ICON_MISSING_FOLDER) 287 || context.isEnabled(ICON_NODPI); 288 boolean checkDipSizes = context.isEnabled(ICON_DIP_SIZE); 289 boolean checkDuplicates = context.isEnabled(DUPLICATES_NAMES) 290 || context.isEnabled(DUPLICATES_CONFIGURATIONS); 291 292 Map<File, Dimension> pixelSizes = null; 293 Map<File, Long> fileSizes = null; 294 if (checkDipSizes || checkDuplicates) { 295 pixelSizes = new HashMap<File, Dimension>(); 296 fileSizes = new HashMap<File, Long>(); 297 } 298 Map<File, Set<String>> folderToNames = new HashMap<File, Set<String>>(); 299 for (File folder : folders) { 300 String folderName = folder.getName(); 301 if (folderName.startsWith(DRAWABLE_FOLDER)) { 302 File[] files = folder.listFiles(); 303 if (files != null) { 304 checkDrawableDir(context, folder, files, pixelSizes, fileSizes); 305 306 if (checkFolders && DENSITY_PATTERN.matcher(folderName).matches()) { 307 Set<String> names = new HashSet<String>(files.length); 308 for (File f : files) { 309 String name = f.getName(); 310 if (isDrawableFile(name)) { 311 names.add(f.getName()); 312 } 313 } 314 folderToNames.put(folder, names); 315 } 316 } 317 } 318 } 319 320 if (checkDipSizes) { 321 checkDipSizes(context, pixelSizes); 322 } 323 324 if (checkDuplicates) { 325 checkDuplicates(context, pixelSizes, fileSizes); 326 } 327 328 if (checkFolders && folderToNames.size() > 0) { 329 checkDensities(context, res, folderToNames); 330 } 331 } 332 } 333 } 334 335 private static boolean isDrawableFile(String name) { 336 // endsWith(name, DOT_PNG) is also true for endsWith(name, DOT_9PNG) 337 return endsWith(name, DOT_PNG)|| endsWith(name, DOT_JPG) || endsWith(name, DOT_GIF) || 338 endsWith(name, DOT_XML); 339 } 340 341 // This method looks for duplicates in the assets. This uses two pieces of information 342 // (file sizes and image dimensions) to quickly reject candidates, such that it only 343 // needs to check actual file contents on a small subset of the available files. 344 private void checkDuplicates(Context context, Map<File, Dimension> pixelSizes, 345 Map<File, Long> fileSizes) { 346 Map<Long, Set<File>> sameSizes = new HashMap<Long, Set<File>>(); 347 Map<Long, File> seenSizes = new HashMap<Long, File>(fileSizes.size()); 348 for (Map.Entry<File, Long> entry : fileSizes.entrySet()) { 349 File file = entry.getKey(); 350 Long size = entry.getValue(); 351 if (seenSizes.containsKey(size)) { 352 Set<File> set = sameSizes.get(size); 353 if (set == null) { 354 set = new HashSet<File>(); 355 set.add(seenSizes.get(size)); 356 sameSizes.put(size, set); 357 } 358 set.add(file); 359 } else { 360 seenSizes.put(size, file); 361 } 362 } 363 364 if (sameSizes.size() == 0) { 365 return; 366 } 367 368 // Now go through the files that have the same size and check to see if we can 369 // split them apart based on image dimensions 370 // Note: we may not have file sizes on all the icons; in particular, 371 // we don't have file sizes for ninepatch files. 372 Collection<Set<File>> candidateLists = sameSizes.values(); 373 for (Set<File> candidates : candidateLists) { 374 Map<Dimension, Set<File>> sameDimensions = new HashMap<Dimension, Set<File>>( 375 candidates.size()); 376 List<File> noSize = new ArrayList<File>(); 377 for (File file : candidates) { 378 Dimension dimension = pixelSizes.get(file); 379 if (dimension != null) { 380 Set<File> set = sameDimensions.get(dimension); 381 if (set == null) { 382 set = new HashSet<File>(); 383 sameDimensions.put(dimension, set); 384 } 385 set.add(file); 386 } else { 387 noSize.add(file); 388 } 389 } 390 391 392 // Files that we have no dimensions for must be compared against everything 393 Collection<Set<File>> sets = sameDimensions.values(); 394 if (noSize.size() > 0) { 395 if (sets.size() > 0) { 396 for (Set<File> set : sets) { 397 set.addAll(noSize); 398 } 399 } else { 400 // Must just test the noSize elements against themselves 401 HashSet<File> noSizeSet = new HashSet<File>(noSize); 402 sets = Collections.<Set<File>>singletonList(noSizeSet); 403 } 404 } 405 406 // Map from file to actual byte contents of the file. 407 // We store this in a map such that for repeated files, such as noSize files 408 // which can appear in multiple buckets, we only need to read them once 409 Map<File, byte[]> fileContents = new HashMap<File, byte[]>(); 410 411 // Now we're ready for the final check where we actually check the 412 // bits. We have to partition the files into buckets of files that 413 // are identical. 414 for (Set<File> set : sets) { 415 if (set.size() < 2) { 416 continue; 417 } 418 419 // Read all files in this set and store in map 420 for (File file : set) { 421 byte[] bits = fileContents.get(file); 422 if (bits == null) { 423 try { 424 bits = Files.toByteArray(file); 425 fileContents.put(file, bits); 426 } catch (IOException e) { 427 context.log(e, null); 428 } 429 } 430 } 431 432 // Map where the key file is known to be equal to the value file. 433 // After we check individual files for equality this will be used 434 // to look for transitive equality. 435 Map<File, File> equal = new HashMap<File, File>(); 436 437 // Now go and compare all the files. This isn't an efficient algorithm 438 // but the number of candidates should be very small 439 440 List<File> files = new ArrayList<File>(set); 441 Collections.sort(files); 442 for (int i = 0; i < files.size() - 1; i++) { 443 for (int j = i + 1; j < files.size(); j++) { 444 File file1 = files.get(i); 445 File file2 = files.get(j); 446 byte[] contents1 = fileContents.get(file1); 447 byte[] contents2 = fileContents.get(file2); 448 if (contents1 == null || contents2 == null) { 449 // File couldn't be read: ignore 450 continue; 451 } 452 if (contents1.length != contents2.length) { 453 // Sizes differ: not identical. 454 // This shouldn't happen since we've already partitioned based 455 // on File.length(), but just make sure here since the file 456 // system could have lied, or cached a value that has changed 457 // if the file was just overwritten 458 continue; 459 } 460 boolean same = true; 461 for (int k = 0; k < contents1.length; k++) { 462 if (contents1[k] != contents2[k]) { 463 same = false; 464 break; 465 } 466 } 467 if (same) { 468 equal.put(file1, file2); 469 } 470 } 471 } 472 473 if (equal.size() > 0) { 474 Map<File, Set<File>> partitions = new HashMap<File, Set<File>>(); 475 List<Set<File>> sameSets = new ArrayList<Set<File>>(); 476 for (Map.Entry<File, File> entry : equal.entrySet()) { 477 File file1 = entry.getKey(); 478 File file2 = entry.getValue(); 479 Set<File> set1 = partitions.get(file1); 480 Set<File> set2 = partitions.get(file2); 481 if (set1 != null) { 482 set1.add(file2); 483 } else if (set2 != null) { 484 set2.add(file1); 485 } else { 486 set = new HashSet<File>(); 487 sameSets.add(set); 488 set.add(file1); 489 set.add(file2); 490 partitions.put(file1, set); 491 partitions.put(file2, set); 492 } 493 } 494 495 // We've computed the partitions of equal files. Now sort them 496 // for stable output. 497 List<List<File>> lists = new ArrayList<List<File>>(); 498 for (Set<File> same : sameSets) { 499 assert same.size() > 0; 500 ArrayList<File> sorted = new ArrayList<File>(same); 501 Collections.sort(sorted); 502 lists.add(sorted); 503 } 504 // Sort overall partitions by the first item in each list 505 Collections.sort(lists, new Comparator<List<File>>() { 506 @Override 507 public int compare(List<File> list1, List<File> list2) { 508 return list1.get(0).compareTo(list2.get(0)); 509 } 510 }); 511 512 for (List<File> sameFiles : lists) { 513 Location location = null; 514 boolean sameNames = true; 515 String lastName = null; 516 for (File file : sameFiles) { 517 if (lastName != null && !lastName.equals(file.getName())) { 518 sameNames = false; 519 } 520 lastName = file.getName(); 521 // Chain locations together 522 Location linkedLocation = location; 523 location = Location.create(file); 524 location.setSecondary(linkedLocation); 525 } 526 527 if (sameNames) { 528 StringBuilder sb = new StringBuilder(); 529 for (File file : sameFiles) { 530 if (sb.length() > 0) { 531 sb.append(", "); //$NON-NLS-1$ 532 } 533 sb.append(file.getParentFile().getName()); 534 } 535 String message = String.format( 536 "The %1$s icon has identical contents in the following configuration folders: %2$s", 537 lastName, sb.toString()); 538 context.report(DUPLICATES_CONFIGURATIONS, location, message, null); 539 } else { 540 StringBuilder sb = new StringBuilder(); 541 for (File file : sameFiles) { 542 if (sb.length() > 0) { 543 sb.append(", "); //$NON-NLS-1$ 544 } 545 sb.append(file.getName()); 546 } 547 String message = String.format( 548 "The following unrelated icon files have identical contents: %1$s", 549 sb.toString()); 550 context.report(DUPLICATES_NAMES, location, message, null); 551 } 552 } 553 } 554 } 555 } 556 557 } 558 559 // This method checks the given map from resource file to pixel dimensions for each 560 // such image and makes sure that the normalized dip sizes across all the densities 561 // are mostly the same. 562 private void checkDipSizes(Context context, Map<File, Dimension> pixelSizes) { 563 // Partition up the files such that I can look at a series by name. This 564 // creates a map from filename (such as foo.png) to a list of files 565 // providing that icon in various folders: drawable-mdpi/foo.png, drawable-hdpi/foo.png 566 // etc. 567 Map<String, List<File>> nameToFiles = new HashMap<String, List<File>>(); 568 for (File file : pixelSizes.keySet()) { 569 String name = file.getName(); 570 List<File> list = nameToFiles.get(name); 571 if (list == null) { 572 list = new ArrayList<File>(); 573 nameToFiles.put(name, list); 574 } 575 list.add(file); 576 } 577 578 ArrayList<String> names = new ArrayList<String>(nameToFiles.keySet()); 579 Collections.sort(names); 580 581 // We have to partition the files further because it's possible for the project 582 // to have different configurations for an icon, such as this: 583 // drawable-large-hdpi/foo.png, drawable-large-mdpi/foo.png, 584 // drawable-hdpi/foo.png, drawable-mdpi/foo.png, 585 // drawable-hdpi-v11/foo.png and drawable-mdpi-v11/foo.png. 586 // In this case we don't want to compare across categories; we want to 587 // ensure that the drawable-large-{density} icons are consistent, 588 // that the drawable-{density}-v11 icons are consistent, and that 589 // the drawable-{density} icons are consistent. 590 591 // Map from name to list of map from parent folder to list of files 592 Map<String, Map<String, List<File>>> configMap = 593 new HashMap<String, Map<String,List<File>>>(); 594 for (Map.Entry<String, List<File>> entry : nameToFiles.entrySet()) { 595 String name = entry.getKey(); 596 List<File> files = entry.getValue(); 597 for (File file : files) { 598 String parentName = file.getParentFile().getName(); 599 // Strip out the density part 600 int index = -1; 601 for (String qualifier : DENSITY_QUALIFIERS) { 602 index = parentName.indexOf(qualifier); 603 if (index != -1) { 604 parentName = parentName.substring(0, index) 605 + parentName.substring(index + qualifier.length()); 606 break; 607 } 608 } 609 if (index == -1) { 610 // No relevant qualifier found in the parent directory name, 611 // e.g. it's just "drawable" or something like "drawable-nodpi". 612 continue; 613 } 614 615 Map<String, List<File>> folderMap = configMap.get(name); 616 if (folderMap == null) { 617 folderMap = new HashMap<String,List<File>>(); 618 configMap.put(name, folderMap); 619 } 620 // Map from name to a map from parent folder to files 621 List<File> list = folderMap.get(parentName); 622 if (list == null) { 623 list = new ArrayList<File>(); 624 folderMap.put(parentName, list); 625 } 626 list.add(file); 627 } 628 } 629 630 for (String name : names) { 631 //List<File> files = nameToFiles.get(name); 632 Map<String, List<File>> configurations = configMap.get(name); 633 if (configurations == null) { 634 // Nothing in this configuration: probably only found in drawable/ or 635 // drawable-nodpi etc directories. 636 continue; 637 } 638 639 for (Map.Entry<String, List<File>> entry : configurations.entrySet()) { 640 List<File> files = entry.getValue(); 641 642 // Ensure that all the dip sizes are *roughly* the same 643 Map<File, Dimension> dipSizes = new HashMap<File, Dimension>(); 644 int dipWidthSum = 0; // Incremental computation of average 645 int dipHeightSum = 0; // Incremental computation of average 646 int count = 0; 647 for (File file : files) { 648 float factor = getMdpiScalingFactor(file.getParentFile().getName()); 649 if (factor > 0) { 650 Dimension size = pixelSizes.get(file); 651 Dimension dip = new Dimension( 652 Math.round(size.width / factor), 653 Math.round(size.height / factor)); 654 dipWidthSum += dip.width; 655 dipHeightSum += dip.height; 656 dipSizes.put(file, dip); 657 count++; 658 } 659 } 660 if (count == 0) { 661 // Icons in drawable/ and drawable-nodpi/ 662 continue; 663 } 664 int meanWidth = dipWidthSum / count; 665 int meanHeight = dipHeightSum / count; 666 667 // Compute standard deviation? 668 int squareWidthSum = 0; 669 int squareHeightSum = 0; 670 for (Dimension size : dipSizes.values()) { 671 squareWidthSum += (size.width - meanWidth) * (size.width - meanWidth); 672 squareHeightSum += (size.height - meanHeight) * (size.height - meanHeight); 673 } 674 double widthStdDev = Math.sqrt(squareWidthSum / count); 675 double heightStdDev = Math.sqrt(squareHeightSum / count); 676 677 if (widthStdDev > meanWidth / 10 || heightStdDev > meanHeight) { 678 Location location = null; 679 StringBuilder sb = new StringBuilder(); 680 681 // Sort entries by decreasing dip size 682 List<Map.Entry<File, Dimension>> entries = 683 new ArrayList<Map.Entry<File,Dimension>>(); 684 for (Map.Entry<File, Dimension> entry2 : dipSizes.entrySet()) { 685 entries.add(entry2); 686 } 687 Collections.sort(entries, 688 new Comparator<Map.Entry<File, Dimension>>() { 689 @Override 690 public int compare(Entry<File, Dimension> e1, 691 Entry<File, Dimension> e2) { 692 Dimension d1 = e1.getValue(); 693 Dimension d2 = e2.getValue(); 694 if (d1.width != d2.width) { 695 return d2.width - d1.width; 696 } 697 698 return d2.height - d1.height; 699 } 700 }); 701 for (Map.Entry<File, Dimension> entry2 : entries) { 702 if (sb.length() > 0) { 703 sb.append(", "); 704 } 705 File file = entry2.getKey(); 706 707 // Chain locations together 708 Location linkedLocation = location; 709 location = Location.create(file); 710 location.setSecondary(linkedLocation); 711 Dimension dip = entry2.getValue(); 712 Dimension px = pixelSizes.get(file); 713 String fileName = file.getParentFile().getName() + File.separator 714 + file.getName(); 715 sb.append(String.format("%1$s: %2$dx%3$d dp (%4$dx%5$d px)", 716 fileName, dip.width, dip.height, px.width, px.height)); 717 } 718 String message = String.format( 719 "The image %1$s varies significantly in its density-independent (dip) " + 720 "size across the various density versions: %2$s", 721 name, sb.toString()); 722 context.report(ICON_DIP_SIZE, location, message, null); 723 } 724 } 725 } 726 } 727 728 private void checkDensities(Context context, File res, Map<File, Set<String>> folderToNames) { 729 // TODO: Is there a way to look at the manifest and figure out whether 730 // all densities are expected to be needed? 731 // Note: ldpi is probably not needed; it has very little usage 732 // (about 2%; http://developer.android.com/resources/dashboard/screens.html) 733 // TODO: Use the matrix to check out if we can eliminate densities based 734 // on the target screens? 735 736 Set<String> definedDensities = new HashSet<String>(); 737 for (File f : folderToNames.keySet()) { 738 definedDensities.add(f.getName()); 739 } 740 741 // Look for missing folders -- if you define say drawable-mdpi then you 742 // should also define -hdpi and -xhdpi. 743 if (context.isEnabled(ICON_MISSING_FOLDER)) { 744 List<String> missing = new ArrayList<String>(); 745 for (String density : REQUIRED_DENSITIES) { 746 if (!definedDensities.contains(density)) { 747 missing.add(density); 748 } 749 } 750 if (missing.size() > 0 ) { 751 context.report( 752 ICON_MISSING_FOLDER, 753 Location.create(res), 754 String.format("Missing density variation folders in %1$s: %2$s", 755 context.getProject().getDisplayPath(res), 756 LintUtils.formatList(missing, -1)), 757 null); 758 } 759 } 760 761 if (context.isEnabled(ICON_NODPI)) { 762 Set<String> noDpiNames = new HashSet<String>(); 763 for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) { 764 if (isNoDpiFolder(entry.getKey())) { 765 noDpiNames.addAll(entry.getValue()); 766 } 767 } 768 if (noDpiNames.size() > 0) { 769 // Make sure that none of the nodpi names appear in a non-nodpi folder 770 Set<String> inBoth = new HashSet<String>(); 771 List<File> files = new ArrayList<File>(); 772 for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) { 773 File folder = entry.getKey(); 774 String folderName = folder.getName(); 775 if (!isNoDpiFolder(folder)) { 776 assert DENSITY_PATTERN.matcher(folderName).matches(); 777 Set<String> overlap = nameIntersection(noDpiNames, entry.getValue()); 778 inBoth.addAll(overlap); 779 for (String name : overlap) { 780 files.add(new File(folder, name)); 781 } 782 } 783 } 784 785 if (inBoth.size() > 0) { 786 List<String> list = new ArrayList<String>(inBoth); 787 Collections.sort(list); 788 789 // Chain locations together 790 Collections.sort(files); 791 Location location = null; 792 for (File file : files) { 793 Location linkedLocation = location; 794 location = Location.create(file); 795 location.setSecondary(linkedLocation); 796 } 797 798 context.report(ICON_NODPI, location, 799 String.format( 800 "The following images appear in both -nodpi and in a density folder: %1$s", 801 LintUtils.formatList(list, 10)), 802 null); 803 } 804 } 805 } 806 807 if (context.isEnabled(ICON_DENSITIES)) { 808 // Look for folders missing some of the specific assets 809 Set<String> allNames = new HashSet<String>(); 810 for (Entry<File,Set<String>> entry : folderToNames.entrySet()) { 811 if (!isNoDpiFolder(entry.getKey())) { 812 Set<String> names = entry.getValue(); 813 allNames.addAll(names); 814 } 815 } 816 817 for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) { 818 File file = entry.getKey(); 819 if (isNoDpiFolder(file)) { 820 continue; 821 } 822 Set<String> names = entry.getValue(); 823 if (names.size() != allNames.size()) { 824 List<String> delta = new ArrayList<String>(nameDifferences(allNames, names)); 825 if (delta.size() == 0) { 826 continue; 827 } 828 Collections.sort(delta); 829 String foundIn = ""; 830 if (delta.size() == 1) { 831 // Produce list of where the icon is actually defined 832 List<String> defined = new ArrayList<String>(); 833 String name = delta.get(0); 834 for (Map.Entry<File, Set<String>> e : folderToNames.entrySet()) { 835 if (e.getValue().contains(name)) { 836 defined.add(e.getKey().getName()); 837 } 838 } 839 if (defined.size() > 0) { 840 foundIn = String.format(" (found in %1$s)", 841 LintUtils.formatList(defined, 5)); 842 } 843 } 844 845 context.report(ICON_DENSITIES, Location.create(file), 846 String.format( 847 "Missing the following drawables in %1$s: %2$s%3$s", 848 file.getName(), 849 LintUtils.formatList(delta, 5), 850 foundIn), 851 null); 852 } 853 } 854 } 855 } 856 857 /** 858 * Compute the difference in names between a and b. This is not just 859 * Sets.difference(a, b) because we want to make the comparisons <b>without 860 * file extensions</b> and return the result <b>with</b>.. 861 */ 862 private Set<String> nameDifferences(Set<String> a, Set<String> b) { 863 Set<String> names1 = new HashSet<String>(a.size()); 864 for (String s : a) { 865 names1.add(LintUtils.getBaseName(s)); 866 } 867 Set<String> names2 = new HashSet<String>(b.size()); 868 for (String s : b) { 869 names2.add(LintUtils.getBaseName(s)); 870 } 871 872 names1.removeAll(names2); 873 874 if (names1.size() > 0) { 875 // Map filenames back to original filenames with extensions 876 Set<String> result = new HashSet<String>(names1.size()); 877 for (String s : a) { 878 if (names1.contains(LintUtils.getBaseName(s))) { 879 result.add(s); 880 } 881 } 882 for (String s : b) { 883 if (names1.contains(LintUtils.getBaseName(s))) { 884 result.add(s); 885 } 886 } 887 888 return result; 889 } 890 891 return Collections.emptySet(); 892 } 893 894 /** 895 * Compute the intersection in names between a and b. This is not just 896 * Sets.intersection(a, b) because we want to make the comparisons <b>without 897 * file extensions</b> and return the result <b>with</b>. 898 */ 899 private Set<String> nameIntersection(Set<String> a, Set<String> b) { 900 Set<String> names1 = new HashSet<String>(a.size()); 901 for (String s : a) { 902 names1.add(LintUtils.getBaseName(s)); 903 } 904 Set<String> names2 = new HashSet<String>(b.size()); 905 for (String s : b) { 906 names2.add(LintUtils.getBaseName(s)); 907 } 908 909 names1.retainAll(names2); 910 911 if (names1.size() > 0) { 912 // Map filenames back to original filenames with extensions 913 Set<String> result = new HashSet<String>(names1.size()); 914 for (String s : a) { 915 if (names1.contains(LintUtils.getBaseName(s))) { 916 result.add(s); 917 } 918 } 919 for (String s : b) { 920 if (names1.contains(LintUtils.getBaseName(s))) { 921 result.add(s); 922 } 923 } 924 925 return result; 926 } 927 928 return Collections.emptySet(); 929 } 930 931 private static boolean isNoDpiFolder(File file) { 932 return file.getName().contains("-nodpi"); 933 } 934 935 private void checkDrawableDir(Context context, File folder, File[] files, 936 Map<File, Dimension> pixelSizes, Map<File, Long> fileSizes) { 937 if (folder.getName().equals(DRAWABLE_FOLDER) 938 && context.isEnabled(ICON_LOCATION) && 939 // If supporting older versions than Android 1.6, it's not an error 940 // to include bitmaps in drawable/ 941 context.getProject().getMinSdk() >= 4) { 942 for (File file : files) { 943 String name = file.getName(); 944 if (name.endsWith(DOT_XML)) { 945 // pass - most common case, avoids checking other extensions 946 } else if (endsWith(name, DOT_PNG) 947 || endsWith(name, DOT_JPG) 948 || endsWith(name, DOT_GIF)) { 949 context.report(ICON_LOCATION, 950 Location.create(file), 951 String.format("Found bitmap drawable res/drawable/%1$s in " + 952 "densityless folder", 953 file.getName()), 954 null); 955 } 956 } 957 } 958 959 if (context.isEnabled(GIF_USAGE)) { 960 for (File file : files) { 961 String name = file.getName(); 962 if (endsWith(name, DOT_GIF)) { 963 context.report(GIF_USAGE, Location.create(file), 964 "Using the .gif format for bitmaps is discouraged", 965 null); 966 } 967 } 968 } 969 970 // Check icon sizes 971 if (context.isEnabled(ICON_EXPECTED_SIZE)) { 972 checkExpectedSizes(context, folder, files); 973 } 974 975 if (pixelSizes != null || fileSizes != null) { 976 for (File file : files) { 977 // TODO: Combine this check with the check for expected sizes such that 978 // I don't check file sizes twice! 979 String fileName = file.getName(); 980 981 if (endsWith(fileName, DOT_PNG) || endsWith(fileName, DOT_JPG)) { 982 // Only scan .png files (except 9-patch png's) and jpg files for 983 // dip sizes. Duplicate checks can also be performed on ninepatch files. 984 if (pixelSizes != null && !endsWith(fileName, DOT_9PNG)) { 985 Dimension size = getSize(file); 986 pixelSizes.put(file, size); 987 } 988 if (fileSizes != null) { 989 fileSizes.put(file, file.length()); 990 } 991 } 992 } 993 } 994 } 995 996 private void checkExpectedSizes(Context context, File folder, File[] files) { 997 String folderName = folder.getName(); 998 999 int folderVersion = -1; 1000 String[] qualifiers = folderName.split("-"); //$NON-NLS-1$ 1001 for (String qualifier : qualifiers) { 1002 if (qualifier.startsWith("v")) { 1003 Matcher matcher = VERSION_PATTERN.matcher(qualifier); 1004 if (matcher.matches()) { 1005 folderVersion = Integer.parseInt(matcher.group(1)); 1006 } 1007 } 1008 } 1009 1010 for (File file : files) { 1011 String name = file.getName(); 1012 1013 // TODO: Look up exact app icon from the manifest rather than simply relying on 1014 // the naming conventions described here: 1015 // http://developer.android.com/guide/practices/ui_guidelines/icon_design.html#design-tips 1016 // See if we can figure out other types of icons from usage too. 1017 1018 String baseName = name; 1019 int index = baseName.indexOf('.'); 1020 if (index != -1) { 1021 baseName = baseName.substring(0, index); 1022 } 1023 1024 if (baseName.equals(mApplicationIcon) || name.startsWith("ic_launcher")) { //$NON-NLS-1$ 1025 // Launcher icons 1026 checkSize(context, folderName, file, 48, 48, true /*exact*/); 1027 } else if (name.startsWith("ic_action_")) { //$NON-NLS-1$ 1028 // Action Bar 1029 checkSize(context, folderName, file, 32, 32, true /*exact*/); 1030 } else if (name.startsWith("ic_dialog_")) { //$NON-NLS-1$ 1031 // Dialog 1032 checkSize(context, folderName, file, 32, 32, true /*exact*/); 1033 } else if (name.startsWith("ic_tab_")) { //$NON-NLS-1$ 1034 // Tab icons 1035 checkSize(context, folderName, file, 32, 32, true /*exact*/); 1036 } else if (name.startsWith("ic_stat_")) { //$NON-NLS-1$ 1037 // Notification icons 1038 1039 if (isAndroid30(context, folderVersion)) { 1040 checkSize(context, folderName, file, 24, 24, true /*exact*/); 1041 } else if (isAndroid23(context, folderVersion)) { 1042 checkSize(context, folderName, file, 16, 25, false /*exact*/); 1043 } else { 1044 // Android 2.2 or earlier 1045 // TODO: Should this be done for each folder size? 1046 checkSize(context, folderName, file, 25, 25, true /*exact*/); 1047 } 1048 } else if (name.startsWith("ic_menu_")) { //$NON-NLS-1$ 1049 // Menu icons (<=2.3 only: Replaced by action bar icons (ic_action_ in 3.0). 1050 if (isAndroid23(context, folderVersion)) { 1051 // The icon should be 32x32 inside the transparent image; should 1052 // we check that this is mostly the case (a few pixels are allowed to 1053 // overlap for anti-aliasing etc) 1054 checkSize(context, folderName, file, 48, 48, true /*exact*/); 1055 } else { 1056 // Android 2.2 or earlier 1057 // TODO: Should this be done for each folder size? 1058 checkSize(context, folderName, file, 48, 48, true /*exact*/); 1059 } 1060 } 1061 // TODO: ListView icons? 1062 } 1063 } 1064 1065 /** 1066 * Is this drawable folder for an Android 3.0 drawable? This will be the 1067 * case if it specifies -v11+, or if the minimum SDK version declared in the 1068 * manifest is at least 11. 1069 */ 1070 private boolean isAndroid30(Context context, int folderVersion) { 1071 return folderVersion >= 11 || context.getMainProject().getMinSdk() >= 11; 1072 } 1073 1074 /** 1075 * Is this drawable folder for an Android 2.3 drawable? This will be the 1076 * case if it specifies -v9 or -v10, or if the minimum SDK version declared in the 1077 * manifest is 9 or 10 (and it does not specify some higher version like -v11 1078 */ 1079 private boolean isAndroid23(Context context, int folderVersion) { 1080 if (isAndroid30(context, folderVersion)) { 1081 return false; 1082 } 1083 1084 if (folderVersion == 9 || folderVersion == 10) { 1085 return true; 1086 } 1087 1088 int minSdk = context.getMainProject().getMinSdk(); 1089 1090 return minSdk == 9 || minSdk == 10; 1091 } 1092 1093 private float getMdpiScalingFactor(String folderName) { 1094 // Can't do startsWith(DRAWABLE_MDPI) because the folder could 1095 // be something like "drawable-sw600dp-mdpi". 1096 if (folderName.contains("-mdpi")) { //$NON-NLS-1$ 1097 return 1.0f; 1098 } else if (folderName.contains("-hdpi")) { //$NON-NLS-1$ 1099 return 1.5f; 1100 } else if (folderName.contains("-xhdpi")) { //$NON-NLS-1$ 1101 return 2.0f; 1102 } else if (folderName.contains("-ldpi")) { //$NON-NLS-1$ 1103 return 0.75f; 1104 } else { 1105 return 0f; 1106 } 1107 } 1108 1109 private void checkSize(Context context, String folderName, File file, 1110 int mdpiWidth, int mdpiHeight, boolean exactMatch) { 1111 String fileName = file.getName(); 1112 // Only scan .png files (except 9-patch png's) and jpg files 1113 if (!((endsWith(fileName, DOT_PNG) && !endsWith(fileName, DOT_9PNG)) || 1114 endsWith(fileName, DOT_JPG))) { 1115 return; 1116 } 1117 1118 int width = -1; 1119 int height = -1; 1120 // Use 3:4:6:8 scaling ratio to look up the other expected sizes 1121 if (folderName.startsWith(DRAWABLE_MDPI)) { 1122 width = mdpiWidth; 1123 height = mdpiHeight; 1124 } else if (folderName.startsWith(DRAWABLE_HDPI)) { 1125 // Perform math using floating point; if we just do 1126 // width = mdpiWidth * 3 / 2; 1127 // then for mdpiWidth = 25 (as in notification icons on pre-GB) we end up 1128 // with width = 37, instead of 38 (with floating point rounding we get 37.5 = 38) 1129 width = Math.round(mdpiWidth * 3.f / 2); 1130 height = Math.round(mdpiHeight * 3f / 2); 1131 } else if (folderName.startsWith(DRAWABLE_XHDPI)) { 1132 width = mdpiWidth * 2; 1133 height = mdpiHeight * 2; 1134 } else if (folderName.startsWith(DRAWABLE_LDPI)) { 1135 width = Math.round(mdpiWidth * 3f / 4); 1136 height = Math.round(mdpiHeight * 3f / 4); 1137 } else { 1138 return; 1139 } 1140 1141 Dimension size = getSize(file); 1142 if (size != null) { 1143 if (exactMatch && size.width != width || size.height != height) { 1144 context.report( 1145 ICON_EXPECTED_SIZE, 1146 Location.create(file), 1147 String.format( 1148 "Incorrect icon size for %1$s: expected %2$dx%3$d, but was %4$dx%5$d", 1149 folderName + File.separator + file.getName(), 1150 width, height, size.width, size.height), 1151 null); 1152 } else if (!exactMatch && size.width > width || size.height > height) { 1153 context.report( 1154 ICON_EXPECTED_SIZE, 1155 Location.create(file), 1156 String.format( 1157 "Incorrect icon size for %1$s: icon size should be at most %2$dx%3$d, but was %4$dx%5$d", 1158 folderName + File.separator + file.getName(), 1159 width, height, size.width, size.height), 1160 null); 1161 } 1162 } 1163 } 1164 1165 private Dimension getSize(File file) { 1166 try { 1167 ImageInputStream input = ImageIO.createImageInputStream(file); 1168 if (input != null) { 1169 try { 1170 Iterator<ImageReader> readers = ImageIO.getImageReaders(input); 1171 if (readers.hasNext()) { 1172 ImageReader reader = readers.next(); 1173 try { 1174 reader.setInput(input); 1175 return new Dimension(reader.getWidth(0), reader.getHeight(0)); 1176 } finally { 1177 reader.dispose(); 1178 } 1179 } 1180 } finally { 1181 if (input != null) { 1182 input.close(); 1183 } 1184 } 1185 } 1186 1187 // Fallback: read the image using the normal means 1188 BufferedImage image = ImageIO.read(file); 1189 if (image != null) { 1190 return new Dimension(image.getWidth(), image.getHeight()); 1191 } else { 1192 return null; 1193 } 1194 } catch (IOException e) { 1195 // Pass -- we can't handle all image types, warn about those we can 1196 return null; 1197 } 1198 } 1199 1200 // XML detector: Skim manifest 1201 1202 @Override 1203 public boolean appliesTo(Context context, File file) { 1204 return file.getName().equals(ANDROID_MANIFEST_XML); 1205 } 1206 1207 @Override 1208 public Collection<String> getApplicableElements() { 1209 return Collections.singletonList(TAG_APPLICATION); 1210 } 1211 1212 @Override 1213 public void visitElement(XmlContext context, Element element) { 1214 assert element.getTagName().equals(TAG_APPLICATION); 1215 mApplicationIcon = element.getAttributeNS(ANDROID_URI, ATTR_ICON); 1216 if (mApplicationIcon.startsWith(DRAWABLE_RESOURCE_PREFIX)) { 1217 mApplicationIcon = mApplicationIcon.substring(DRAWABLE_RESOURCE_PREFIX.length()); 1218 } 1219 } 1220 } 1221