| /* |
| * 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.ATTR_NAME; |
| import static com.android.SdkConstants.ATTR_TYPE; |
| import static com.android.SdkConstants.DOT_9PNG; |
| import static com.android.SdkConstants.DOT_CLASS; |
| import static com.android.SdkConstants.DOT_DEX; |
| import static com.android.SdkConstants.DOT_JAR; |
| 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.TAG_RESOURCES; |
| import static com.android.utils.SdkUtils.endsWithIgnoreCase; |
| import static com.google.common.base.Charsets.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.builder.dexing.AnalysisCallback; |
| import com.android.builder.dexing.R8ResourceShrinker; |
| import com.android.builder.utils.ZipEntryUtils; |
| import com.android.ide.common.resources.usage.ResourceUsageModel; |
| import com.android.ide.common.resources.usage.ResourceUsageModel.Resource; |
| 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.annotations.VisibleForTesting; |
| import com.google.common.base.Charsets; |
| import com.google.common.base.Joiner; |
| import com.google.common.collect.Iterables; |
| 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.BufferedInputStream; |
| import java.io.BufferedOutputStream; |
| import java.io.File; |
| import java.io.FileInputStream; |
| import java.io.FileOutputStream; |
| import java.io.IOException; |
| import java.io.PrintWriter; |
| import java.io.StringWriter; |
| import java.nio.file.InvalidPathException; |
| import java.nio.file.Path; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.Enumeration; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.jar.JarEntry; |
| import java.util.jar.JarInputStream; |
| import java.util.jar.JarOutputStream; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| import java.util.regex.PatternSyntaxException; |
| import java.util.zip.ZipEntry; |
| import java.util.zip.ZipFile; |
| import java.util.zip.ZipInputStream; |
| import javax.xml.parsers.ParserConfigurationException; |
| 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.Document; |
| import org.w3c.dom.Element; |
| 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 shrinking 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>The merged R class (to find the actual integer constants assigned to resources) |
| * <li>The ProGuard mapping files (to find the mapping from original symbol names to short names)* |
| * <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>The shrinked output classes (to find resource references in code that are actually |
| * reachable) |
| * </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 shrinked 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>Analyzing dex files is also supported. It follows the same rules as analyzing class files. |
| * |
| * <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 ResourceUsageAnalyzer { |
| private static final String ANDROID_RES = "android_res/"; |
| |
| /** |
| * Whether we should create small/empty dummy files instead of actually |
| * removing file resources. This is to work around crashes on some devices |
| * where the device is traversing resources. See http://b.android.com/79325 for more. |
| */ |
| public static final boolean REPLACE_DELETED_WITH_EMPTY = true; |
| |
| /** |
| Whether we support running aapt twice, to regenerate the resources.arsc file |
| such that we can strip out value resources as well. We don't do this yet, for |
| reasons detailed in the ShrinkResources task |
| |
| We have two options: |
| (1) Copy the resource files over to a new destination directory, filtering out |
| removed file resources and rewriting value resource files by stripping out |
| the declarations for removed value resources. We then re-run aapt on this |
| new destination directory. |
| |
| The problem with this approach is that when we re-run aapt it will assign new |
| id's to all the resources, so we have to create dummy placeholders for all the |
| removed resources. (The alternative would be to then run compilation one more |
| time -- regenerating classes.jar, regenerating .dex) -- this would really slow |
| down builds.) |
| |
| A cleaner solution than this is to get aapt to support using a predefined set |
| of id's. It can emit R.txt symbol files now; if we can get it to read R.txt |
| and use those numbers in its assignment, we can solve this cleanly. This request |
| is tracked in https://code.google.com/p/android/issues/detail?id=70869 |
| |
| (2) Just rewrite the .ap_ file directly. It's just a .zip file which contains |
| (a) binary files for bitmaps and XML file resources such as layouts and menus |
| (b) a binary file, resources.arsc, containing all the values. |
| The resources.arsc format is opaque to us. However, MOST of the resource bulk |
| comes from the bitmap and other resource files. |
| |
| So here we don't even need to run aapt a second time; we simply rewrite the |
| .ap_ zip file directly, filtering out res/ files we know to be unused. |
| |
| Approach #2 gives us most of the space savings without the risk of #1 (running aapt |
| a second time introduces the possibility of aapt compilation errors if we haven't |
| been careful enough to insert resource aliases for all necessary items (such as |
| inline @+id declarations), or if we haven't carefully not created aliases for items |
| already defined in other value files as aliases, and perhaps most importantly, |
| introduces risk that aapt will pick a different resource order anyway, which we can |
| only guard against by doing a full compilation over again. |
| |
| Therefore, for now the below code uses #2, but since we can solve #1 with support |
| from aapt), we're preserving all the code to rewrite resource files since that will |
| give additional space savings, particularly for apps with a lot of strings or a lot |
| of translations. |
| */ |
| @SuppressWarnings("SpellCheckingInspection") // arsc |
| public static final boolean TWO_PASS_AAPT = false; |
| |
| /** Special marker regexp which does not match a resource name */ |
| static final String NO_MATCH = "-nomatch-"; |
| |
| /* A source of resource classes to track, can be either a folder or a jar */ |
| private final File mResourceClasseseSource; |
| private final File mProguardMapping; |
| /** These can be class or dex files. */ |
| private final Iterable<File> mClasses; |
| private final File mMergedManifest; |
| private final Iterable<File> mResourceDirs; |
| |
| private final File mReportFile; |
| private final StringWriter mDebugOutput; |
| private final PrintWriter mDebugPrinter; |
| private final ApkFormat format; |
| |
| private boolean mVerbose; |
| private boolean mDebug; |
| private boolean mDryRun; |
| |
| /** The computed set of unused resources */ |
| private List<Resource> mUnused; |
| |
| /** |
| * Map from resource class owners (VM format class) to corresponding resource entries. |
| * This lets us map back from code references (obfuscated class and possibly obfuscated field |
| * reference) back to the corresponding resource type and name. |
| */ |
| private Map<String, Pair<ResourceType, Map<String, String>>> mResourceObfuscation = |
| Maps.newHashMapWithExpectedSize(30); |
| |
| /** Obfuscated name of android/support/v7/widget/SuggestionsAdapter.java */ |
| private String mSuggestionsAdapter; |
| |
| /** Obfuscated name of android/support/v7/internal/widget/ResourcesWrapper.java */ |
| private String mResourcesWrapper; |
| |
| public ResourceUsageAnalyzer( |
| @NonNull File rClasses, |
| @NonNull Iterable<File> classes, |
| @NonNull File manifest, |
| @Nullable File mapping, |
| @NonNull Iterable<File> resources, |
| @Nullable File reportFile, |
| @NonNull ApkFormat format) { |
| mResourceClasseseSource = rClasses; |
| mProguardMapping = mapping; |
| mClasses = classes; |
| mMergedManifest = manifest; |
| mResourceDirs = resources; |
| |
| mReportFile = reportFile; |
| if (reportFile != null || mDebug) { |
| mDebugOutput = new StringWriter(8*1024); |
| mDebugPrinter = new PrintWriter(mDebugOutput); |
| } else { |
| mDebugOutput = null; |
| mDebugPrinter = null; |
| } |
| this.format = format; |
| } |
| |
| public enum ApkFormat { |
| BINARY, |
| PROTO, |
| } |
| |
| public ResourceUsageAnalyzer( |
| @NonNull File rClasses, |
| @NonNull Iterable<File> classes, |
| @NonNull File manifest, |
| @Nullable File mapping, |
| @NonNull File resources, |
| @Nullable File reportFile, |
| @NonNull ApkFormat format) { |
| this(rClasses, classes, manifest, mapping, Arrays.asList(resources), reportFile, format); |
| } |
| |
| public void dispose() { |
| if (mDebugOutput != null) { |
| String output = mDebugOutput.toString(); |
| |
| if (mDebug) { |
| System.out.println(output); |
| } |
| |
| if (mReportFile != null) { |
| File dir = mReportFile.getParentFile(); |
| if (dir != null) { |
| if ((dir.exists() || dir.mkdir()) && dir.canWrite()) { |
| try { |
| Files.asCharSink(mReportFile, Charsets.UTF_8).write(output); |
| } catch (IOException ignore) { |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| public void analyze() throws IOException, ParserConfigurationException, SAXException { |
| gatherResourceValues(mResourceClasseseSource); |
| recordMapping(mProguardMapping); |
| |
| for (File jarOrDir : mClasses) { |
| recordClassUsages(jarOrDir); |
| } |
| recordManifestUsages(mMergedManifest); |
| recordResources(mResourceDirs); |
| keepPossiblyReferencedResources(); |
| dumpReferences(); |
| mModel.processToolsAttributes(); |
| mUnused = mModel.findUnused(); |
| } |
| |
| public boolean isDryRun() { |
| return mDryRun; |
| } |
| |
| public void setDryRun(boolean dryRun) { |
| mDryRun = dryRun; |
| } |
| |
| public boolean isVerbose() { |
| return mVerbose; |
| } |
| |
| public void setVerbose(boolean verbose) { |
| mVerbose = verbose; |
| } |
| |
| |
| public boolean isDebug() { |
| return mDebug; |
| } |
| |
| public void setDebug(boolean verbose) { |
| mDebug = verbose; |
| } |
| |
| // A 1x1 pixel PNG of type BufferedImage.TYPE_BYTE_GRAY |
| public static final byte[] TINY_PNG = new byte[] { |
| (byte)-119, (byte) 80, (byte) 78, (byte) 71, (byte) 13, (byte) 10, |
| (byte) 26, (byte) 10, (byte) 0, (byte) 0, (byte) 0, (byte) 13, |
| (byte) 73, (byte) 72, (byte) 68, (byte) 82, (byte) 0, (byte) 0, |
| (byte) 0, (byte) 1, (byte) 0, (byte) 0, (byte) 0, (byte) 1, |
| (byte) 8, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 58, |
| (byte) 126, (byte)-101, (byte) 85, (byte) 0, (byte) 0, (byte) 0, |
| (byte) 10, (byte) 73, (byte) 68, (byte) 65, (byte) 84, (byte) 120, |
| (byte) -38, (byte) 99, (byte) 96, (byte) 0, (byte) 0, (byte) 0, |
| (byte) 2, (byte) 0, (byte) 1, (byte) -27, (byte) 39, (byte) -34, |
| (byte) -4, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 73, |
| (byte) 69, (byte) 78, (byte) 68, (byte) -82, (byte) 66, (byte) 96, |
| (byte)-126 |
| }; |
| |
| public static final long TINY_PNG_CRC = 0x88b2a3b0L; |
| |
| // A 3x3 pixel PNG of type BufferedImage.TYPE_INT_ARGB with 9-patch markers |
| public static final byte[] TINY_9PNG = new byte[] { |
| (byte)-119, (byte) 80, (byte) 78, (byte) 71, (byte) 13, (byte) 10, |
| (byte) 26, (byte) 10, (byte) 0, (byte) 0, (byte) 0, (byte) 13, |
| (byte) 73, (byte) 72, (byte) 68, (byte) 82, (byte) 0, (byte) 0, |
| (byte) 0, (byte) 3, (byte) 0, (byte) 0, (byte) 0, (byte) 3, |
| (byte) 8, (byte) 6, (byte) 0, (byte) 0, (byte) 0, (byte) 86, |
| (byte) 40, (byte) -75, (byte) -65, (byte) 0, (byte) 0, (byte) 0, |
| (byte) 20, (byte) 73, (byte) 68, (byte) 65, (byte) 84, (byte) 120, |
| (byte) -38, (byte) 99, (byte) 96, (byte)-128, (byte)-128, (byte) -1, |
| (byte) 12, (byte) 48, (byte) 6, (byte) 8, (byte) -96, (byte) 8, |
| (byte)-128, (byte) 8, (byte) 0, (byte)-107, (byte)-111, (byte) 7, |
| (byte) -7, (byte) -64, (byte) -82, (byte) 8, (byte) 0, (byte) 0, |
| (byte) 0, (byte) 0, (byte) 0, (byte) 73, (byte) 69, (byte) 78, |
| (byte) 68, (byte) -82, (byte) 66, (byte) 96, (byte)-126 |
| }; |
| |
| public static final long TINY_9PNG_CRC = 0x1148f987L; |
| |
| // The XML document <x/> as binary-packed with AAPT |
| public static final byte[] TINY_BINARY_XML = |
| new byte[] { |
| (byte) 3, (byte) 0, (byte) 8, (byte) 0, (byte) 104, (byte) 0, |
| (byte) 0, (byte) 0, (byte) 1, (byte) 0, (byte) 28, (byte) 0, |
| (byte) 36, (byte) 0, (byte) 0, (byte) 0, (byte) 1, (byte) 0, |
| (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, |
| (byte) 0, (byte) 1, (byte) 0, (byte) 0, (byte) 32, (byte) 0, |
| (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, |
| (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 1, (byte) 1, |
| (byte) 120, (byte) 0, (byte) 2, (byte) 1, (byte) 16, (byte) 0, |
| (byte) 36, (byte) 0, (byte) 0, (byte) 0, (byte) 1, (byte) 0, |
| (byte) 0, (byte) 0, (byte) -1, (byte) -1, (byte) -1, (byte) -1, |
| (byte) -1, (byte) -1, (byte) -1, (byte) -1, (byte) 0, (byte) 0, |
| (byte) 0, (byte) 0, (byte) 20, (byte) 0, (byte) 20, (byte) 0, |
| (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, |
| (byte) 0, (byte) 0, (byte) 3, (byte) 1, (byte) 16, (byte) 0, |
| (byte) 24, (byte) 0, (byte) 0, (byte) 0, (byte) 1, (byte) 0, |
| (byte) 0, (byte) 0, (byte) -1, (byte) -1, (byte) -1, (byte) -1, |
| (byte) -1, (byte) -1, (byte) -1, (byte) -1, (byte) 0, (byte) 0, |
| (byte) 0, (byte) 0 |
| }; |
| |
| public static final long TINY_BINARY_XML_CRC = 0xd7e65643L; |
| |
| // The XML document <x/> as a proto packed with AAPT2 |
| public static final byte[] TINY_PROTO_XML = |
| new byte[] {0xa, 0x3, 0x1a, 0x1, 0x78, 0x1a, 0x2, 0x8, 0x1}; |
| public static final long TINY_PROTO_XML_CRC = 3204905971L; |
| |
| /** |
| * "Removes" resources from an .ap_ file by writing it out while filtering out |
| * unused resources. This won't touch the values XML data (resources.arsc) but |
| * will remove the individual file-based resources, which is where most of |
| * the data is anyway (usually in drawable bitmaps) |
| * |
| * @param source the .ap_ file created by aapt |
| * @param dest a new .ap_ file with unused file-based resources removed |
| */ |
| public void rewriteResourceZip(@NonNull File source, @NonNull File dest) |
| throws IOException { |
| if (dest.exists()) { |
| boolean deleted = dest.delete(); |
| if (!deleted) { |
| throw new IOException("Could not delete " + dest); |
| } |
| } |
| |
| try (JarInputStream zis = |
| new JarInputStream(new BufferedInputStream(new FileInputStream(source))); |
| JarOutputStream zos = |
| new JarOutputStream(new BufferedOutputStream(new FileOutputStream(dest)))) { |
| |
| // Rather than using Deflater.DEFAULT_COMPRESSION we use 9 here, |
| // since that seems to match the compressed sizes we observe in source |
| // .ap_ files encountered by the resource shrinker: |
| zos.setLevel(9); |
| |
| ZipEntry entry = zis.getNextEntry(); |
| while (entry != null) { |
| String name = entry.getName(); |
| boolean directory = entry.isDirectory(); |
| Resource resource = getResourceByJarPath(name); |
| if (!ZipEntryUtils.isValidZipEntryName(entry)) { |
| throw new InvalidPathException( |
| entry.getName(), "Entry name contains invalid characters"); |
| } |
| if (resource == null || resource.isReachable()) { |
| copyToOutput(zis, zos, entry, name, directory); |
| } else if (REPLACE_DELETED_WITH_EMPTY && !directory) { |
| replaceWithDummyEntry(zos, entry, name); |
| } else if (isVerbose() || mDebugPrinter != null) { |
| String message = |
| "Skipped unused resource " + name + ": " + entry.getSize() + " bytes"; |
| if (isVerbose()) { |
| System.out.println(message); |
| } |
| if (mDebugPrinter != null) { |
| mDebugPrinter.println(message); |
| } |
| } |
| entry = zis.getNextEntry(); |
| } |
| zos.flush(); |
| } |
| |
| // If net negative, copy original back. This is unusual, but can happen |
| // in some circumstances, such as the one described in |
| // https://plus.google.com/+SaidTahsinDane/posts/X9sTSwoVUhB |
| // "Removed unused resources: Binary resource data reduced from 588KB to 595KB: Removed -1%" |
| // Guard against that, and worst case, just use the original. |
| long before = source.length(); |
| long after = dest.length(); |
| if (after > before) { |
| String message = "Resource shrinking did not work (grew from " + before + " to " |
| + after + "); using original instead"; |
| if (isVerbose()) { |
| System.out.println(message); |
| } |
| if (mDebugPrinter != null) { |
| mDebugPrinter.println(message); |
| } |
| |
| Files.copy(source, dest); |
| } |
| } |
| |
| /** |
| * Replaces the given entry with a minimal valid file of that type. |
| * |
| * @see #REPLACE_DELETED_WITH_EMPTY |
| */ |
| private void replaceWithDummyEntry(JarOutputStream zos, ZipEntry entry, String name) |
| throws IOException { |
| // Create a new entry so that the compressed len is recomputed. |
| byte[] bytes; |
| long crc; |
| if (name.endsWith(DOT_9PNG)) { |
| bytes = TINY_9PNG; |
| crc = TINY_9PNG_CRC; |
| } else if (name.endsWith(DOT_PNG)) { |
| bytes = TINY_PNG; |
| crc = TINY_PNG_CRC; |
| } else if (name.endsWith(DOT_XML)) { |
| switch (format) { |
| case BINARY: |
| bytes = TINY_BINARY_XML; |
| crc = TINY_BINARY_XML_CRC; |
| break; |
| case PROTO: |
| bytes = TINY_PROTO_XML; |
| crc = TINY_PROTO_XML_CRC; |
| break; |
| default: |
| throw new IllegalStateException(""); |
| } |
| } else { |
| bytes = new byte[0]; |
| crc = 0L; |
| } |
| JarEntry outEntry = new JarEntry(name); |
| if (entry.getTime() != -1L) { |
| outEntry.setTime(entry.getTime()); |
| } |
| if (entry.getMethod() == JarEntry.STORED) { |
| outEntry.setMethod(JarEntry.STORED); |
| outEntry.setSize(bytes.length); |
| outEntry.setCrc(crc); |
| } |
| zos.putNextEntry(outEntry); |
| zos.write(bytes); |
| zos.closeEntry(); |
| |
| if (isVerbose() || mDebugPrinter != null) { |
| String message = |
| "Skipped unused resource " |
| + name |
| + ": " |
| + entry.getSize() |
| + " bytes (replaced with small dummy file of size " |
| + bytes.length |
| + " bytes)"; |
| if (isVerbose()) { |
| System.out.println(message); |
| } |
| if (mDebugPrinter != null) { |
| mDebugPrinter.println(message); |
| } |
| } |
| } |
| |
| private static void copyToOutput( |
| JarInputStream zis, JarOutputStream zos, ZipEntry entry, String name, boolean directory) |
| throws IOException { |
| // We can't just compress all files; files that are not |
| // compressed in the source .ap_ file must be left uncompressed |
| // here, since for example RAW files need to remain uncompressed in |
| // the APK such that they can be mmap'ed at runtime. |
| // Preserve the STORED method of the input entry. |
| JarEntry outEntry; |
| if (entry.getMethod() == JarEntry.STORED) { |
| outEntry = new JarEntry(entry); |
| } else { |
| // Create a new entry so that the compressed len is recomputed. |
| outEntry = new JarEntry(name); |
| if (entry.getTime() != -1L) { |
| outEntry.setTime(entry.getTime()); |
| } |
| } |
| |
| zos.putNextEntry(outEntry); |
| |
| if (!directory) { |
| byte[] bytes = ByteStreams.toByteArray(zis); |
| if (bytes != null) { |
| zos.write(bytes); |
| } |
| } |
| |
| zos.closeEntry(); |
| } |
| |
| /** Writes the whitelist string to whitelist file specified by destination */ |
| public void emitWhitelist(Path destination) throws IOException { |
| File destinationFile = destination.toFile(); |
| if (!destinationFile.exists()) { |
| destinationFile.getParentFile().mkdirs(); |
| boolean success = destinationFile.createNewFile(); |
| if (!success) { |
| throw new IOException("Could not create " + destination); |
| } |
| } |
| Files.asCharSink(destinationFile, UTF_8).write(mModel.dumpWhitelistedResources()); |
| } |
| |
| public void emitConfig(Path destination) throws IOException { |
| File destinationFile = destination.toFile(); |
| if (!destinationFile.exists()) { |
| destinationFile.getParentFile().mkdirs(); |
| boolean success = destinationFile.createNewFile(); |
| if (!success) { |
| throw new IOException("Could not create " + destination); |
| } |
| } |
| Files.asCharSink(destinationFile, UTF_8).write(mModel.dumpConfig()); |
| } |
| |
| |
| /** |
| * Remove resources (already identified by {@link #analyze()}). |
| * |
| * <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 |
| */ |
| public void removeUnused(@Nullable File destination) |
| throws IOException, ParserConfigurationException, SAXException { |
| if (TWO_PASS_AAPT) { |
| assert mUnused != null; // should always call analyze() first |
| |
| int resourceCount = mUnused.size() |
| * 4; // *4: account for some resource folder repetition |
| boolean inPlace = destination == null; |
| Set<File> skip = inPlace ? null : Sets.newHashSetWithExpectedSize(resourceCount); |
| Set<File> rewrite = Sets.newHashSetWithExpectedSize(resourceCount); |
| for (Resource resource : mUnused) { |
| 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) { |
| if (isVerbose()) { |
| System.out.println("Deleted unused resource " + file); |
| } |
| if (inPlace) { |
| if (!isDryRun()) { |
| boolean delete = file.delete(); |
| if (!delete) { |
| System.err.println("Could not delete " + file); |
| } |
| } |
| } else { |
| 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( |
| Iterables.get(mResourceDirs, 0), |
| 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); |
| if (isVerbose()) { |
| System.out.println("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 (isDryRun()) { |
| return; |
| } |
| |
| if (valuesExists) { |
| String xml = rewritten.get(values); |
| if (xml == null) { |
| xml = Files.toString(values, UTF_8); |
| } |
| Document document = XmlUtils.parseDocument(xml, true); |
| |
| assert false; |
| /* This doesn't work; we don't need this when we have stable aapt id's anyway |
| Element root = document.getDocumentElement(); |
| for (Resource resource : mModel.getAllResources()) { |
| 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); |
| } |
| |
| if (inPlace) { |
| for (Map.Entry<File, String> entry : rewritten.entrySet()) { |
| File file = entry.getKey(); |
| String formatted = entry.getValue(); |
| Files.asCharSink(file, UTF_8).write(formatted); |
| } |
| } else { |
| for (File dir : mResourceDirs) { |
| filteredCopy(dir, destination, skip, rewritten); |
| } |
| } |
| } else { |
| assert false; |
| } |
| } |
| |
| /** |
| * 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, File destination, Set<File> skip, |
| Map<File, String> replace) throws IOException { |
| if (TWO_PASS_AAPT) { |
| if (source.isDirectory()) { |
| File[] children = source.listFiles(); |
| if (children != null) { |
| if (!destination.exists()) { |
| boolean success = destination.mkdirs(); |
| if (!success) { |
| throw new IOException("Could not create " + destination); |
| } |
| } |
| for (File child : children) { |
| filteredCopy(child, new File(destination, child.getName()), skip, replace); |
| } |
| } |
| } else if (!skip.contains(source) && source.isFile()) { |
| String contents = replace.get(source); |
| if (contents != null) { |
| Files.asCharSink(destination, UTF_8).write(contents); |
| } else { |
| Files.copy(source, destination); |
| } |
| } |
| } else { |
| assert false; |
| } |
| } |
| |
| private void stripUnused(Element element, List<String> removed) { |
| if (TWO_PASS_AAPT) { |
| ResourceType type = ResourceType.fromXmlTag(element); |
| if (type == ResourceType.ATTR) { |
| // Not yet properly handled |
| return; |
| } |
| |
| Resource resource = mModel.getResource(element); |
| if (resource != null) { |
| if (resource.type == ResourceType.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); |
| } |
| 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.isReachable()) { |
| if (mVerbose) { |
| 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()); |
| final String text; |
| switch (resource.type) { |
| case BOOL: |
| text = "true"; |
| break; |
| case DIMEN: |
| text = "0dp"; |
| break; |
| case INTEGER: |
| text = "0"; |
| break; |
| default: |
| text = null; |
| break; |
| } |
| element.setTextContent(text); |
| parent.insertBefore(element, nextSibling); |
| } |
| } else { |
| assert false; |
| } |
| } |
| |
| @Nullable |
| private Resource getResourceByJarPath(String path) { |
| if (!path.startsWith("res/")) { |
| return null; |
| } |
| |
| // Jars use forward slash paths, not File.separator |
| int folderStart = 4; // "res/".length |
| int folderEnd = path.indexOf('/', folderStart); |
| if (folderEnd == -1) { |
| return null; |
| } |
| |
| String folderName = path.substring(folderStart, folderEnd); |
| ResourceFolderType folderType = ResourceFolderType.getFolderType(folderName); |
| if (folderType == null) { |
| return null; |
| } |
| |
| int nameStart = folderEnd + 1; |
| int nameEnd = path.indexOf('.', nameStart); |
| if (nameEnd == -1) { |
| nameEnd = path.length(); |
| } |
| |
| String name = path.substring(nameStart, nameEnd); |
| for (ResourceType type : FolderTypeRelationship.getRelatedResourceTypes(folderType)) { |
| if (type == ResourceType.ID) { |
| continue; |
| } |
| |
| Resource resource = mModel.getResource(type, name); |
| if (resource != null) { |
| return resource; |
| } |
| } |
| |
| return null; |
| } |
| |
| private void dumpReferences() { |
| if (mDebugPrinter != null) { |
| mDebugPrinter.print(mModel.dumpReferences()); |
| } |
| } |
| |
| private void keepPossiblyReferencedResources() { |
| if ((!mFoundGetIdentifier && !mFoundWebContent) || mStrings == null) { |
| // No calls to android.content.res.Resources#getIdentifier; no need |
| // to worry about string references to resources |
| return; |
| } |
| |
| if (!mModel.isSafeMode()) { |
| // User specifically asked for us not to guess resources to keep; they will |
| // explicitly mark them as kept if necessary instead |
| return; |
| } |
| |
| if (mDebugPrinter != null) { |
| List<String> strings = new ArrayList<>(mStrings); |
| Collections.sort(strings); |
| mDebugPrinter.println("android.content.res.Resources#getIdentifier present: " |
| + mFoundGetIdentifier); |
| mDebugPrinter.println("Web content present: " + mFoundWebContent); |
| mDebugPrinter.println("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; |
| } |
| mDebugPrinter.println(" " + s); |
| } |
| } |
| |
| int shortest = Integer.MAX_VALUE; |
| Set<String> names = Sets.newHashSetWithExpectedSize(50); |
| for (Resource resource : mModel.getResources()) { |
| String name = resource.name; |
| names.add(name); |
| int length = name.length(); |
| if (length < shortest) { |
| shortest = length; |
| } |
| } |
| |
| for (String string : mStrings) { |
| 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 mFoundWebContent is true, look for android_res/ URL strings as well |
| |
| if (mFoundWebContent) { |
| Resource resource = mModel.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 : mModel.getResourceMaps()) { |
| resource = map.get(name); |
| if (mDebug && resource != null) { |
| mDebugPrinter.println("Marking " + resource + " used because it " |
| + "matches string pool constant " + string); |
| } |
| ResourceUsageModel.markReachable(resource); |
| mModel.addResourceToWhitelist(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 : mModel.getResources()) { |
| if (resource.name.startsWith(name)) { |
| if (mDebugPrinter != null) { |
| mDebugPrinter.println("Marking " + resource + " used because its " |
| + "prefix matches string pool constant " + 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 : mModel.getResources()) { |
| if (pattern.matcher(resource.name).matches()) { |
| if (mDebugPrinter != null) { |
| mDebugPrinter.println("Marking " + resource + " used because " |
| + "it format-string matches string pool constant " |
| + 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.fromClassName(typeName); |
| if (type == null) { |
| continue; |
| } |
| Resource resource = mModel.getResource(type, name); |
| if (mDebug && resource != null) { |
| mDebugPrinter.println("Marking " + resource + " used because it " |
| + "matches string pool constant " + string); |
| } |
| ResourceUsageModel.markReachable(resource); |
| continue; |
| } |
| |
| // fall through and check the name |
| } |
| |
| if (names.contains(name)) { |
| for (Map<String, Resource> map : mModel.getResourceMaps()) { |
| Resource resource = map.get(name); |
| if (mDebug && resource != null) { |
| mDebugPrinter.println("Marking " + resource + " used because it " |
| + "matches string pool constant " + 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(mModel.getResource(id)); |
| } |
| } catch (NumberFormatException e) { |
| // pass |
| } |
| } |
| } |
| } |
| |
| // Copied from StringFormatDetector |
| // See java.util.Formatter docs |
| public static final Pattern FORMAT = Pattern.compile( |
| // Generic format: |
| // %[argument_index$][flags][width][.precision]conversion |
| // |
| "%" + |
| // Argument Index |
| "(\\d+\\$)?" + |
| // Flags |
| "([-+#, 0(<]*)?" + |
| // Width |
| "(\\d+)?" + |
| // Precision |
| "(\\.\\d+)?" + |
| // Conversion. These are all a single character, except date/time conversions |
| // which take a prefix of t/T: |
| "([tT])?" + |
| // The current set of conversion characters are |
| // b,h,s,c,d,o,x,e,f,g,a,t (as well as all those as upper-case characters), plus |
| // n for newlines and % as a literal %. And then there are all the time/date |
| // characters: HIKLm etc. Just match on all characters here since there should |
| // be at least one. |
| "([a-zA-Z%])"); |
| |
| @VisibleForTesting |
| static String convertFormatStringToRegexp(String formatString) { |
| StringBuilder regexp = new StringBuilder(); |
| int from = 0; |
| boolean hasEscapedLetters = false; |
| Matcher matcher = 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); |
| } |
| String pattern = ".*"; |
| String conversion = matcher.group(6); |
| String timePrefix = matcher.group(5); |
| |
| //noinspection VariableNotUsedInsideIf,StatementWithEmptyBody: for readability. |
| if (timePrefix != null) { |
| // date notation; just use .* to match these |
| } else if (conversion != null && conversion.length() == 1) { |
| char type = conversion.charAt(0); |
| switch (type) { |
| case 's': |
| case 'S': |
| case 't': |
| case 'T': |
| // Match everything |
| break; |
| case '%': |
| pattern = "%"; break; |
| case 'n': |
| pattern = "\n"; break; |
| case 'c': |
| case 'C': |
| pattern = "."; break; |
| case 'x': |
| case 'X': |
| pattern = "\\p{XDigit}+"; break; |
| case 'd': |
| case 'o': |
| pattern = "\\p{Digit}+"; break; |
| case 'b': |
| pattern = "(true|false)"; break; |
| case 'B': |
| pattern = "(TRUE|FALSE)"; break; |
| case 'h': |
| case 'H': |
| pattern = "(null|\\p{XDigit}+)"; break; |
| case 'f': |
| pattern = "-?[\\p{XDigit},.]+"; break; |
| case 'e': |
| pattern = "-?\\p{Digit}+[,.]\\p{Digit}+e\\+?\\p{Digit}+"; break; |
| case 'E': |
| pattern = "-?\\p{Digit}+[,.]\\p{Digit}+E\\+?\\p{Digit}+"; break; |
| case 'a': |
| pattern = "0x[\\p{XDigit},.+p]+"; break; |
| case 'A': |
| pattern = "0X[\\p{XDigit},.+P]+"; break; |
| case 'g': |
| case 'G': |
| pattern = "-?[\\p{XDigit},.+eE]+"; break; |
| } |
| |
| // Allow space or 0 prefix |
| if (!".*".equals(pattern)) { |
| String width = matcher.group(3); |
| //noinspection VariableNotUsedInsideIf |
| if (width != null) { |
| String flags = matcher.group(2); |
| if ("0".equals(flags)) { |
| pattern = "0*" + pattern; |
| } else { |
| pattern = " " + pattern; |
| } |
| } |
| } |
| |
| // If it's a general .* wildcard which follows a previous .* wildcard, |
| // just skip it (e.g. don't convert %s%s into .*.*; .* is enough.) |
| int regexLength = regexp.length(); |
| if (!".*".equals(pattern) |
| || regexLength < 2 |
| || regexp.charAt(regexLength - 1) != '*' |
| || regexp.charAt(regexLength - 2) != '.') { |
| regexp.append(pattern); |
| } |
| } |
| 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(Iterable<File> resources) |
| throws IOException, SAXException, ParserConfigurationException { |
| for (File resDir : resources) { |
| File[] resourceFolders = resDir.listFiles(); |
| if (resourceFolders != null) { |
| for (File folder : resourceFolders) { |
| ResourceFolderType folderType = |
| ResourceFolderType.getFolderType(folder.getName()); |
| if (folderType != null) { |
| recordResources(folderType, folder); |
| } |
| } |
| } |
| } |
| } |
| |
| private void recordResources(@NonNull ResourceFolderType folderType, File folder) |
| throws ParserConfigurationException, SAXException, IOException { |
| File[] files = folder.listFiles(); |
| if (files != null) { |
| for (File file : files) { |
| String path = file.getPath(); |
| mModel.file = file; |
| try { |
| boolean isXml = endsWithIgnoreCase(path, DOT_XML); |
| if (isXml) { |
| String xml = Files.toString(file, UTF_8); |
| Document document = XmlUtils.parseDocument(xml, true); |
| mModel.visitXmlDocument(file, folderType, document); |
| } else { |
| mModel.visitBinaryResource(folderType, file); |
| } |
| } finally { |
| mModel.file = null; |
| } |
| } |
| } |
| } |
| |
| @VisibleForTesting |
| void recordMapping(@Nullable File mapping) throws IOException { |
| if (mapping == null || !mapping.exists()) { |
| return; |
| } |
| final String ARROW = " -> "; |
| final String RESOURCE = ".R$"; |
| Map<String, String> nameMap = null; |
| for (String line : Files.readLines(mapping, UTF_8)) { |
| if (line.startsWith(" ") || line.startsWith("\t")) { |
| if (nameMap != null) { |
| // We're processing the members of a resource class: record names into the map |
| int n = line.length(); |
| int i = 0; |
| for (; i < n; i++) { |
| if (!Character.isWhitespace(line.charAt(i))) { |
| break; |
| } |
| } |
| if (i < n && line.startsWith("int", i)) { // int or int[] |
| int start = line.indexOf(' ', i + 3) + 1; |
| int arrow = line.indexOf(ARROW); |
| 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 + ARROW.length()).trim(); |
| if (!newName.equals(oldName)) { |
| nameMap.put(newName, oldName); |
| } |
| } |
| } |
| } |
| } |
| continue; |
| } else { |
| nameMap = null; |
| } |
| int index = line.indexOf(RESOURCE); |
| if (index == -1) { |
| // Record obfuscated names of a few known appcompat usages of |
| // Resources#getIdentifier that are unlikely to be used for general |
| // resource name reflection |
| if (line.startsWith("android.support.v7.widget.SuggestionsAdapter ")) { |
| mSuggestionsAdapter = line.substring(line.indexOf(ARROW) + ARROW.length(), |
| line.indexOf(':') != -1 ? line.indexOf(':') : line.length()) |
| .trim().replace('.','/') + DOT_CLASS; |
| } else if (line.startsWith("android.support.v7.internal.widget.ResourcesWrapper ") |
| || line.startsWith("android.support.v7.widget.ResourcesWrapper ") |
| || (mResourcesWrapper == null // Recently wrapper moved |
| && line.startsWith("android.support.v7.widget.TintContextWrapper$TintResources "))) { |
| mResourcesWrapper = line.substring(line.indexOf(ARROW) + ARROW.length(), |
| line.indexOf(':') != -1 ? line.indexOf(':') : line.length()) |
| .trim().replace('.','/') + DOT_CLASS; |
| } |
| continue; |
| } |
| int arrow = line.indexOf(ARROW, index + 3); |
| if (arrow == -1) { |
| continue; |
| } |
| String typeName = line.substring(index + RESOURCE.length(), arrow); |
| ResourceType type = ResourceType.fromClassName(typeName); |
| if (type == null) { |
| continue; |
| } |
| int end = line.indexOf(':', arrow + ARROW.length()); |
| if (end == -1) { |
| end = line.length(); |
| } |
| String target = line.substring(arrow + ARROW.length(), end).trim(); |
| String ownerName = target.replace('.', '/'); |
| |
| nameMap = Maps.newHashMap(); |
| Pair<ResourceType, Map<String, String>> pair = Pair.of(type, nameMap); |
| mResourceObfuscation.put(ownerName, pair); |
| // For fast lookup in isResourceClass |
| mResourceObfuscation.put(ownerName + DOT_CLASS, pair); |
| } |
| } |
| |
| private void recordManifestUsages(File manifest) |
| throws IOException, ParserConfigurationException, SAXException { |
| String xml = Files.toString(manifest, UTF_8); |
| Document document = XmlUtils.parseDocument(xml, true); |
| mModel.visitXmlDocument(manifest, null, document); |
| } |
| |
| private Set<String> mStrings; |
| private boolean mFoundGetIdentifier; |
| private boolean mFoundWebContent; |
| |
| 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 (mStrings == null) { |
| mStrings = Sets.newHashSetWithExpectedSize(300); |
| } |
| mStrings.add(string); |
| |
| if (!mFoundWebContent && string.contains(ANDROID_RES)) { |
| mFoundWebContent = true; |
| } |
| } |
| |
| private void recordClassUsages(File file) throws IOException { |
| if (file.isDirectory()) { |
| File[] children = file.listFiles(); |
| if (children != null) { |
| for (File child : children) { |
| recordClassUsages(child); |
| } |
| } |
| } else if (file.isFile()) { |
| if (file.getPath().endsWith(DOT_CLASS) || file.getPath().endsWith(DOT_DEX)) { |
| byte[] bytes = Files.toByteArray(file); |
| recordClassUsages(file, file.getName(), bytes); |
| } else if (file.getPath().endsWith(DOT_JAR)) { |
| ZipInputStream zis = null; |
| try { |
| FileInputStream fis = new FileInputStream(file); |
| try { |
| zis = new ZipInputStream(fis); |
| ZipEntry entry = zis.getNextEntry(); |
| while (entry != null) { |
| String name = entry.getName(); |
| if ((name.endsWith(DOT_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)) |
| || name.endsWith(DOT_DEX)) { |
| byte[] bytes = ByteStreams.toByteArray(zis); |
| if (bytes != null) { |
| recordClassUsages(file, name, bytes); |
| } |
| } |
| |
| entry = zis.getNextEntry(); |
| } |
| } finally { |
| Closeables.close(fis, true); |
| } |
| } finally { |
| Closeables.close(zis, true); |
| } |
| } |
| } |
| } |
| |
| private void recordClassUsages(File file, String name, byte[] bytes) { |
| if (name.endsWith(DOT_CLASS)) { |
| ClassReader classReader = new ClassReader(bytes); |
| classReader.accept(new UsageVisitor(file, name), SKIP_DEBUG | SKIP_FRAMES); |
| } else { |
| assert name.endsWith(DOT_DEX); |
| AnalysisCallback callback = |
| new AnalysisCallback() { |
| @Override |
| public boolean shouldProcess(@NonNull String internalName) { |
| return !isResourceClass(internalName + DOT_CLASS); |
| } |
| |
| @Override |
| public void referencedInt(int value) { |
| ResourceUsageAnalyzer.this.referencedInt("dex", value, file, name); |
| } |
| |
| @Override |
| public void referencedString(@NonNull String value) { |
| ResourceUsageAnalyzer.this.referencedString(value); |
| } |
| |
| @Override |
| public void referencedStaticField( |
| @NonNull String internalName, @NonNull String fieldName) { |
| Resource resource = getResourceFromCode(internalName, fieldName); |
| if (resource != null) { |
| ResourceUsageModel.markReachable(resource); |
| } |
| } |
| |
| @Override |
| public void referencedMethod( |
| @NonNull String internalName, |
| @NonNull String methodName, |
| @NonNull String methodDescriptor) { |
| ResourceUsageAnalyzer.this.referencedMethodInvocation( |
| internalName, |
| methodName, |
| methodDescriptor, |
| internalName + DOT_CLASS); |
| } |
| }; |
| R8ResourceShrinker.runResourceShrinkerAnalysis(bytes, file, callback); |
| } |
| } |
| |
| /** Returns whether the given class file name points to an aapt-generated compiled R class */ |
| @VisibleForTesting |
| boolean isResourceClass(@NonNull String name) { |
| if (mResourceObfuscation.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.fromClassName(typeName) != null; |
| } |
| |
| return false; |
| } |
| |
| @VisibleForTesting |
| @Nullable |
| Resource getResourceFromCode(@NonNull String owner, @NonNull String name) { |
| Pair<ResourceType, Map<String, String>> pair = mResourceObfuscation.get(owner); |
| if (pair != null) { |
| ResourceType type = pair.getFirst(); |
| Map<String, String> nameMap = pair.getSecond(); |
| String renamedField = nameMap.get(name); |
| if (renamedField != null) { |
| name = renamedField; |
| } |
| return mModel.getResource(type, name); |
| } |
| return null; |
| } |
| |
| private void gatherResourceValues(File file) throws IOException { |
| if (file.isDirectory()) { |
| File[] children = file.listFiles(); |
| if (children != null) { |
| for (File child : children) { |
| gatherResourceValues(child); |
| } |
| } |
| } else if (file.isFile()) { |
| if (file.getName().equals(SdkConstants.FN_RESOURCE_CLASS)) { |
| parseResourceSourceClass(file); |
| } else if (file.getName().equals(SdkConstants.FN_R_CLASS_JAR)) { |
| parseResourceRJar(file); |
| } |
| } |
| } |
| |
| private static ResourceType extractResourceType(String entryName) { |
| String rClassName = entryName.substring(entryName.lastIndexOf('/') + 1); |
| if (!rClassName.startsWith("R$")) { |
| return null; |
| } |
| String resourceTypeName = |
| rClassName.substring("R$".length(), rClassName.length() - DOT_CLASS.length()); |
| return ResourceType.fromClassName(resourceTypeName); |
| } |
| |
| private void parseResourceRJar(File jarFile) throws IOException { |
| try (ZipFile zFile = new ZipFile(jarFile)) { |
| Enumeration<? extends ZipEntry> entries = zFile.entries(); |
| while (entries.hasMoreElements()) { |
| ZipEntry entry = entries.nextElement(); |
| String entryName = entry.getName(); |
| if (entryName.endsWith(DOT_CLASS)) { |
| ResourceType resourceType = extractResourceType(entryName); |
| if (resourceType == null) { |
| continue; |
| } |
| String owner = entryName.substring(0, entryName.length() - DOT_CLASS.length()); |
| byte[] classData = ByteStreams.toByteArray(zFile.getInputStream(entry)); |
| parseResourceCompiledClass(classData, owner, resourceType); |
| } |
| } |
| } |
| } |
| |
| private void parseResourceCompiledClass( |
| byte[] classData, String owner, ResourceType resourceType) { |
| ClassReader classReader = new ClassReader(classData); |
| ClassVisitor fieldVisitor = |
| new ClassVisitor(Opcodes.ASM5) { |
| @Override |
| public FieldVisitor visitField( |
| int access, String name, String desc, String signature, Object value) { |
| // We only want integer or integer array (styleable) fields |
| if (desc.equals("I") || desc.equals("[I")) { |
| String resourceValue = |
| resourceType == ResourceType.STYLEABLE |
| ? null |
| : value.toString(); |
| mModel.addResource(resourceType, name, resourceValue); |
| addOwner(owner, resourceType); |
| } |
| return null; |
| } |
| }; |
| classReader.accept(fieldVisitor, SKIP_DEBUG | SKIP_FRAMES); |
| } |
| |
| private void addOwner(@NonNull String owner, @NonNull ResourceType type) { |
| Pair<ResourceType, Map<String, String>> pair = mResourceObfuscation.get(owner); |
| if (pair == null) { |
| Map<String, String> nameMap = Maps.newHashMap(); |
| pair = Pair.of(type, nameMap); |
| } |
| mResourceObfuscation.put(owner, pair); |
| } |
| |
| // TODO: Use PSI here |
| private void parseResourceSourceClass(File file) throws IOException { |
| String s = Files.toString(file, UTF_8); |
| // Simple parser which handles only aapt's special R output |
| String pkg = null; |
| int index = s.indexOf("package "); |
| if (index != -1) { |
| int end = s.indexOf(';', index); |
| pkg = s.substring(index + "package ".length(), end).trim().replace('.', '/'); |
| } |
| index = 0; |
| int length = s.length(); |
| String classDeclaration = "public static final class "; |
| while (true) { |
| index = s.indexOf(classDeclaration, index); |
| if (index == -1) { |
| break; |
| } |
| int start = index + classDeclaration.length(); |
| int end = s.indexOf(' ', start); |
| if (end == -1) { |
| break; |
| } |
| String typeName = s.substring(start, end); |
| ResourceType type = ResourceType.fromClassName(typeName); |
| if (type == null) { |
| break; |
| } |
| |
| if (pkg != null) { |
| addOwner(pkg + "/R$" + type.getName(), type); |
| } |
| |
| index = end; |
| |
| // Find next declaration |
| for (; index < length - 1; index++) { |
| char c = s.charAt(index); |
| if (Character.isWhitespace(c)) { |
| //noinspection UnnecessaryContinue |
| continue; |
| } |
| |
| if (c == '/') { |
| char next = s.charAt(index + 1); |
| if (next == '*') { |
| // Scan forward to comment end |
| end = index + 2; |
| while (end < length -2) { |
| c = s.charAt(end); |
| if (c == '*' && s.charAt(end + 1) == '/') { |
| end++; |
| break; |
| } else { |
| end++; |
| } |
| } |
| index = end; |
| } else if (next == '/') { |
| // Scan forward to next newline |
| assert false : s.substring(index - 1, index + 50); // we don't put line comments in R files |
| } else { |
| assert false : s.substring(index - 1, index + 50); // unexpected division |
| } |
| } else if (c == 'p' && s.startsWith("public ", index)) { |
| if (type == ResourceType.STYLEABLE) { |
| start = s.indexOf(" int", index); |
| if (s.startsWith(" int[] ", start)) { |
| start += " int[] ".length(); |
| end = s.indexOf('=', start); |
| assert end != -1; |
| String styleable = s.substring(start, end).trim(); |
| mModel.addResource(ResourceType.STYLEABLE, styleable, null); |
| // TODO: Read in all the action bar ints! |
| // For now, we're simply treating all R.attr fields as used |
| index = s.indexOf(';', index); |
| if (index == -1) { |
| break; |
| } |
| } else if (s.startsWith(" int ", start)) { |
| // Read these fields in and correlate with the attr R's. Actually |
| // we don't need this for anything; the local attributes are |
| // found by the R attr thing. I just need to record the class |
| // (style). |
| // public static final int ActionBar_background = 10; |
| // ignore - jump to end |
| index = s.indexOf(';', index); |
| if (index == -1) { |
| break; |
| } |
| // For now, we're simply treating all R.attr fields as used |
| } |
| } else { |
| start = s.indexOf(" int ", index); |
| if (start != -1) { |
| start += " int ".length(); |
| // e.g. abc_fade_in=0x7f040000; |
| end = s.indexOf('=', start); |
| assert end != -1; |
| String name = s.substring(start, end).trim(); |
| start = end + 1; |
| end = s.indexOf(';', start); |
| assert end != -1; |
| String value = s.substring(start, end).trim(); |
| mModel.addResource(type, name, value); |
| } |
| } |
| } else if (c == '}') { |
| // Done with resource class |
| break; |
| } |
| } |
| } |
| } |
| |
| public int getUnusedResourceCount() { |
| return mUnused.size(); |
| } |
| |
| @VisibleForTesting |
| ResourceUsageModel getModel() { |
| return mModel; |
| } |
| |
| /** |
| * 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 mJarFile; |
| private final String mCurrentClass; |
| |
| public UsageVisitor(File jarFile, String name) { |
| super(Opcodes.ASM5); |
| mJarFile = jarFile; |
| mCurrentClass = 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); |
| referencedMethodInvocation(owner, name, desc, mCurrentClass); |
| } |
| |
| @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; |
| referencedInt(context, value, mJarFile, mCurrentClass); |
| } else if (cst instanceof Long) { |
| Long value = (Long) cst; |
| referencedInt(context, value.intValue(), mJarFile, mCurrentClass); |
| } else if (cst instanceof int[]) { |
| int[] values = (int[]) cst; |
| for (int value : values) { |
| referencedInt(context, value, mJarFile, mCurrentClass); |
| } |
| } else if (cst instanceof String) { |
| String string = (String) cst; |
| referencedString(string); |
| } |
| } |
| } |
| |
| private void referencedInt(@NonNull String context, int value, File file, String currentClass) { |
| Resource resource = mModel.getResource(value); |
| if (ResourceUsageModel.markReachable(resource) && mDebug) { |
| assert mDebugPrinter != null : "mDebug is true, but mDebugPrinter is null."; |
| mDebugPrinter.println( |
| "Marking " |
| + resource |
| + " reachable: referenced from " |
| + context |
| + " in " |
| + file |
| + ":" |
| + currentClass); |
| } |
| } |
| |
| private void referencedMethodInvocation( |
| @NonNull String owner, |
| @NonNull String name, |
| @NonNull String desc, |
| @NonNull String currentClass) { |
| 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(mResourcesWrapper) |
| || currentClass.equals(mSuggestionsAdapter)) { |
| // "benign" usages: don't trigger reflection mode just because |
| // the user has included appcompat |
| return; |
| } |
| |
| 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! |
| } |
| if (owner.equals("android/webkit/WebView") && name.startsWith("load")) { |
| mFoundWebContent = true; |
| } |
| } |
| |
| private final ResourceShrinkerUsageModel mModel = |
| new ResourceShrinkerUsageModel(); |
| |
| private class ResourceShrinkerUsageModel extends ResourceUsageModel { |
| public File file; |
| |
| /** |
| * Whether we should ignore tools attribute resource references. |
| * <p> |
| * For example, for resource shrinking we want to ignore tools attributes, |
| * whereas for resource refactoring on the source code we do not. |
| * |
| * @return whether tools attributes should be ignored |
| */ |
| @Override |
| protected boolean ignoreToolsAttributes() { |
| return true; |
| } |
| |
| @NonNull |
| @Override |
| protected List<Resource> findRoots(@NonNull List<Resource> resources) { |
| List<Resource> roots = super.findRoots(resources); |
| if (mDebugPrinter != null) { |
| mDebugPrinter.println("\nThe root reachable resources are:\n" + |
| Joiner.on(",\n ").join(roots)); |
| } |
| |
| 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); |
| mFoundWebContent = true; |
| } |
| } |
| } |