Add resource shrinker sources to R8 repo
This is a verbatim copy for now since this will allow us to easily
merge in either direction - downside is that this does not pass our formatter.
I have added 8.2.20-dev to third_party/r8 to allow us to compile this standalone, this is not used when compiled into r8 (in a follow up). We should be able to refactor the setup to eventually not need this hack.
Add build setup in new gradle build files
Bug: 287398085
Change-Id: Ib9ab46acb67dc4d713e91b82f27b30ef67fb065a
diff --git a/d8_r8/resourceshrinker/build.gradle.kts b/d8_r8/resourceshrinker/build.gradle.kts
new file mode 100644
index 0000000..c15b7b8
--- /dev/null
+++ b/d8_r8/resourceshrinker/build.gradle.kts
@@ -0,0 +1,58 @@
+// Copyright (c) 2023, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+plugins {
+ `kotlin-dsl`
+ id("dependencies-plugin")
+}
+
+java {
+ sourceSets.main.configure {
+ kotlin.srcDir(getRoot().resolveAll("src", "resourceshrinker", "java"))
+ java.srcDir(getRoot().resolveAll("src", "resourceshrinker", "java"))
+ }
+ sourceCompatibility = JvmCompatibility.sourceCompatibility
+ targetCompatibility = JvmCompatibility.targetCompatibility
+}
+
+fun jarDependencies() : FileCollection {
+ return sourceSets
+ .main
+ .get()
+ .compileClasspath
+ .filter({ "$it".contains("third_party")
+ && "$it".contains("dependencies")
+ })
+}
+
+tasks {
+ withType<KotlinCompile> {
+ kotlinOptions {
+ // We cannot use languageVersion.set(JavaLanguageVersion.of(8)) because gradle cannot figure
+ // out that the jdk is 1_8 and will try to download it.
+ jvmTarget = "11"
+ }
+ }
+
+ val depsJar by registering(Jar::class) {
+ println(header("Resource shrinker dependencies"))
+ jarDependencies().forEach({ println(it) })
+ from(jarDependencies().map(::zipTree))
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+ archiveFileName.set("resourceshrinker_deps.jar")
+ }
+}
+
+dependencies {
+ compileOnly(Deps.asm)
+ compileOnly(Deps.guava)
+ compileOnly(files(getRoot().resolve("third_party/r8/r8lib_8.2.20-dev.jar")))
+ implementation("com.android.tools.build:aapt2-proto:8.2.0-alpha10-10154469")
+ implementation("com.google.protobuf:protobuf-java:3.19.3")
+ implementation("com.android.tools.layoutlib:layoutlib-api:31.2.0-alpha10")
+ implementation("com.android.tools:common:31.2.0-alpha10")
+ implementation("com.android.tools:sdk-common:31.2.0-alpha10")
+}
diff --git a/d8_r8/resourceshrinker/gradle.properties b/d8_r8/resourceshrinker/gradle.properties
new file mode 100644
index 0000000..1de43f9
--- /dev/null
+++ b/d8_r8/resourceshrinker/gradle.properties
@@ -0,0 +1,17 @@
+# Copyright (c) 2023, the R8 project authors. Please see the AUTHORS file
+# for details. All rights reserved. Use of this source code is governed by a
+# BSD-style license that can be found in the LICENSE file.
+
+org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8
+kotlin.daemon.jvmargs=-Xmx3g -Dkotlin.js.compiler.legacy.force_enabled=true
+systemProp.file.encoding=UTF-8
+
+# Enable new incremental compilation
+kotlin.incremental.useClasspathSnapshot=true
+
+org.gradle.parallel=true
+org.gradle.caching=true
+
+# Do not download any jdks or detect them. We provide them.
+org.gradle.java.installations.auto-detect=false
+org.gradle.java.installations.auto-download=false
diff --git a/d8_r8/resourceshrinker/settings.gradle.kts b/d8_r8/resourceshrinker/settings.gradle.kts
new file mode 100644
index 0000000..604d9cb
--- /dev/null
+++ b/d8_r8/resourceshrinker/settings.gradle.kts
@@ -0,0 +1,27 @@
+// Copyright (c) 2023, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+pluginManagement {
+ repositories {
+ maven {
+ url = uri("file:../../third_party/dependencies")
+ }
+ maven {
+ url = uri("file:../../third_party/dependencies_new")
+ }
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ maven {
+ url = uri("file:../../third_party/dependencies")
+ }
+ maven {
+ url = uri("file:../../third_party/dependencies_new")
+ }
+ }
+}
+
+rootProject.name = "resourceshrinker"
diff --git a/d8_r8/settings.gradle.kts b/d8_r8/settings.gradle.kts
index 77ad2e0..a02c904 100644
--- a/d8_r8/settings.gradle.kts
+++ b/d8_r8/settings.gradle.kts
@@ -50,6 +50,7 @@
// This project is temporarily located in d8_r8. When moved to root, the parent
// folder should just be removed.
includeBuild(root.resolve("keepanno"))
+includeBuild(root.resolve("resourceshrinker"))
// We need to include src/main as a composite-build otherwise our test-modules
// will compete with the test to compile the source files.
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/DummyContent.java b/src/resourceshrinker/java/com/android/build/shrinker/DummyContent.java
new file mode 100644
index 0000000..b9aaa3e
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/DummyContent.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2022 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.shrinker;
+
+public class DummyContent {
+
+ // 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;
+
+ // The XML document <x/> as binary-packed with AAPT
+ public static final byte[] TINY_PROTO_CONVERTED_TO_BINARY_XML =
+ new byte[] {
+ (byte) 3, (byte) 0, (byte) 8, (byte) 0, (byte) 112, (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) -128, (byte) 1, (byte) 8, (byte) 0,
+ (byte) 8, (byte) 0, (byte) 0, (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
+ };
+
+ private DummyContent() {}
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/LinkedResourcesFormat.java b/src/resourceshrinker/java/com/android/build/shrinker/LinkedResourcesFormat.java
new file mode 100644
index 0000000..67b06a8
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/LinkedResourcesFormat.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2020 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.shrinker;
+
+public enum LinkedResourcesFormat {
+ BINARY,
+ PROTO,
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/PossibleResourcesMarker.java b/src/resourceshrinker/java/com/android/build/shrinker/PossibleResourcesMarker.java
new file mode 100644
index 0000000..5089061
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/PossibleResourcesMarker.java
@@ -0,0 +1,372 @@
+/*
+ * Copyright (C) 2022 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.shrinker;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.lang.Character.isDigit;
+
+import com.android.annotations.NonNull;
+import com.android.ide.common.resources.usage.ResourceStore;
+import com.android.ide.common.resources.usage.ResourceUsageModel;
+import com.android.ide.common.resources.usage.ResourceUsageModel.Resource;
+import com.android.resources.ResourceType;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Streams;
+import com.google.common.primitives.Ints;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import java.util.stream.Stream;
+
+/** Marks resources which are possibly referenced by string constants as reachable. */
+public class PossibleResourcesMarker {
+
+ // 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%])");
+
+ static final String NO_MATCH = "-nomatch-";
+
+ private final ShrinkerDebugReporter debugReporter;
+ private final ResourceStore resourceStore;
+ private final Set<String> strings;
+ private final boolean foundWebContent;
+
+ public PossibleResourcesMarker(ShrinkerDebugReporter debugReporter,
+ ResourceStore resourceStore,
+ Set<String> strings,
+ boolean foundWebContent) {
+ this.debugReporter = debugReporter;
+ this.resourceStore = resourceStore;
+ this.strings = strings;
+ this.foundWebContent = foundWebContent;
+ }
+
+ public void markPossibleResourcesReachable() {
+ Set<String> names =
+ resourceStore.getResources().stream()
+ .map(resource -> resource.name)
+ .collect(toImmutableSet());
+
+ int shortest = names.stream().mapToInt(String::length).min().orElse(Integer.MAX_VALUE);
+
+ // Check whether the string looks relevant
+ // We consider four types of strings:
+ // (1) simple resource names, e.g. "foo" from @layout/foo
+ // These might be the parameter to a getIdentifier() call, or could
+ // be composed into a fully qualified resource name for the getIdentifier()
+ // method. We match these for *all* resource types.
+ // (2) Relative source names, e.g. layout/foo, from @layout/foo
+ // These might be composed into a fully qualified resource name for
+ // getIdentifier().
+ // (3) Fully qualified resource names of the form package:type/name.
+ // (4) If foundWebContent is true, look for android_res/ URL strings as well
+ strings.stream()
+ .filter(string -> string.length() >= shortest)
+ .flatMap(
+ string -> {
+ 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);
+ haveSlash |= c == '/';
+ formatting |= c == '%';
+ justName =
+ justName && !(c == '.' || c == ':' || c == '%' || c == '/');
+ }
+
+ Stream<Resource> reachable = Streams.concat(
+ foundWebContent
+ ? possibleWebResources(names, string)
+ : Stream.empty(),
+ justName ? possiblePrefixMatch(string) : Stream.empty(),
+ formatting && !haveSlash
+ ? possibleFormatting(string)
+ : Stream.empty(),
+ haveSlash
+ ? possibleTypedResource(names, string)
+ : Stream.empty(),
+ possibleIntResource(string));
+
+ return reachable
+ .peek(resource -> debugReporter.debug(() -> "Marking "
+ + resource + " used because it matches string pool constant "
+ + string));
+ })
+ .forEach(ResourceUsageModel::markReachable);
+ }
+
+ private Stream<Resource> possibleWebResources(
+ Set<String> names, String string) {
+ // Look for android_res/ URL strings.
+ List<Resource> resources = resourceStore.getResourcesFromWebUrl(string);
+ if (!resources.isEmpty()) {
+ return resources.stream();
+ }
+
+ int start = Math.max(string.lastIndexOf('/'), 0);
+ int dot = string.indexOf('.', start);
+ String name = string.substring(start, dot != -1 ? dot : string.length());
+
+ if (names.contains(name)) {
+ return resourceStore.getResourceMaps().stream()
+ .filter(map -> map.containsKey(name))
+ .flatMap(map -> map.get(name).stream());
+ }
+ return Stream.empty();
+ }
+
+ private Stream<Resource> possiblePrefixMatch(String string) {
+ // Check for a simple prefix match, e.g. as in
+ // getResources().getIdentifier("ic_video_codec_" + codecName, "drawable", ...)
+ return resourceStore.getResources().stream()
+ .filter(resource -> resource.name.startsWith(string));
+ }
+
+ private Stream<Resource> possibleFormatting(String string) {
+ // 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));
+ return resourceStore.getResources().stream()
+ .filter(resource -> pattern.matcher(resource.name).matches());
+ } catch (PatternSyntaxException ignored) {
+ return Stream.empty();
+ }
+ }
+
+ private Stream<Resource> possibleTypedResource(
+ Set<String> names, String string) {
+ // 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('/');
+ String name = string.substring(slash + 1);
+ if (name.isEmpty() || !names.contains(name)) {
+ return Stream.empty();
+ }
+ // See if have a known specific resource type
+ if (slash > 0) {
+ int colon = string.indexOf(':');
+ String typeName = string.substring(colon + 1, slash);
+ ResourceType type = ResourceType.fromClassName(typeName);
+ return type != null
+ ? resourceStore.getResources(type, name).stream()
+ : Stream.empty();
+ }
+ return resourceStore.getResourceMaps().stream()
+ .filter(map -> map.containsKey(name))
+ .flatMap(map -> map.get(name).stream());
+ }
+
+ private Stream<Resource> possibleIntResource(String string) {
+ // 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".
+ String withoutSlash = string.substring(string.lastIndexOf('/') + 1);
+ if (withoutSlash.isEmpty() || !isDigit(withoutSlash.charAt(0))) {
+ return Stream.empty();
+ }
+ Integer id = Ints.tryParse(withoutSlash);
+ Resource resource = null;
+ if (id != null) {
+ resource = resourceStore.getResource(id);
+ }
+ return resource != null ? Stream.of(resource) : Stream.empty();
+ }
+
+ @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;
+ }
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinker.java b/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinker.java
new file mode 100644
index 0000000..e38056a
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinker.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2022 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.shrinker;
+
+import com.android.annotations.NonNull;
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+import javax.xml.parsers.ParserConfigurationException;
+import org.xml.sax.SAXException;
+
+/**
+ * Interface for unit that analyzes all resources (after resource merging, compilation and code
+ * shrinking has been completed) and figures out which resources are unused, and replaces them with
+ * dummy content inside zip archive file.
+ */
+public interface ResourceShrinker extends AutoCloseable {
+
+ /**
+ * Analyzes resources and detects unreachable ones. It includes the following steps:
+ *
+ * <ul>
+ * <li>Gather resources available in project.
+ * <li>Record resource usages via analyzing compiled code, AndroidManifest etc.
+ * <li>Build reference graph and connect dependent resources.
+ * <li>Detects WebView and/or {@code Resources#getIdentifier} usages and guess which
+ * resources are reachable analyzing string constants available in compiled code.
+ * <li>Processes resources explicitly asked to keep and discard. <tools:keep> and
+ * <tools:discard> attribute.
+ * <li>Based on the root references computes unreachable resources.
+ * </ul>
+ */
+ void analyze() throws IOException, ParserConfigurationException, SAXException;
+
+ /**
+ * Returns count of unused resources. Should be called after {@code ResourceShrinker#analyze}.
+ */
+ int getUnusedResourceCount();
+
+ /**
+ * Replaces entries in {@param source} zip archive that belong to unused resources with dummy
+ * content and produces a new {@param dest} zip archive. Zip archive should contain resources
+ * in 'res/' folder like it is stored in APK.
+ *
+ * <p>For now, doesn't change resource table and applies to file-based resources like layouts,
+ * menus and drawables, not value-based resources like strings and dimensions.
+ *
+ * <p>Should be called after {@code ResourceShrinker#analyze}.
+ */
+ void rewriteResourcesInApkFormat(
+ @NonNull File source,
+ @NonNull File dest,
+ @NonNull LinkedResourcesFormat format
+ ) throws IOException;
+
+ /**
+ * Replaces entries in {@param source} zip archive that belong to unused resources with dummy
+ * content and produces a new {@param dest} zip archive. Zip archive represents App Bundle which
+ * may have multiple modules and each module has its own resource directory: '${module}/res/'.
+ * Package name for each bundle module should be passed as {@param moduleToPackageName}.
+ *
+ * <p>For now, doesn't change resource table and applies to file-based resources like layouts,
+ * menus and drawables, not value-based resources like strings and dimensions.
+ *
+ * <p>Should be called after {@code ResourceShrinker#analyze}.
+ */
+ void rewriteResourcesInBundleFormat(
+ @NonNull File source,
+ @NonNull File dest,
+ @NonNull Map<String, String> moduleToPackageName
+ ) throws IOException;
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinkerCli.java b/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinkerCli.java
new file mode 100644
index 0000000..3e30568
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinkerCli.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright (C) 2022 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.shrinker;
+
+import com.android.build.shrinker.gatherer.ProtoResourceTableGatherer;
+import com.android.build.shrinker.gatherer.ResourcesGatherer;
+import com.android.build.shrinker.graph.ProtoResourcesGraphBuilder;
+import com.android.build.shrinker.usages.DexUsageRecorder;
+import com.android.build.shrinker.usages.ProtoAndroidManifestUsageRecorder;
+import com.android.build.shrinker.usages.ResourceUsageRecorder;
+import com.android.build.shrinker.usages.ToolsAttributeUsageRecorder;
+import com.android.utils.FileUtils;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.file.FileSystem;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.zip.ZipFile;
+import javax.xml.parsers.ParserConfigurationException;
+import org.xml.sax.SAXException;
+
+public class ResourceShrinkerCli {
+
+ private static final String INPUT_ARG = "--input";
+ private static final String DEX_INPUT_ARG = "--dex_input";
+ private static final String OUTPUT_ARG = "--output";
+ private static final String RES_ARG = "--raw_resources";
+ private static final String HELP_ARG = "--help";
+ private static final String PRINT_USAGE_LOG = "--print_usage_log";
+
+ private static final String ANDROID_MANIFEST_XML = "AndroidManifest.xml";
+ private static final String RESOURCES_PB = "resources.pb";
+ private static final String RES_FOLDER = "res";
+
+ private static class Options {
+ private String input;
+ private final List<String> dex_inputs = new ArrayList<>();
+ private String output;
+ private String usageLog;
+ private final List<String> rawResources = new ArrayList<>();
+ private boolean help;
+
+ private Options() {}
+
+ public static Options parseOptions(String[] args) {
+ Options options = new Options();
+ for (int i = 0; i < args.length; i++) {
+ String arg = args[i];
+ if (arg.startsWith(INPUT_ARG)) {
+ i++;
+ if (i == args.length) {
+ throw new ResourceShrinkingFailedException("No argument given for input");
+ }
+ if (options.input != null) {
+ throw new ResourceShrinkingFailedException(
+ "More than one input not supported");
+ }
+ options.input = args[i];
+ } else if (arg.startsWith(OUTPUT_ARG)) {
+ i++;
+ if (i == args.length) {
+ throw new ResourceShrinkingFailedException("No argument given for output");
+ }
+ if (options.output != null) {
+ throw new ResourceShrinkingFailedException(
+ "More than one output not supported");
+ }
+ options.output = args[i];
+ } else if (arg.startsWith(DEX_INPUT_ARG)) {
+ i++;
+ if (i == args.length) {
+ throw new ResourceShrinkingFailedException(
+ "No argument given for dex_input");
+ }
+ options.dex_inputs.add(args[i]);
+ } else if (arg.startsWith(PRINT_USAGE_LOG)) {
+ i++;
+ if (i == args.length) {
+ throw new ResourceShrinkingFailedException(
+ "No argument given for usage log");
+ }
+ if (options.usageLog != null) {
+ throw new ResourceShrinkingFailedException(
+ "More than usage log not supported");
+ }
+ options.usageLog = args[i];
+ } else if (arg.startsWith(RES_ARG)) {
+ i++;
+ if (i == args.length) {
+ throw new ResourceShrinkingFailedException(
+ "No argument given for raw_resources");
+ }
+ options.rawResources.add(args[i]);
+ } else if (arg.equals(HELP_ARG)) {
+ options.help = true;
+ } else {
+ throw new ResourceShrinkingFailedException("Unknown argument " + arg);
+ }
+ }
+ return options;
+ }
+
+ public String getInput() {
+ return input;
+ }
+
+ public String getOutput() {
+ return output;
+ }
+
+ public String getUsageLog() {
+ return usageLog;
+ }
+
+ public List<String> getRawResources() {
+ return rawResources;
+ }
+
+ public boolean isHelp() {
+ return help;
+ }
+ }
+
+ public static void main(String[] args) {
+ run(args);
+ }
+
+ protected static ResourceShrinkerImpl run(String[] args) {
+ try {
+ Options options = Options.parseOptions(args);
+ if (options.isHelp()) {
+ printUsage();
+ return null;
+ }
+ validateOptions(options);
+ ResourceShrinkerImpl resourceShrinker = runResourceShrinking(options);
+ return resourceShrinker;
+ } catch (IOException | ParserConfigurationException | SAXException e) {
+ throw new ResourceShrinkingFailedException(
+ "Failed running resource shrinking: " + e.getMessage(), e);
+ }
+ }
+
+ private static ResourceShrinkerImpl runResourceShrinking(Options options)
+ throws IOException, ParserConfigurationException, SAXException {
+ validateInput(options.getInput());
+ List<ResourceUsageRecorder> resourceUsageRecorders = new ArrayList<>();
+ for (String dexInput : options.dex_inputs) {
+ validateFileExists(dexInput);
+ resourceUsageRecorders.add(
+ new DexUsageRecorder(
+ FileUtils.createZipFilesystem(Paths.get(dexInput)).getPath("")));
+ }
+ Path protoApk = Paths.get(options.getInput());
+ Path protoApkOut = Paths.get(options.getOutput());
+ FileSystem fileSystemProto = FileUtils.createZipFilesystem(protoApk);
+ resourceUsageRecorders.add(new DexUsageRecorder(fileSystemProto.getPath("")));
+ resourceUsageRecorders.add(
+ new ProtoAndroidManifestUsageRecorder(
+ fileSystemProto.getPath(ANDROID_MANIFEST_XML)));
+ // If the apk contains a raw folder, find keep rules in there
+ if (new ZipFile(options.getInput())
+ .stream().anyMatch(zipEntry -> zipEntry.getName().startsWith("res/raw"))) {
+ Path rawPath = fileSystemProto.getPath("res", "raw");
+ resourceUsageRecorders.add(new ToolsAttributeUsageRecorder(rawPath));
+ }
+ ResourcesGatherer gatherer =
+ new ProtoResourceTableGatherer(fileSystemProto.getPath(RESOURCES_PB));
+ ProtoResourcesGraphBuilder res =
+ new ProtoResourcesGraphBuilder(
+ fileSystemProto.getPath(RES_FOLDER), fileSystemProto.getPath(RESOURCES_PB));
+ ResourceShrinkerImpl resourceShrinker =
+ new ResourceShrinkerImpl(
+ List.of(gatherer),
+ null,
+ resourceUsageRecorders,
+ List.of(res),
+ options.usageLog != null
+ ? new FileReporter(Paths.get(options.usageLog).toFile())
+ : NoDebugReporter.INSTANCE,
+ false, // TODO(b/245721267): Add support for bundles
+ true);
+ resourceShrinker.analyze();
+
+ resourceShrinker.rewriteResourcesInApkFormat(
+ protoApk.toFile(), protoApkOut.toFile(), LinkedResourcesFormat.PROTO);
+ return resourceShrinker;
+ }
+
+ private static void validateInput(String input) throws IOException {
+ ZipFile zipfile = new ZipFile(input);
+ if (zipfile.getEntry(ANDROID_MANIFEST_XML) == null) {
+ throw new ResourceShrinkingFailedException(
+ "Input must include " + ANDROID_MANIFEST_XML);
+ }
+ if (zipfile.getEntry(RESOURCES_PB) == null) {
+ throw new ResourceShrinkingFailedException(
+ "Input must include "
+ + RESOURCES_PB
+ + ". Did you not convert the input apk"
+ + " to proto?");
+ }
+ if (zipfile.stream().noneMatch(zipEntry -> zipEntry.getName().startsWith(RES_FOLDER))) {
+ throw new ResourceShrinkingFailedException(
+ "Input must include a " + RES_FOLDER + " folder");
+ }
+ }
+
+ private static void validateFileExists(String file) {
+ if (!Paths.get(file).toFile().exists()) {
+ throw new RuntimeException("Can't find file: " + file);
+ }
+ }
+
+ private static void validateOptions(Options options) {
+ if (options.getInput() == null) {
+ throw new ResourceShrinkingFailedException("No input given.");
+ }
+ if (options.getOutput() == null) {
+ throw new ResourceShrinkingFailedException("No output destination given.");
+ }
+ validateFileExists(options.getInput());
+ for (String rawResource : options.getRawResources()) {
+ validateFileExists(rawResource);
+ }
+ }
+
+ private static void printUsage() {
+ PrintStream out = System.err;
+ out.println("Usage:");
+ out.println(" resourceshrinker ");
+ out.println(" --input <input-file>, container with manifest, resources table and res");
+ out.println(" folder. May contain dex.");
+ out.println(" --dex_input <input-file> Container with dex files (only dex will be ");
+ out.println(" handled if this contains other files. Several --dex_input arguments");
+ out.println(" are supported");
+ out.println(" --output <output-file>");
+ out.println(" --raw_resource <xml-file or res directory>");
+ out.println(" optional, more than one raw_resoures argument might be given");
+ out.println(" --help prints this help message");
+ }
+
+ private static class ResourceShrinkingFailedException extends RuntimeException {
+ public ResourceShrinkingFailedException(String message) {
+ super(message);
+ }
+
+ public ResourceShrinkingFailedException(String message, Exception e) {
+ super(message, e);
+ }
+ }
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinkerImpl.kt b/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinkerImpl.kt
new file mode 100644
index 0000000..1136085
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinkerImpl.kt
@@ -0,0 +1,316 @@
+/*
+ * Copyright (C) 2022 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.shrinker
+
+import com.android.SdkConstants.DOT_9PNG
+import com.android.SdkConstants.DOT_PNG
+import com.android.SdkConstants.DOT_XML
+import com.android.aapt.Resources
+import com.android.build.shrinker.DummyContent.TINY_9PNG
+import com.android.build.shrinker.DummyContent.TINY_9PNG_CRC
+import com.android.build.shrinker.DummyContent.TINY_BINARY_XML
+import com.android.build.shrinker.DummyContent.TINY_BINARY_XML_CRC
+import com.android.build.shrinker.DummyContent.TINY_PNG
+import com.android.build.shrinker.DummyContent.TINY_PNG_CRC
+import com.android.build.shrinker.DummyContent.TINY_PROTO_XML
+import com.android.build.shrinker.DummyContent.TINY_PROTO_XML_CRC
+import com.android.build.shrinker.gatherer.ResourcesGatherer
+import com.android.build.shrinker.graph.ResourcesGraphBuilder
+import com.android.build.shrinker.obfuscation.ObfuscationMappingsRecorder
+import com.android.build.shrinker.usages.ResourceUsageRecorder
+import com.android.ide.common.resources.findUnusedResources
+import com.android.ide.common.resources.usage.ResourceStore
+import com.android.ide.common.resources.usage.ResourceUsageModel.Resource
+import com.android.resources.FolderTypeRelationship
+import com.android.resources.ResourceFolderType
+import com.android.resources.ResourceType
+import com.google.common.io.ByteStreams
+import com.google.common.io.Files
+import java.io.BufferedOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.util.jar.JarEntry
+import java.util.jar.JarOutputStream
+import java.util.zip.CRC32
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+
+/**
+ * Unit that analyzes all resources (after resource merging, compilation and code shrinking has
+ * been completed) and figures out which resources are unused, and replaces them with dummy content
+ * inside zip archive file.
+ *
+ * Resource shrinker implementation that allows to customize:
+ * <ul>
+ * <li>application resource gatherer (from R files, resource tables, etc);
+ * <li>recorder for mappings from obfuscated class/methods to original class/methods;
+ * <li>sources from which resource usages are recorded (Dex files, compiled JVM classes,
+ * AndroidManifests, etc);
+ * <li>resources graph builder that connects resources dependent on each other (analyzing
+ * raw resources content in XML, HTML, CSS, JS, analyzing resource content in proto
+ * compiled format);
+ * </ul>
+ */
+class ResourceShrinkerImpl(
+ private val resourcesGatherers: List<ResourcesGatherer>,
+ private val obfuscationMappingsRecorder: ObfuscationMappingsRecorder?,
+ private val usageRecorders: List<ResourceUsageRecorder>,
+ private val graphBuilders: List<ResourcesGraphBuilder>,
+ private val debugReporter: ShrinkerDebugReporter,
+ val supportMultipackages: Boolean,
+ private val usePreciseShrinking: Boolean
+) : ResourceShrinker {
+ val model = ResourceShrinkerModel(debugReporter, supportMultipackages)
+ private lateinit var unused: List<Resource>
+
+ override fun analyze() {
+ resourcesGatherers.forEach { it.gatherResourceValues(model) }
+ obfuscationMappingsRecorder?.recordObfuscationMappings(model)
+ usageRecorders.forEach { it.recordUsages(model) }
+ graphBuilders.forEach { it.buildGraph(model) }
+
+ model.resourceStore.processToolsAttributes()
+ model.keepPossiblyReferencedResources()
+
+ debugReporter.debug { model.resourceStore.dumpResourceModel() }
+
+ unused = findUnusedResources(model.resourceStore.resources) { roots ->
+ debugReporter.debug { "The root reachable resources are:" }
+ debugReporter.debug { roots.joinToString("\n", transform = { " $it" }) }
+ }
+ debugReporter.debug { "Unused resources are: " }
+ debugReporter.debug { unused.joinToString("\n", transform = { " $it" })}
+
+ }
+
+ override fun close() {
+ debugReporter.close()
+ }
+
+ override fun getUnusedResourceCount(): Int {
+ return unused.size
+ }
+
+ override fun rewriteResourcesInApkFormat(
+ source: File,
+ dest: File,
+ format: LinkedResourcesFormat
+ ) {
+ rewriteResourceZip(source, dest, ApkArchiveFormat(model.resourceStore, format))
+ }
+
+ override fun rewriteResourcesInBundleFormat(
+ source: File,
+ dest: File,
+ moduleNameToPackageNameMap: Map<String, String>
+ ) {
+ rewriteResourceZip(
+ source,
+ dest,
+ BundleArchiveFormat(model.resourceStore, moduleNameToPackageNameMap)
+ )
+ }
+
+ private fun rewriteResourceZip(source: File, dest: File, format: ArchiveFormat) {
+ if (dest.exists() && !dest.delete()) {
+ throw IOException("Could not delete $dest")
+ }
+ JarOutputStream(BufferedOutputStream(FileOutputStream(dest))).use { zos ->
+ ZipFile(source).use { zip ->
+ // 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)
+ zip.entries().asSequence().forEach {
+ if (format.fileIsNotReachable(it)) {
+ // If we don't use precise shrinking we don't remove the files, see:
+ // https://b.corp.google.com/issues/37010152
+ if (!usePreciseShrinking) {
+ replaceWithDummyEntry(zos, it, format.resourcesFormat)
+ }
+ } else if (it.name.endsWith("resources.pb") && usePreciseShrinking) {
+ removeResourceUnusedTableEntries(zip.getInputStream(it), zos, it)
+ } else {
+ copyToOutput(zip.getInputStream(it), zos, it)
+ }
+ }
+ }
+ }
+ // 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.
+ val before = source.length()
+ val after = dest.length()
+ if (after > before) {
+ debugReporter.info {
+ "Resource shrinking did not work (grew from $before to $after); using original " +
+ "instead"
+ }
+ Files.copy(source, dest)
+ }
+ }
+
+ private fun removeResourceUnusedTableEntries(zis: InputStream,
+ zos: JarOutputStream,
+ srcEntry: ZipEntry) {
+ val resourceIdsToRemove =
+ model.resourceStore.resources.filter { !it.isReachable }.map { it.value }.toList()
+ val shrunkenResourceTable = Resources.ResourceTable.parseFrom(zis)
+ .nullOutEntriesWithIds(resourceIdsToRemove)
+ val bytes = shrunkenResourceTable.toByteArray()
+ val outEntry = JarEntry(srcEntry.name)
+ if (srcEntry.time != -1L) {
+ outEntry.time = srcEntry.time
+ }
+ if (srcEntry.method == JarEntry.STORED) {
+ outEntry.method = JarEntry.STORED
+ outEntry.size = bytes.size.toLong()
+ val crc = CRC32()
+ crc.update(bytes, 0, bytes.size)
+ outEntry.crc = crc.getValue()
+ }
+ zos.putNextEntry(outEntry)
+ zos.write(bytes)
+ zos.closeEntry()
+ }
+
+ /** Replaces the given entry with a minimal valid file of that type. */
+ private fun replaceWithDummyEntry(
+ zos: JarOutputStream,
+ entry: ZipEntry,
+ format: LinkedResourcesFormat
+ ) {
+ // Create a new entry so that the compressed len is recomputed.
+ val name = entry.name
+ val (bytes, crc) = when {
+ // DOT_9PNG (.9.png) must be always before DOT_PNG (.png)
+ name.endsWith(DOT_9PNG) -> TINY_9PNG to TINY_9PNG_CRC
+ name.endsWith(DOT_PNG) -> TINY_PNG to TINY_PNG_CRC
+ name.endsWith(DOT_XML) && format == LinkedResourcesFormat.BINARY ->
+ TINY_BINARY_XML to TINY_BINARY_XML_CRC
+ name.endsWith(DOT_XML) && format == LinkedResourcesFormat.PROTO ->
+ TINY_PROTO_XML to TINY_PROTO_XML_CRC
+ else -> ByteArray(0) to 0L
+ }
+
+ val outEntry = JarEntry(name)
+ if (entry.time != -1L) {
+ outEntry.time = entry.time
+ }
+ if (entry.method == JarEntry.STORED) {
+ outEntry.method = JarEntry.STORED
+ outEntry.size = bytes.size.toLong()
+ outEntry.crc = crc
+ }
+ zos.putNextEntry(outEntry)
+ zos.write(bytes)
+ zos.closeEntry()
+ debugReporter.info {
+ "Skipped unused resource $name: ${entry.size} bytes (replaced with small dummy file " +
+ "of size ${bytes.size} bytes)"
+ }
+ }
+
+ private fun copyToOutput(zis: InputStream, zos: JarOutputStream, entry: ZipEntry) {
+ // 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.
+ val outEntry = when (entry.method) {
+ JarEntry.STORED -> JarEntry(entry)
+ else -> JarEntry(entry.name)
+ }
+ if (entry.time != -1L) {
+ outEntry.time = entry.time
+ }
+ zos.putNextEntry(outEntry)
+ if (!entry.isDirectory) {
+ zos.write(ByteStreams.toByteArray(zis))
+ }
+ zos.closeEntry()
+ }
+}
+
+private interface ArchiveFormat {
+ val resourcesFormat: LinkedResourcesFormat
+ fun fileIsNotReachable(entry: ZipEntry): Boolean
+}
+
+private class ApkArchiveFormat(
+ private val store: ResourceStore,
+ override val resourcesFormat: LinkedResourcesFormat
+) : ArchiveFormat {
+
+ override fun fileIsNotReachable(entry: ZipEntry): Boolean {
+ if (entry.isDirectory || !entry.name.startsWith("res/")) {
+ return false
+ }
+ val (_, folder, name) = entry.name.split('/', limit = 3)
+ return !store.isJarPathReachable(folder, name)
+ }
+}
+
+private class BundleArchiveFormat(
+ private val store: ResourceStore,
+ private val moduleNameToPackageName: Map<String, String>
+) : ArchiveFormat {
+
+ override val resourcesFormat = LinkedResourcesFormat.PROTO
+
+ override fun fileIsNotReachable(entry: ZipEntry): Boolean {
+ val module = entry.name.substringBefore('/')
+ val packageName = moduleNameToPackageName[module]
+ if (entry.isDirectory || packageName == null || !entry.name.startsWith("$module/res/")) {
+ return false
+ }
+ val (_, _, folder, name) = entry.name.split('/', limit = 4)
+ return !store.isJarPathReachable(folder, name)
+ }
+}
+
+private fun ResourceStore.isJarPathReachable(
+ folder: String,
+ name: String
+): Boolean {
+ val folderType = ResourceFolderType.getFolderType(folder) ?: return true
+ val resourceName = name.substringBefore('.')
+ // Bundle format has a restriction: in case the same resource is duplicated in multiple modules
+ // its content should be the same in all of them. This restriction means that we can't replace
+ // resource with dummy content if its duplicate is used in some module.
+ return FolderTypeRelationship.getRelatedResourceTypes(folderType)
+ .filterNot { it == ResourceType.ID }
+ .flatMap { getResources(it, resourceName) }
+ .any { it.isReachable }
+}
+
+private fun ResourceStore.getResourceId(
+ folder: String,
+ name: String
+): Int {
+ val folderType = ResourceFolderType.getFolderType(folder) ?: return -1
+ val resourceName = name.substringBefore('.')
+ return FolderTypeRelationship.getRelatedResourceTypes(folderType)
+ .filterNot { it == ResourceType.ID }
+ .flatMap { getResources(it, resourceName) }
+ .map { it.value }
+ .getOrElse(0) { -1 }
+
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinkerModel.java b/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinkerModel.java
new file mode 100644
index 0000000..ce1b39f
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/ResourceShrinkerModel.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2022 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.shrinker;
+
+import com.android.aapt.Resources.ResourceTable;
+import com.android.annotations.NonNull;
+import com.android.annotations.Nullable;
+import com.android.build.shrinker.obfuscation.ObfuscatedClasses;
+import com.android.ide.common.resources.usage.ResourceStore;
+import com.android.ide.common.resources.usage.ResourceUsageModel.Resource;
+import com.android.resources.ResourceType;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Represents resource shrinker state that is shared between shrinker stages ResourcesGatherer,
+ * ObfuscationMappingsRecorder, ResourceUsageRecorder, ResourcesGraphBuilder and each stage
+ * contributes an information via changing its state.
+ */
+public class ResourceShrinkerModel {
+
+ private final ShrinkerDebugReporter debugReporter;
+
+ private final ResourceStore resourceStore;
+ private ObfuscatedClasses obfuscatedClasses = ObfuscatedClasses.NO_OBFUSCATION;
+
+ private final Set<String> strings = Sets.newHashSetWithExpectedSize(300);
+
+ private final Map<String, ResourceTable> resourceTableCache = Maps.newHashMap();
+
+ private boolean foundGetIdentifier = false;
+ private boolean foundWebContent = false;
+
+ public ResourceShrinkerModel(
+ ShrinkerDebugReporter debugReporter, boolean supportMultipackages) {
+ this.debugReporter = debugReporter;
+ resourceStore = new ResourceStore(supportMultipackages);
+ }
+
+ @NonNull
+ public ShrinkerDebugReporter getDebugReporter() {
+ return debugReporter;
+ }
+
+ @NonNull
+ public ResourceStore getResourceStore() {
+ return resourceStore;
+ }
+
+ @NonNull
+ public ObfuscatedClasses getObfuscatedClasses() {
+ return obfuscatedClasses;
+ }
+
+ /** Reports recorded mappings from obfuscated classes to original classes */
+ public void setObfuscatedClasses(@NonNull ObfuscatedClasses obfuscatedClasses) {
+ this.obfuscatedClasses = obfuscatedClasses;
+ }
+
+ /** Adds a new gathered resource to model. */
+ @NonNull
+ public Resource addResource(
+ @NonNull ResourceType type,
+ @Nullable String packageName,
+ @NonNull String name,
+ @Nullable String value) {
+ int intValue = -1;
+ try {
+ intValue = value != null ? Integer.decode(value) : -1;
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ return addResource(type, packageName, name, intValue);
+ }
+
+ /** Adds a new gathered resource to model. */
+ @NonNull
+ public Resource addResource(
+ @NonNull ResourceType type,
+ @Nullable String packageName,
+ @NonNull String name,
+ int value) {
+ return resourceStore.addResource(new Resource(packageName, type, name, value));
+ }
+
+ /** Adds string constant found in code to strings pool. */
+ public void addStringConstant(@NonNull String string) {
+ strings.add(string);
+ }
+
+ /** Returns is reference to {@code Resources#getIdentifier} is found in code. */
+ public boolean isFoundGetIdentifier() {
+ return foundGetIdentifier;
+ }
+
+ /** Reports that reference to {@code Resources#getIdentifier} is found in code. */
+ public void setFoundGetIdentifier(boolean foundGetIdentifier) {
+ this.foundGetIdentifier = foundGetIdentifier;
+ }
+
+ /** Returns is web content is found in code. */
+ public boolean isFoundWebContent() {
+ return foundWebContent;
+ }
+
+ /** Reports that reference to web content is found in code. */
+ public void setFoundWebContent(boolean foundWebContent) {
+ this.foundWebContent = foundWebContent;
+ }
+
+ /** Returns all string constants gathered from compiled classes. */
+ public Set<String> getStrings() {
+ return strings;
+ }
+
+ /**
+ * Mark resources that match string constants as reachable in case invocation of
+ * {@code Resources#getIdentifier} or web content is found in code and safe mode in enabled.
+ */
+ public void keepPossiblyReferencedResources() {
+ if (strings.isEmpty()
+ || !resourceStore.getSafeMode()
+ || (!foundGetIdentifier && !foundWebContent)) {
+ // No calls to android.content.res.Resources#getIdentifier; or user specifically asked
+ // for us not to guess resources to keep
+ return;
+ }
+
+ debugReporter.debug(() -> ""
+ + "android.content.res.Resources#getIdentifier present: " + foundGetIdentifier + "\n"
+ + "Web content present: " + foundWebContent + "\n"
+ + "Referenced Strings:\n"
+ + strings.stream()
+ .map(s -> s.trim().replace("\n", "\\n"))
+ .filter(s -> !s.isEmpty())
+ .map(s -> s.length() > 40 ? s.substring(0, 37) + "..." : s)
+ .collect(Collectors.joining("\n"))
+ );
+
+ new PossibleResourcesMarker(debugReporter, resourceStore, strings, foundWebContent)
+ .markPossibleResourcesReachable();
+ }
+
+ /**
+ * Reads resource table from specified path and stores it in cache to be able to reuse it if
+ * the same resource table is requested by another unit.
+ */
+ public ResourceTable readResourceTable(Path resourceTablePath) {
+ return resourceTableCache.computeIfAbsent(resourceTablePath.toString(), (path) -> {
+ try {
+ return ResourceTable.parseFrom(Files.readAllBytes(resourceTablePath));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ });
+ }
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/ResourceTableUtil.kt b/src/resourceshrinker/java/com/android/build/shrinker/ResourceTableUtil.kt
new file mode 100644
index 0000000..69952ef
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/ResourceTableUtil.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2022 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.shrinker
+
+import com.android.aapt.Resources
+
+internal fun Resources.ResourceTable.entriesSequence(): Sequence<EntryWrapper> = sequence {
+ for (resourcePackage in packageList) {
+ for (resourceType in resourcePackage.typeList) {
+ for (resourceEntry in resourceType.entryList) {
+ val id = toIdentifier(resourcePackage, resourceType, resourceEntry)
+ yield(
+ EntryWrapper(id, resourcePackage.packageName, resourceType.name, resourceEntry)
+ )
+ }
+ }
+ }
+}
+
+internal fun Resources.ResourceTable.nullOutEntriesWithIds(ids: List<Int>)
+ : Resources.ResourceTable {
+ if (ids.isEmpty()) {
+ return this
+ }
+ val packageMappings = calculatePackageMappings(ids)
+ val tableBuilder = this.toBuilder()
+ tableBuilder.packageBuilderList.forEach{
+ val typeMappings = packageMappings[it.packageId.id]
+ if (typeMappings != null) {
+ it.typeBuilderList.forEach { type->
+ val entryList = typeMappings[type.typeId.id]
+ if (entryList != null) {
+ type.entryBuilderList.forEach { entry ->
+ if (entryList.contains(entry.entryId.id)) {
+ entry.clearConfigValue()
+ if (entry.hasOverlayableItem()) {
+ entry.clearOverlayableItem()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ return tableBuilder.build()
+}
+
+private fun calculatePackageMappings(ids: List<Int>): MutableMap<Int, Map<Int, List<Int>>> {
+ val sortedIds = ids.sorted()
+ val packageMapping = mutableMapOf<Int, Map<Int, List<Int>>>()
+ var typeMapping = mutableMapOf<Int, List<Int>>()
+ var entryList = mutableListOf<Int>()
+ var oldPackageId = -1
+ var oldTypeId = -1
+ for (value in sortedIds) {
+ val packageId = packageIdFromIdentifier(value)
+ val typeId = typeIdFromIdentifier(value)
+ val entryId = entryIdFromIdentifier(value)
+ if (packageId != oldPackageId) {
+ typeMapping = mutableMapOf()
+ packageMapping.put(packageId, typeMapping)
+ oldPackageId = packageId
+ oldTypeId = -1
+ }
+ if (typeId != oldTypeId) {
+ entryList = mutableListOf()
+ typeMapping.put(typeId, entryList)
+ oldTypeId = typeId
+ }
+ entryList.add(entryId)
+ }
+ return packageMapping
+}
+
+internal data class EntryWrapper(
+ val id: Int,
+ val packageName: String,
+ val type: String,
+ val entry: Resources.Entry
+)
+
+private fun toIdentifier(
+ resourcePackage: Resources.Package,
+ type: Resources.Type,
+ entry: Resources.Entry
+): Int =
+ (resourcePackage.packageId.id shl 24) or (type.typeId.id shl 16) or entry.entryId.id
+
+private fun packageIdFromIdentifier(
+ identifier: Int
+): Int =
+ identifier shr 24
+
+private fun typeIdFromIdentifier(
+ identifier: Int
+): Int =
+ (identifier and 0x00FF0000) shr 16
+
+private fun entryIdFromIdentifier(
+ identifier: Int
+): Int =
+ (identifier and 0x0000FFFF)
+
+
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/ShrinkerDebugReporter.kt b/src/resourceshrinker/java/com/android/build/shrinker/ShrinkerDebugReporter.kt
new file mode 100644
index 0000000..b592bb8
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/ShrinkerDebugReporter.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2022 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.shrinker
+
+import java.io.File
+import java.io.PrintWriter
+
+interface ShrinkerDebugReporter : AutoCloseable {
+ fun debug(f: () -> String)
+ fun info(f: () -> String)
+}
+
+object NoDebugReporter : ShrinkerDebugReporter {
+ override fun debug(f: () -> String) = Unit
+
+ override fun info(f: () -> String) = Unit
+
+ override fun close() = Unit
+}
+
+class FileReporter(
+ reportFile: File
+) : ShrinkerDebugReporter {
+ private val writer: PrintWriter = reportFile.let { PrintWriter(it) }
+ override fun debug(f: () -> String) {
+ writer.println(f())
+ }
+
+ override fun info(f: () -> String) {
+ writer.println(f())
+ }
+
+ override fun close() {
+ writer.close()
+ }
+}
+
+class LoggerAndFileDebugReporter(
+ private val logDebug: (String) -> Unit,
+ private val logInfo: (String) -> Unit,
+ reportFile: File?
+) : ShrinkerDebugReporter {
+ private val writer: PrintWriter? = reportFile?.let { PrintWriter(it) }
+
+ override fun debug(f: () -> String) {
+ val message = f()
+ writer?.println(message)
+ logDebug(message)
+ }
+
+ override fun info(f: () -> String) {
+ val message = f()
+ writer?.println(message)
+ logInfo(message)
+ }
+
+ override fun close() {
+ writer?.close()
+ }
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/gatherer/ProtoResourceTableGatherer.kt b/src/resourceshrinker/java/com/android/build/shrinker/gatherer/ProtoResourceTableGatherer.kt
new file mode 100644
index 0000000..c3c00eb
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/gatherer/ProtoResourceTableGatherer.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2020 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.shrinker.gatherer
+
+import com.android.build.shrinker.ResourceShrinkerModel
+import com.android.build.shrinker.entriesSequence
+import com.android.ide.common.resources.resourceNameToFieldName
+import com.android.resources.ResourceType
+import java.nio.file.Path
+
+/**
+ * Gathers application resources from proto compiled resource table. Flattens each resource name to
+ * be compatible with field names in R classes.
+ *
+ * @param resourceTablePath path to resource table in proto format.
+ */
+class ProtoResourceTableGatherer(private val resourceTablePath: Path) : ResourcesGatherer {
+
+ override fun gatherResourceValues(model: ResourceShrinkerModel) {
+ model.readResourceTable(resourceTablePath).entriesSequence()
+ .forEach { (id, packageName, type, entry) ->
+ // We need to flatten resource names here to match fields names in R classes via
+ // invoking ResourcesUtil.resourceNameToFieldName because we need to record R fields
+ // usages in code.
+ ResourceType.fromClassName(type)
+ ?.takeIf { it != ResourceType.STYLEABLE }
+ ?.let {
+ model.addResource(it, packageName, resourceNameToFieldName(entry.name), id)
+ }
+ }
+ }
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/gatherer/ResourcesGatherer.java b/src/resourceshrinker/java/com/android/build/shrinker/gatherer/ResourcesGatherer.java
new file mode 100644
index 0000000..ffe5f5b
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/gatherer/ResourcesGatherer.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2020 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.shrinker.gatherer;
+
+import com.android.annotations.NonNull;
+import com.android.build.shrinker.ResourceShrinkerModel;
+import java.io.IOException;
+
+/**
+ * Interface for unit that should gather application resources and contribute them to
+ * ResourceShrinkerModel
+ */
+public interface ResourcesGatherer {
+
+ /**
+ * Gathers application resources and contribute them to ResourceShrinkerModel via
+ * ResourceShrinkerModel.addResource
+ */
+ void gatherResourceValues(@NonNull ResourceShrinkerModel model) throws IOException;
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/graph/ProtoResourcesGraphBuilder.kt b/src/resourceshrinker/java/com/android/build/shrinker/graph/ProtoResourcesGraphBuilder.kt
new file mode 100644
index 0000000..130e585
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/graph/ProtoResourcesGraphBuilder.kt
@@ -0,0 +1,302 @@
+/*
+ * Copyright (C) 2020 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.shrinker.graph
+
+import com.android.aapt.Resources
+import com.android.aapt.Resources.Entry
+import com.android.aapt.Resources.FileReference
+import com.android.aapt.Resources.FileReference.Type.PROTO_XML
+import com.android.aapt.Resources.Reference
+import com.android.aapt.Resources.XmlAttribute
+import com.android.aapt.Resources.XmlElement
+import com.android.aapt.Resources.XmlNode
+import com.android.build.shrinker.ResourceShrinkerModel
+import com.android.build.shrinker.entriesSequence
+import com.android.ide.common.resources.usage.ResourceUsageModel
+import com.android.ide.common.resources.usage.ResourceUsageModel.Resource
+import com.android.ide.common.resources.usage.WebTokenizers
+import com.android.resources.ResourceType
+import com.android.utils.SdkUtils.IMAGE_EXTENSIONS
+import com.google.common.base.Ascii
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Builds resources graph starting from each resource in resource table in proto format and follow
+ * all references to other resources inside inlined values and external files from res/ folder.
+ *
+ * <p>Supports external files in the following formats:
+ * <ul>
+ * <li>XML files compiled to proto format;
+ * <li>HTML, CSS, JS files inside res/raw folder;
+ * <li>Unknown files inside res/raw folder (only looks for 'android_res/<type>/<name>' pattern);
+ * </ul>
+ *
+ * <p>As ID resources don't specify parent-child relations between resources but are just
+ * identifiers for a resource or some part of the resource we don't gather them as references to
+ * examined resource.
+ *
+ * @param resourceRoot path to <module>/res/ folder.
+ * @param resourceTable path to resource table in proto format.
+ */
+class ProtoResourcesGraphBuilder(
+ private val resourceRoot: Path,
+ private val resourceTable: Path
+) : ResourcesGraphBuilder {
+
+ override fun buildGraph(model: ResourceShrinkerModel) {
+ model.readResourceTable(resourceTable).entriesSequence()
+ .map { (id, _, _, entry) ->
+ model.resourceStore.getResource(id)?.let {
+ ReferencesForResourceFinder(resourceRoot, model, entry, it)
+ }
+ }
+ .filterNotNull()
+ .forEach { it.findReferences() }
+ }
+}
+
+private class ReferencesForResourceFinder(
+ private val resourcesRoot: Path,
+ private val model: ResourceShrinkerModel,
+ private val entry: Entry,
+ private val current: Resource
+) {
+ companion object {
+ /**
+ * 'android_res/' is a synthetic directory for resource references in URL format. For
+ * example: file:///android_res/raw/intro_page.
+ */
+ private const val ANDROID_RES = "android_res/"
+
+ private const val CONSTRAINT_REFERENCED_IDS = "constraint_referenced_ids"
+
+ private fun Reference.asItem(): Resources.Item =
+ Resources.Item.newBuilder().setRef(this).build()
+ }
+
+ private val webTokenizers: WebTokenizers by lazy {
+ WebTokenizers(object : WebTokenizers.WebTokensCallback {
+ override fun referencedHtmlAttribute(tag: String?, attribute: String?, value: String) {
+ if (attribute == "href" || attribute == "src") {
+ referencedUrl(value)
+ }
+ }
+
+ override fun referencedJsString(jsString: String) {
+ referencedStringFromWebContent(jsString)
+ }
+
+ override fun referencedCssUrl(url: String) {
+ referencedUrl(url)
+ }
+
+ private fun referencedUrl(url: String) {
+ // 1. if url contains '/' try to find resources from this web url.
+ // 2. if there is no '/' it might be just relative reference to another resource.
+ val resources = when {
+ url.contains('/') -> model.resourceStore.getResourcesFromWebUrl(url)
+ else ->
+ model.resourceStore.getResources(ResourceType.RAW, url.substringBefore('.'))
+ }
+ if (resources.isNotEmpty()) {
+ resources.forEach { current.addReference(it) }
+ } else {
+ // if there is no resources found by provided url just gather this string as
+ // found inside web content to process it afterwards.
+ referencedStringFromWebContent(url)
+ }
+ }
+
+ private fun referencedStringFromWebContent(string: String) {
+ if (string.isNotEmpty() && string.length <= 80) {
+ model.addStringConstant(string)
+ model.isFoundWebContent = true
+ }
+ }
+ })
+ }
+
+ fun findReferences() {
+ // Walk through all values of the entry and find all Item instances that may reference
+ // other resources in resource table itself or specify external files that should be
+ // analyzed for references.
+ entry.configValueList.asSequence()
+ .map { it.value }
+ .flatMap { value ->
+ val compoundValue = value.compoundValue
+ // compoundValue.attr and compoundValue.styleable are skipped, attr defines
+ // references to ID resources only, but ID and STYLEABLE resources are not supported
+ // by shrinker.
+ when {
+ value.hasItem() ->
+ sequenceOf(value.item)
+ compoundValue.hasStyle() ->
+ sequenceOf(compoundValue.style.parent.asItem()) +
+ compoundValue.style.entryList.asSequence().flatMap {
+ sequenceOf(
+ it.item,
+ it.key.asItem()
+ )
+ }
+ compoundValue.hasArray() ->
+ compoundValue.array.elementList.asSequence().map { it.item }
+ compoundValue.hasPlural() ->
+ compoundValue.plural.entryList.asSequence().map { it.item }
+ else -> emptySequence()
+ }
+ }
+ .forEach { findFromItem(it) }
+ }
+
+ private fun findFromItem(item: Resources.Item) {
+ try {
+ when {
+ item.hasRef() -> findFromReference(item.ref)
+ item.hasFile() && item.file.path.startsWith("res/") -> findFromFile(item.file)
+ }
+ } catch (e: IOException) {
+ model.debugReporter.debug { "File '${item.file.path}' can not be processed. Skipping." }
+ }
+ }
+
+ private fun findFromReference(reference: Reference) {
+ // Reference object may have id of referenced resource, in this case prefer resolved id.
+ // In case id is not provided try to find referenced resource by name. Name is converted
+ // to resource url here, because name in resource table is not normalized to R style field
+ // and to find it we need normalize it first (for example, in case name in resource table is
+ // MyStyle.Child in R file it is R.style.MyStyle_child).
+ val referencedResources = when {
+ reference.id != 0 -> listOf(model.resourceStore.getResource(reference.id))
+ reference.name.isNotEmpty() ->
+ model.resourceStore.getResourcesFromUrl("@${reference.name}")
+ else -> emptyList()
+ }
+ // IDs are not supported by shrinker for now, just skip it.
+ referencedResources.asSequence()
+ .filterNotNull()
+ .filter { it.type != ResourceType.ID }
+ .forEach { current.addReference(it) }
+ }
+
+ private fun findFromFile(file: FileReference) {
+ val path = resourcesRoot.resolve(file.path.substringAfter("res/"))
+ val bytes: ByteArray by lazy { Files.readAllBytes(path) }
+ val content: String by lazy { String(bytes, StandardCharsets.UTF_8) }
+ val extension = Ascii.toLowerCase(path.fileName.toString()).substringAfter('.')
+ when {
+ file.type == PROTO_XML -> fillFromXmlNode(XmlNode.parseFrom(bytes))
+ extension in listOf("html", "htm") -> webTokenizers.tokenizeHtml(content)
+ extension == "css" -> webTokenizers.tokenizeCss(content)
+ extension == "js" -> webTokenizers.tokenizeJs(content)
+ extension !in IMAGE_EXTENSIONS -> maybeAndroidResUrl(content, markAsReachable = false)
+ }
+ }
+
+ private fun fillFromXmlNode(node: XmlNode) {
+ // Check for possible reference as 'android_res/<type>/<name>' pattern inside element text.
+ if (current.type == ResourceType.XML) {
+ maybeAndroidResUrl(node.text, markAsReachable = true)
+ }
+ // Check special xml element <rawPathResId> which provides reference to res/raw/ apk
+ // resource for wear application. Applies to all XML files for now but might be re-scoped
+ // to only apply to <wearableApp> XMLs.
+ maybeWearAppReference(node.element)
+ node.element.attributeList.forEach { fillFromAttribute(it) }
+ node.element.childList.forEach { fillFromXmlNode(it) }
+ }
+
+ private fun fillFromAttribute(attribute: XmlAttribute) {
+ if (attribute.name == CONSTRAINT_REFERENCED_IDS) {
+ fillFromConstraintReferencedIds(attribute.value)
+ }
+ if (attribute.hasCompiledItem()) {
+ findFromItem(attribute.compiledItem)
+ }
+ // Check for possible reference as 'android_res/<type>/<name>' pattern inside attribute val.
+ if (current.type == ResourceType.XML) {
+ maybeAndroidResUrl(attribute.value, markAsReachable = true)
+ }
+ }
+
+ private fun fillFromConstraintReferencedIds(value: String?) {
+ value
+ ?.split(",")
+ ?.map { it.trim() }
+ ?.forEach {
+ model.resourceStore.getResources(ResourceType.ID, it)
+ .forEach(ResourceUsageModel::markReachable)
+ }
+ }
+
+ private fun maybeAndroidResUrl(text: String, markAsReachable: Boolean) {
+ findAndroidResReferencesInText(text)
+ .map { it.split('/', limit = 2) }
+ .filter { it.size == 2 }
+ .map { (dir, fileName) ->
+ Pair(
+ ResourceType.fromFolderName(dir.substringBefore('-')),
+ fileName.substringBefore('.')
+ )
+ }
+ .filter { (type, _) -> type != null }
+ .flatMap { (type, name) -> model.resourceStore.getResources(type!!, name).asSequence() }
+ .forEach {
+ if (markAsReachable) {
+ ResourceUsageModel.markReachable(it)
+ } else {
+ current.addReference(it)
+ }
+ }
+ }
+
+ private fun maybeWearAppReference(element: XmlElement) {
+ if (element.name == "rawPathResId") {
+ val rawResourceName = element.childList
+ .map { it.text }
+ .joinToString(separator = "")
+ .trim()
+
+ model.resourceStore.getResources(ResourceType.RAW, rawResourceName)
+ .forEach { current.addReference(it) }
+ }
+ }
+
+ /**
+ * Splits input text to parts that starts with 'android_res/' and returns sequence of strings
+ * between 'android_res/' occurrences and first whitespace after it. This method is used instead
+ * of {@link CharSequence#splitToSequence} because does not spawn full substrings between
+ * 'android_res/' in memory when text is big enough.
+ */
+ private fun findAndroidResReferencesInText(text: String): Sequence<String> = sequence {
+ var start = 0
+ while (start < text.length) {
+ start = text.indexOf(ANDROID_RES, start)
+ if (start == -1) {
+ break
+ }
+ var end = start + ANDROID_RES.length
+ while (end < text.length && !Character.isWhitespace(text[end])) {
+ end++
+ }
+ yield(text.substring(start + ANDROID_RES.length, end))
+ start = end
+ }
+ }
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/graph/ResourcesGraphBuilder.java b/src/resourceshrinker/java/com/android/build/shrinker/graph/ResourcesGraphBuilder.java
new file mode 100644
index 0000000..e09b0eb
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/graph/ResourcesGraphBuilder.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2020 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.shrinker.graph;
+
+import com.android.annotations.NonNull;
+import com.android.build.shrinker.ResourceShrinkerModel;
+import java.io.IOException;
+
+/**
+ * Interface for unit that should find references between resources which are gathered inside
+ * ResourceShrinkerModel.
+ */
+public interface ResourcesGraphBuilder {
+
+ /**
+ * Finds references between resources and connects them. May introduce and contribute new
+ * resources to ResourceShrinkerModel.
+ */
+ void buildGraph(@NonNull ResourceShrinkerModel model) throws IOException;
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/obfuscation/ObfuscatedClasses.kt b/src/resourceshrinker/java/com/android/build/shrinker/obfuscation/ObfuscatedClasses.kt
new file mode 100644
index 0000000..3cf757d
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/obfuscation/ObfuscatedClasses.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2020 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.shrinker.obfuscation
+
+import com.google.common.collect.ImmutableMap
+
+/** Contains mappings between obfuscated classes and methods to original ones. */
+class ObfuscatedClasses private constructor(builder: Builder) {
+
+ companion object {
+ @JvmField
+ val NO_OBFUSCATION = Builder().build()
+ }
+
+ private val obfuscatedClasses = ImmutableMap.copyOf(builder.obfuscatedClasses)
+ private val obfuscatedMethods = ImmutableMap.copyOf(builder.obfuscatedMethods)
+
+ fun resolveOriginalMethod(obfuscatedMethod: ClassAndMethod): ClassAndMethod {
+ return obfuscatedMethods.getOrElse(obfuscatedMethod) {
+ val realClassName = obfuscatedClasses[obfuscatedMethod.className] ?: obfuscatedMethod.className
+ ClassAndMethod(realClassName, obfuscatedMethod.methodName)
+ }
+ }
+
+ fun resolveOriginalClass(obfuscatedClass: String): String {
+ return obfuscatedClasses[obfuscatedClass] ?: obfuscatedClass
+ }
+
+ /**
+ * Builder that allows to build obfuscated mappings in a way when next method mapping is added
+ * to previous class mapping. Example:
+ * builder
+ * .addClassMapping(Pair(classA, obfuscatedClassA))
+ * .addMethodMapping(Pair(classAMethod1, obfuscatedClassAMethod1))
+ * .addMethodMapping(Pair(classAMethod2, obfuscatedClassAMethod2))
+ * .addClassMapping(Pair(classB, obfuscatedClassB))
+ * .addMethodMapping(Pair(classBMethod1, obfuscatedClassBMethod1))
+ */
+ class Builder {
+
+ val obfuscatedClasses: MutableMap<String, String> = mutableMapOf()
+ val obfuscatedMethods: MutableMap<ClassAndMethod, ClassAndMethod> = mutableMapOf()
+
+ var currentClassMapping: Pair<String, String>? = null
+
+ /**
+ * Adds class mapping: original class name -> obfuscated class name.
+ *
+ * @param mapping Pair(originalClassName, obfuscatedClassName)
+ */
+ fun addClassMapping(mapping: Pair<String, String>): Builder {
+ currentClassMapping = mapping
+ obfuscatedClasses += Pair(mapping.second, mapping.first)
+ return this
+ }
+
+ /**
+ * Adds method mapping: original method name -> obfuscated method name to the latest added
+ * class mapping.
+ *
+ * @param mapping Pair(originalMethodName, obfuscatedMethodName)
+ */
+ fun addMethodMapping(mapping: Pair<String, String>): Builder {
+ if (currentClassMapping != null) {
+ obfuscatedMethods += Pair(
+ ClassAndMethod(currentClassMapping!!.second, mapping.second),
+ ClassAndMethod(currentClassMapping!!.first, mapping.first)
+ )
+ }
+ return this
+ }
+
+ fun build(): ObfuscatedClasses =
+ ObfuscatedClasses(this)
+ }
+}
+
+data class ClassAndMethod(val className: String, val methodName: String)
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/obfuscation/ObfuscationMappingsRecorder.java b/src/resourceshrinker/java/com/android/build/shrinker/obfuscation/ObfuscationMappingsRecorder.java
new file mode 100644
index 0000000..6e76c8d
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/obfuscation/ObfuscationMappingsRecorder.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2020 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.shrinker.obfuscation;
+
+import com.android.annotations.NonNull;
+import com.android.build.shrinker.ResourceShrinkerModel;
+import java.io.IOException;
+
+/**
+ * Interface for unit that should record obfuscation mappings and contribute it to
+ * ResourceShrinkerModel.obfuscatedClasses.
+ */
+public interface ObfuscationMappingsRecorder {
+
+ /**
+ * Records obfuscation mappings to be able to resolve original class name and original method
+ * name when all references in code are obfuscated. Should create and contribute
+ * ObfuscatedClasses instance to ResourceShrinkerModel.obfuscatedClasses.
+ */
+ void recordObfuscationMappings(@NonNull ResourceShrinkerModel model) throws IOException;
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/obfuscation/ProguardMappingsRecorder.kt b/src/resourceshrinker/java/com/android/build/shrinker/obfuscation/ProguardMappingsRecorder.kt
new file mode 100644
index 0000000..dd538c7
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/obfuscation/ProguardMappingsRecorder.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2020 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.shrinker.obfuscation
+
+import com.android.build.shrinker.ResourceShrinkerModel
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Records obfuscation mappings from single file in proguard format.
+ *
+ * @param mappingsFile path to proguard.map file.
+ */
+class ProguardMappingsRecorder(private val mappingsFile: Path) : ObfuscationMappingsRecorder {
+
+ override fun recordObfuscationMappings(model: ResourceShrinkerModel) {
+ model.obfuscatedClasses = extractObfuscatedResourceClasses()
+ }
+
+ internal fun extractObfuscatedResourceClasses(): ObfuscatedClasses {
+ // Proguard obfuscation mappings file has the following structure:
+ // # comment1
+ // com.package.MainClass -> a.a:
+ // int field -> a
+ // boolean getFlag() -> b
+ // com.package.R -> a.b:
+ // com.package.R$style -> a.b.a:
+ // int Toolbar_android_gravity -> i1
+
+ val builder = ObfuscatedClasses.Builder()
+ Files.readAllLines(mappingsFile, StandardCharsets.UTF_8).forEach { line ->
+ when {
+ isMethodMapping(line) -> builder.addMethodMapping(extractMethodMapping(line))
+ isClassMapping(line) -> builder.addClassMapping(extractClassMapping(line))
+ }
+ }
+ return builder.build()
+ }
+
+ private fun isClassMapping(line: String): Boolean = line.contains("->")
+
+ private fun isMethodMapping(line: String): Boolean =
+ (line.startsWith(" ") || line.startsWith("\t")) && line.contains("->")
+
+ private fun extractClassMapping(line: String): Pair<String, String> {
+ val mapping = line.split("->", limit = 2)
+ return Pair(mapping[0].trim(), mapping[1].trim(' ', '\t', ':'))
+ }
+
+ private fun extractMethodMapping(line: String): Pair<String, String> {
+ val mapping = line.split("->", limit = 2)
+ val originalMethod = mapping[0].trim()
+ .substringBeforeLast('(')
+ .substringAfter(' ')
+ val obfuscatedMethod = mapping[1].trim()
+ return Pair(originalMethod, obfuscatedMethod)
+ }
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/usages/AppCompat.kt b/src/resourceshrinker/java/com/android/build/shrinker/usages/AppCompat.kt
new file mode 100644
index 0000000..bbfea7c
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/usages/AppCompat.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2020 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.shrinker.usages
+
+import com.android.build.shrinker.obfuscation.ObfuscatedClasses
+
+internal object AppCompat {
+ // Known AppCompat classes which use Resources.getIdentifier but should not trigger mode that
+ // tries to find used resources based on collected strings.
+ internal val APP_COMPAT_CLASSES_ALLOWED_FOR_GET_IDENTIFIER = setOf(
+ "android.support.v7.widget.SuggestionsAdapter",
+ "android.support.v7.internal.widget.ResourcesWrapper",
+ "android.support.v7.widget.ResourcesWrapper",
+ "android.support.v7.widget.TintContextWrapper\$TintResources"
+ )
+
+ internal fun isAppCompatClass(name: String, obfuscation: ObfuscatedClasses): Boolean =
+ APP_COMPAT_CLASSES_ALLOWED_FOR_GET_IDENTIFIER.contains(obfuscation.resolveOriginalClass(name))
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/usages/DexUsageRecorder.kt b/src/resourceshrinker/java/com/android/build/shrinker/usages/DexUsageRecorder.kt
new file mode 100644
index 0000000..3f1a676
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/usages/DexUsageRecorder.kt
@@ -0,0 +1,195 @@
+/*
+ * Copyright (C) 2020 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.shrinker.usages
+
+import com.android.SdkConstants.DOT_DEX
+import com.android.build.shrinker.ResourceShrinkerModel
+import com.android.build.shrinker.obfuscation.ClassAndMethod
+import com.android.build.shrinker.usages.AppCompat.isAppCompatClass
+import com.android.ide.common.resources.usage.ResourceUsageModel
+import com.android.resources.ResourceType
+import com.android.tools.r8.references.MethodReference
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Records resource usages, detects usages of WebViews and {@code Resources#getIdentifier},
+ * gathers string constants from compiled .dex files.
+ *
+ * @param root directory starting from which all .dex files are analyzed.
+ */
+class DexUsageRecorder(val root: Path) : ResourceUsageRecorder {
+
+ override fun recordUsages(model: ResourceShrinkerModel) {
+ // Record resource usages from dex classes. The following cases are covered:
+ // 1. Integer constant which refers to resource id.
+ // 2. Reference to static field in R classes.
+ // 3. Usages of android.content.res.Resources.getIdentifier(...) and
+ // android.webkit.WebView.load...
+ // 4. All strings which might be used to reference resources by name via
+ // Resources.getIdentifier.
+
+ Files.walk(root)
+ .filter { Files.isRegularFile(it) }
+ .filter { it.toString().endsWith(DOT_DEX, ignoreCase = true) }
+ .forEach { path ->
+ runResourceShrinkerAnalysis(
+ Files.readAllBytes(path),
+ path,
+ DexFileAnalysisCallback(path, model)
+ )
+ }
+ }
+}
+
+private class DexFileAnalysisCallback(
+ private val path: Path,
+ private val model: ResourceShrinkerModel
+) : AnalysisCallback {
+ companion object {
+ const val ANDROID_RES = "android_res/"
+
+ private fun String.toSourceClassName(): String {
+ return this.replace('/', '.')
+ }
+ }
+
+ // R class methods should only be processed for reachable resource IDs. R class fields that are
+ // not referenced should not be considered since there is no usage in the program.
+ // If the fields have been inlined, the values at the callsite will be recorded when visited.
+ var isRClass: Boolean = false
+
+ // In cases where a value from a method is inlined into a constant, we should still mark the
+ // resource as used.
+ val visitingMethod = MethodVisitingStatus()
+
+ override fun shouldProcess(internalName: String): Boolean {
+ isRClass = isResourceClass(internalName)
+ return true
+ }
+
+ /** Returns whether the given class file name points to an aapt-generated compiled R class. */
+ fun isResourceClass(internalName: String): Boolean {
+ val realClassName =
+ model.obfuscatedClasses.resolveOriginalClass(internalName.toSourceClassName())
+ val lastPart = realClassName.substringAfterLast('.')
+ if (lastPart.startsWith("R$")) {
+ val typeName = lastPart.substring(2)
+ return ResourceType.fromClassName(typeName) != null
+ }
+ return false
+ }
+
+ override fun referencedInt(value: Int) {
+ // Avoid marking R class fields as reachable.
+ if (shouldIgnoreField()) {
+ return
+ }
+ val resource = model.resourceStore.getResource(value)
+ if (ResourceUsageModel.markReachable(resource)) {
+ model.debugReporter.debug {
+ "Marking $resource reachable: referenced from $path"
+ }
+ }
+ }
+
+ override fun referencedStaticField(internalName: String, fieldName: String) {
+ // Avoid marking R class fields as reachable.
+ if (shouldIgnoreField()) {
+ return
+ }
+ val realMethod = model.obfuscatedClasses.resolveOriginalMethod(
+ ClassAndMethod(internalName.toSourceClassName(), fieldName)
+ )
+
+ if (isValidResourceType(realMethod.className)) {
+ val typePart = realMethod.className.substringAfterLast('$')
+ ResourceType.fromClassName(typePart)?.let { type ->
+ model.resourceStore.getResources(type, realMethod.methodName)
+ .forEach { ResourceUsageModel.markReachable(it) }
+ }
+ }
+ }
+
+ override fun referencedString(value: String) {
+ // Avoid marking R class fields as reachable.
+ if (shouldIgnoreField()) {
+ return
+ }
+ // 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 (value.isEmpty() || value.length > 80) {
+ return
+ }
+ fun isSpecialCharacter(c: Char) = c == '.' || c == ':' || c == '/' || c == '%'
+
+ if (value.all { Character.isJavaIdentifierPart(it) || isSpecialCharacter(it) } &&
+ value.any { Character.isJavaIdentifierPart(it) }) {
+ model.addStringConstant(value)
+ model.isFoundWebContent = model.isFoundWebContent || value.contains(ANDROID_RES)
+ }
+ }
+
+ override fun referencedMethod(
+ internalName: String,
+ methodName: String,
+ methodDescriptor: String
+ ) {
+ if (isRClass && visitingMethod.isVisiting && visitingMethod.methodName == "<clinit>") {
+ return
+ }
+ if (internalName == "android/content/res/Resources" &&
+ methodName == "getIdentifier" &&
+ methodDescriptor == "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I"
+ ) {
+ // "benign" usages: don't trigger reflection mode just because the user has included
+ // appcompat
+ if (isAppCompatClass(internalName.toSourceClassName(), model.obfuscatedClasses)) {
+ return
+ }
+ model.isFoundGetIdentifier = 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 (internalName == "android/webkit/WebView" && methodName.startsWith("load")) {
+ model.isFoundWebContent = true
+ }
+ }
+
+ override fun startMethodVisit(methodReference: MethodReference) {
+ visitingMethod.isVisiting = true
+ visitingMethod.methodName = methodReference.methodName
+ }
+
+ override fun endMethodVisit(methodReference: MethodReference) {
+ visitingMethod.isVisiting = false
+ visitingMethod.methodName = null
+ }
+
+ private fun shouldIgnoreField(): Boolean {
+ val visitingFromStaticInitRClass = (isRClass
+ && visitingMethod.isVisiting
+ && (visitingMethod.methodName == "<clinit>"))
+ return visitingFromStaticInitRClass ||
+ isRClass && !visitingMethod.isVisiting
+ }
+
+ private fun isValidResourceType(className: String): Boolean =
+ className.substringAfterLast('.').startsWith("R$")
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/usages/ProtoAndroidManifestUsageRecorder.kt b/src/resourceshrinker/java/com/android/build/shrinker/usages/ProtoAndroidManifestUsageRecorder.kt
new file mode 100644
index 0000000..4e6aa3e
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/usages/ProtoAndroidManifestUsageRecorder.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2020 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.shrinker.usages
+
+import com.android.aapt.Resources.XmlNode
+import com.android.build.shrinker.ResourceShrinkerModel
+import com.android.ide.common.resources.usage.ResourceUsageModel
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Records resource usages from AndroidManifest.xml in proto compiled format.
+ *
+ * @param manifest path to AndroidManifest.xml file.
+ */
+class ProtoAndroidManifestUsageRecorder(private val manifest: Path) : ResourceUsageRecorder {
+
+ override fun recordUsages(model: ResourceShrinkerModel) {
+ val root = XmlNode.parseFrom(Files.readAllBytes(manifest))
+ recordUsagesFromNode(root, model)
+ }
+
+ private fun recordUsagesFromNode(node: XmlNode, model: ResourceShrinkerModel) {
+ // Records only resources from element attributes that have reference items with resolved
+ // ids or names.
+ if (!node.hasElement()) {
+ return
+ }
+ node.element.attributeList.asSequence()
+ .filter { it.hasCompiledItem() }
+ .map { it.compiledItem }
+ .filter { it.hasRef() }
+ .map { it.ref }
+ .flatMap {
+ // If resource id is available prefer this id to name.
+ when {
+ it.id != 0 -> listOfNotNull(model.resourceStore.getResource(it.id))
+ else -> model.resourceStore.getResourcesFromUrl("@${it.name}")
+ }.asSequence()
+ }
+ .forEach { ResourceUsageModel.markReachable(it) }
+ node.element.childList.forEach { recordUsagesFromNode(it, model) }
+ }
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/usages/ResourceUsageRecorder.java b/src/resourceshrinker/java/com/android/build/shrinker/usages/ResourceUsageRecorder.java
new file mode 100644
index 0000000..46bfddb
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/usages/ResourceUsageRecorder.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2020 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.shrinker.usages;
+
+import com.android.annotations.NonNull;
+import com.android.build.shrinker.ResourceShrinkerModel;
+import java.io.IOException;
+
+/**
+ * Interface for unit that should record resource usages and contribute this information to
+ * ResourceShrinkerModel.
+ */
+public interface ResourceUsageRecorder {
+
+ /** Records resource usages. */
+ void recordUsages(@NonNull ResourceShrinkerModel model) throws IOException;
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/usages/ToolsAttributeUsageRecorder.kt b/src/resourceshrinker/java/com/android/build/shrinker/usages/ToolsAttributeUsageRecorder.kt
new file mode 100644
index 0000000..3d0dc6f
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/usages/ToolsAttributeUsageRecorder.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2020 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.shrinker.usages
+
+import com.android.SdkConstants.VALUE_STRICT
+import com.android.build.shrinker.ResourceShrinkerModel
+import com.android.utils.XmlUtils
+import com.google.common.collect.ImmutableMap.copyOf
+import java.io.Reader
+import java.nio.file.Files
+import java.nio.file.Path
+import javax.xml.stream.XMLInputFactory
+
+/**
+ * Records usages of tools:keep, tools:discard and tools:shrinkMode in resources.
+ *
+ * <p>This unit requires to analyze resources in raw XML format because as said in
+ * <a href="https://developer.android.com/studio/write/tool-attributes>documentation</a> these
+ * attributes may appear in any <resources> element and some files that contain such element
+ * are not compiled to proto. For example raw and values resources like res/raw/keep.xml,
+ * res/values/values.xml etc.
+ *
+ * @param rawResourcesPath path to folder with resources in raw format.
+ */
+class ToolsAttributeUsageRecorder(val rawResourcesPath: Path) : ResourceUsageRecorder {
+ companion object {
+ private val TOOLS_NAMESPACE = "http://schemas.android.com/tools"
+ }
+
+ override fun recordUsages(model: ResourceShrinkerModel) {
+ Files.walk(rawResourcesPath)
+ .filter { it.fileName.toString().endsWith(".xml", ignoreCase = true) }
+ .forEach { processRawXml(it, model) }
+ }
+
+ private fun processRawXml(path: Path, model: ResourceShrinkerModel) {
+ processResourceToolsAttributes(path).forEach { key, value ->
+ when (key) {
+ "keep" -> model.resourceStore.recordKeepToolAttribute(value)
+ "discard" -> model.resourceStore.recordDiscardToolAttribute(value)
+ "shrinkMode" ->
+ if (value == VALUE_STRICT) {
+ model.resourceStore.safeMode = false
+ }
+ }
+ }
+ }
+
+ private fun processResourceToolsAttributes(path: Path): Map<String, String> {
+ val toolsAttributes = mutableMapOf<String, String>()
+ XmlUtils.getUtfReader(path).use { reader: Reader ->
+ val factory = XMLInputFactory.newInstance()
+ val xmlStreamReader = factory.createXMLStreamReader(reader)
+
+ var rootElementProcessed = false
+ while (!rootElementProcessed && xmlStreamReader.hasNext()) {
+ xmlStreamReader.next()
+ if (xmlStreamReader.isStartElement) {
+ if (xmlStreamReader.localName == "resources") {
+ for (i in 0 until xmlStreamReader.attributeCount) {
+ if (xmlStreamReader.getAttributeNamespace(i) == TOOLS_NAMESPACE) {
+ toolsAttributes.put(
+ xmlStreamReader.getAttributeLocalName(i),
+ xmlStreamReader.getAttributeValue(i)
+ )
+ }
+ }
+ }
+ rootElementProcessed = true
+ }
+ }
+ }
+ return copyOf(toolsAttributes)
+ }
+}
diff --git a/src/resourceshrinker/java/com/android/build/shrinker/usages/r8ResourceShrinker.kt b/src/resourceshrinker/java/com/android/build/shrinker/usages/r8ResourceShrinker.kt
new file mode 100644
index 0000000..6bfd90f
--- /dev/null
+++ b/src/resourceshrinker/java/com/android/build/shrinker/usages/r8ResourceShrinker.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2023 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.
+ */
+
+@file:JvmName("R8ResourceShrinker")
+
+package com.android.build.shrinker.usages
+
+import com.android.tools.r8.ProgramResource
+import com.android.tools.r8.ProgramResourceProvider
+import com.android.tools.r8.ResourceShrinker
+import com.android.tools.r8.origin.PathOrigin
+import com.android.tools.r8.references.MethodReference
+import java.nio.file.Path
+
+
+fun runResourceShrinkerAnalysis(bytes: ByteArray, file: Path, callback: AnalysisCallback) {
+ val resource =
+ ProgramResource.fromBytes(PathOrigin(file), ProgramResource.Kind.DEX, bytes, null)
+ val provider = ProgramResourceProvider { listOf(resource) }
+
+ val command = ResourceShrinker.Builder().addProgramResourceProvider(provider).build()
+ ResourceShrinker.run(command, AnalysisAdapter(callback))
+}
+
+/** An adapter so R8 API classes do not leak into other modules. */
+class AnalysisAdapter(val impl: AnalysisCallback) : ResourceShrinker.ReferenceChecker {
+ override fun shouldProcess(internalName: String): Boolean = impl.shouldProcess(internalName)
+
+ override fun referencedStaticField(internalName: String, fieldName: String) =
+ impl.referencedStaticField(internalName, fieldName)
+
+ override fun referencedInt(value: Int) = impl.referencedInt(value)
+
+ override fun referencedString(value: String) = impl.referencedString(value)
+
+ override fun referencedMethod(
+ internalName: String, methodName: String, methodDescriptor: String
+ ) = impl.referencedMethod(internalName, methodName, methodDescriptor)
+
+ override fun startMethodVisit(methodReference: MethodReference
+ ) = impl.startMethodVisit(methodReference)
+
+ override fun endMethodVisit(methodReference: MethodReference
+ ) = impl.endMethodVisit(methodReference)
+}
+
+interface AnalysisCallback {
+
+ fun shouldProcess(internalName: String): Boolean
+
+ fun referencedInt(value: Int)
+
+ fun referencedString(value: String)
+
+ fun referencedStaticField(internalName: String, fieldName: String)
+
+ fun referencedMethod(internalName: String, methodName: String, methodDescriptor: String)
+
+ fun startMethodVisit(methodReference: MethodReference)
+
+ fun endMethodVisit(methodReference: MethodReference)
+}
+
+class MethodVisitingStatus(var isVisiting: Boolean = false, var methodName: String? = null)
diff --git a/third_party/r8.tar.gz.sha1 b/third_party/r8.tar.gz.sha1
index 4dd64c4..919e57c 100644
--- a/third_party/r8.tar.gz.sha1
+++ b/third_party/r8.tar.gz.sha1
@@ -1 +1 @@
-1ca30cd6edf6ea17a666947b22a60d2fa5cc8d5f
\ No newline at end of file
+1834c0701c4d967fde7e120aee4b2083a39e535e
\ No newline at end of file