Java tutorial
// Copyright 2015 The Bazel Authors. All rights reserved. // // 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.google.devtools.build.android; import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX; import static com.android.SdkConstants.ANDROID_URI; import static com.android.SdkConstants.ATTR_NAME; import static com.android.SdkConstants.ATTR_PARENT; import static com.android.SdkConstants.ATTR_TYPE; import static com.android.SdkConstants.DOT_CLASS; import static com.android.SdkConstants.DOT_GIF; import static com.android.SdkConstants.DOT_JPEG; import static com.android.SdkConstants.DOT_JPG; import static com.android.SdkConstants.DOT_PNG; 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.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.utils.SdkUtils.endsWith; import static com.android.utils.SdkUtils.endsWithIgnoreCase; import static com.google.common.base.Charsets.UTF_8; import com.google.common.base.Charsets; 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 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.resources.configuration.DensityQualifier; import com.android.ide.common.resources.configuration.FolderConfiguration; import com.android.ide.common.resources.configuration.ResourceQualifier; 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.utils.XmlUtils; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; import org.w3c.dom.Attr; 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; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Logger; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import javax.xml.parsers.ParserConfigurationException; /** * 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 merged R class (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. <p> For now, for reasons detailed in the code, this only applies to * file-based resources like layouts, menus and drawables, not value-based resources like strings * and dimensions. */ public class ResourceShrinker { private static final Logger logger = Logger.getLogger(ResourceShrinker.class.getName()); public static final int TYPICAL_RESOURCE_COUNT = 200; private final List<String> resourcePackages; private final Path rTxt; private final Path classesJar; private final Path mergedManifest; private final Path mergedResourceDir; /** * The computed set of unused resources */ private List<Resource> unused; /** * List of all known resources (parsed from R.java) */ private List<Resource> resources = Lists.newArrayListWithExpectedSize(TYPICAL_RESOURCE_COUNT); /** * Map from R field value to corresponding resource */ private Map<Integer, Resource> valueToResource = Maps.newHashMapWithExpectedSize(TYPICAL_RESOURCE_COUNT); /** * Map from resource type to map from resource name to resource object */ private Map<ResourceType, Map<String, Resource>> typeToName = Maps.newEnumMap(ResourceType.class); /** * Map from resource class owners (VM format class) to corresponding resource types. This will * typically be the fully qualified names of the R classes, as well as any renamed versions of * those discovered in the mapping.txt file from ProGuard */ private Map<String, ResourceType> resourceClassOwners = Maps.newHashMapWithExpectedSize(20); public ResourceShrinker(List<String> resourcePackages, @NonNull Path rTxt, @NonNull Path classesJar, @NonNull Path manifest, @NonNull Path resources) { this.resourcePackages = resourcePackages; this.rTxt = rTxt; this.classesJar = classesJar; this.mergedManifest = manifest; this.mergedResourceDir = resources; } public void shrink(Path destinationDir) throws IOException, ParserConfigurationException, SAXException { parseResourceTxtFile(rTxt, resourcePackages); recordUsages(classesJar); recordManifestUsages(mergedManifest); recordResources(mergedResourceDir); keepPossiblyReferencedResources(); dumpReferences(); 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. However, it can't just delete the resources, because it has no * way to tell aapt to continue to use the same id's for the resources. When we re-run aapt on the * stripped resource directory, it will assign new id's to some of the resources (to fill the * gaps) which means the resource id's no longer match the constants compiled into the dex files, * and as a result, the app crashes at runtime. <p> Therefore, it needs to preserve all id's by * actually keeping all the resource names. It can still save a lot of space by making these * resources tiny; e.g. all strings are set to empty, all styles, arrays and plurals are set to * not contain any children, and most importantly, all file based resources like bitmaps and * layouts are replaced by simple resource aliases which just point to @null. * * @param destination directory to copy resources into; if null, delete resources in place */ 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); 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) { logger.fine("Deleted unused resource " + file); assert skip != null; skip.add(file); } else { // Can't delete values immediately; there can be many resources // in this file, so we have to process them all rewrite.add(file); } } } } // Special case the base values.xml folder File values = new File(mergedResourceDir.toFile(), FD_RES_VALUES + File.separatorChar + "values.xml"); boolean valuesExists = values.exists(); if (valuesExists) { rewrite.add(values); } Map<File, String> rewritten = Maps.newHashMapWithExpectedSize(rewrite.size()); // 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<String> removed = Lists.newArrayList(); stripUnused(root, removed); logger.info("Removed " + removed.size() + " unused resources from " + file + ":\n " + Joiner.on(", ").join(removed)); String formatted = XmlPrettyPrinter.prettyPrint(document, xml.endsWith("\n")); rewritten.put(file, formatted); } } if (valuesExists) { String xml = rewritten.get(values); if (xml == null) { xml = Files.toString(values, UTF_8); } Document document = XmlUtils.parseDocument(xml, true); Element root = document.getDocumentElement(); for (Resource resource : resources) { if (resource.type == ResourceType.ID && !resource.hasDefault) { Element item = document.createElement(TAG_ITEM); item.setAttribute(ATTR_TYPE, resource.type.getName()); item.setAttribute(ATTR_NAME, resource.name); root.appendChild(item); } else if (!resource.reachable && !resource.hasDefault && resource.type != ResourceType.DECLARE_STYLEABLE && resource.type != ResourceType.STYLE && resource.type != ResourceType.PLURALS && resource.type != ResourceType.ARRAY && resource.isRelevantType()) { Element item = document.createElement(TAG_ITEM); item.setAttribute(ATTR_TYPE, resource.type.getName()); item.setAttribute(ATTR_NAME, resource.name); root.appendChild(item); String s = "@null"; item.appendChild(document.createTextNode(s)); } } String formatted = XmlPrettyPrinter.prettyPrint(document, xml.endsWith("\n")); rewritten.put(values, formatted); } filteredCopy(mergedResourceDir.toFile(), destination, skip, rewritten); } /** * 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, Charsets.UTF_8); } else { Files.copy(source, destinationFile); } } } private void stripUnused(Element element, List<String> removed) { ResourceType type = getResourceType(element); if (type == ResourceType.ATTR) { // Not yet properly handled return; } Resource resource = 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.reachable && (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); } return; } } 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.reachable) { removed.add(resource.getUrl()); // for themes etc where .'s have been replaced by _'s String name = element.getAttribute(ATTR_NAME); if (name.isEmpty()) { name = resource.name; } Node nextSibling = element.getNextSibling(); Node parent = element.getParentNode(); NodeList oldChildren = element.getChildNodes(); parent.removeChild(element); Document document = element.getOwnerDocument(); element = document.createElement("item"); for (int i = 0; i < oldChildren.getLength(); i++) { element.appendChild(oldChildren.item(i)); } element.setAttribute(ATTR_NAME, name); element.setAttribute(ATTR_TYPE, resource.type.getName()); String text = null; switch (resource.type) { case BOOL: text = "true"; break; case DIMEN: text = "0dp"; break; case INTEGER: text = "0"; break; } element.setTextContent(text); parent.insertBefore(element, nextSibling); } } private static String getFieldName(Element element) { return getFieldName(element.getAttribute(ATTR_NAME)); } @Nullable private Resource getResource(Element element) { ResourceType type = getResourceType(element); if (type != null) { String name = getFieldName(element); return getResource(type, name); } return null; } private static ResourceType getResourceType(Element element) { String tagName = element.getTagName(); switch (tagName) { case TAG_ITEM: String typeName = element.getAttribute(ATTR_TYPE); if (!typeName.isEmpty()) { return ResourceType.getEnum(typeName); } break; case "string-array": case "integer-array": return ResourceType.ARRAY; default: return ResourceType.getEnum(tagName); } return null; } private void findUnused() { List<Resource> roots = Lists.newArrayList(); for (Resource resource : resources) { if (resource.reachable && resource.type != ResourceType.ID && resource.type != ResourceType.ATTR) { roots.add(resource); } } logger.fine(String.format("The root reachable resources are: %s", Joiner.on(",\n ").join(roots))); Map<Resource, Boolean> seen = new IdentityHashMap<>(resources.size()); for (Resource root : roots) { visit(root, seen); } List<Resource> unused = Lists.newArrayListWithExpectedSize(resources.size()); for (Resource resource : resources) { if (!resource.reachable && resource.isRelevantType()) { unused.add(resource); } } this.unused = unused; } private static void visit(Resource root, Map<Resource, Boolean> seen) { if (seen.containsKey(root)) { return; } seen.put(root, Boolean.TRUE); root.reachable = true; if (root.references != null) { for (Resource referenced : root.references) { visit(referenced, seen); } } } private void dumpReferences() { for (Resource resource : resources) { if (resource.references != null) { logger.info(resource + " => " + resource.references); } } } private void keepPossiblyReferencedResources() { if (!mFoundGetIdentifier || mStrings == null) { // No calls to android.content.res.Resources#getIdentifier; no need // to worry about string references to resources return; } List<String> strings = new ArrayList<String>(mStrings); Collections.sort(strings); logger.fine(String.format("android.content.res.Resources#getIdentifier present: %s", mFoundGetIdentifier)); logger.fine("Referenced Strings:"); for (String s : strings) { s = s.trim().replace("\n", "\\n"); if (s.length() > 40) { s = s.substring(0, 37) + "..."; } else if (s.isEmpty()) { continue; } logger.fine(" " + s); } Set<String> names = Sets.newHashSetWithExpectedSize(50); for (Map<String, Resource> map : typeToName.values()) { names.addAll(map.keySet()); } for (String string : mStrings) { // Check whether the string looks relevant // We consider three 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. int n = string.length(); boolean justName = true; 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 == ':') { justName = false; } 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; } else if (!haveSlash) { // 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 = getResource(type, name); if (resource != null) { logger.fine("Marking " + resource + " used because it " + "matches string pool constant " + string); } markReachable(resource); continue; } // fall through and check the name } if (names.contains(name)) { for (Map<String, Resource> map : typeToName.values()) { Resource resource = map.get(string); if (resource != null) { logger.fine("Marking " + resource + " used because it " + "matches string pool constant " + string); } 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) { markReachable(valueToResource.get(id)); } } catch (NumberFormatException e) { // pass } } } } 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(); FolderConfiguration config = FolderConfiguration.getConfigForFolder(folder.getName()); boolean isDefaultFolder = false; if (config != null) { isDefaultFolder = true; for (int i = 0, n = FolderConfiguration.getQualifierCount(); i < n; i++) { ResourceQualifier qualifier = config.getQualifier(i); // Densities are special: even if they're present in just (say) drawable-hdpi // we'll match it on any other density if (qualifier != null && !(qualifier instanceof DensityQualifier)) { isDefaultFolder = false; break; } } } if (files != null) { for (File file : files) { String path = file.getPath(); boolean isXml = endsWithIgnoreCase(path, DOT_XML); Resource from = null; // Record resource for the whole file if (folderType != ResourceFolderType.VALUES && (isXml || endsWith(path, DOT_PNG) //also true for endsWith(name, DOT_9PNG) || endsWith(path, DOT_JPG) || endsWith(path, DOT_GIF) || endsWith(path, DOT_JPEG))) { List<ResourceType> types = FolderTypeRelationship.getRelatedResourceTypes(folderType); ResourceType type = types.get(0); assert type != ResourceType.ID : folderType; String name = file.getName(); name = name.substring(0, name.indexOf('.')); Resource resource = getResource(type, name); if (resource != null) { resource.addLocation(file); if (isDefaultFolder) { resource.hasDefault = true; } from = resource; } } if (isXml) { // For value files, and drawables and colors etc also pull in resource // references inside the file recordResourcesUsages(file, isDefaultFolder, from); } } } } private void recordManifestUsages(Path manifest) throws IOException, ParserConfigurationException, SAXException { String xml = Files.toString(manifest.toFile(), UTF_8); Document document = XmlUtils.parseDocument(xml, true); recordManifestUsages(document.getDocumentElement()); } private void recordResourcesUsages(@NonNull File file, boolean isDefaultFolder, @Nullable Resource from) throws IOException, ParserConfigurationException, SAXException { String xml = Files.toString(file, UTF_8); Document document = XmlUtils.parseDocument(xml, true); recordResourceReferences(file, isDefaultFolder, document.getDocumentElement(), from); } @Nullable private Resource getResource(@NonNull ResourceType type, @NonNull String name) { Map<String, Resource> nameMap = typeToName.get(type); if (nameMap != null) { return nameMap.get(getFieldName(name)); } return null; } @Nullable private Resource getResource(@NonNull String possibleUrlReference) { ResourceUrl url = ResourceUrl.parse(possibleUrlReference); if (url != null && !url.framework) { return getResource(url.type, url.name); } return null; } private void recordManifestUsages(Node node) { short nodeType = node.getNodeType(); if (nodeType == Node.ELEMENT_NODE) { Element element = (Element) node; NamedNodeMap attributes = element.getAttributes(); for (int i = 0, n = attributes.getLength(); i < n; i++) { Attr attr = (Attr) attributes.item(i); markReachable(getResource(attr.getValue())); } } else if (nodeType == Node.TEXT_NODE) { // Does this apply to any manifests?? String text = node.getNodeValue().trim(); markReachable(getResource(text)); } NodeList children = node.getChildNodes(); for (int i = 0, n = children.getLength(); i < n; i++) { Node child = children.item(i); recordManifestUsages(child); } } private void recordResourceReferences(@NonNull File file, boolean isDefaultFolder, @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); Resource resource = getResource(attr.getValue()); if (resource != null) { from.addReference(resource); } } // 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); } } } Resource definition = getResource(element); if (definition != null) { from = definition; definition.addLocation(file); if (isDefaultFolder) { definition.hasDefault = true; } } String tagName = element.getTagName(); if (TAG_STYLE.equals(tagName)) { if (element.hasAttribute(ATTR_PARENT)) { String parent = element.getAttribute(ATTR_PARENT); if (!parent.isEmpty() && !parent.startsWith(ANDROID_STYLE_RESOURCE_PREFIX) && !parent.startsWith(PREFIX_ANDROID)) { String parentStyle = parent; if (!parentStyle.startsWith(STYLE_RESOURCE_PREFIX)) { parentStyle = STYLE_RESOURCE_PREFIX + parentStyle; } Resource ps = getResource(getFieldName(parentStyle)); if (ps != null && definition != null) { 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 = getResource(STYLE_RESOURCE_PREFIX + getFieldName(name)); if (ps != null && definition != null) { 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(); Resource textResource = getResource(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(file, isDefaultFolder, child, from); } } public static String getFieldName(@NonNull String styleName) { return styleName.replace('.', '_').replace('-', '_').replace(':', '_'); } private static void markReachable(@Nullable Resource resource) { if (resource != null) { resource.reachable = true; } } private Set<String> mStrings; private boolean mFoundGetIdentifier; 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 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 != '/') { // .:/ are for the fully qualified resuorce names return; } else if (identifierChar) { haveIdentifierChar = true; } } if (!haveIdentifierChar) { return; } if (mStrings == null) { mStrings = Sets.newHashSetWithExpectedSize(300); } mStrings.add(string); } private void recordUsages(Path jarFile) throws IOException { if (!jarFile.toFile().exists()) { return; } ZipInputStream zis = null; try { FileInputStream fis = new FileInputStream(jarFile.toFile()); try { zis = new ZipInputStream(fis); ZipEntry entry = zis.getNextEntry(); while (entry != null) { String name = entry.getName(); if (name.endsWith(DOT_CLASS)) { byte[] bytes = ByteStreams.toByteArray(zis); if (bytes != null) { ClassReader classReader = new ClassReader(bytes); classReader.accept(new UsageVisitor(), 0); } } entry = zis.getNextEntry(); } } finally { Closeables.close(fis, true); } } finally { Closeables.close(zis, true); } } private void parseResourceTxtFile(Path rTxt, List<String> resourcePackages) throws IOException { BufferedReader reader = java.nio.file.Files.newBufferedReader(rTxt, Charset.defaultCharset()); String line; while ((line = reader.readLine()) != null) { String[] tokens = line.split(" "); ResourceType type = ResourceType.getEnum(tokens[1]); for (String resourcePackage : resourcePackages) { resourceClassOwners.put(resourcePackage.replace('.', '/') + "/R$" + type.getName(), type); } if (type == ResourceType.STYLEABLE) { if (tokens[0].equals("int[]")) { addResource(ResourceType.DECLARE_STYLEABLE, tokens[2], null); } else { // TODO(jongerrish): Implement stripping of styleables. } } else { addResource(type, tokens[2], tokens[3]); } } } private void addResource(@NonNull ResourceType type, @NonNull String name, @Nullable String value) { int realValue = value != null ? Integer.decode(value) : -1; Resource resource = getResource(type, name); if (resource != null) { //noinspection VariableNotUsedInsideIf if (value != null) { if (resource.value == -1) { resource.value = realValue; } else { assert realValue == resource.value; } } return; } resource = new Resource(type, name, realValue); resources.add(resource); if (realValue != -1) { valueToResource.put(realValue, resource); } Map<String, Resource> nameMap = typeToName.get(type); if (nameMap == null) { nameMap = Maps.newHashMapWithExpectedSize(30); typeToName.put(type, nameMap); } nameMap.put(name, resource); // TODO: Assert that we don't set the same resource multiple times to different values. // Could happen if you pass in stale data! } @VisibleForTesting List<Resource> getAllResources() { return resources; } /** * Metadata about an Android resource */ public static class Resource { /** * Type of resource */ public ResourceType type; /** * Name of resource */ public String name; /** * Integer id location */ public int value; /** * Whether this resource can be reached from one of the roots (manifest, code) */ public boolean reachable; /** * Whether this resource has a default definition (e.g. present in a resource folder with no * qualifiers). For id references, an inline definition (@+id) does not count as a default * definition. */ public boolean hasDefault; /** * Resources this resource references. For example, a layout can reference another via an * include; a style reference in a layout references that layout style, and so on. */ public List<Resource> references; public final List<File> declarations = Lists.newArrayList(); private Resource(ResourceType type, String name, int value) { this.type = type; this.name = name; this.value = value; } @Override public String toString() { return type + ":" + name + ":" + value; } @SuppressWarnings("RedundantIfStatement") // Generated by IDE @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Resource resource = (Resource) o; if (name != null ? !name.equals(resource.name) : resource.name != null) { return false; } if (type != resource.type) { return false; } return true; } @Override public int hashCode() { int result = type != null ? type.hashCode() : 0; result = 31 * result + (name != null ? name.hashCode() : 0); return result; } public void addLocation(@NonNull File file) { declarations.add(file); } public void addReference(@Nullable Resource resource) { if (resource != null) { if (references == null) { references = Lists.newArrayList(); } else if (references.contains(resource)) { return; } references.add(resource); } } public String getUrl() { return '@' + type.getName() + '/' + name; } public boolean isRelevantType() { return type != ResourceType.ID; // && getFolderType() != ResourceFolderType.VALUES; } } private class UsageVisitor extends ClassVisitor { public UsageVisitor() { super(Opcodes.ASM4); } @Override public MethodVisitor visitMethod(int access, final String name, String desc, String signature, String[] exceptions) { return new MethodVisitor(Opcodes.ASM4) { @Override public void visitLdcInsn(Object cst) { if (cst instanceof Integer) { Integer value = (Integer) cst; markReachable(valueToResource.get(value)); } else if (cst instanceof String) { String string = (String) cst; referencedString(string); } } @Override public void visitFieldInsn(int opcode, String owner, String name, String desc) { if (opcode == Opcodes.GETSTATIC) { ResourceType type = resourceClassOwners.get(owner); if (type != null) { Resource resource = getResource(type, name); if (resource != null) { markReachable(resource); } } } } @Override public void visitMethodInsn(int opcode, String owner, String name, String desc) { super.visitMethodInsn(opcode, owner, name, desc); if (owner.equals("android/content/res/Resources") && name.equals("getIdentifier") && desc.equals("(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I")) { mFoundGetIdentifier = 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! } } }; } } }