| // 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_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.endsWithIgnoreCase; |
| import static java.nio.charset.StandardCharsets.UTF_8; |
| |
| 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.Pair; |
| import com.android.utils.XmlUtils; |
| 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.IdentityHashMap; |
| 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.zip.ZipEntry; |
| import java.util.zip.ZipInputStream; |
| import javax.xml.parsers.ParserConfigurationException; |
| 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; |
| |
| /** |
| * 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. <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 { |
| |
| public static final int TYPICAL_RESOURCE_COUNT = 200; |
| private final Set<String> resourcePackages; |
| private final Path rTxt; |
| private final Path proguardMapping; |
| private final Path classesJar; |
| private final Path mergedManifest; |
| private final Path mergedResourceDir; |
| private final Logger logger; |
| |
| /** |
| * 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 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 final Map<String, Pair<ResourceType, Map<String, String>>> resourceObfuscation = |
| Maps.newHashMapWithExpectedSize(30); |
| |
| public ResourceShrinker( |
| Set<String> resourcePackages, |
| @NonNull Path rTxt, |
| @NonNull Path classesJar, |
| @NonNull Path manifest, |
| @Nullable Path mapping, |
| @NonNull Path resources, |
| Path logFile) { |
| this.resourcePackages = resourcePackages; |
| this.rTxt = rTxt; |
| this.proguardMapping = mapping; |
| this.classesJar = classesJar; |
| 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); |
| 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 and removes unused values from all value xml files. |
| * |
| * @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); |
| Set<Resource> deleted = Sets.newHashSetWithExpectedSize(resourceCount); |
| for (Resource resource : unused) { |
| deleted.add(resource); |
| 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"); |
| if (values.exists()) { |
| rewrite.add(values); |
| } |
| |
| Map<File, String> rewritten = Maps.newHashMapWithExpectedSize(rewrite.size()); |
| rewriteXml(rewrite, rewritten); |
| // TODO(apell): The graph traversal does not mark IDs as reachable or not, so they cannot be |
| // accurately removed from public.xml, but the declarations may be deleted if they occur in |
| // other files. IDs should be added to values.xml so that there are no definitions in public.xml |
| // without declarations. |
| createStubIds(values, rewritten); |
| |
| File publicXml = new File(mergedResourceDir.toFile(), |
| FD_RES_VALUES + File.separatorChar + "public.xml"); |
| 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) |
| 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<String> removed = Lists.newArrayList(); |
| stripUnused(root, removed); |
| logger.fine("Removed " + removed.size() + " unused resources from " + file + ":\n " |
| + Joiner.on(", ").join(removed)); |
| 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) |
| 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 : 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); |
| 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 = 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<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); |
| } |
| } |
| } |
| 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 && resource.isRelevantType()) { |
| removed.add(resource.getUrl()); |
| Node parent = element.getParentNode(); |
| parent.removeChild(element); |
| } |
| } |
| |
| 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:\n %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.fine(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) { |
| List<ResourceType> types = FolderTypeRelationship.getRelatedResourceTypes( |
| folderType); |
| ResourceType type = types.get(0); |
| assert type != ResourceType.ID : folderType; |
| String name = file.getName(); |
| int extension = name.indexOf('.'); |
| if (extension > 0) { |
| name = name.substring(0, extension); |
| } |
| 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 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) { |
| 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 = target.replace('.', '/'); |
| |
| nameMap = Maps.newHashMap(); |
| Pair<ResourceType, Map<String, String>> pair = Pair.of(type, nameMap); |
| resourceObfuscation.put(ownerName, pair); |
| } |
| } |
| |
| 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; |
| } |
| |
| @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 getResource(type, 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, 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) { |
| resourceObfuscation.put(resourcePackage.replace('.', '/') + "/R$" + type.getName(), |
| Pair.<ResourceType, Map<String, String>>of(type, Maps.<String, String>newHashMap())); |
| } |
| 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.ASM5); |
| } |
| |
| @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) { |
| 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) { |
| Resource resource = getResourceFromCode(owner, name); |
| if (resource != null) { |
| markReachable(resource); |
| } |
| } |
| } |
| |
| @Override |
| public void visitMethodInsn( |
| int opcode, String owner, String name, String desc, boolean isInterface) { |
| super.visitMethodInsn(opcode, owner, name, desc, isInterface); |
| 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! |
| } |
| } |
| }; |
| } |
| } |
| } |