/* * Copyright (C) 2014 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // Modifications are owned by the Chromium Authors. // Copyright 2021 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package build.android.unused_resources; import static com.android.ide.common.symbols.SymbolIo.readFromAapt; import static com.android.utils.SdkUtils.endsWithIgnoreCase; import static com.google.common.base.Charsets.UTF_8; import com.android.ide.common.resources.usage.ResourceUsageModel; import com.android.ide.common.resources.usage.ResourceUsageModel.Resource; import com.android.ide.common.symbols.Symbol; import com.android.ide.common.symbols.SymbolTable; import com.android.resources.ResourceFolderType; import com.android.resources.ResourceType; import com.android.tools.r8.CompilationFailedException; import com.android.tools.r8.ProgramResource; import com.android.tools.r8.ProgramResourceProvider; import com.android.tools.r8.ResourceShrinker; import com.android.tools.r8.ResourceShrinker.Command; import com.android.tools.r8.ResourceShrinker.ReferenceChecker; import com.android.tools.r8.origin.PathOrigin; import com.android.utils.XmlUtils; import com.google.common.base.Charsets; import com.google.common.base.Joiner; import com.google.common.collect.Maps; import com.google.common.io.ByteStreams; import com.google.common.io.Closeables; import com.google.common.io.Files; import org.w3c.dom.Document; import org.w3c.dom.Node; import org.xml.sax.SAXException; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import javax.xml.parsers.ParserConfigurationException; /** Copied with modifications from gradle core source https://android.googlesource.com/platform/tools/base/+/master/build-system/gradle-core/src/main/groovy/com/android/build/gradle/tasks/ResourceUsageAnalyzer.java Modifications are mostly to: - Remove unused code paths to reduce complexity. - Reduce dependencies unless absolutely required. */ public class UnusedResources { private static final String ANDROID_RES = "android_res/"; private static final String DOT_DEX = ".dex"; private static final String DOT_CLASS = ".class"; private static final String DOT_XML = ".xml"; private static final String DOT_JAR = ".jar"; private static final String FN_RESOURCE_TEXT = "R.txt"; /* A source of resource classes to track, can be either a folder or a jar */ private final Iterable mRTxtFiles; private final File mProguardMapping; /** These can be class or dex files. */ private final Iterable mClasses; private final Iterable mManifests; private final Iterable mResourceDirs; private final File mReportFile; private final StringWriter mDebugOutput; private final PrintWriter mDebugPrinter; /** The computed set of unused resources */ private List mUnused; /** * Map from resource class owners (VM format class) to corresponding resource entries. * This lets us map back from code references (obfuscated class and possibly obfuscated field * reference) back to the corresponding resource type and name. */ private Map>> mResourceObfuscation = Maps.newHashMapWithExpectedSize(30); /** Obfuscated name of android/support/v7/widget/SuggestionsAdapter.java */ private String mSuggestionsAdapter; /** Obfuscated name of android/support/v7/internal/widget/ResourcesWrapper.java */ private String mResourcesWrapper; /* A Pair class because java does not come with batteries included. */ private static class Pair { private U mFirst; private V mSecond; Pair(U first, V second) { this.mFirst = first; this.mSecond = second; } public U getFirst() { return mFirst; } public V getSecond() { return mSecond; } } public UnusedResources(Iterable rTxtFiles, Iterable classes, Iterable manifests, File mapping, Iterable resources, File reportFile) { mRTxtFiles = rTxtFiles; mProguardMapping = mapping; mClasses = classes; mManifests = manifests; mResourceDirs = resources; mReportFile = reportFile; if (reportFile != null) { mDebugOutput = new StringWriter(8 * 1024); mDebugPrinter = new PrintWriter(mDebugOutput); } else { mDebugOutput = null; mDebugPrinter = null; } } public void close() { if (mDebugOutput != null) { String output = mDebugOutput.toString(); if (mReportFile != null) { File dir = mReportFile.getParentFile(); if (dir != null) { if ((dir.exists() || dir.mkdir()) && dir.canWrite()) { try { Files.asCharSink(mReportFile, Charsets.UTF_8).write(output); } catch (IOException ignore) { } } } } } } public void analyze() throws IOException, ParserConfigurationException, SAXException { gatherResourceValues(mRTxtFiles); recordMapping(mProguardMapping); for (File jarOrDir : mClasses) { recordClassUsages(jarOrDir); } recordManifestUsages(mManifests); recordResources(mResourceDirs); dumpReferences(); mModel.processToolsAttributes(); mUnused = mModel.findUnused(); } public void emitConfig(Path destination) throws IOException { File destinationFile = destination.toFile(); if (!destinationFile.exists()) { destinationFile.getParentFile().mkdirs(); boolean success = destinationFile.createNewFile(); if (!success) { throw new IOException("Could not create " + destination); } } StringBuilder sb = new StringBuilder(); Collections.sort(mUnused); for (Resource resource : mUnused) { sb.append(resource.type + "/" + resource.name + "#remove\n"); } Files.asCharSink(destinationFile, UTF_8).write(sb.toString()); } private void dumpReferences() { if (mDebugPrinter != null) { mDebugPrinter.print(mModel.dumpReferences()); } } private void recordResources(Iterable resources) throws IOException, SAXException, ParserConfigurationException { for (File resDir : resources) { File[] resourceFolders = resDir.listFiles(); assert resourceFolders != null : "Invalid resource directory " + resDir; for (File folder : resourceFolders) { ResourceFolderType folderType = ResourceFolderType.getFolderType(folder.getName()); if (folderType != null) { recordResources(folderType, folder); } } } } private void recordResources(ResourceFolderType folderType, File folder) throws ParserConfigurationException, SAXException, IOException { File[] files = folder.listFiles(); if (files != null) { for (File file : files) { String path = file.getPath(); mModel.file = file; try { boolean isXml = endsWithIgnoreCase(path, DOT_XML); if (isXml) { String xml = Files.toString(file, UTF_8); Document document = XmlUtils.parseDocument(xml, true); mModel.visitXmlDocument(file, folderType, document); } else { mModel.visitBinaryResource(folderType, file); } } finally { mModel.file = null; } } } } void recordMapping(File mapping) throws IOException { if (mapping == null || !mapping.exists()) { return; } final String arrowString = " -> "; final String resourceString = ".R$"; Map nameMap = null; for (String line : Files.readLines(mapping, UTF_8)) { if (line.startsWith(" ") || line.startsWith("\t")) { if (nameMap != null) { // We're processing the members of a resource class: record names into the map int n = line.length(); int i = 0; for (; i < n; i++) { if (!Character.isWhitespace(line.charAt(i))) { break; } } if (i < n && line.startsWith("int", i)) { // int or int[] int start = line.indexOf(' ', i + 3) + 1; int arrow = line.indexOf(arrowString); if (start > 0 && arrow != -1) { int end = line.indexOf(' ', start + 1); if (end != -1) { String oldName = line.substring(start, end); String newName = line.substring(arrow + arrowString.length()).trim(); if (!newName.equals(oldName)) { nameMap.put(newName, oldName); } } } } } continue; } else { nameMap = null; } int index = line.indexOf(resourceString); if (index == -1) { // Record obfuscated names of a few known appcompat usages of // Resources#getIdentifier that are unlikely to be used for general // resource name reflection if (line.startsWith("android.support.v7.widget.SuggestionsAdapter ")) { mSuggestionsAdapter = line.substring(line.indexOf(arrowString) + arrowString.length(), line.indexOf(':') != -1 ? line.indexOf(':') : line.length()) .trim() .replace('.', '/') + DOT_CLASS; } else if (line.startsWith("android.support.v7.internal.widget.ResourcesWrapper ") || line.startsWith("android.support.v7.widget.ResourcesWrapper ") || (mResourcesWrapper == null // Recently wrapper moved && line.startsWith( "android.support.v7.widget.TintContextWrapper$TintResources "))) { mResourcesWrapper = line.substring(line.indexOf(arrowString) + arrowString.length(), line.indexOf(':') != -1 ? line.indexOf(':') : line.length()) .trim() .replace('.', '/') + DOT_CLASS; } continue; } int arrow = line.indexOf(arrowString, index + 3); if (arrow == -1) { continue; } String typeName = line.substring(index + resourceString.length(), arrow); ResourceType type = ResourceType.fromClassName(typeName); if (type == null) { continue; } int end = line.indexOf(':', arrow + arrowString.length()); if (end == -1) { end = line.length(); } String target = line.substring(arrow + arrowString.length(), end).trim(); String ownerName = target.replace('.', '/'); nameMap = Maps.newHashMap(); Pair> pair = new Pair(type, nameMap); mResourceObfuscation.put(ownerName, pair); // For fast lookup in isResourceClass mResourceObfuscation.put(ownerName + DOT_CLASS, pair); } } private void recordManifestUsages(File manifest) throws IOException, ParserConfigurationException, SAXException { String xml = Files.toString(manifest, UTF_8); Document document = XmlUtils.parseDocument(xml, true); mModel.visitXmlDocument(manifest, null, document); } private void recordManifestUsages(Iterable manifests) throws IOException, ParserConfigurationException, SAXException { for (File manifest : manifests) { recordManifestUsages(manifest); } } private void recordClassUsages(File file) throws IOException { assert file.isFile(); if (file.getPath().endsWith(DOT_DEX)) { byte[] bytes = Files.toByteArray(file); recordClassUsages(file, file.getName(), bytes); } else if (file.getPath().endsWith(DOT_JAR)) { ZipInputStream zis = null; try { FileInputStream fis = new FileInputStream(file); try { zis = new ZipInputStream(fis); ZipEntry entry = zis.getNextEntry(); while (entry != null) { String name = entry.getName(); if (name.endsWith(DOT_DEX)) { byte[] bytes = ByteStreams.toByteArray(zis); if (bytes != null) { recordClassUsages(file, name, bytes); } } entry = zis.getNextEntry(); } } finally { Closeables.close(fis, true); } } finally { Closeables.close(zis, true); } } } private void recordClassUsages(File file, String name, byte[] bytes) { assert name.endsWith(DOT_DEX); ReferenceChecker callback = new ReferenceChecker() { @Override public boolean shouldProcess(String internalName) { return !isResourceClass(internalName + DOT_CLASS); } @Override public void referencedInt(int value) { UnusedResources.this.referencedInt("dex", value, file, name); } @Override public void referencedString(String value) { // do nothing. } @Override public void referencedStaticField(String internalName, String fieldName) { Resource resource = getResourceFromCode(internalName, fieldName); if (resource != null) { ResourceUsageModel.markReachable(resource); } } @Override public void referencedMethod( String internalName, String methodName, String methodDescriptor) { // Do nothing. } }; ProgramResource resource = ProgramResource.fromBytes( new PathOrigin(file.toPath()), ProgramResource.Kind.DEX, bytes, null); ProgramResourceProvider provider = () -> Arrays.asList(resource); try { Command command = (new ResourceShrinker.Builder()).addProgramResourceProvider(provider).build(); ResourceShrinker.run(command, callback); } catch (CompilationFailedException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } } /** Returns whether the given class file name points to an aapt-generated compiled R class. */ boolean isResourceClass(String name) { if (mResourceObfuscation.containsKey(name)) { return true; } int index = name.lastIndexOf('/'); if (index != -1 && name.startsWith("R$", index + 1) && name.endsWith(DOT_CLASS)) { String typeName = name.substring(index + 3, name.length() - DOT_CLASS.length()); return ResourceType.fromClassName(typeName) != null; } return false; } Resource getResourceFromCode(String owner, String name) { Pair> pair = mResourceObfuscation.get(owner); if (pair != null) { ResourceType type = pair.getFirst(); Map nameMap = pair.getSecond(); String renamedField = nameMap.get(name); if (renamedField != null) { name = renamedField; } return mModel.getResource(type, name); } if (isValidResourceType(owner)) { ResourceType type = ResourceType.fromClassName(owner.substring(owner.lastIndexOf('$') + 1)); if (type != null) { return mModel.getResource(type, name); } } return null; } private Boolean isValidResourceType(String candidateString) { return candidateString.contains("/") && candidateString.substring(candidateString.lastIndexOf('/') + 1).contains("$"); } private void gatherResourceValues(Iterable rTxts) throws IOException { for (File rTxt : rTxts) { assert rTxt.isFile(); assert rTxt.getName().endsWith(FN_RESOURCE_TEXT); addResourcesFromRTxtFile(rTxt); } } private void addResourcesFromRTxtFile(File file) { try { SymbolTable st = readFromAapt(file, null); for (Symbol symbol : st.getSymbols().values()) { String symbolValue = symbol.getValue(); if (symbol.getResourceType() == ResourceType.STYLEABLE) { if (symbolValue.trim().startsWith("{")) { // Only add the styleable parent, styleable children are not yet supported. mModel.addResource(symbol.getResourceType(), symbol.getName(), null); } } else { mModel.addResource(symbol.getResourceType(), symbol.getName(), symbolValue); } } } catch (Exception e) { e.printStackTrace(); } } ResourceUsageModel getModel() { return mModel; } private void referencedInt(String context, int value, File file, String currentClass) { Resource resource = mModel.getResource(value); if (ResourceUsageModel.markReachable(resource) && mDebugPrinter != null) { mDebugPrinter.println("Marking " + resource + " reachable: referenced from " + context + " in " + file + ":" + currentClass); } } private final ResourceShrinkerUsageModel mModel = new ResourceShrinkerUsageModel(); private class ResourceShrinkerUsageModel extends ResourceUsageModel { public File file; /** * Whether we should ignore tools attribute resource references. *

* For example, for resource shrinking we want to ignore tools attributes, * whereas for resource refactoring on the source code we do not. * * @return whether tools attributes should be ignored */ @Override protected boolean ignoreToolsAttributes() { return true; } @Override protected void onRootResourcesFound(List roots) { if (mDebugPrinter != null) { mDebugPrinter.println( "\nThe root reachable resources are:\n" + Joiner.on(",\n ").join(roots)); } } @Override protected Resource declareResource(ResourceType type, String name, Node node) { Resource resource = super.declareResource(type, name, node); resource.addLocation(file); return resource; } @Override protected void referencedString(String string) { // Do nothing } } public static void main(String[] args) throws Exception { List rTxtFiles = null; // R.txt files List classes = null; // Dex/jar w dex List manifests = null; // manifests File mapping = null; // mapping List resources = null; // resources dirs File log = null; // output log for debugging Path configPath = null; // output config for (int i = 0; i < args.length; i += 2) { switch (args[i]) { case "--rtxts": rTxtFiles = Arrays.stream(args[i + 1].split(":")) .map(s -> new File(s)) .collect(Collectors.toList()); break; case "--dexes": classes = Arrays.stream(args[i + 1].split(":")) .map(s -> new File(s)) .collect(Collectors.toList()); break; case "--manifests": manifests = Arrays.stream(args[i + 1].split(":")) .map(s -> new File(s)) .collect(Collectors.toList()); break; case "--mapping": mapping = new File(args[i + 1]); break; case "--resourceDirs": resources = Arrays.stream(args[i + 1].split(":")) .map(s -> new File(s)) .collect(Collectors.toList()); break; case "--log": log = new File(args[i + 1]); break; case "--outputConfig": configPath = Paths.get(args[i + 1]); break; default: throw new IllegalArgumentException(args[i] + " is not a valid arg."); } } UnusedResources unusedResources = new UnusedResources(rTxtFiles, classes, manifests, mapping, resources, log); unusedResources.analyze(); unusedResources.close(); unusedResources.emitConfig(configPath); } }