| /* |
| * 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; |
| } |
| } |