/* * 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); } } } }