| /* | |
| * 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.android.ide.common.resources.ResourcesUtil.resourceNameToFieldName; | |
| 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 == '/'); | |
| } | |
| 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(resourceNameToFieldName(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 = resourceNameToFieldName(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; | |
| } | |
| } |