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.SdkConstants.ANDROID_MANIFEST_XML; 20 import static com.android.SdkConstants.ANDROID_URI; 21 import static com.android.SdkConstants.ATTR_ICON; 22 import static com.android.SdkConstants.DOT_9PNG; 23 import static com.android.SdkConstants.DOT_GIF; 24 import static com.android.SdkConstants.DOT_JPG; 25 import static com.android.SdkConstants.DOT_PNG; 26 import static com.android.SdkConstants.DOT_XML; 27 import static com.android.SdkConstants.DRAWABLE_FOLDER; 28 import static com.android.SdkConstants.DRAWABLE_HDPI; 29 import static com.android.SdkConstants.DRAWABLE_LDPI; 30 import static com.android.SdkConstants.DRAWABLE_MDPI; 31 import static com.android.SdkConstants.DRAWABLE_PREFIX; 32 import static com.android.SdkConstants.DRAWABLE_XHDPI; 33 import static com.android.SdkConstants.RES_FOLDER; 34 import static com.android.SdkConstants.TAG_APPLICATION; 35 import static com.android.tools.lint.detector.api.LintUtils.endsWith; 36 37 import com.android.annotations.NonNull; 38 import com.android.tools.lint.detector.api.Category; 39 import com.android.tools.lint.detector.api.Context; 40 import com.android.tools.lint.detector.api.Detector; 41 import com.android.tools.lint.detector.api.Issue; 42 import com.android.tools.lint.detector.api.LintUtils; 43 import com.android.tools.lint.detector.api.Location; 44 import com.android.tools.lint.detector.api.Scope; 45 import com.android.tools.lint.detector.api.Severity; 46 import com.android.tools.lint.detector.api.Speed; 47 import com.android.tools.lint.detector.api.XmlContext; 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 @NonNull Speed getSpeed() { 262 return Speed.SLOW; 263 } 264 265 @Override 266 public void beforeCheckProject(@NonNull Context context) { 267 mApplicationIcon = null; 268 } 269 270 @Override 271 public void afterCheckLibraryProject(@NonNull Context context) { 272 checkResourceFolder(context, context.getProject().getDir()); 273 } 274 275 @Override 276 public void afterCheckProject(@NonNull 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 = context.getClient().readBytes(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 if (size == null) { 652 continue; 653 } 654 Dimension dip = new Dimension( 655 Math.round(size.width / factor), 656 Math.round(size.height / factor)); 657 dipWidthSum += dip.width; 658 dipHeightSum += dip.height; 659 dipSizes.put(file, dip); 660 count++; 661 } 662 } 663 if (count == 0) { 664 // Icons in drawable/ and drawable-nodpi/ 665 continue; 666 } 667 int meanWidth = dipWidthSum / count; 668 int meanHeight = dipHeightSum / count; 669 670 // Compute standard deviation? 671 int squareWidthSum = 0; 672 int squareHeightSum = 0; 673 for (Dimension size : dipSizes.values()) { 674 squareWidthSum += (size.width - meanWidth) * (size.width - meanWidth); 675 squareHeightSum += (size.height - meanHeight) * (size.height - meanHeight); 676 } 677 double widthStdDev = Math.sqrt(squareWidthSum / count); 678 double heightStdDev = Math.sqrt(squareHeightSum / count); 679 680 if (widthStdDev > meanWidth / 10 || heightStdDev > meanHeight) { 681 Location location = null; 682 StringBuilder sb = new StringBuilder(); 683 684 // Sort entries by decreasing dip size 685 List<Map.Entry<File, Dimension>> entries = 686 new ArrayList<Map.Entry<File,Dimension>>(); 687 for (Map.Entry<File, Dimension> entry2 : dipSizes.entrySet()) { 688 entries.add(entry2); 689 } 690 Collections.sort(entries, 691 new Comparator<Map.Entry<File, Dimension>>() { 692 @Override 693 public int compare(Entry<File, Dimension> e1, 694 Entry<File, Dimension> e2) { 695 Dimension d1 = e1.getValue(); 696 Dimension d2 = e2.getValue(); 697 if (d1.width != d2.width) { 698 return d2.width - d1.width; 699 } 700 701 return d2.height - d1.height; 702 } 703 }); 704 for (Map.Entry<File, Dimension> entry2 : entries) { 705 if (sb.length() > 0) { 706 sb.append(", "); 707 } 708 File file = entry2.getKey(); 709 710 // Chain locations together 711 Location linkedLocation = location; 712 location = Location.create(file); 713 location.setSecondary(linkedLocation); 714 Dimension dip = entry2.getValue(); 715 Dimension px = pixelSizes.get(file); 716 String fileName = file.getParentFile().getName() + File.separator 717 + file.getName(); 718 sb.append(String.format("%1$s: %2$dx%3$d dp (%4$dx%5$d px)", 719 fileName, dip.width, dip.height, px.width, px.height)); 720 } 721 String message = String.format( 722 "The image %1$s varies significantly in its density-independent (dip) " + 723 "size across the various density versions: %2$s", 724 name, sb.toString()); 725 context.report(ICON_DIP_SIZE, location, message, null); 726 } 727 } 728 } 729 } 730 731 private void checkDensities(Context context, File res, Map<File, Set<String>> folderToNames) { 732 // TODO: Is there a way to look at the manifest and figure out whether 733 // all densities are expected to be needed? 734 // Note: ldpi is probably not needed; it has very little usage 735 // (about 2%; http://developer.android.com/resources/dashboard/screens.html) 736 // TODO: Use the matrix to check out if we can eliminate densities based 737 // on the target screens? 738 739 Set<String> definedDensities = new HashSet<String>(); 740 for (File f : folderToNames.keySet()) { 741 definedDensities.add(f.getName()); 742 } 743 744 // Look for missing folders -- if you define say drawable-mdpi then you 745 // should also define -hdpi and -xhdpi. 746 if (context.isEnabled(ICON_MISSING_FOLDER)) { 747 List<String> missing = new ArrayList<String>(); 748 for (String density : REQUIRED_DENSITIES) { 749 if (!definedDensities.contains(density)) { 750 missing.add(density); 751 } 752 } 753 if (missing.size() > 0 ) { 754 context.report( 755 ICON_MISSING_FOLDER, 756 Location.create(res), 757 String.format("Missing density variation folders in %1$s: %2$s", 758 context.getProject().getDisplayPath(res), 759 LintUtils.formatList(missing, -1)), 760 null); 761 } 762 } 763 764 if (context.isEnabled(ICON_NODPI)) { 765 Set<String> noDpiNames = new HashSet<String>(); 766 for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) { 767 if (isNoDpiFolder(entry.getKey())) { 768 noDpiNames.addAll(entry.getValue()); 769 } 770 } 771 if (noDpiNames.size() > 0) { 772 // Make sure that none of the nodpi names appear in a non-nodpi folder 773 Set<String> inBoth = new HashSet<String>(); 774 List<File> files = new ArrayList<File>(); 775 for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) { 776 File folder = entry.getKey(); 777 String folderName = folder.getName(); 778 if (!isNoDpiFolder(folder)) { 779 assert DENSITY_PATTERN.matcher(folderName).matches(); 780 Set<String> overlap = nameIntersection(noDpiNames, entry.getValue()); 781 inBoth.addAll(overlap); 782 for (String name : overlap) { 783 files.add(new File(folder, name)); 784 } 785 } 786 } 787 788 if (inBoth.size() > 0) { 789 List<String> list = new ArrayList<String>(inBoth); 790 Collections.sort(list); 791 792 // Chain locations together 793 Collections.sort(files); 794 Location location = null; 795 for (File file : files) { 796 Location linkedLocation = location; 797 location = Location.create(file); 798 location.setSecondary(linkedLocation); 799 } 800 801 context.report(ICON_NODPI, location, 802 String.format( 803 "The following images appear in both -nodpi and in a density folder: %1$s", 804 LintUtils.formatList(list, 10)), 805 null); 806 } 807 } 808 } 809 810 if (context.isEnabled(ICON_DENSITIES)) { 811 // Look for folders missing some of the specific assets 812 Set<String> allNames = new HashSet<String>(); 813 for (Entry<File,Set<String>> entry : folderToNames.entrySet()) { 814 if (!isNoDpiFolder(entry.getKey())) { 815 Set<String> names = entry.getValue(); 816 allNames.addAll(names); 817 } 818 } 819 820 for (Map.Entry<File, Set<String>> entry : folderToNames.entrySet()) { 821 File file = entry.getKey(); 822 if (isNoDpiFolder(file)) { 823 continue; 824 } 825 Set<String> names = entry.getValue(); 826 if (names.size() != allNames.size()) { 827 List<String> delta = new ArrayList<String>(nameDifferences(allNames, names)); 828 if (delta.size() == 0) { 829 continue; 830 } 831 Collections.sort(delta); 832 String foundIn = ""; 833 if (delta.size() == 1) { 834 // Produce list of where the icon is actually defined 835 List<String> defined = new ArrayList<String>(); 836 String name = delta.get(0); 837 for (Map.Entry<File, Set<String>> e : folderToNames.entrySet()) { 838 if (e.getValue().contains(name)) { 839 defined.add(e.getKey().getName()); 840 } 841 } 842 if (defined.size() > 0) { 843 foundIn = String.format(" (found in %1$s)", 844 LintUtils.formatList(defined, 5)); 845 } 846 } 847 848 context.report(ICON_DENSITIES, Location.create(file), 849 String.format( 850 "Missing the following drawables in %1$s: %2$s%3$s", 851 file.getName(), 852 LintUtils.formatList(delta, 5), 853 foundIn), 854 null); 855 } 856 } 857 } 858 } 859 860 /** 861 * Compute the difference in names between a and b. This is not just 862 * Sets.difference(a, b) because we want to make the comparisons <b>without 863 * file extensions</b> and return the result <b>with</b>.. 864 */ 865 private Set<String> nameDifferences(Set<String> a, Set<String> b) { 866 Set<String> names1 = new HashSet<String>(a.size()); 867 for (String s : a) { 868 names1.add(LintUtils.getBaseName(s)); 869 } 870 Set<String> names2 = new HashSet<String>(b.size()); 871 for (String s : b) { 872 names2.add(LintUtils.getBaseName(s)); 873 } 874 875 names1.removeAll(names2); 876 877 if (names1.size() > 0) { 878 // Map filenames back to original filenames with extensions 879 Set<String> result = new HashSet<String>(names1.size()); 880 for (String s : a) { 881 if (names1.contains(LintUtils.getBaseName(s))) { 882 result.add(s); 883 } 884 } 885 for (String s : b) { 886 if (names1.contains(LintUtils.getBaseName(s))) { 887 result.add(s); 888 } 889 } 890 891 return result; 892 } 893 894 return Collections.emptySet(); 895 } 896 897 /** 898 * Compute the intersection in names between a and b. This is not just 899 * Sets.intersection(a, b) because we want to make the comparisons <b>without 900 * file extensions</b> and return the result <b>with</b>. 901 */ 902 private Set<String> nameIntersection(Set<String> a, Set<String> b) { 903 Set<String> names1 = new HashSet<String>(a.size()); 904 for (String s : a) { 905 names1.add(LintUtils.getBaseName(s)); 906 } 907 Set<String> names2 = new HashSet<String>(b.size()); 908 for (String s : b) { 909 names2.add(LintUtils.getBaseName(s)); 910 } 911 912 names1.retainAll(names2); 913 914 if (names1.size() > 0) { 915 // Map filenames back to original filenames with extensions 916 Set<String> result = new HashSet<String>(names1.size()); 917 for (String s : a) { 918 if (names1.contains(LintUtils.getBaseName(s))) { 919 result.add(s); 920 } 921 } 922 for (String s : b) { 923 if (names1.contains(LintUtils.getBaseName(s))) { 924 result.add(s); 925 } 926 } 927 928 return result; 929 } 930 931 return Collections.emptySet(); 932 } 933 934 private static boolean isNoDpiFolder(File file) { 935 return file.getName().contains("-nodpi"); 936 } 937 938 private void checkDrawableDir(Context context, File folder, File[] files, 939 Map<File, Dimension> pixelSizes, Map<File, Long> fileSizes) { 940 if (folder.getName().equals(DRAWABLE_FOLDER) 941 && context.isEnabled(ICON_LOCATION) && 942 // If supporting older versions than Android 1.6, it's not an error 943 // to include bitmaps in drawable/ 944 context.getProject().getMinSdk() >= 4) { 945 for (File file : files) { 946 String name = file.getName(); 947 if (name.endsWith(DOT_XML)) { 948 // pass - most common case, avoids checking other extensions 949 } else if (endsWith(name, DOT_PNG) 950 || endsWith(name, DOT_JPG) 951 || endsWith(name, DOT_GIF)) { 952 context.report(ICON_LOCATION, 953 Location.create(file), 954 String.format("Found bitmap drawable res/drawable/%1$s in " + 955 "densityless folder", 956 file.getName()), 957 null); 958 } 959 } 960 } 961 962 if (context.isEnabled(GIF_USAGE)) { 963 for (File file : files) { 964 String name = file.getName(); 965 if (endsWith(name, DOT_GIF)) { 966 context.report(GIF_USAGE, Location.create(file), 967 "Using the .gif format for bitmaps is discouraged", 968 null); 969 } 970 } 971 } 972 973 // Check icon sizes 974 if (context.isEnabled(ICON_EXPECTED_SIZE)) { 975 checkExpectedSizes(context, folder, files); 976 } 977 978 if (pixelSizes != null || fileSizes != null) { 979 for (File file : files) { 980 // TODO: Combine this check with the check for expected sizes such that 981 // I don't check file sizes twice! 982 String fileName = file.getName(); 983 984 if (endsWith(fileName, DOT_PNG) || endsWith(fileName, DOT_JPG)) { 985 // Only scan .png files (except 9-patch png's) and jpg files for 986 // dip sizes. Duplicate checks can also be performed on ninepatch files. 987 if (pixelSizes != null && !endsWith(fileName, DOT_9PNG)) { 988 Dimension size = getSize(file); 989 pixelSizes.put(file, size); 990 } 991 if (fileSizes != null) { 992 fileSizes.put(file, file.length()); 993 } 994 } 995 } 996 } 997 } 998 999 private void checkExpectedSizes(Context context, File folder, File[] files) { 1000 String folderName = folder.getName(); 1001 1002 int folderVersion = -1; 1003 String[] qualifiers = folderName.split("-"); //$NON-NLS-1$ 1004 for (String qualifier : qualifiers) { 1005 if (qualifier.startsWith("v")) { 1006 Matcher matcher = VERSION_PATTERN.matcher(qualifier); 1007 if (matcher.matches()) { 1008 folderVersion = Integer.parseInt(matcher.group(1)); 1009 } 1010 } 1011 } 1012 1013 for (File file : files) { 1014 String name = file.getName(); 1015 1016 // TODO: Look up exact app icon from the manifest rather than simply relying on 1017 // the naming conventions described here: 1018 // http://developer.android.com/guide/practices/ui_guidelines/icon_design.html#design-tips 1019 // See if we can figure out other types of icons from usage too. 1020 1021 String baseName = name; 1022 int index = baseName.indexOf('.'); 1023 if (index != -1) { 1024 baseName = baseName.substring(0, index); 1025 } 1026 1027 if (baseName.equals(mApplicationIcon) || name.startsWith("ic_launcher")) { //$NON-NLS-1$ 1028 // Launcher icons 1029 checkSize(context, folderName, file, 48, 48, true /*exact*/); 1030 } else if (name.startsWith("ic_action_")) { //$NON-NLS-1$ 1031 // Action Bar 1032 checkSize(context, folderName, file, 32, 32, true /*exact*/); 1033 } else if (name.startsWith("ic_dialog_")) { //$NON-NLS-1$ 1034 // Dialog 1035 checkSize(context, folderName, file, 32, 32, true /*exact*/); 1036 } else if (name.startsWith("ic_tab_")) { //$NON-NLS-1$ 1037 // Tab icons 1038 checkSize(context, folderName, file, 32, 32, true /*exact*/); 1039 } else if (name.startsWith("ic_stat_")) { //$NON-NLS-1$ 1040 // Notification icons 1041 1042 if (isAndroid30(context, folderVersion)) { 1043 checkSize(context, folderName, file, 24, 24, true /*exact*/); 1044 } else if (isAndroid23(context, folderVersion)) { 1045 checkSize(context, folderName, file, 16, 25, false /*exact*/); 1046 } else { 1047 // Android 2.2 or earlier 1048 // TODO: Should this be done for each folder size? 1049 checkSize(context, folderName, file, 25, 25, true /*exact*/); 1050 } 1051 } else if (name.startsWith("ic_menu_")) { //$NON-NLS-1$ 1052 if (isAndroid30(context, folderVersion)) { 1053 // Menu icons (<=2.3 only: Replaced by action bar icons (ic_action_ in 3.0). 1054 // However the table halfway down the page on 1055 // http://developer.android.com/guide/practices/ui_guidelines/icon_design.html 1056 // and the README in the icon template download says that convention is ic_menu 1057 checkSize(context, folderName, file, 32, 32, true); 1058 } else if (isAndroid23(context, folderVersion)) { 1059 // The icon should be 32x32 inside the transparent image; should 1060 // we check that this is mostly the case (a few pixels are allowed to 1061 // overlap for anti-aliasing etc) 1062 checkSize(context, folderName, file, 48, 48, true /*exact*/); 1063 } else { 1064 // Android 2.2 or earlier 1065 // TODO: Should this be done for each folder size? 1066 checkSize(context, folderName, file, 48, 48, true /*exact*/); 1067 } 1068 } 1069 // TODO: ListView icons? 1070 } 1071 } 1072 1073 /** 1074 * Is this drawable folder for an Android 3.0 drawable? This will be the 1075 * case if it specifies -v11+, or if the minimum SDK version declared in the 1076 * manifest is at least 11. 1077 */ 1078 private boolean isAndroid30(Context context, int folderVersion) { 1079 return folderVersion >= 11 || context.getMainProject().getMinSdk() >= 11; 1080 } 1081 1082 /** 1083 * Is this drawable folder for an Android 2.3 drawable? This will be the 1084 * case if it specifies -v9 or -v10, or if the minimum SDK version declared in the 1085 * manifest is 9 or 10 (and it does not specify some higher version like -v11 1086 */ 1087 private boolean isAndroid23(Context context, int folderVersion) { 1088 if (isAndroid30(context, folderVersion)) { 1089 return false; 1090 } 1091 1092 if (folderVersion == 9 || folderVersion == 10) { 1093 return true; 1094 } 1095 1096 int minSdk = context.getMainProject().getMinSdk(); 1097 1098 return minSdk == 9 || minSdk == 10; 1099 } 1100 1101 private float getMdpiScalingFactor(String folderName) { 1102 // Can't do startsWith(DRAWABLE_MDPI) because the folder could 1103 // be something like "drawable-sw600dp-mdpi". 1104 if (folderName.contains("-mdpi")) { //$NON-NLS-1$ 1105 return 1.0f; 1106 } else if (folderName.contains("-hdpi")) { //$NON-NLS-1$ 1107 return 1.5f; 1108 } else if (folderName.contains("-xhdpi")) { //$NON-NLS-1$ 1109 return 2.0f; 1110 } else if (folderName.contains("-ldpi")) { //$NON-NLS-1$ 1111 return 0.75f; 1112 } else { 1113 return 0f; 1114 } 1115 } 1116 1117 private void checkSize(Context context, String folderName, File file, 1118 int mdpiWidth, int mdpiHeight, boolean exactMatch) { 1119 String fileName = file.getName(); 1120 // Only scan .png files (except 9-patch png's) and jpg files 1121 if (!((endsWith(fileName, DOT_PNG) && !endsWith(fileName, DOT_9PNG)) || 1122 endsWith(fileName, DOT_JPG))) { 1123 return; 1124 } 1125 1126 int width = -1; 1127 int height = -1; 1128 // Use 3:4:6:8 scaling ratio to look up the other expected sizes 1129 if (folderName.startsWith(DRAWABLE_MDPI)) { 1130 width = mdpiWidth; 1131 height = mdpiHeight; 1132 } else if (folderName.startsWith(DRAWABLE_HDPI)) { 1133 // Perform math using floating point; if we just do 1134 // width = mdpiWidth * 3 / 2; 1135 // then for mdpiWidth = 25 (as in notification icons on pre-GB) we end up 1136 // with width = 37, instead of 38 (with floating point rounding we get 37.5 = 38) 1137 width = Math.round(mdpiWidth * 3.f / 2); 1138 height = Math.round(mdpiHeight * 3f / 2); 1139 } else if (folderName.startsWith(DRAWABLE_XHDPI)) { 1140 width = mdpiWidth * 2; 1141 height = mdpiHeight * 2; 1142 } else if (folderName.startsWith(DRAWABLE_LDPI)) { 1143 width = Math.round(mdpiWidth * 3f / 4); 1144 height = Math.round(mdpiHeight * 3f / 4); 1145 } else { 1146 return; 1147 } 1148 1149 Dimension size = getSize(file); 1150 if (size != null) { 1151 if (exactMatch && size.width != width || size.height != height) { 1152 context.report( 1153 ICON_EXPECTED_SIZE, 1154 Location.create(file), 1155 String.format( 1156 "Incorrect icon size for %1$s: expected %2$dx%3$d, but was %4$dx%5$d", 1157 folderName + File.separator + file.getName(), 1158 width, height, size.width, size.height), 1159 null); 1160 } else if (!exactMatch && size.width > width || size.height > height) { 1161 context.report( 1162 ICON_EXPECTED_SIZE, 1163 Location.create(file), 1164 String.format( 1165 "Incorrect icon size for %1$s: icon size should be at most %2$dx%3$d, but was %4$dx%5$d", 1166 folderName + File.separator + file.getName(), 1167 width, height, size.width, size.height), 1168 null); 1169 } 1170 } 1171 } 1172 1173 private Dimension getSize(File file) { 1174 try { 1175 ImageInputStream input = ImageIO.createImageInputStream(file); 1176 if (input != null) { 1177 try { 1178 Iterator<ImageReader> readers = ImageIO.getImageReaders(input); 1179 if (readers.hasNext()) { 1180 ImageReader reader = readers.next(); 1181 try { 1182 reader.setInput(input); 1183 return new Dimension(reader.getWidth(0), reader.getHeight(0)); 1184 } finally { 1185 reader.dispose(); 1186 } 1187 } 1188 } finally { 1189 if (input != null) { 1190 input.close(); 1191 } 1192 } 1193 } 1194 1195 // Fallback: read the image using the normal means 1196 BufferedImage image = ImageIO.read(file); 1197 if (image != null) { 1198 return new Dimension(image.getWidth(), image.getHeight()); 1199 } else { 1200 return null; 1201 } 1202 } catch (IOException e) { 1203 // Pass -- we can't handle all image types, warn about those we can 1204 return null; 1205 } 1206 } 1207 1208 // XML detector: Skim manifest 1209 1210 @Override 1211 public boolean appliesTo(@NonNull Context context, @NonNull File file) { 1212 return file.getName().equals(ANDROID_MANIFEST_XML); 1213 } 1214 1215 @Override 1216 public Collection<String> getApplicableElements() { 1217 return Collections.singletonList(TAG_APPLICATION); 1218 } 1219 1220 @Override 1221 public void visitElement(@NonNull XmlContext context, @NonNull Element element) { 1222 assert element.getTagName().equals(TAG_APPLICATION); 1223 mApplicationIcon = element.getAttributeNS(ANDROID_URI, ATTR_ICON); 1224 if (mApplicationIcon.startsWith(DRAWABLE_PREFIX)) { 1225 mApplicationIcon = mApplicationIcon.substring(DRAWABLE_PREFIX.length()); 1226 } 1227 } 1228 } 1229