blob: b6e70ffd9d66de25ee946fd7a58cc4e24e816eab [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.
*/
package com.android.build.gradle.tasks;
import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_DISCARD;
import static com.android.SdkConstants.ATTR_KEEP;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_PARENT;
import static com.android.SdkConstants.ATTR_SHRINK_MODE;
import static com.android.SdkConstants.ATTR_TYPE;
import static com.android.SdkConstants.DOT_CLASS;
import static com.android.SdkConstants.DOT_JAR;
import static com.android.SdkConstants.DOT_XML;
import static com.android.SdkConstants.FD_RES_VALUES;
import static com.android.SdkConstants.PREFIX_ANDROID;
import static com.android.SdkConstants.PREFIX_RESOURCE_REF;
import static com.android.SdkConstants.PREFIX_THEME_REF;
import static com.android.SdkConstants.REFERENCE_STYLE;
import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX;
import static com.android.SdkConstants.TAG_ITEM;
import static com.android.SdkConstants.TAG_RESOURCES;
import static com.android.SdkConstants.TAG_STYLE;
import static com.android.SdkConstants.TOOLS_URI;
import static com.android.utils.SdkUtils.endsWithIgnoreCase;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.objectweb.asm.ClassReader.SKIP_DEBUG;
import static org.objectweb.asm.ClassReader.SKIP_FRAMES;
import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.ide.common.resources.ResourceUrl;
import com.android.ide.common.xml.XmlPrettyPrinter;
import com.android.resources.FolderTypeRelationship;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.tools.lint.checks.ResourceUsageModel;
import com.android.tools.lint.checks.ResourceUsageModel.Resource;
import com.android.tools.lint.checks.StringFormatDetector;
import com.android.tools.lint.detector.api.LintUtils;
import com.android.utils.AsmUtils;
import com.android.utils.Pair;
import com.android.utils.XmlUtils;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.io.ByteStreams;
import com.google.common.io.Closeables;
import com.google.common.io.Files;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.FileHandler;
import java.util.logging.Formatter;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathException;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathFactory;
import org.objectweb.asm.AnnotationVisitor;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.FieldVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.w3c.dom.Attr;
import org.w3c.dom.DOMException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
/**
* Class responsible for searching through a Gradle built tree (after resource merging, compilation
* and ProGuarding has been completed, but before final .apk assembly), which figures out which
* resources if any are unused, and removes them.
* <p>It does this by examining
* <ul>
* <li>The merged manifest, to find root resource references (such as drawables used for activity
* icons)</li>
* <li>The R.txt file (to find the actual integer constants assigned to resources)</li>
* <li>The ProGuard log files (to find the mapping from original symbol names to short names)</li>
* <li>The merged resources (to find which resources reference other resources, e.g. drawable
* state lists including other drawables, or layouts including other layouts, or styles
* referencing other drawables, or menus items including action layouts, etc.)</li>
* <li>The ProGuard output classes (to find resource references in code that are actually
* reachable)</li>
* </ul>
* From all this, it builds up a reference graph, and based on the root references (e.g. from the
* manifest and from the remaining code) it computes which resources are actually reachable in the
* app, and anything that is not reachable is then marked for deletion.
* <p>A resource is referenced in code if either the field R.type.name is referenced (which is the
* case for non-final resource references, e.g. in libraries), or if the corresponding int value is
* referenced (for final resource values). We check this by looking at the ProGuard output classes
* with an ASM visitor. One complication is that code can also call
* {@code Resources#getIdentifier(String,String,String)} where they can pass in the names of
* resources to look up. To handle this scenario, we use the ClassVisitor to see if there are any
* calls to the specific {@code Resources#getIdentifier} method. If not, great, the usage analysis
* is completely accurate. If we <b>do</b> find one, we check <b>all</b> the string constants found
* anywhere in the app, and look to see if any look relevant. For example, if we find the string
* "string/foo" or "my.pkg:string/foo", we will then mark the string resource named foo (if any) as
* potentially used. Similarly, if we find just "foo" or "/foo", we will mark <b>all</b> resources
* named "foo" as potentially used. However, if the string is "bar/foo" or " foo " these strings are
* ignored. This means we can potentially miss resources usages where the resource name is completed
* computed (e.g. by concatenating individual characters or taking substrings of strings that do not
* look like resource names), but that seems extremely unlikely to be a real-world scenario.
*/
public class ResourceUsageAnalyzer {
private static final String ANDROID_RES = "android_res/";
/** Special marker regexp which does not match a resource name */
static final String NO_MATCH = "-nomatch-";
private final ResourceShrinkerUsageModel model;
private final Set<String> resourcePackages;
private final Path rTxt;
private final Path proguardMapping;
private final Path classes;
private final Path mergedManifest;
private final Path mergedResourceDir;
private final Logger logger;
/**
* The computed set of unused resources
*/
private List<Resource> unused;
/**
* 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>>> resourceObfuscation =
Maps.newHashMapWithExpectedSize(30);
/** Obfuscated name of android/support/v7/widget/SuggestionsAdapter.java */
private String suggestionsAdapter;
/** Obfuscated name of android/support/v7/internal/widget/ResourcesWrapper.java */
private String resourcesWrapper;
public ResourceUsageAnalyzer(
Set<String> resourcePackages,
@NonNull Path rTxt,
@NonNull Path classes,
@NonNull Path manifest,
@Nullable Path mapping,
@NonNull Path resources,
@Nullable Path logFile) throws DOMException, ParserConfigurationException {
this.model = new ResourceShrinkerUsageModel();
this.resourcePackages = resourcePackages;
this.rTxt = rTxt;
this.proguardMapping = mapping;
this.classes = classes;
this.mergedManifest = manifest;
this.mergedResourceDir = resources;
this.logger = Logger.getLogger(getClass().getName());
logger.setLevel(Level.FINE);
if (logFile != null) {
try {
FileHandler fileHandler = new FileHandler(logFile.toString());
fileHandler.setLevel(Level.FINE);
fileHandler.setFormatter(new Formatter(){
@Override public String format(LogRecord record) {
return record.getMessage() + "\n";
}
});
logger.addHandler(fileHandler);
} catch (SecurityException | IOException e) {
logger.warning(String.format("Unable to open '%s' to write log.", logFile));
}
}
}
public void shrink(Path destinationDir) throws IOException,
ParserConfigurationException, SAXException {
parseResourceTxtFile(rTxt, resourcePackages);
recordMapping(proguardMapping);
recordClassUsages(classes);
recordManifestUsages(mergedManifest);
recordResources(mergedResourceDir);
keepPossiblyReferencedResources();
dumpReferences();
model.processToolsAttributes();
unused = model.findUnused();
removeUnused(destinationDir);
}
/**
* Remove resources (already identified by {@link #shrink(Path)}).
*
* <p>This task will copy all remaining used resources over from the full resource directory to a
* new reduced resource directory and removes unused values from all value xml files.
*
* @param destination directory to copy resources into; if null, delete resources in place
* @throws IOException
* @throws ParserConfigurationException
* @throws SAXException
*/
private void removeUnused(Path destination) throws IOException,
ParserConfigurationException, SAXException {
assert unused != null; // should always call analyze() first
int resourceCount = unused.size() * 4; // *4: account for some resource folder repetition
Set<File> skip = Sets.newHashSetWithExpectedSize(resourceCount);
Set<File> rewrite = Sets.newHashSetWithExpectedSize(resourceCount);
Set<Resource> deleted = Sets.newHashSetWithExpectedSize(resourceCount);
for (Resource resource : unused) {
if (resource.declarations != null) {
for (File file : resource.declarations) {
String folder = file.getParentFile().getName();
ResourceFolderType folderType = ResourceFolderType.getFolderType(folder);
if (folderType != null && folderType != ResourceFolderType.VALUES) {
List<ResourceType> types = FolderTypeRelationship.getRelatedResourceTypes(folderType);
ResourceType type = types.get(0);
assert type != ResourceType.ID : folderType;
Resource fileResource = model.getResource(type, LintUtils.getBaseName(file.getName()));
// Only delete the file if there is no owning resource or this is the owning resource of
// the file, i.e. not an id declared within it, because id declarations are not
// considered uses and would otherwise cause deletion of the file.
if (fileResource == null || fileResource.equals(resource)) {
logger.fine("Deleted unused file " + file + " for resource " + resource);
assert skip != null;
skip.add(file);
deleted.add(resource);
}
} else {
// Can't delete values immediately; there can be many resources
// in this file, so we have to process them all
rewrite.add(file);
}
}
} else {
// Not declared anywhere; mark as deleted. Covers the case of inline resources.
// https://developer.android.com/guide/topics/resources/complex-xml-resources.html
deleted.add(resource);
}
}
// Special case the base values.xml folder
File values = new File(mergedResourceDir.toFile(),
FD_RES_VALUES + File.separatorChar + "values.xml");
if (values.exists()) {
rewrite.add(values);
}
Map<File, String> rewritten = Maps.newHashMapWithExpectedSize(rewrite.size());
rewriteXml(rewrite, rewritten, deleted);
// TODO(apell): The graph traversal does not mark IDs as reachable or not, so they cannot be
// accurately removed from public.xml, but the definitions may be deleted if they occur in
// other files. IDs should be added to values.xml so that there are no declarations in
// public.xml without definitions.
File publicXml = new File(mergedResourceDir.toFile(),
FD_RES_VALUES + File.separatorChar + "public.xml");
createStubIds(values, rewritten, publicXml);
trimPublicResources(publicXml, deleted, rewritten);
filteredCopy(mergedResourceDir.toFile(), destination, skip, rewritten);
}
/**
* Deletes unused resources from value XML files.
*/
private void rewriteXml(Set<File> rewrite, Map<File, String> rewritten, Set<Resource> deleted)
throws IOException, ParserConfigurationException, SAXException {
// Delete value resources: Must rewrite the XML files
for (File file : rewrite) {
String xml = Files.toString(file, UTF_8);
Document document = XmlUtils.parseDocument(xml, true);
Element root = document.getDocumentElement();
if (root != null && TAG_RESOURCES.equals(root.getTagName())) {
List<Resource> removed = Lists.newArrayList();
stripUnused(root, removed);
deleted.addAll(removed);
logger.fine(String.format("Removed %d unused resources from %s:\n %s",
removed.size(), file,
Joiner.on(", ").join(Lists.transform(removed, new Function<Resource, String>() {
@Override
public String apply(Resource resource) {
return resource.getUrl();
}
}))));
String formatted = XmlPrettyPrinter.prettyPrint(document, xml.endsWith("\n"));
rewritten.put(file, formatted);
}
}
}
/**
* Write stub values for IDs to values.xml to match those available in public.xml.
*/
private void createStubIds(File values, Map<File, String> rewritten, File publicXml)
throws IOException, ParserConfigurationException, SAXException {
if (values.exists()) {
String xml = rewritten.get(values);
if (xml == null) {
xml = Files.toString(values, UTF_8);
}
List<String> stubbed = Lists.newArrayList();
Document document = XmlUtils.parseDocument(xml, true);
Element root = document.getDocumentElement();
for (Resource resource : model.getResources()) {
boolean inPublicXml = resource.declarations != null
&& resource.declarations.contains(publicXml);
NodeList existing = null;
try {
XPathExpression expr = XPathFactory.newInstance().newXPath().compile(
String.format("//item[@type=\"id\"][@name=\"%s\"]", resource.name));
existing = (NodeList) expr.evaluate(document, XPathConstants.NODESET);
} catch (XPathException e) {
// Failed to retrieve any existing declarations for resource.
}
if (resource.type == ResourceType.ID && inPublicXml
&& (existing == null || existing.getLength() == 0)) {
Element item = document.createElement(TAG_ITEM);
item.setAttribute(ATTR_TYPE, resource.type.getName());
item.setAttribute(ATTR_NAME, resource.name);
root.appendChild(item);
stubbed.add(resource.getUrl());
}
}
logger.fine("Created " + stubbed.size() + " stub IDs for:\n "
+ Joiner.on(", ").join(stubbed));
String formatted = XmlPrettyPrinter.prettyPrint(document, xml.endsWith("\n"));
rewritten.put(values, formatted);
}
}
/**
* Remove public definitions of unused resources.
*/
private void trimPublicResources(File publicXml, Set<Resource> deleted,
Map<File, String> rewritten) throws IOException, ParserConfigurationException, SAXException {
if (publicXml.exists()) {
String xml = rewritten.get(publicXml);
if (xml == null) {
xml = Files.toString(publicXml, UTF_8);
}
Document document = XmlUtils.parseDocument(xml, true);
Element root = document.getDocumentElement();
if (root != null && TAG_RESOURCES.equals(root.getTagName())) {
NodeList children = root.getChildNodes();
for (int i = children.getLength() - 1; i >= 0; i--) {
Node child = children.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE) {
Element resourceElement = (Element) child;
ResourceType type = ResourceType.getEnum(resourceElement.getAttribute(ATTR_TYPE));
String name = resourceElement.getAttribute(ATTR_NAME);
if (type != null && name != null) {
Resource resource = model.getResource(type, name);
if (resource != null && deleted.contains(resource)) {
root.removeChild(child);
}
}
}
}
}
String formatted = XmlPrettyPrinter.prettyPrint(document, xml.endsWith("\n"));
rewritten.put(publicXml, formatted);
}
}
/**
* Copies one resource directory tree into another; skipping some files, replacing the contents of
* some, and passing everything else through unmodified
*/
private static void filteredCopy(File source, Path destination, Set<File> skip,
Map<File, String> replace) throws IOException {
File destinationFile = destination.toFile();
if (source.isDirectory()) {
File[] children = source.listFiles();
if (children != null) {
if (!destinationFile.exists()) {
boolean success = destinationFile.mkdirs();
if (!success) {
throw new IOException("Could not create " + destination);
}
}
for (File child : children) {
filteredCopy(child, destination.resolve(child.getName()), skip, replace);
}
}
} else if (!skip.contains(source) && source.isFile()) {
String contents = replace.get(source);
if (contents != null) {
Files.write(contents, destinationFile, UTF_8);
} else {
Files.copy(source, destinationFile);
}
}
}
private void stripUnused(Element element, List<Resource> removed) {
ResourceType type = ResourceUsageModel.getResourceType(element);
if (type == ResourceType.ATTR) {
// Not yet properly handled
return;
}
Resource resource = model.getResource(element);
if (resource != null) {
if (resource.type == ResourceType.DECLARE_STYLEABLE
|| resource.type == ResourceType.ATTR) {
// Don't strip children of declare-styleable; we're not correctly
// tracking field references of the R_styleable_attr fields yet
return;
}
if (!resource.isReachable()
&& (resource.type == ResourceType.STYLE
|| resource.type == ResourceType.PLURALS
|| resource.type == ResourceType.ARRAY)) {
NodeList children = element.getChildNodes();
for (int i = children.getLength() - 1; i >= 0; i--) {
Node child = children.item(i);
element.removeChild(child);
}
}
}
NodeList children = element.getChildNodes();
for (int i = children.getLength() - 1; i >= 0; i--) {
Node child = children.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE) {
stripUnused((Element) child, removed);
}
}
if (resource != null && !resource.isReachable() && resource.type != ResourceType.ID) {
removed.add(resource);
Node parent = element.getParentNode();
parent.removeChild(element);
}
}
private void dumpReferences() {
logger.fine(model.dumpReferences());
}
private void keepPossiblyReferencedResources() {
if ((!foundGetIdentifier && !foundWebContent) || strings == null) {
// No calls to android.content.res.Resources#getIdentifier; no need
// to worry about string references to resources
return;
}
if (!model.isSafeMode()) {
// User specifically asked for us not to guess resources to keep; they will
// explicitly mark them as kept if necessary instead
return;
}
List<String> sortedStrings = new ArrayList<String>(strings);
Collections.sort(sortedStrings);
logger.fine(
"android.content.res.Resources#getIdentifier present: " + foundGetIdentifier);
logger.fine("Web content present: " + foundWebContent);
logger.fine("Referenced Strings:");
for (String string : sortedStrings) {
string = string.trim().replace("\n", "\\n");
if (string.length() > 40) {
string = string.substring(0, 37) + "...";
} else if (string.isEmpty()) {
continue;
}
logger.fine(" " + string);
}
int shortest = Integer.MAX_VALUE;
Set<String> names = Sets.newHashSetWithExpectedSize(50);
for (Resource resource : model.getResources()) {
String name = resource.name;
names.add(name);
int length = name.length();
if (length < shortest) {
shortest = length;
}
}
for (String string : strings) {
if (string.length() < shortest) {
continue;
}
// Check whether the string looks relevant
// We consider four types of strings:
// (1) simple resource names, e.g. "foo" from @layout/foo
// These might be the parameter to a getIdentifier() call, or could
// be composed into a fully qualified resource name for the getIdentifier()
// method. We match these for *all* resource types.
// (2) Relative source names, e.g. layout/foo, from @layout/foo
// These might be composed into a fully qualified resource name for
// getIdentifier().
// (3) Fully qualified resource names of the form package:type/name.
// (4) If foundWebContent is true, look for android_res/ URL strings as well
if (foundWebContent) {
Resource resource = model.getResourceFromFilePath(string);
if (resource != null) {
ResourceUsageModel.markReachable(resource);
continue;
} else {
int start = 0;
int slash = string.lastIndexOf('/');
if (slash != -1) {
start = slash + 1;
}
int dot = string.indexOf('.', start);
String name = string.substring(start, dot != -1 ? dot : string.length());
if (names.contains(name)) {
for (Map<String, Resource> map : model.getResourceMaps()) {
resource = map.get(name);
if (resource != null) {
logger.fine(String.format(
"Marking %s used because it matches string pool constant %s",
resource, string));
}
ResourceUsageModel.markReachable(resource);
}
}
}
}
// Look for normal getIdentifier resource URLs
int n = string.length();
boolean justName = true;
boolean formatting = false;
boolean haveSlash = false;
for (int i = 0; i < n; i++) {
char c = string.charAt(i);
if (c == '/') {
haveSlash = true;
justName = false;
} else if (c == '.' || c == ':' || c == '%') {
justName = false;
if (c == '%') {
formatting = true;
}
} else if (!Character.isJavaIdentifierPart(c)) {
// This shouldn't happen; we've filtered out these strings in
// the {@link #referencedString} method
assert false : string;
break;
}
}
String name;
if (justName) {
// Check name (below)
name = string;
// Check for a simple prefix match, e.g. as in
// getResources().getIdentifier("ic_video_codec_" + codecName, "drawable", ...)
for (Resource resource : model.getResources()) {
if (resource.name.startsWith(name)) {
logger.fine(String.format(
"Marking %s used because its prefix matches string pool constant %s",
resource, string));
ResourceUsageModel.markReachable(resource);
}
}
} else if (!haveSlash) {
if (formatting) {
// Possibly a formatting string, e.g.
// String name = String.format("my_prefix_%1d", index);
// int res = getContext().getResources().getIdentifier(name, "drawable", ...)
try {
Pattern pattern = Pattern.compile(convertFormatStringToRegexp(string));
for (Resource resource : model.getResources()) {
if (pattern.matcher(resource.name).matches()) {
logger.fine(String.format(
"Marking %s used because it format-string matches string pool constant %s",
resource, string));
ResourceUsageModel.markReachable(resource);
}
}
} catch (PatternSyntaxException ignored) {
// Might not have been a formatting string after all!
}
}
// If we have more than just a symbol name, we expect to also see a slash
//noinspection UnnecessaryContinue
continue;
} else {
// Try to pick out the resource name pieces; if we can find the
// resource type unambiguously; if not, just match on names
int slash = string.indexOf('/');
assert slash != -1; // checked with haveSlash above
name = string.substring(slash + 1);
if (name.isEmpty() || !names.contains(name)) {
continue;
}
// See if have a known specific resource type
if (slash > 0) {
int colon = string.indexOf(':');
String typeName = string.substring(colon != -1 ? colon + 1 : 0, slash);
ResourceType type = ResourceType.getEnum(typeName);
if (type == null) {
continue;
}
Resource resource = model.getResource(type, name);
if (resource != null) {
logger.fine(String.format(
"Marking %s used because it matches string pool constant %s",
resource, string));
}
ResourceUsageModel.markReachable(resource);
continue;
}
// fall through and check the name
}
if (names.contains(name)) {
for (Map<String, Resource> map : model.getResourceMaps()) {
Resource resource = map.get(name);
if (resource != null) {
logger.fine(String.format(
"Marking %s used because it matches string pool constant %s",
resource, string));
}
ResourceUsageModel.markReachable(resource);
}
} else if (Character.isDigit(name.charAt(0))) {
// Just a number? There are cases where it calls getIdentifier by
// a String number; see for example SuggestionsAdapter in the support
// library which reports supporting a string like "2130837524" and
// "android.resource://com.android.alarmclock/2130837524".
try {
int id = Integer.parseInt(name);
if (id != 0) {
ResourceUsageModel.markReachable(model.getResource(id));
}
} catch (NumberFormatException e) {
// pass
}
}
}
}
@VisibleForTesting
static String convertFormatStringToRegexp(String formatString) {
StringBuilder regexp = new StringBuilder();
int from = 0;
boolean hasEscapedLetters = false;
Matcher matcher = StringFormatDetector.FORMAT.matcher(formatString);
int length = formatString.length();
while (matcher.find(from)) {
int start = matcher.start();
int end = matcher.end();
if (start == 0 && end == length) {
// Don't match if the entire string literal starts with % and ends with
// the a formatting character, such as just "%d": this just matches absolutely
// everything and is unlikely to be used in a resource lookup
return NO_MATCH;
}
if (start > from) {
hasEscapedLetters |= appendEscapedPattern(formatString, regexp, from, start);
}
// If the wildcard follows a previous wildcard, just skip it
// (e.g. don't convert %s%s into .*.*; .* is enough.
int regexLength = regexp.length();
if (regexLength < 2
|| regexp.charAt(regexLength - 1) != '*'
|| regexp.charAt(regexLength - 2) != '.') {
regexp.append(".*");
}
from = end;
}
if (from < length) {
hasEscapedLetters |= appendEscapedPattern(formatString, regexp, from, length);
}
if (!hasEscapedLetters) {
// If the regexp contains *only* formatting characters, e.g. "%.0f%d", or
// if it contains only formatting characters and punctuation, e.g. "%s_%d",
// don't treat this as a possible resource name pattern string: it is unlikely
// to be intended for actual resource names, and has the side effect of matching
// most names.
return NO_MATCH;
}
return regexp.toString();
}
/**
* Appends the characters in the range [from,to> from formatString as escaped regexp characters
* into the given string builder. Returns true if there were any letters in the appended text.
*/
private static boolean appendEscapedPattern(
@NonNull String formatString, @NonNull StringBuilder regexp, int from, int to) {
regexp.append(Pattern.quote(formatString.substring(from, to)));
for (int i = from; i < to; i++) {
if (Character.isLetter(formatString.charAt(i))) {
return true;
}
}
return false;
}
private void recordResources(Path resDir)
throws IOException, SAXException, ParserConfigurationException {
File[] resourceFolders = resDir.toFile().listFiles();
if (resourceFolders != null) {
for (File folder : resourceFolders) {
ResourceFolderType folderType = ResourceFolderType.getFolderType(folder.getName());
if (folderType != null) {
recordResources(folderType, folder);
}
}
}
}
private void recordResources(@NonNull ResourceFolderType folderType, File folder)
throws ParserConfigurationException, SAXException, IOException {
File[] files = folder.listFiles();
if (files != null) {
for (File file : files) {
String path = file.getPath();
model.file = file;
try {
boolean isXml = endsWithIgnoreCase(path, DOT_XML);
if (isXml) {
String xml = Files.toString(file, UTF_8);
Document document = XmlUtils.parseDocument(xml, true);
model.visitXmlDocument(file, folderType, document);
} else {
model.visitBinaryResource(folderType, file);
}
} finally {
model.file = null;
}
}
}
}
private void recordMapping(@Nullable Path mapping) throws IOException {
if (mapping == null || !mapping.toFile().exists()) {
return;
}
final String arrowIndicator = " -> ";
final String resourceIndicator = ".R$";
Map<String, String> nameMap = null;
for (String line : Files.readLines(mapping.toFile(), 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(arrowIndicator);
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 + arrowIndicator.length()).trim();
if (!newName.equals(oldName)) {
nameMap.put(newName, oldName);
}
}
}
}
}
continue;
} else {
nameMap = null;
}
int index = line.indexOf(resourceIndicator);
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 ")) {
suggestionsAdapter =
line.substring(
line.indexOf(arrowIndicator) + arrowIndicator.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 ")
|| (resourcesWrapper == null // Recently wrapper moved
&& line.startsWith(
"android.support.v7.widget.TintContextWrapper$TintResources "))) {
resourcesWrapper =
line.substring(
line.indexOf(arrowIndicator) + arrowIndicator.length(),
line.indexOf(':') != -1 ? line.indexOf(':') : line.length())
.trim()
.replace('.', '/')
+ DOT_CLASS;
}
continue;
}
int arrow = line.indexOf(arrowIndicator, index + 3);
if (arrow == -1) {
continue;
}
String typeName = line.substring(index + resourceIndicator.length(), arrow);
ResourceType type = ResourceType.getEnum(typeName);
if (type == null) {
continue;
}
int end = line.indexOf(':', arrow + arrowIndicator.length());
if (end == -1) {
end = line.length();
}
String target = line.substring(arrow + arrowIndicator.length(), end).trim();
String ownerName = AsmUtils.toInternalName(target);
nameMap = Maps.newHashMap();
Pair<ResourceType, Map<String, String>> pair = Pair.of(type, nameMap);
resourceObfuscation.put(ownerName, pair);
// For fast lookup in isResourceClass
resourceObfuscation.put(ownerName + DOT_CLASS, pair);
}
}
private void recordManifestUsages(Path manifest)
throws IOException, ParserConfigurationException, SAXException {
String xml = Files.toString(manifest.toFile(), UTF_8);
Document document = XmlUtils.parseDocument(xml, true);
model.visitXmlDocument(manifest.toFile(), null, document);
}
public static String getFieldName(@NonNull String styleName) {
return styleName.replace('.', '_').replace('-', '_').replace(':', '_');
}
private Set<String> strings;
private boolean foundGetIdentifier;
private boolean foundWebContent;
private void referencedString(@NonNull String string) {
// See if the string is at all eligible; ignore strings that aren't
// identifiers (has java identifier chars and nothing but .:/), or are empty or too long
// We also allow "%", used for formatting strings.
if (string.isEmpty() || string.length() > 80) {
return;
}
boolean haveIdentifierChar = false;
for (int i = 0, n = string.length(); i < n; i++) {
char c = string.charAt(i);
boolean identifierChar = Character.isJavaIdentifierPart(c);
if (!identifierChar && c != '.' && c != ':' && c != '/' && c != '%') {
// .:/ are for the fully qualified resource names, or for resource URLs or
// relative file names
return;
} else if (identifierChar) {
haveIdentifierChar = true;
}
}
if (!haveIdentifierChar) {
return;
}
if (strings == null) {
strings = Sets.newHashSetWithExpectedSize(300);
}
strings.add(string);
if (!foundWebContent && string.contains(ANDROID_RES)) {
foundWebContent = true;
}
}
private void recordClassUsages(Path file) throws IOException {
if (file.toFile().isDirectory()) {
File[] children = file.toFile().listFiles();
if (children != null) {
for (File child : children) {
recordClassUsages(child.toPath());
}
}
} else if (file.toFile().isFile()) {
if (file.toFile().getPath().endsWith(DOT_CLASS)) {
byte[] bytes = Files.toByteArray(file.toFile());
recordClassUsages(file.toFile(), file.toFile().getName(), bytes);
} else if (file.toFile().getPath().endsWith(DOT_JAR)) {
ZipInputStream zis = null;
try {
FileInputStream fis = new FileInputStream(file.toFile());
try {
zis = new ZipInputStream(fis);
ZipEntry entry = zis.getNextEntry();
while (entry != null) {
String name = entry.getName();
if (name.endsWith(DOT_CLASS)
&&
// Skip resource type classes like R$drawable; they will
// reference the integer id's we're looking for, but these aren't
// actual usages we need to track; if somebody references the
// field elsewhere, we'll catch that
!isResourceClass(name)) {
byte[] bytes = ByteStreams.toByteArray(zis);
if (bytes != null) {
recordClassUsages(file.toFile(), name, bytes);
}
}
entry = zis.getNextEntry();
}
} finally {
Closeables.close(fis, true);
}
} finally {
Closeables.close(zis, true);
}
}
}
}
private void recordClassUsages(File file, String name, byte[] bytes) {
ClassReader classReader = new ClassReader(bytes);
classReader.accept(new UsageVisitor(file, name), SKIP_DEBUG | SKIP_FRAMES);
}
private void parseResourceTxtFile(Path rTxt, Set<String> resourcePackages) throws IOException {
BufferedReader reader = java.nio.file.Files.newBufferedReader(rTxt, UTF_8);
String line;
while ((line = reader.readLine()) != null) {
String[] tokens = line.split(" ");
ResourceType type = ResourceType.getEnum(tokens[1]);
for (String resourcePackage : resourcePackages) {
String owner = resourcePackage.replace('.', '/') + "/R$" + type.getName();
Pair<ResourceType, Map<String, String>> pair = resourceObfuscation.get(owner);
if (pair == null) {
Map<String, String> nameMap = Maps.newHashMap();
pair = Pair.of(type, nameMap);
}
resourceObfuscation.put(owner, pair);
}
if (type == ResourceType.STYLEABLE) {
if (tokens[0].equals("int[]")) {
model.addResource(ResourceType.DECLARE_STYLEABLE, tokens[2], null);
} else {
// TODO(jongerrish): Implement stripping of styleables.
}
} else {
model.addResource(type, tokens[2], tokens[3]);
}
}
}
/** Returns whether the given class file name points to an aapt-generated compiled R class */
@VisibleForTesting
boolean isResourceClass(@NonNull String name) {
if (resourceObfuscation.containsKey(name)) {
return true;
}
assert name.endsWith(DOT_CLASS) : name;
int index = name.lastIndexOf('/');
if (index != -1 && name.startsWith("R$", index + 1)) {
String typeName = name.substring(index + 3, name.length() - DOT_CLASS.length());
return ResourceType.getEnum(typeName) != null;
}
return false;
}
@VisibleForTesting
@Nullable
Resource getResourceFromCode(@NonNull String owner, @NonNull String name) {
Pair<ResourceType, Map<String, String>> pair = resourceObfuscation.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 model.getResource(type, name);
}
return null;
}
public int getUnusedResourceCount() {
return unused.size();
}
@VisibleForTesting
ResourceUsageModel getModel() {
return model;
}
/**
* Class visitor responsible for looking for resource references in code. It looks for R.type.name
* references (as well as inlined constants for these, in the case of non-library code), as well
* as looking both for Resources#getIdentifier calls and recording string literals, used to handle
* dynamic lookup of resources.
*/
private class UsageVisitor extends ClassVisitor {
private final File jarFile;
private final String currentClass;
public UsageVisitor(File jarFile, String name) {
super(Opcodes.ASM5);
this.jarFile = jarFile;
currentClass = name;
}
@Override
public MethodVisitor visitMethod(
int access, final String name, String desc, String signature, String[] exceptions) {
return new MethodVisitor(Opcodes.ASM5) {
@Override
public void visitLdcInsn(Object cst) {
handleCodeConstant(cst, "ldc");
}
@Override
public void visitFieldInsn(int opcode, String owner, String name, String desc) {
if (opcode == Opcodes.GETSTATIC) {
Resource resource = getResourceFromCode(owner, name);
if (resource != null) {
ResourceUsageModel.markReachable(resource);
}
}
}
@Override
public void visitMethodInsn(
int opcode, String owner, String name, String desc, boolean itf) {
super.visitMethodInsn(opcode, owner, name, desc, itf);
if (owner.equals("android/content/res/Resources")
&& name.equals("getIdentifier")
&& desc.equals("(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I")) {
if (currentClass.equals(resourcesWrapper)
|| currentClass.equals(suggestionsAdapter)) {
// "benign" usages: don't trigger reflection mode just because
// the user has included appcompat
return;
}
foundGetIdentifier = true;
// TODO: Check previous instruction and see if we can find a literal
// String; if so, we can more accurately dispatch the resource here
// rather than having to check the whole string pool!
}
if (owner.equals("android/webkit/WebView") && name.startsWith("load")) {
foundWebContent = true;
}
}
@Override
public AnnotationVisitor visitAnnotationDefault() {
return new AnnotationUsageVisitor();
}
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
return new AnnotationUsageVisitor();
}
@Override
public AnnotationVisitor visitParameterAnnotation(
int parameter, String desc, boolean visible) {
return new AnnotationUsageVisitor();
}
};
}
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
return new AnnotationUsageVisitor();
}
@Override
public FieldVisitor visitField(
int access, String name, String desc, String signature, Object value) {
handleCodeConstant(value, "field");
return new FieldVisitor(Opcodes.ASM5) {
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
return new AnnotationUsageVisitor();
}
};
}
private class AnnotationUsageVisitor extends AnnotationVisitor {
public AnnotationUsageVisitor() {
super(Opcodes.ASM5);
}
@Override
public AnnotationVisitor visitAnnotation(String name, String desc) {
return new AnnotationUsageVisitor();
}
@Override
public AnnotationVisitor visitArray(String name) {
return new AnnotationUsageVisitor();
}
@Override
public void visit(String name, Object value) {
handleCodeConstant(value, "annotation");
super.visit(name, value);
}
}
/** Invoked when an ASM visitor encounters a constant: record corresponding reference */
private void handleCodeConstant(@Nullable Object cst, @NonNull String context) {
if (cst instanceof Integer) {
Integer value = (Integer) cst;
Resource resource = model.getResource(value);
if (ResourceUsageModel.markReachable(resource)) {
logger.fine(String.format("Marking %s reachable: referenced from %s in %s:%s",
resource, context, jarFile, currentClass));
}
} else if (cst instanceof int[]) {
int[] values = (int[]) cst;
for (int value : values) {
Resource resource = model.getResource(value);
if (ResourceUsageModel.markReachable(resource)) {
logger.fine(String.format("Marking %s reachable: referenced from %s in %s:%s",
resource, context, jarFile, currentClass));
}
}
} else if (cst instanceof String) {
String string = (String) cst;
referencedString(string);
}
}
}
private class ResourceShrinkerUsageModel extends ResourceUsageModel {
public File file;
private ResourceShrinkerUsageModel() throws DOMException, ParserConfigurationException {
Attr attr = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()
.createAttributeNS(SdkConstants.TOOLS_URI, SdkConstants.ATTR_SHRINK_MODE);
attr.setValue(SdkConstants.VALUE_STRICT);
super.recordToolsAttributes(attr);
}
@NonNull
@Override
protected List<Resource> findRoots(@NonNull List<Resource> resources) {
List<Resource> roots = super.findRoots(resources);
logger.fine("The root reachable resources are:\n " + Joiner.on(",\n ").join(roots) + "\n");
return 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(@NonNull String string) {
ResourceUsageAnalyzer.this.referencedString(string);
foundWebContent = true;
}
@Override
public Resource getResource(Element element) {
if (isPublic(element)) {
ResourceType type = getTypeFromPublic(element);
if (type != null) {
String name = getFieldName(element);
Resource resource = getResource(type, name);
return resource;
}
return null;
} else {
return super.getResource(element);
}
}
public boolean isPublic(Element element) {
return element.getTagName().equals(ResourceType.PUBLIC.getName());
}
public ResourceType getTypeFromPublic(Element element) {
String typeName = element.getAttribute(ATTR_TYPE);
if (!typeName.isEmpty()) {
return ResourceType.getEnum(typeName);
}
return null;
}
@Nullable
Resource getResourceFromUrl(@NonNull String possibleUrlReference) {
ResourceUrl url = ResourceUrl.parse(possibleUrlReference);
if (url != null && !url.framework) {
return addResource(url.type, LintUtils.getFieldName(url.name), null);
}
return null;
}
/**
* Records resource declarations and usages within an XML resource file
*
* @param folderType the type of resource file
* @param node the root node to start the recursive search from
* @param from a referencing context, if any.
*/
// Override from parent ResourceUsageModel to fix <style> analysis bugs.
// TODO(apell): remove this override once the packaged version of ResourceUsageModel includes
// these fixes. See inline comments for location of fixes.
@Override
public void recordResourceReferences(
@NonNull ResourceFolderType folderType, @NonNull Node node, @Nullable Resource from) {
short nodeType = node.getNodeType();
if (nodeType == Node.ELEMENT_NODE) {
Element element = (Element) node;
if (from != null) {
NamedNodeMap attributes = element.getAttributes();
for (int i = 0, n = attributes.getLength(); i < n; i++) {
Attr attr = (Attr) attributes.item(i);
// Ignore tools: namespace attributes, unless it's
// a keep attribute
if (TOOLS_URI.equals(attr.getNamespaceURI())) {
recordToolsAttributes(attr);
// Skip all other tools: attributes
continue;
}
String value = attr.getValue();
if (!(value.startsWith(PREFIX_RESOURCE_REF) || value.startsWith(PREFIX_THEME_REF))) {
continue;
}
ResourceUrl url = ResourceUrl.parse(value);
if (url != null && !url.framework) {
Resource resource;
if (url.create) {
resource = declareResource(url.type, url.name, attr);
} else {
resource = addResource(url.type, url.name, null);
from.addReference(resource);
}
} else if (value.startsWith("@{")) {
// Data binding expression: there could be multiple references here
int length = value.length();
int index = 2; // skip @{
while (true) {
index = value.indexOf('@', index);
if (index == -1) {
break;
}
// Find end of (potential) resource URL: first non resource URL character
int end = index + 1;
while (end < length) {
char c = value.charAt(end);
if (!(Character.isJavaIdentifierPart(c)
|| c == '_'
|| c == '.'
|| c == '/'
|| c == '+')) {
break;
}
end++;
}
url = ResourceUrl.parse(value.substring(index, end));
if (url != null && !url.framework) {
Resource resource;
if (url.create) {
resource = declareResource(url.type, url.name, attr);
} else {
resource = addResource(url.type, url.name, null);
}
from.addReference(resource);
}
index = end;
}
}
}
// Android Wear. We *could* limit ourselves to only doing this in files
// referenced from a manifest meta-data element, e.g.
// <meta-data android:name="com.google.android.wearable.beta.app"
// android:resource="@xml/wearable_app_desc"/>
// but given that that property has "beta" in the name, it seems likely
// to change and therefore hardcoding it for that key risks breakage
// in the future.
if ("rawPathResId".equals(element.getTagName())) {
StringBuilder sb = new StringBuilder();
NodeList children = node.getChildNodes();
for (int i = 0, n = children.getLength(); i < n; i++) {
Node child = children.item(i);
if (child.getNodeType() == Element.TEXT_NODE
|| child.getNodeType() == Element.CDATA_SECTION_NODE) {
sb.append(child.getNodeValue());
}
}
if (sb.length() > 0) {
Resource resource = getResource(ResourceType.RAW, sb.toString().trim());
from.addReference(resource);
}
}
} else {
// Look for keep attributes everywhere else since they don't require a source
recordToolsAttributes(element.getAttributeNodeNS(TOOLS_URI, ATTR_KEEP));
recordToolsAttributes(element.getAttributeNodeNS(TOOLS_URI, ATTR_DISCARD));
recordToolsAttributes(element.getAttributeNodeNS(TOOLS_URI, ATTR_SHRINK_MODE));
}
if (folderType == ResourceFolderType.VALUES) {
Resource definition = null;
ResourceType type = getResourceType(element);
if (type != null) {
String name = getFieldName(element);
if (type == ResourceType.PUBLIC) {
String typeName = element.getAttribute(ATTR_TYPE);
if (!typeName.isEmpty()) {
type = ResourceType.getEnum(typeName);
if (type != null) {
definition = declareResource(type, name, element);
definition.setPublic(true);
}
}
} else {
definition = declareResource(type, name, element);
}
}
if (definition != null) {
from = definition;
}
String tagName = element.getTagName();
if (TAG_STYLE.equals(tagName)) {
if (element.hasAttribute(ATTR_PARENT)) {
String parent = element.getAttribute(ATTR_PARENT);
// Fix (see method comment): don't treat empty parent tag the same as extending
// builtin theme.
if (parent.startsWith(ANDROID_STYLE_RESOURCE_PREFIX)
|| parent.startsWith(PREFIX_ANDROID)) {
// Extending a builtin theme: treat these as used
if (definition != null) {
markReachable(definition);
}
} else if (!parent.isEmpty()) {
String parentStyle = parent;
if (!parentStyle.startsWith(STYLE_RESOURCE_PREFIX)) {
// Fix (see method comment): allow parent references to start with 'style/'
// as well as the more strict '@style/'.
// TODO(apell): Remove handling of 'style/' references when no longer supported
// by AAPT.
if (parentStyle.startsWith(REFERENCE_STYLE)) {
parentStyle = "@" + parentStyle;
} else {
parentStyle = STYLE_RESOURCE_PREFIX + parentStyle;
}
}
Resource ps = getResourceFromUrl(LintUtils.getFieldName(parentStyle));
if (ps != null && definition != null) {
// Fix (see method comment): don't create parent to child reference.
definition.addReference(ps);
}
}
} else {
// Implicit parent styles by name
String name = getFieldName(element);
while (true) {
int index = name.lastIndexOf('_');
if (index != -1) {
name = name.substring(0, index);
Resource ps =
getResourceFromUrl(STYLE_RESOURCE_PREFIX + LintUtils.getFieldName(name));
if (ps != null && definition != null) {
// Fix (see method comment): don't create parent to child reference.
definition.addReference(ps);
}
} else {
break;
}
}
}
}
if (TAG_ITEM.equals(tagName)) {
// In style? If so the name: attribute can be a reference
if (element.getParentNode() != null
&& element.getParentNode().getNodeName().equals(TAG_STYLE)) {
String name = element.getAttributeNS(ANDROID_URI, ATTR_NAME);
if (!name.isEmpty() && !name.startsWith("android:")) {
Resource resource = getResource(ResourceType.ATTR, name);
if (definition == null) {
Element style = (Element) element.getParentNode();
definition = getResource(style);
if (definition != null) {
from = definition;
definition.addReference(resource);
}
}
}
}
}
}
} else if (nodeType == Node.TEXT_NODE || nodeType == Node.CDATA_SECTION_NODE) {
String text = node.getNodeValue().trim();
// Why are we calling getFieldName here? That doesn't make sense! for styles I guess
Resource textResource = getResourceFromUrl(LintUtils.getFieldName(text));
if (textResource != null && from != null) {
from.addReference(textResource);
}
}
NodeList children = node.getChildNodes();
for (int i = 0, n = children.getLength(); i < n; i++) {
Node child = children.item(i);
recordResourceReferences(folderType, child, from);
}
}
}
}