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