1 /* 2 * Copyright (C) 2014 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 // Modifications are owned by the Chromium Authors. 18 // Copyright 2021 The Chromium Authors 19 // Use of this source code is governed by a BSD-style license that can be 20 // found in the LICENSE file. 21 22 package build.android.unused_resources; 23 24 import static com.android.ide.common.symbols.SymbolIo.readFromAapt; 25 import static com.android.utils.SdkUtils.endsWithIgnoreCase; 26 27 import static com.google.common.base.Charsets.UTF_8; 28 29 import com.android.ide.common.resources.usage.ResourceUsageModel; 30 import com.android.ide.common.resources.usage.ResourceUsageModel.Resource; 31 import com.android.ide.common.symbols.Symbol; 32 import com.android.ide.common.symbols.SymbolTable; 33 import com.android.resources.ResourceFolderType; 34 import com.android.resources.ResourceType; 35 import com.android.tools.r8.CompilationFailedException; 36 import com.android.tools.r8.ProgramResource; 37 import com.android.tools.r8.ProgramResourceProvider; 38 import com.android.tools.r8.ResourceShrinker; 39 import com.android.tools.r8.ResourceShrinker.Command; 40 import com.android.tools.r8.ResourceShrinker.ReferenceChecker; 41 import com.android.tools.r8.origin.PathOrigin; 42 import com.android.utils.XmlUtils; 43 44 import com.google.common.base.Charsets; 45 import com.google.common.collect.Maps; 46 import com.google.common.io.ByteStreams; 47 import com.google.common.io.Closeables; 48 import com.google.common.io.Files; 49 50 import org.w3c.dom.Document; 51 import org.w3c.dom.Node; 52 import org.xml.sax.SAXException; 53 54 import java.io.File; 55 import java.io.FileInputStream; 56 import java.io.IOException; 57 import java.io.PrintWriter; 58 import java.io.StringWriter; 59 import java.nio.file.Path; 60 import java.nio.file.Paths; 61 import java.util.Arrays; 62 import java.util.Collections; 63 import java.util.List; 64 import java.util.Map; 65 import java.util.concurrent.ExecutionException; 66 import java.util.stream.Collectors; 67 import java.util.zip.ZipEntry; 68 import java.util.zip.ZipInputStream; 69 70 import javax.xml.parsers.ParserConfigurationException; 71 72 /** 73 Copied with modifications from gradle core source 74 https://cs.android.com/search?q=f:build-system.*ResourceUsageAnalyzer.java 75 76 Modifications are mostly to: 77 - Remove unused code paths to reduce complexity. 78 - Reduce dependencies unless absolutely required. 79 */ 80 81 public class UnusedResources { 82 private static final String ANDROID_RES = "android_res/"; 83 private static final String DOT_DEX = ".dex"; 84 private static final String DOT_CLASS = ".class"; 85 private static final String DOT_XML = ".xml"; 86 private static final String DOT_JAR = ".jar"; 87 private static final String FN_RESOURCE_TEXT = "R.txt"; 88 89 /* A source of resource classes to track, can be either a folder or a jar */ 90 private final Iterable<File> mRTxtFiles; 91 private final File mProguardMapping; 92 /** These can be class or dex files. */ 93 private final Iterable<File> mClasses; 94 private final Iterable<File> mManifests; 95 private final Iterable<File> mResourceDirs; 96 97 private final File mReportFile; 98 private final StringWriter mDebugOutput; 99 private final PrintWriter mDebugPrinter; 100 101 /** The computed set of unused resources */ 102 private List<Resource> mUnused; 103 104 /** 105 * Map from resource class owners (VM format class) to corresponding resource entries. 106 * This lets us map back from code references (obfuscated class and possibly obfuscated field 107 * reference) back to the corresponding resource type and name. 108 */ 109 private Map<String, Pair<ResourceType, Map<String, String>>> mResourceObfuscation = 110 Maps.newHashMapWithExpectedSize(30); 111 112 /** Obfuscated name of android/support/v7/widget/SuggestionsAdapter.java */ 113 private String mSuggestionsAdapter; 114 115 /** Obfuscated name of android/support/v7/internal/widget/ResourcesWrapper.java */ 116 private String mResourcesWrapper; 117 118 /* A Pair class because java does not come with batteries included. */ 119 private static class Pair<U, V> { 120 private U mFirst; 121 private V mSecond; 122 Pair(U first, V second)123 Pair(U first, V second) { 124 this.mFirst = first; 125 this.mSecond = second; 126 } 127 getFirst()128 public U getFirst() { 129 return mFirst; 130 } 131 getSecond()132 public V getSecond() { 133 return mSecond; 134 } 135 } 136 UnusedResources(Iterable<File> rTxtFiles, Iterable<File> classes, Iterable<File> manifests, File mapping, Iterable<File> resources, File reportFile)137 public UnusedResources(Iterable<File> rTxtFiles, Iterable<File> classes, 138 Iterable<File> manifests, File mapping, Iterable<File> resources, File reportFile) { 139 mRTxtFiles = rTxtFiles; 140 mProguardMapping = mapping; 141 mClasses = classes; 142 mManifests = manifests; 143 mResourceDirs = resources; 144 145 mReportFile = reportFile; 146 if (reportFile != null) { 147 mDebugOutput = new StringWriter(8 * 1024); 148 mDebugPrinter = new PrintWriter(mDebugOutput); 149 } else { 150 mDebugOutput = null; 151 mDebugPrinter = null; 152 } 153 } 154 close()155 public void close() { 156 if (mDebugOutput != null) { 157 String output = mDebugOutput.toString(); 158 159 if (mReportFile != null) { 160 File dir = mReportFile.getParentFile(); 161 if (dir != null) { 162 if ((dir.exists() || dir.mkdir()) && dir.canWrite()) { 163 try { 164 Files.asCharSink(mReportFile, Charsets.UTF_8).write(output); 165 } catch (IOException ignore) { 166 } 167 } 168 } 169 } 170 } 171 } 172 analyze()173 public void analyze() throws IOException, ParserConfigurationException, SAXException { 174 gatherResourceValues(mRTxtFiles); 175 recordMapping(mProguardMapping); 176 177 for (File jarOrDir : mClasses) { 178 recordClassUsages(jarOrDir); 179 } 180 recordManifestUsages(mManifests); 181 recordResources(mResourceDirs); 182 dumpReferences(); 183 mModel.processToolsAttributes(); 184 mUnused = mModel.findUnused(); 185 } 186 emitConfig(Path destination)187 public void emitConfig(Path destination) throws IOException { 188 File destinationFile = destination.toFile(); 189 if (!destinationFile.exists()) { 190 destinationFile.getParentFile().mkdirs(); 191 boolean success = destinationFile.createNewFile(); 192 if (!success) { 193 throw new IOException("Could not create " + destination); 194 } 195 } 196 StringBuilder sb = new StringBuilder(); 197 Collections.sort(mUnused); 198 for (Resource resource : mUnused) { 199 if (resource.type.isSynthetic()) { 200 // Ignore synthetic resources like overlayable or macro that are 201 // not actually listed in the ResourceTable. 202 continue; 203 } 204 sb.append(resource.type + "/" + resource.name + "#remove\n"); 205 } 206 Files.asCharSink(destinationFile, UTF_8).write(sb.toString()); 207 } 208 dumpReferences()209 private void dumpReferences() { 210 if (mDebugPrinter != null) { 211 mDebugPrinter.print(mModel.dumpReferences()); 212 } 213 } 214 dumpModel()215 private void dumpModel() { 216 if (mDebugPrinter != null) { 217 mDebugPrinter.print(mModel.dumpResourceModel()); 218 } 219 } 220 recordResources(Iterable<File> resources)221 private void recordResources(Iterable<File> resources) 222 throws IOException, SAXException, ParserConfigurationException { 223 for (File resDir : resources) { 224 File[] resourceFolders = resDir.listFiles(); 225 assert resourceFolders != null : "Invalid resource directory " + resDir; 226 for (File folder : resourceFolders) { 227 ResourceFolderType folderType = ResourceFolderType.getFolderType(folder.getName()); 228 if (folderType != null) { 229 recordResources(folderType, folder); 230 } 231 } 232 } 233 } 234 recordResources(ResourceFolderType folderType, File folder)235 private void recordResources(ResourceFolderType folderType, File folder) 236 throws ParserConfigurationException, SAXException, IOException { 237 File[] files = folder.listFiles(); 238 if (files != null) { 239 for (File file : files) { 240 String path = file.getPath(); 241 mModel.file = file; 242 try { 243 boolean isXml = endsWithIgnoreCase(path, DOT_XML); 244 if (isXml) { 245 String xml = Files.toString(file, UTF_8); 246 Document document = XmlUtils.parseDocument(xml, true); 247 mModel.visitXmlDocument(file, folderType, document); 248 } else { 249 mModel.visitBinaryResource(folderType, file); 250 } 251 } finally { 252 mModel.file = null; 253 } 254 } 255 } 256 } 257 recordMapping(File mapping)258 void recordMapping(File mapping) throws IOException { 259 if (mapping == null || !mapping.exists()) { 260 return; 261 } 262 final String arrowString = " -> "; 263 final String resourceString = ".R$"; 264 Map<String, String> nameMap = null; 265 for (String line : Files.readLines(mapping, UTF_8)) { 266 // Ignore R8's mapping comments. 267 if (line.startsWith("#")) { 268 continue; 269 } 270 if (line.startsWith(" ") || line.startsWith("\t")) { 271 if (nameMap != null) { 272 // We're processing the members of a resource class: record names into the map 273 int n = line.length(); 274 int i = 0; 275 for (; i < n; i++) { 276 if (!Character.isWhitespace(line.charAt(i))) { 277 break; 278 } 279 } 280 if (i < n && line.startsWith("int", i)) { // int or int[] 281 int start = line.indexOf(' ', i + 3) + 1; 282 int arrow = line.indexOf(arrowString); 283 if (start > 0 && arrow != -1) { 284 int end = line.indexOf(' ', start + 1); 285 if (end != -1) { 286 String oldName = line.substring(start, end); 287 String newName = 288 line.substring(arrow + arrowString.length()).trim(); 289 if (!newName.equals(oldName)) { 290 nameMap.put(newName, oldName); 291 } 292 } 293 } 294 } 295 } 296 continue; 297 } else { 298 nameMap = null; 299 } 300 int index = line.indexOf(resourceString); 301 if (index == -1) { 302 // Record obfuscated names of a few known appcompat usages of 303 // Resources#getIdentifier that are unlikely to be used for general 304 // resource name reflection 305 if (line.startsWith("android.support.v7.widget.SuggestionsAdapter ")) { 306 mSuggestionsAdapter = 307 line.substring(line.indexOf(arrowString) + arrowString.length(), 308 line.indexOf(':') != -1 ? line.indexOf(':') : line.length()) 309 .trim() 310 .replace('.', '/') 311 + DOT_CLASS; 312 } else if (line.startsWith("android.support.v7.internal.widget.ResourcesWrapper ") 313 || line.startsWith("android.support.v7.widget.ResourcesWrapper ") 314 || (mResourcesWrapper == null // Recently wrapper moved 315 && line.startsWith( 316 "android.support.v7.widget.TintContextWrapper$TintResources" 317 + " "))) { 318 mResourcesWrapper = 319 line.substring(line.indexOf(arrowString) + arrowString.length(), 320 line.indexOf(':') != -1 ? line.indexOf(':') : line.length()) 321 .trim() 322 .replace('.', '/') 323 + DOT_CLASS; 324 } 325 continue; 326 } 327 int arrow = line.indexOf(arrowString, index + 3); 328 if (arrow == -1) { 329 continue; 330 } 331 String typeName = line.substring(index + resourceString.length(), arrow); 332 ResourceType type = ResourceType.fromClassName(typeName); 333 if (type == null) { 334 continue; 335 } 336 int end = line.indexOf(':', arrow + arrowString.length()); 337 if (end == -1) { 338 end = line.length(); 339 } 340 String target = line.substring(arrow + arrowString.length(), end).trim(); 341 String ownerName = target.replace('.', '/'); 342 343 nameMap = Maps.newHashMap(); 344 Pair<ResourceType, Map<String, String>> pair = new Pair(type, nameMap); 345 mResourceObfuscation.put(ownerName, pair); 346 // For fast lookup in isResourceClass 347 mResourceObfuscation.put(ownerName + DOT_CLASS, pair); 348 } 349 } 350 recordManifestUsages(File manifest)351 private void recordManifestUsages(File manifest) 352 throws IOException, ParserConfigurationException, SAXException { 353 String xml = Files.toString(manifest, UTF_8); 354 Document document = XmlUtils.parseDocument(xml, true); 355 mModel.visitXmlDocument(manifest, null, document); 356 } 357 recordManifestUsages(Iterable<File> manifests)358 private void recordManifestUsages(Iterable<File> manifests) 359 throws IOException, ParserConfigurationException, SAXException { 360 for (File manifest : manifests) { 361 recordManifestUsages(manifest); 362 } 363 } 364 recordClassUsages(File file)365 private void recordClassUsages(File file) throws IOException { 366 assert file.isFile(); 367 if (file.getPath().endsWith(DOT_DEX)) { 368 byte[] bytes = Files.toByteArray(file); 369 recordClassUsages(file, file.getName(), bytes); 370 } else if (file.getPath().endsWith(DOT_JAR)) { 371 ZipInputStream zis = null; 372 try { 373 FileInputStream fis = new FileInputStream(file); 374 try { 375 zis = new ZipInputStream(fis); 376 ZipEntry entry = zis.getNextEntry(); 377 while (entry != null) { 378 String name = entry.getName(); 379 if (name.endsWith(DOT_DEX)) { 380 byte[] bytes = ByteStreams.toByteArray(zis); 381 if (bytes != null) { 382 recordClassUsages(file, name, bytes); 383 } 384 } 385 386 entry = zis.getNextEntry(); 387 } 388 } finally { 389 Closeables.close(fis, true); 390 } 391 } finally { 392 Closeables.close(zis, true); 393 } 394 } 395 } 396 stringifyResource(Resource resource)397 private String stringifyResource(Resource resource) { 398 return String.format("%s:%s:0x%08x", resource.type, resource.name, resource.value); 399 } 400 recordClassUsages(File file, String name, byte[] bytes)401 private void recordClassUsages(File file, String name, byte[] bytes) { 402 assert name.endsWith(DOT_DEX); 403 ReferenceChecker callback = new ReferenceChecker() { 404 @Override 405 public boolean shouldProcess(String internalName) { 406 // We do not need to ignore R subclasses since R8 now removes 407 // unused resource id fields in R subclasses thus their 408 // remaining presence means real usage. 409 return true; 410 } 411 412 @Override 413 public void referencedInt(int value) { 414 UnusedResources.this.referencedInt("dex", value, file, name); 415 } 416 417 @Override 418 public void referencedString(String value) { 419 // do nothing. 420 } 421 422 @Override 423 public void referencedStaticField(String internalName, String fieldName) { 424 Resource resource = getResourceFromCode(internalName, fieldName); 425 if (resource != null) { 426 ResourceUsageModel.markReachable(resource); 427 if (mDebugPrinter != null) { 428 mDebugPrinter.println("Marking " + stringifyResource(resource) 429 + " reachable: referenced from dex" 430 + " in " + file + ":" + name + " (static field access " 431 + internalName + "." + fieldName + ")"); 432 } 433 } 434 } 435 436 @Override 437 public void referencedMethod( 438 String internalName, String methodName, String methodDescriptor) { 439 // Do nothing. 440 } 441 }; 442 ProgramResource resource = ProgramResource.fromBytes( 443 new PathOrigin(file.toPath()), ProgramResource.Kind.DEX, bytes, null); 444 ProgramResourceProvider provider = () -> Arrays.asList(resource); 445 try { 446 Command command = 447 (new ResourceShrinker.Builder()).addProgramResourceProvider(provider).build(); 448 ResourceShrinker.run(command, callback); 449 } catch (CompilationFailedException e) { 450 e.printStackTrace(); 451 } catch (IOException e) { 452 e.printStackTrace(); 453 } catch (ExecutionException e) { 454 e.printStackTrace(); 455 } 456 } 457 458 /** Returns whether the given class file name points to an aapt-generated compiled R class. */ isResourceClass(String name)459 boolean isResourceClass(String name) { 460 if (mResourceObfuscation.containsKey(name)) { 461 return true; 462 } 463 int index = name.lastIndexOf('/'); 464 if (index != -1 && name.startsWith("R$", index + 1) && name.endsWith(DOT_CLASS)) { 465 String typeName = name.substring(index + 3, name.length() - DOT_CLASS.length()); 466 return ResourceType.fromClassName(typeName) != null; 467 } 468 return false; 469 } 470 getResourceFromCode(String owner, String name)471 Resource getResourceFromCode(String owner, String name) { 472 Pair<ResourceType, Map<String, String>> pair = mResourceObfuscation.get(owner); 473 if (pair != null) { 474 ResourceType type = pair.getFirst(); 475 Map<String, String> nameMap = pair.getSecond(); 476 String renamedField = nameMap.get(name); 477 if (renamedField != null) { 478 name = renamedField; 479 } 480 return mModel.getResource(type, name); 481 } 482 if (isValidResourceType(owner)) { 483 ResourceType type = 484 ResourceType.fromClassName(owner.substring(owner.lastIndexOf('$') + 1)); 485 if (type != null) { 486 return mModel.getResource(type, name); 487 } 488 } 489 return null; 490 } 491 isValidResourceType(String candidateString)492 private Boolean isValidResourceType(String candidateString) { 493 return candidateString.contains("/") 494 && candidateString.substring(candidateString.lastIndexOf('/') + 1).contains("$"); 495 } 496 gatherResourceValues(Iterable<File> rTxts)497 private void gatherResourceValues(Iterable<File> rTxts) throws IOException { 498 for (File rTxt : rTxts) { 499 assert rTxt.isFile(); 500 assert rTxt.getName().endsWith(FN_RESOURCE_TEXT); 501 addResourcesFromRTxtFile(rTxt); 502 } 503 } 504 addResourcesFromRTxtFile(File file)505 private void addResourcesFromRTxtFile(File file) { 506 try { 507 SymbolTable st = readFromAapt(file, null); 508 for (Symbol symbol : st.getSymbols().values()) { 509 String symbolValue = symbol.getValue(); 510 if (symbol.getResourceType() == ResourceType.STYLEABLE) { 511 if (symbolValue.trim().startsWith("{")) { 512 // Only add the styleable parent, styleable children are not yet supported. 513 mModel.addResource(symbol.getResourceType(), symbol.getName(), null); 514 } 515 } else { 516 if (mDebugPrinter != null) { 517 mDebugPrinter.println("Extracted R.txt resource: " 518 + symbol.getResourceType() + ":" + symbol.getName() + ":" 519 + String.format( 520 "0x%08x", Integer.parseInt(symbolValue.substring(2), 16))); 521 } 522 mModel.addResource(symbol.getResourceType(), symbol.getName(), symbolValue); 523 } 524 } 525 } catch (Exception e) { 526 e.printStackTrace(); 527 } 528 } 529 getModel()530 ResourceUsageModel getModel() { 531 return mModel; 532 } 533 referencedInt(String context, int value, File file, String currentClass)534 private void referencedInt(String context, int value, File file, String currentClass) { 535 Resource resource = mModel.getResource(value); 536 if (ResourceUsageModel.markReachable(resource) && mDebugPrinter != null) { 537 mDebugPrinter.println("Marking " + stringifyResource(resource) 538 + " reachable: referenced from " + context + " in " + file + ":" 539 + currentClass); 540 } 541 } 542 543 private final ResourceShrinkerUsageModel mModel = new ResourceShrinkerUsageModel(); 544 545 private class ResourceShrinkerUsageModel extends ResourceUsageModel { 546 public File file; 547 548 /** 549 * Whether we should ignore tools attribute resource references. 550 * <p> 551 * For example, for resource shrinking we want to ignore tools attributes, 552 * whereas for resource refactoring on the source code we do not. 553 * 554 * @return whether tools attributes should be ignored 555 */ 556 @Override ignoreToolsAttributes()557 protected boolean ignoreToolsAttributes() { 558 return true; 559 } 560 561 @Override onRootResourcesFound(List<Resource> roots)562 protected void onRootResourcesFound(List<Resource> roots) { 563 if (mDebugPrinter != null) { 564 mDebugPrinter.println("\nThe root reachable resources are:"); 565 for (Resource root : roots) { 566 mDebugPrinter.println(" " + stringifyResource(root) + ","); 567 } 568 } 569 } 570 571 @Override declareResource(ResourceType type, String name, Node node)572 protected Resource declareResource(ResourceType type, String name, Node node) { 573 Resource resource = super.declareResource(type, name, node); 574 resource.addLocation(file); 575 return resource; 576 } 577 578 @Override referencedString(String string)579 protected void referencedString(String string) { 580 // Do nothing 581 } 582 } 583 parsePathsFromFile(String path)584 private static List<File> parsePathsFromFile(String path) throws IOException { 585 return java.nio.file.Files.readAllLines(new File(path).toPath()).stream() 586 .map(File::new) 587 .collect(Collectors.toList()); 588 } 589 main(String[] args)590 public static void main(String[] args) throws Exception { 591 List<File> rTxtFiles = null; // R.txt files 592 List<File> classes = null; // Dex/jar w dex 593 List<File> manifests = null; // manifests 594 File mapping = null; // mapping 595 List<File> resources = null; // resources dirs 596 File log = null; // output log for debugging 597 Path configPath = null; // output config 598 for (int i = 0; i < args.length; i += 2) { 599 switch (args[i]) { 600 case "--rtxts": 601 rTxtFiles = Arrays.stream(args[i + 1].split(":")) 602 .map(s -> new File(s)) 603 .collect(Collectors.toList()); 604 break; 605 case "--dexes": 606 classes = parsePathsFromFile(args[i + 1]); 607 break; 608 case "--manifests": 609 manifests = parsePathsFromFile(args[i + 1]); 610 break; 611 case "--mapping": 612 mapping = new File(args[i + 1]); 613 break; 614 case "--resourceDirs": 615 resources = parsePathsFromFile(args[i + 1]); 616 break; 617 case "--log": 618 log = new File(args[i + 1]); 619 break; 620 case "--outputConfig": 621 configPath = Paths.get(args[i + 1]); 622 break; 623 default: 624 throw new IllegalArgumentException(args[i] + " is not a valid arg."); 625 } 626 } 627 UnusedResources unusedResources = 628 new UnusedResources(rTxtFiles, classes, manifests, mapping, resources, log); 629 unusedResources.analyze(); 630 unusedResources.close(); 631 unusedResources.emitConfig(configPath); 632 } 633 } 634