• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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