blob: 50e2f93e9bc22ddd320a57676249b7988b729be9 [file] [log] [blame]
/*
* 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);
}
}