| /* |
| * 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.gyp.resources_shrinker; |
| |
| 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 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.Files; |
| 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 Shrinker { |
| 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<File> mRTxtFiles; |
| private final File mProguardMapping; |
| /** These can be class or dex files. */ |
| private final Iterable<File> mClasses; |
| private final Iterable<File> mManifests; |
| private final Iterable<File> mResourceDirs; |
| |
| private final File mReportFile; |
| private final StringWriter mDebugOutput; |
| private final PrintWriter mDebugPrinter; |
| |
| /** Easy way to invoke more verbose output for debugging */ |
| private boolean mDebug = false; |
| |
| /** The computed set of unused resources */ |
| private List<Resource> 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<String, Pair<ResourceType, Map<String, String>>> 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<U, V> { |
| 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 Shrinker(Iterable<File> rTxtFiles, Iterable<File> classes, Iterable<File> manifests, |
| File mapping, Iterable<File> 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<File> resources) |
| throws IOException, SAXException, ParserConfigurationException { |
| for (File resDir : resources) { |
| File[] resourceFolders = resDir.listFiles(); |
| if (resourceFolders != null) { |
| 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<String, String> 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<ResourceType, Map<String, String>> 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<File> 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) { |
| Shrinker.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<ResourceType, Map<String, String>> pair = mResourceObfuscation.get(owner); |
| if (pair != null) { |
| ResourceType type = pair.getFirst(); |
| Map<String, String> 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<File> 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) && mDebug) { |
| assert mDebugPrinter != null : "mDebug is true, but mDebugPrinter is 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. |
| * <p> |
| * 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<Resource> 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<File> rTxtFiles = null; // R.txt files |
| List<File> classes = null; // Dex/jar w dex |
| List<File> manifests = null; // manifests |
| File mapping = null; // mapping |
| List<File> 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 "--dex": |
| 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."); |
| } |
| } |
| Shrinker shrinker = new Shrinker(rTxtFiles, classes, manifests, mapping, resources, log); |
| shrinker.analyze(); |
| shrinker.close(); |
| shrinker.emitConfig(configPath); |
| } |
| } |