blob: 11eab6e6a47ed023df9ff901b75ff426e9236954 [file] [log] [blame]
/*
* Copyright (C) 2016 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.tools.lint.checks;
import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_DISCARD;
import static com.android.SdkConstants.ATTR_ID;
import static com.android.SdkConstants.ATTR_KEEP;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_PARENT;
import static com.android.SdkConstants.ATTR_SHRINK_MODE;
import static com.android.SdkConstants.ATTR_TYPE;
import static com.android.SdkConstants.PREFIX_ANDROID;
import static com.android.SdkConstants.PREFIX_BINDING_EXPR;
import static com.android.SdkConstants.PREFIX_RESOURCE_REF;
import static com.android.SdkConstants.PREFIX_THEME_REF;
import static com.android.SdkConstants.PREFIX_TWOWAY_BINDING_EXPR;
import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX;
import static com.android.SdkConstants.TAG_ITEM;
import static com.android.SdkConstants.TAG_LAYOUT;
import static com.android.SdkConstants.TAG_STYLE;
import static com.android.SdkConstants.TOOLS_URI;
import static com.android.SdkConstants.VALUE_SAFE;
import static com.android.SdkConstants.VALUE_STRICT;
import static com.android.utils.SdkUtils.endsWithIgnoreCase;
import static com.google.common.base.Charsets.UTF_8;
import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.resources.ResourceUrl;
import com.android.resources.FolderTypeRelationship;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.tools.lint.client.api.DefaultConfiguration;
import com.android.tools.lint.detector.api.LintUtils;
import com.android.tools.lint.detector.api.Location;
import com.google.common.base.Splitter;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.io.Files;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.io.File;
import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
/**
* A model for Android resource declarations and usages
*/
public class ResourceUsageModel {
private static final int TYPICAL_RESOURCE_COUNT = 200;
/** List of all known resources (parsed from R.java) */
private final List<Resource> mResources = Lists.newArrayListWithExpectedSize(TYPICAL_RESOURCE_COUNT);
/** Map from resource type to map from resource name to resource object */
private final Map<ResourceType, Map<String, Resource>> mTypeToName =
Maps.newEnumMap(ResourceType.class);
/** Map from R field value to corresponding resource */
private final Map<Integer, Resource> mValueToResource =
Maps.newHashMapWithExpectedSize(TYPICAL_RESOURCE_COUNT);
public static String getFieldName(Element element) {
return LintUtils.getFieldName(element.getAttribute(ATTR_NAME));
}
public static ResourceType getResourceType(Element element) {
String tagName = element.getTagName();
if (tagName.equals(TAG_ITEM)) {
String typeName = element.getAttribute(ATTR_TYPE);
if (!typeName.isEmpty()) {
return ResourceType.getEnum(typeName);
}
} else if ("string-array".equals(tagName) || "integer-array".equals(tagName)) {
return ResourceType.ARRAY;
} else {
return ResourceType.getEnum(tagName);
}
return null;
}
@Nullable
public Resource getResource(Element element) {
return getResource(element, false);
}
public Resource getResource(Element element, boolean declare) {
ResourceType type = getResourceType(element);
if (type != null) {
String name = getFieldName(element);
Resource resource = getResource(type, name);
if (resource == null && declare) {
resource = addResource(type, name, null);
resource.setDeclared(true);
}
return resource;
}
return null;
}
@SuppressWarnings("unused") // Used by (temporary) copy in Gradle resource shrinker
@Nullable
public Resource getResource(@NonNull Integer value) {
return mValueToResource.get(value);
}
@Nullable
public Resource getResource(@NonNull ResourceType type, @NonNull String name) {
Map<String, Resource> nameMap = mTypeToName.get(type);
if (nameMap != null) {
return nameMap.get(LintUtils.getFieldName(name));
}
return null;
}
@Nullable
Resource getResourceFromUrl(@NonNull String possibleUrlReference) {
ResourceUrl url = ResourceUrl.parse(possibleUrlReference);
if (url != null && !url.framework) {
return addResource(url.type, LintUtils.getFieldName(url.name), null);
}
return null;
}
private static final String ANDROID_RES = "android_res/";
@Nullable
public Resource getResourceFromFilePath(@NonNull String url) {
int nameSlash = url.lastIndexOf('/');
if (nameSlash == -1) {
return null;
}
// Look for
// (1) a full resource URL: /android_res/type/name.ext
// (2) a partial URL that uniquely identifies a given resource: drawable/name.ext
// e.g. file:///android_res/drawable/bar.png
int androidRes = url.indexOf(ANDROID_RES);
if (androidRes != -1) {
androidRes += ANDROID_RES.length();
int slash = url.indexOf('/', androidRes);
if (slash != -1) {
String folderName = url.substring(androidRes, slash);
ResourceFolderType folderType = ResourceFolderType.getFolderType(folderName);
if (folderType != null) {
List<ResourceType> types = FolderTypeRelationship.getRelatedResourceTypes(
folderType);
if (!types.isEmpty()) {
ResourceType type = types.get(0);
int nameBegin = slash + 1;
int dot = url.indexOf('.', nameBegin);
String name = url.substring(nameBegin, dot != -1 ? dot : url.length());
return getResource(type, name);
}
}
}
}
// Some other relative path. Just look from the end:
int typeSlash = url.lastIndexOf('/', nameSlash - 1);
ResourceType type = ResourceType.getEnum(url.substring(typeSlash + 1, nameSlash));
if (type != null) {
int nameBegin = nameSlash + 1;
int dot = url.indexOf('.', nameBegin);
String name = url.substring(nameBegin, dot != -1 ? dot : url.length());
return getResource(type, name);
}
return null;
}
/**
* Marks the given resource (if non-null) as reachable, and returns true if
* this is the first time the resource is marked reachable
*/
public static boolean markReachable(@Nullable Resource resource) {
if (resource != null) {
boolean wasReachable = resource.isReachable();
resource.setReachable(true);
return !wasReachable;
}
return false;
}
private static void markUnreachable(@Nullable Resource resource) {
if (resource != null) {
resource.setReachable(false);
}
}
public void recordManifestUsages(Node node) {
short nodeType = node.getNodeType();
if (nodeType == Node.ELEMENT_NODE) {
Element element = (Element) node;
NamedNodeMap attributes = element.getAttributes();
for (int i = 0, n = attributes.getLength(); i < n; i++) {
Attr attr = (Attr) attributes.item(i);
markReachable(getResourceFromUrl(attr.getValue()));
}
} else if (nodeType == Node.TEXT_NODE) {
// Does this apply to any manifests??
String text = node.getNodeValue().trim();
markReachable(getResourceFromUrl(text));
}
NodeList children = node.getChildNodes();
for (int i = 0, n = children.getLength(); i < n; i++) {
Node child = children.item(i);
recordManifestUsages(child);
}
}
private static final int RESOURCE_DECLARED = 1 << 1;
private static final int RESOURCE_PUBLIC = 1 << 2;
private static final int RESOURCE_KEEP = 1 << 3;
private static final int RESOURCE_DISCARD = 1 << 4;
private static final int RESOURCE_REACHABLE = 1 << 5;
public static class Resource implements Comparable<Resource> {
private int mFlags;
/** Type of resource */
public final ResourceType type;
/** Name of resource */
public final String name;
/** Integer id location */
public int value;
/** Resources this resource references. For example, a layout can reference another via
* an include; a style reference in a layout references that layout style, and so on. */
public List<Resource> references;
/** Chained list of declaration locations */
public Location locations;
public List<File> declarations;
/** Whether we found a declaration for this resource (otherwise we might have seen
* a reference to this before we came across its potential declaration, so we added it
* to the map, but we don't want to report unused resources for invalid resource
* references */
public boolean isDeclared() {
return (mFlags & RESOURCE_DECLARED) != 0;
}
/** Whether we found a declaration for this resource (otherwise we might have seen
* a reference to this before we came across its potential declaration, so we added it
* to the map, but we don't want to report unused resources for invalid resource
* references */
public void setDeclared(boolean on) {
mFlags = on ? (mFlags | RESOURCE_DECLARED) : (mFlags & ~RESOURCE_DECLARED);
}
/** This resource is marked as public */
public boolean isPublic() {
return (mFlags & RESOURCE_PUBLIC) != 0;
}
/** This resource is marked as public */
public void setPublic(boolean on) {
mFlags = on ? (mFlags | RESOURCE_PUBLIC) : (mFlags & ~RESOURCE_PUBLIC);
}
/** This resource is marked as to be ignored for usage analysis, regardless of
* references */
public boolean isKeep() {
return (mFlags & RESOURCE_KEEP) != 0;
}
/** This resource is marked as to be ignored for usage analysis, regardless of
* references */
public void setKeep(boolean on) {
mFlags = on ? (mFlags | RESOURCE_KEEP) : (mFlags & ~RESOURCE_KEEP);
}
/** This resource is marked as to be ignored for usage analysis, regardless of lack of
* references */
public boolean isDiscard() {
return (mFlags & RESOURCE_DISCARD) != 0;
}
/** This resource is marked as to be ignored for usage analysis, regardless of lack of
* references */
public void setDiscard(boolean on) {
mFlags = on ? (mFlags | RESOURCE_DISCARD) : (mFlags & ~RESOURCE_DISCARD);
}
public boolean isReachable() {
return (mFlags & RESOURCE_REACHABLE) != 0;
}
public void setReachable(boolean on) {
mFlags = on ? (mFlags | RESOURCE_REACHABLE) : (mFlags & ~RESOURCE_REACHABLE);
}
public Resource(ResourceType type, String name, int value) {
this.type = type;
this.name = name;
this.value = value;
}
@Override
public String toString() {
return type + ":" + name + ":" + value;
}
@SuppressWarnings("RedundantIfStatement") // Generated by IDE
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Resource resource = (Resource) o;
if (name != null ? !name.equals(resource.name) : resource.name != null) {
return false;
}
if (type != resource.type) {
return false;
}
return true;
}
@Override
public int hashCode() {
int result = type != null ? type.hashCode() : 0;
result = 31 * result + (name != null ? name.hashCode() : 0);
return result;
}
public void addLocation(@NonNull File file) {
if (declarations == null) {
declarations = Lists.newArrayList();
}
declarations.add(file);
}
public void recordLocation(@NonNull Location location) {
Location oldLocation = this.locations;
if (oldLocation != null) {
location.setSecondary(oldLocation);
}
this.locations = location;
}
public void addReference(@Nullable Resource resource) {
if (resource != null) {
if (references == null) {
references = Lists.newArrayList();
} else if (references.contains(resource)) {
return;
}
references.add(resource);
}
}
public String getUrl() {
return '@' + type.getName() + '/' + name;
}
public String getField() {
return "R." + type.getName() + '.' + name;
}
@Override
public int compareTo(@NonNull Resource other) {
if (type != other.type) {
return type.compareTo(other.type);
}
return name.compareTo(other.name);
}
}
public List<Resource> findUnused() {
return findUnused(mResources);
}
public String dumpReferences() {
StringBuilder sb = new StringBuilder(1000);
sb.append("Resource Reference Graph:\n");
for (Resource resource : mResources) {
if (resource.references != null) {
sb.append(resource).append(" => ").append(resource.references).append('\n');
}
}
return sb.toString();
}
public String dumpResourceModel() {
StringBuilder sb = new StringBuilder(1000);
Collections.sort(mResources, new Comparator<Resource>() {
@Override
public int compare(Resource resource1,
Resource resource2) {
int delta = resource1.type.compareTo(resource2.type);
if (delta != 0) {
return delta;
}
return resource1.name.compareTo(resource2.name);
}
});
for (Resource resource : mResources) {
sb.append(resource.getUrl()).append(" : reachable=").append(resource.isReachable());
sb.append("\n");
if (resource.references != null) {
for (Resource referenced : resource.references) {
sb.append(" ");
sb.append(referenced.getUrl());
sb.append("\n");
}
}
}
return sb.toString();
}
public List<Resource> findUnused(List<Resource> resources) {
List<Resource> roots = findRoots(resources);
Map<Resource,Boolean> seen = new IdentityHashMap<Resource,Boolean>(resources.size());
for (Resource root : roots) {
visit(root, seen);
}
List<Resource> unused = Lists.newArrayListWithExpectedSize(resources.size());
for (Resource resource : resources) {
if (!resource.isReachable()
// Styles not yet handled correctly: don't mark as unused
&& resource.type != ResourceType.ATTR
&& resource.type != ResourceType.DECLARE_STYLEABLE
// Don't flag known service keys read by library
&& !TranslationDetector.isServiceKey(resource.name)) {
unused.add(resource);
}
}
return unused;
}
@SuppressWarnings("MethodMayBeStatic")
@NonNull
protected List<Resource> findRoots(@NonNull List<Resource> resources) {
List<Resource> roots = Lists.newArrayList();
for (Resource resource : resources) {
if (resource.isReachable() || resource.isKeep()) {
roots.add(resource);
}
}
return roots;
}
private static void visit(Resource root, Map<Resource, Boolean> seen) {
if (seen.containsKey(root)) {
return;
}
seen.put(root, Boolean.TRUE);
root.setReachable(true);
if (root.references != null) {
for (Resource referenced : root.references) {
visit(referenced, seen);
}
}
}
@NonNull
public Resource addDeclaredResource(@NonNull ResourceType type, @NonNull String name,
@Nullable String value, boolean declared) {
Resource resource = addResource(type, name, value);
if (declared) {
resource.setDeclared(true);
}
return resource;
}
@NonNull
public Resource addResource(@NonNull ResourceType type, @NonNull String name,
@Nullable String value) {
int realValue = value != null ? Integer.decode(value) : -1;
Resource resource = getResource(type, name);
if (resource != null) {
//noinspection VariableNotUsedInsideIf
if (value != null) {
if (resource.value == -1) {
resource.value = realValue;
} else {
assert realValue == resource.value;
}
}
return resource;
}
resource = new Resource(type, name, realValue);
mResources.add(resource);
if (realValue != -1) {
mValueToResource.put(realValue, resource);
}
Map<String, Resource> nameMap = mTypeToName.get(type);
if (nameMap == null) {
nameMap = Maps.newHashMapWithExpectedSize(30);
mTypeToName.put(type, nameMap);
}
nameMap.put(name, resource);
// TODO: Assert that we don't set the same resource multiple times to different values.
// Could happen if you pass in stale data!
return resource;
}
/**
* Called for a tools:keep attribute containing a resource URL where that resource name
* is not referencing a known resource
*
* @param value The keep value
*/
private void processKeepAttributes(@NonNull String value) {
// TODO: When nothing matches one of these attributes, mark it as unused too!
// Handle comma separated lists of URLs and globs
if (value.indexOf(',') != -1) {
for (String portion : Splitter.on(',').omitEmptyStrings().trimResults().split(value)) {
processKeepAttributes(portion);
}
return;
}
ResourceUrl url = ResourceUrl.parse(value);
if (url == null || url.framework) {
return;
}
Resource resource = getResource(url.type, url.name);
if (resource != null) {
markReachable(resource);
} else if (url.name.contains("*") || url.name.contains("?")) {
// Look for globbing patterns
String regexp = DefaultConfiguration.globToRegexp(LintUtils.getFieldName(url.name));
try {
Pattern pattern = Pattern.compile(regexp);
Map<String, Resource> nameMap = mTypeToName.get(url.type);
if (nameMap != null) {
for (Resource r : nameMap.values()) {
if (pattern.matcher(r.name).matches()) {
markReachable(r);
}
}
}
} catch (PatternSyntaxException ignored) {
}
}
}
private void processDiscardAttributes(@NonNull String value) {
// Handle comma separated lists of URLs and globs
if (value.indexOf(',') != -1) {
for (String portion : Splitter.on(',').omitEmptyStrings().trimResults().split(value)) {
processDiscardAttributes(portion);
}
return;
}
ResourceUrl url = ResourceUrl.parse(value);
if (url == null || url.framework) {
return;
}
Resource resource = getResource(url.type, url.name);
if (resource != null) {
markUnreachable(resource);
} else if (url.name.contains("*") || url.name.contains("?")) {
// Look for globbing patterns
String regexp = DefaultConfiguration.globToRegexp(LintUtils.getFieldName(url.name));
try {
Pattern pattern = Pattern.compile(regexp);
Map<String, Resource> nameMap = mTypeToName.get(url.type);
if (nameMap != null) {
for (Resource r : nameMap.values()) {
if (pattern.matcher(r.name).matches()) {
markUnreachable(r);
}
}
}
} catch (PatternSyntaxException ignored) {
}
}
}
/**
* Recorded list of keep attributes: these can contain wildcards,
* so they can't be applied immediately; we have to apply them after
* scanning through all resources (done by {@link #processToolsAttributes()}
*/
private List<String> mKeepAttributes;
/**
* Recorded list of discard attributes: these can contain wildcards,
* so they can't be applied immediately; we have to apply them after
* scanning through all resources (done by {@link #processToolsAttributes()}
*/
private List<String> mDiscardAttributes;
private boolean mSafeMode = true;
/**
* Whether we should attempt to guess resources that should be kept based on looking
* at the string pool and assuming some of the strings can be used to dynamically construct
* the resource names. Can be turned off via {@code tools:shrinkMode="strict"}.
*/
public boolean isSafeMode() {
return mSafeMode;
}
public void processToolsAttributes() {
if (mKeepAttributes != null) {
for (String keep : mKeepAttributes) {
processKeepAttributes(keep);
}
}
if (mDiscardAttributes != null) {
for (String discard : mDiscardAttributes) {
processDiscardAttributes(discard);
}
}
}
public void recordToolsAttributes(@Nullable Attr attr) {
if (attr == null) {
return;
}
String localName = attr.getLocalName();
String value = attr.getValue();
if (ATTR_KEEP.equals(localName)) {
if (mKeepAttributes == null) {
mKeepAttributes = Lists.newArrayList();
}
mKeepAttributes.add(value);
} else if (ATTR_DISCARD.equals(localName)) {
if (mDiscardAttributes == null) {
mDiscardAttributes = Lists.newArrayList();
}
mDiscardAttributes.add(value);
} else if (ATTR_SHRINK_MODE.equals(localName)) {
if (VALUE_STRICT.equals(value)) {
mSafeMode = false;
} else if (VALUE_SAFE.equals(value)) {
mSafeMode = true;
}
}
}
protected Resource declareResource(ResourceType type, String name, Node node) {
return addDeclaredResource(type, name, null, true);
}
@NonNull
protected String readText(@NonNull File file) {
try {
return Files.toString(file, UTF_8);
} catch (IOException ignore) {
return "";
}
}
public void visitBinaryResource(
@Nullable ResourceFolderType folderType,
@NonNull File file) {
Resource from = null;
if (folderType != ResourceFolderType.VALUES) {
// Record resource for the whole file
List<ResourceType> types = FolderTypeRelationship.getRelatedResourceTypes(
folderType);
ResourceType type = types.get(0);
assert type != ResourceType.ID : folderType;
String name = LintUtils.getBaseName(file.getName());
from = declareResource(type, name, null);
}
if (folderType == ResourceFolderType.RAW) {
// Is this an HTML, CSS or JavaScript document bundled with the app?
// If so tokenize and look for resource references.
String path = file.getPath();
if (endsWithIgnoreCase(path, ".html") || endsWithIgnoreCase(path, ".htm")) {
tokenizeHtml(from, readText(file));
} else if (endsWithIgnoreCase(path, ".css")) {
tokenizeCss(from, readText(file));
} else if (endsWithIgnoreCase(path, ".js")) {
tokenizeJs(from, readText(file));
} else if (file.isFile() && !LintUtils.isBitmapFile(file)) {
tokenizeUnknownBinary(from, file);
}
}
}
public void visitXmlDocument(
@NonNull File file,
@Nullable ResourceFolderType folderType,
@NonNull Document document) {
if (folderType == null) {
// Manifest file
recordManifestUsages(document.getDocumentElement());
return;
}
Resource from = null;
if (folderType != ResourceFolderType.VALUES) {
// Record resource for the whole file
List<ResourceType> types = FolderTypeRelationship.getRelatedResourceTypes(
folderType);
ResourceType type = types.get(0);
assert type != ResourceType.ID : folderType;
String name = LintUtils.getBaseName(file.getName());
from = declareResource(type, name, document.getDocumentElement());
} else if (isAnalyticsFile(file)) {
return;
}
// For value files, and drawables and colors etc also pull in resource
// references inside the context.file
recordResourceReferences(folderType, document.getDocumentElement(), from);
if (folderType == ResourceFolderType.XML) {
tokenizeUnknownText(readText(file));
}
}
private static final String ANALYTICS_FILE = "analytics.xml"; //$NON-NLS-1$
/**
* Returns true if this XML file corresponds to an Analytics configuration file;
* these contain some attributes read by the library which won't be flagged as
* used by the application
*
* @param file the file in question
* @return true if the file represents an analytics file
*/
public static boolean isAnalyticsFile(File file) {
return file.getPath().endsWith(ANALYTICS_FILE) && file.getName().equals(ANALYTICS_FILE);
}
/**
* Records resource declarations and usages within an XML resource file
* @param folderType the type of resource file
* @param node the root node to start the recursive search from
* @param from a referencing context, if any.
*/
public void recordResourceReferences(
@NonNull ResourceFolderType folderType,
@NonNull Node node,
@Nullable Resource from) {
short nodeType = node.getNodeType();
if (nodeType == Node.ELEMENT_NODE) {
Element element = (Element) node;
if (from != null) {
NamedNodeMap attributes = element.getAttributes();
for (int i = 0, n = attributes.getLength(); i < n; i++) {
Attr attr = (Attr) attributes.item(i);
// Ignore tools: namespace attributes, unless it's
// a keep attribute
if (TOOLS_URI.equals(attr.getNamespaceURI())) {
recordToolsAttributes(attr);
// Skip all other tools: attributes
continue;
}
String value = attr.getValue();
if (!(value.startsWith(PREFIX_RESOURCE_REF)
|| value.startsWith(PREFIX_THEME_REF))) {
continue;
}
ResourceUrl url = ResourceUrl.parse(value);
if (url != null && !url.framework) {
Resource resource;
if (url.create) {
boolean isId = ATTR_ID.equals(attr.getLocalName());
if (isId && TAG_LAYOUT.equals(
element.getOwnerDocument().getDocumentElement().getTagName())) {
// When using data binding (root <layout> tag) the id's will be
// automatically bound (the binder will look through the layout
// and find all the id's.) Therefore, treat these as read for
// now; longer term, it would be cool if we could track uses of
// the binding field instead.
markReachable(addResource(url.type, url.name, null));
} else {
resource = declareResource(url.type, url.name, attr);
if (!isId || !ANDROID_URI.equals(attr.getNamespaceURI())) {
// Declaring an id is not a reference to that id
from.addReference(resource);
}
}
} else {
resource = addResource(url.type, url.name, null);
from.addReference(resource);
}
} else if (value.startsWith(PREFIX_BINDING_EXPR) ||
value.startsWith(PREFIX_TWOWAY_BINDING_EXPR)) {
// Data binding expression: there could be multiple references here
int length = value.length();
int index = value.startsWith(PREFIX_TWOWAY_BINDING_EXPR)
? PREFIX_TWOWAY_BINDING_EXPR.length()
: PREFIX_BINDING_EXPR.length();
while (true) {
index = value.indexOf('@', index);
if (index == -1) {
break;
}
// Find end of (potential) resource URL: first non resource URL character
int end = index + 1;
while (end < length) {
char c = value.charAt(end);
if (!(Character.isJavaIdentifierPart(c) ||
c == '_' ||
c == '.' ||
c == '/' ||
c == '+')) {
break;
}
end++;
}
url = ResourceUrl.parse(value.substring(index, end));
if (url != null && !url.framework) {
Resource resource;
if (url.create) {
resource = declareResource(url.type, url.name, attr);
} else {
resource = addResource(url.type, url.name, null);
}
from.addReference(resource);
}
index = end;
}
}
}
// Android Wear. We *could* limit ourselves to only doing this in files
// referenced from a manifest meta-data element, e.g.
// <meta-data android:name="com.google.android.wearable.beta.app"
// android:resource="@xml/wearable_app_desc"/>
// but given that that property has "beta" in the name, it seems likely
// to change and therefore hardcoding it for that key risks breakage
// in the future.
if ("rawPathResId".equals(element.getTagName())) {
StringBuilder sb = new StringBuilder();
NodeList children = node.getChildNodes();
for (int i = 0, n = children.getLength(); i < n; i++) {
Node child = children.item(i);
if (child.getNodeType() == Node.TEXT_NODE
|| child.getNodeType() == Node.CDATA_SECTION_NODE) {
sb.append(child.getNodeValue());
}
}
if (sb.length() > 0) {
Resource resource = getResource(ResourceType.RAW, sb.toString().trim());
from.addReference(resource);
}
}
} else {
// Look for keep attributes everywhere else since they don't require a source
recordToolsAttributes(element.getAttributeNodeNS(TOOLS_URI, ATTR_KEEP));
recordToolsAttributes(element.getAttributeNodeNS(TOOLS_URI, ATTR_DISCARD));
recordToolsAttributes(element.getAttributeNodeNS(TOOLS_URI, ATTR_SHRINK_MODE));
}
if (folderType == ResourceFolderType.VALUES) {
Resource definition = null;
ResourceType type = getResourceType(element);
if (type != null) {
String name = getFieldName(element);
if (type == ResourceType.PUBLIC) {
String typeName = element.getAttribute(ATTR_TYPE);
if (!typeName.isEmpty()) {
type = ResourceType.getEnum(typeName);
if (type != null) {
definition = declareResource(type, name, element);
definition.setPublic(true);
}
}
} else {
definition = declareResource(type, name, element);
}
}
if (definition != null) {
from = definition;
}
String tagName = element.getTagName();
if (TAG_STYLE.equals(tagName)) {
if (element.hasAttribute(ATTR_PARENT)) {
String parent = element.getAttribute(ATTR_PARENT);
if (!parent.isEmpty() && !parent.startsWith(ANDROID_STYLE_RESOURCE_PREFIX)
&& !parent.startsWith(PREFIX_ANDROID)) {
String parentStyle = parent;
if (!parentStyle.startsWith(STYLE_RESOURCE_PREFIX)) {
parentStyle = STYLE_RESOURCE_PREFIX + parentStyle;
}
Resource ps = getResourceFromUrl(
LintUtils.getFieldName(parentStyle));
if (ps != null && definition != null) {
ps.addReference(definition);
definition.addReference(ps);
}
} else if (definition != null) {
// Extending a builtin theme: treat these as used
markReachable(definition);
}
} else {
// Implicit parent styles by name
String name = getFieldName(element);
while (true) {
int index = name.lastIndexOf('_');
if (index != -1) {
name = name.substring(0, index);
Resource ps = getResourceFromUrl(
STYLE_RESOURCE_PREFIX + LintUtils.getFieldName(name));
if (ps != null && definition != null) {
ps.addReference(definition);
definition.addReference(ps);
}
} else {
break;
}
}
}
}
if (TAG_ITEM.equals(tagName)) {
// In style? If so the name: attribute can be a reference
if (element.getParentNode() != null
&& element.getParentNode().getNodeName().equals(TAG_STYLE)) {
String name = element.getAttributeNS(ANDROID_URI, ATTR_NAME);
if (!name.isEmpty() && !name.startsWith("android:")) {
Resource resource = getResource(ResourceType.ATTR, name);
if (definition == null) {
Element style = (Element) element.getParentNode();
definition = getResource(style);
if (definition != null) {
from = definition;
definition.addReference(resource);
}
}
}
}
}
}
} else if (nodeType == Node.TEXT_NODE || nodeType == Node.CDATA_SECTION_NODE) {
String text = node.getNodeValue().trim();
// Why are we calling getFieldName here? That doesn't make sense! for styles I guess
Resource textResource = getResourceFromUrl(
LintUtils.getFieldName(text));
if (textResource != null && from != null) {
from.addReference(textResource);
}
}
NodeList children = node.getChildNodes();
for (int i = 0, n = children.getLength(); i < n; i++) {
Node child = children.item(i);
recordResourceReferences(folderType, child, from);
}
}
public void tokenizeHtml(@Nullable Resource from, @NonNull String html) {
// Look for
// (1) URLs of the form /android_res/drawable/foo.ext
// which we will use to keep R.drawable.foo
// and
// (2) Filenames. If the web content is loaded with something like
// WebView.loadDataWithBaseURL("file:///android_res/drawable/", ...)
// this is similar to Resources#getIdentifier handling where all
// *potentially* aliased filenames are kept to play it safe.
// Simple HTML tokenizer
int length = html.length();
final int STATE_TEXT = 1;
final int STATE_SLASH = 2;
final int STATE_ATTRIBUTE_NAME = 3;
final int STATE_BEFORE_TAG = 4;
final int STATE_IN_TAG = 5;
final int STATE_BEFORE_ATTRIBUTE = 6;
final int STATE_ATTRIBUTE_BEFORE_EQUALS = 7;
final int STATE_ATTRIBUTE_AFTER_EQUALS = 8;
final int STATE_ATTRIBUTE_VALUE_NONE = 9;
final int STATE_ATTRIBUTE_VALUE_SINGLE = 10;
final int STATE_ATTRIBUTE_VALUE_DOUBLE = 11;
final int STATE_CLOSE_TAG = 12;
int state = STATE_TEXT;
int offset = 0;
int valueStart = 0;
int tagStart = 0;
String tag = null;
String attribute = null;
int attributeStart = 0;
int prev = -1;
while (offset < length) {
if (offset == prev) {
// Purely here to prevent potential bugs in the state machine from looping
// infinitely
offset++;
}
prev = offset;
char c = html.charAt(offset);
// MAke sure I handle doctypes properly.
// Make sure I handle cdata properly.
// Oh and what about <style> tags? tokenize everything inside as CSS!
// ANd <script> tag content as js!
switch (state) {
case STATE_TEXT: {
if (c == '<') {
state = STATE_SLASH;
offset++;
continue;
}
// Other text is just ignored
offset++;
break;
}
case STATE_SLASH: {
if (c == '!') {
if (html.startsWith("!--", offset)) {
// Comment
int end = html.indexOf("-->", offset + 3);
if (end == -1) {
offset = length;
break;
}
offset = end + 3;
continue;
} else if (html.startsWith("![CDATA[", offset)) {
// Skip CDATA text content; HTML text is irrelevant to this tokenizer
// anyway
int end = html.indexOf("]]>", offset + 8);
if (end == -1) {
offset = length;
break;
}
offset = end + 3;
continue;
}
} else if (c == '/') {
state = STATE_CLOSE_TAG;
offset++;
continue;
} else if (c == '?') {
// XML Prologue
int end = html.indexOf('>', offset + 2);
if (end == -1) {
offset = length;
break;
}
offset = end + 1;
continue;
}
state = STATE_IN_TAG;
tagStart = offset;
break;
}
case STATE_CLOSE_TAG: {
if (c == '>') {
state = STATE_TEXT;
}
offset++;
break;
}
case STATE_BEFORE_TAG: {
if (!Character.isWhitespace(c)) {
state = STATE_IN_TAG;
tagStart = offset;
}
// (For an end tag we'll include / in the tag name here)
offset++;
break;
}
case STATE_IN_TAG: {
if (Character.isWhitespace(c)) {
state = STATE_BEFORE_ATTRIBUTE;
tag = html.substring(tagStart, offset).trim();
} else if (c == '>') {
tag = html.substring(tagStart, offset).trim();
endHtmlTag(from, html, offset, tag);
state = STATE_TEXT;
}
offset++;
break;
}
case STATE_BEFORE_ATTRIBUTE: {
if (c == '>') {
endHtmlTag(from, html, offset, tag);
state = STATE_TEXT;
} else //noinspection StatementWithEmptyBody
if (c == '/') {
// we expect an '>' next to close the tag
} else if (!Character.isWhitespace(c)) {
state = STATE_ATTRIBUTE_NAME;
attributeStart = offset;
}
offset++;
break;
}
case STATE_ATTRIBUTE_NAME: {
if (c == '>') {
endHtmlTag(from, html, offset, tag);
state = STATE_TEXT;
} else if (c == '=') {
attribute = html.substring(attributeStart, offset);
state = STATE_ATTRIBUTE_AFTER_EQUALS;
} else if (Character.isWhitespace(c)) {
attribute = html.substring(attributeStart, offset);
state = STATE_ATTRIBUTE_BEFORE_EQUALS;
}
offset++;
break;
}
case STATE_ATTRIBUTE_BEFORE_EQUALS: {
if (c == '=') {
state = STATE_ATTRIBUTE_AFTER_EQUALS;
} else if (c == '>') {
endHtmlTag(from, html, offset, tag);
state = STATE_TEXT;
} else if (!Character.isWhitespace(c)) {
// Attribute value not specified (used for some boolean attributes)
state = STATE_ATTRIBUTE_NAME;
attributeStart = offset;
}
offset++;
break;
}
case STATE_ATTRIBUTE_AFTER_EQUALS: {
if (c == '\'') {
// a='b'
state = STATE_ATTRIBUTE_VALUE_SINGLE;
valueStart = offset + 1;
} else if (c == '"') {
// a="b"
state = STATE_ATTRIBUTE_VALUE_DOUBLE;
valueStart = offset + 1;
} else if (!Character.isWhitespace(c)) {
// a=b
state = STATE_ATTRIBUTE_VALUE_NONE;
valueStart = offset + 1;
}
offset++;
break;
}
case STATE_ATTRIBUTE_VALUE_SINGLE: {
if (c == '\'') {
state = STATE_BEFORE_ATTRIBUTE;
recordHtmlAttributeValue(from, tag, attribute,
html.substring(valueStart, offset));
}
offset++;
break;
}
case STATE_ATTRIBUTE_VALUE_DOUBLE: {
if (c == '"') {
state = STATE_BEFORE_ATTRIBUTE;
recordHtmlAttributeValue(from, tag, attribute,
html.substring(valueStart, offset));
}
offset++;
break;
}
case STATE_ATTRIBUTE_VALUE_NONE: {
if (c == '>') {
recordHtmlAttributeValue(from, tag, attribute,
html.substring(valueStart, offset));
endHtmlTag(from, html, offset, tag);
state = STATE_TEXT;
} else if (Character.isWhitespace(c)) {
state = STATE_BEFORE_ATTRIBUTE;
recordHtmlAttributeValue(from, tag, attribute,
html.substring(valueStart, offset));
}
offset++;
break;
}
default:
assert false : state;
}
}
}
private void endHtmlTag(@Nullable Resource from, @NonNull String html, int offset,
@Nullable String tag) {
if ("script".equals(tag)) {
int end = html.indexOf("</script>", offset + 1);
if (end != -1) {
// Attempt to tokenize the text as JavaScript
String js = html.substring(offset + 1, end);
tokenizeJs(from, js);
}
} else if ("style".equals(tag)) {
int end = html.indexOf("</style>", offset + 1);
if (end != -1) {
// Attempt to tokenize the text as CSS
String css = html.substring(offset + 1, end);
tokenizeCss(from, css);
}
}
}
public void tokenizeJs(@Nullable Resource from, @NonNull String js) {
// Simple JavaScript tokenizer: only looks for literal strings,
// and records those as string references
int length = js.length();
final int STATE_INIT = 1;
final int STATE_SLASH = 2;
final int STATE_STRING_DOUBLE = 3;
final int STATE_STRING_DOUBLE_QUOTED = 4;
final int STATE_STRING_SINGLE = 5;
final int STATE_STRING_SINGLE_QUOTED = 6;
int state = STATE_INIT;
int offset = 0;
int stringStart = 0;
int prev = -1;
while (offset < length) {
if (offset == prev) {
// Purely here to prevent potential bugs in the state machine from looping
// infinitely
offset++;
}
prev = offset;
char c = js.charAt(offset);
switch (state) {
case STATE_INIT: {
if (c == '/') {
state = STATE_SLASH;
} else if (c == '"') {
stringStart = offset + 1;
state = STATE_STRING_DOUBLE;
} else if (c == '\'') {
stringStart = offset + 1;
state = STATE_STRING_SINGLE;
}
offset++;
break;
}
case STATE_SLASH: {
if (c == '*') {
// Comment block
state = STATE_INIT;
int end = js.indexOf("*/", offset + 1);
if (end == -1) {
offset = length; // unterminated
break;
}
offset = end + 2;
continue;
} else if (c == '/') {
// Line comment
state = STATE_INIT;
int end = js.indexOf('\n', offset + 1);
if (end == -1) {
offset = length;
break;
}
offset = end + 1;
continue;
} else {
// division - just continue
state = STATE_INIT;
offset++;
break;
}
}
case STATE_STRING_DOUBLE: {
if (c == '"') {
recordJsString(js.substring(stringStart, offset));
state = STATE_INIT;
} else if (c == '\\') {
state = STATE_STRING_DOUBLE_QUOTED;
}
offset++;
break;
}
case STATE_STRING_DOUBLE_QUOTED: {
state = STATE_STRING_DOUBLE;
offset++;
break;
}
case STATE_STRING_SINGLE: {
if (c == '\'') {
recordJsString(js.substring(stringStart, offset));
state = STATE_INIT;
} else if (c == '\\') {
state = STATE_STRING_SINGLE_QUOTED;
}
offset++;
break;
}
case STATE_STRING_SINGLE_QUOTED: {
state = STATE_STRING_SINGLE;
offset++;
break;
}
default:
assert false : state;
}
}
}
public void tokenizeCss(@Nullable Resource from, @NonNull String css) {
// Simple CSS tokenizer: Only looks for URL references, and records those
// filenames. Skips everything else (unrelated to images).
int length = css.length();
final int STATE_INIT = 1;
final int STATE_SLASH = 2;
int state = STATE_INIT;
int offset = 0;
int prev = -1;
while (offset < length) {
if (offset == prev) {
// Purely here to prevent potential bugs in the state machine from looping
// infinitely
offset++;
}
prev = offset;
char c = css.charAt(offset);
switch (state) {
case STATE_INIT: {
if (c == '/') {
state = STATE_SLASH;
} else if (c == 'u' && css.startsWith("url(", offset) && offset > 0) {
char prevChar = css.charAt(offset-1);
if (Character.isWhitespace(prevChar) || prevChar == ':') {
int end = css.indexOf(')', offset);
offset += 4; // skip url(
while (offset < length && Character.isWhitespace(css.charAt(offset))) {
offset++;
}
if (end != -1 && end > offset + 1) {
while (end > offset
&& Character.isWhitespace(css.charAt(end - 1))) {
end--;
}
if ((css.charAt(offset) == '"'
&& css.charAt(end - 1) == '"')
|| (css.charAt(offset) == '\''
&& css.charAt(end - 1) == '\'')) {
// Strip " or '
offset++;
end--;
}
recordCssUrl(from, css.substring(offset, end).trim());
}
offset = end + 1;
continue;
}
}
offset++;
break;
}
case STATE_SLASH: {
if (c == '*') {
// CSS comment? Skip the whole block rather than staying within the
// character tokenizer.
int end = css.indexOf("*/", offset + 1);
if (end == -1) {
offset = length;
break;
}
offset = end + 2;
continue;
}
state = STATE_INIT;
offset++;
break;
}
default:
assert false : state;
}
}
}
private static byte[] sAndroidResBytes;
/** Look through binary/unknown files looking for resource URLs */
public void tokenizeUnknownBinary(@Nullable Resource from, @NonNull File file) {
try {
if (sAndroidResBytes == null) {
sAndroidResBytes = ANDROID_RES.getBytes(SdkConstants.UTF_8);
}
byte[] bytes = Files.toByteArray(file);
int index = 0;
while (index != -1) {
index = indexOf(bytes, sAndroidResBytes, index);
if (index != -1) {
index += sAndroidResBytes.length;
// Find the end of the URL
int begin = index;
int end = begin;
for (; end < bytes.length; end++) {
byte c = bytes[end];
if (c != '/' && !Character.isJavaIdentifierPart((char)c)) {
// android_res/raw/my_drawable.png => @raw/my_drawable
String url = "@" + new String(bytes, begin, end - begin, UTF_8);
Resource resource = getResourceFromUrl(url);
if (resource != null) {
if (from != null) {
from.addReference(resource);
} else {
markReachable(resource);
}
}
break;
}
}
}
}
} catch (IOException e) {
// Ignore
}
}
/**
* Returns the index of the given target array in the first array, looking from the given
* index
*/
private static int indexOf(byte[] array, byte[] target, int fromIndex) {
outer:
for (int i = fromIndex; i < array.length - target.length + 1; i++) {
for (int j = 0; j < target.length; j++) {
if (array[i + j] != target[j]) {
continue outer;
}
}
return i;
}
return -1;
}
/** Look through text files of unknown structure looking for resource URLs */
private void tokenizeUnknownText(@NonNull String text) {
int index = 0;
while (index != -1) {
index = text.indexOf(ANDROID_RES, index);
if (index != -1) {
index += ANDROID_RES.length();
// Find the end of the URL
int begin = index;
int end = begin;
int length = text.length();
for (; end < length; end++) {
char c = text.charAt(end);
if (c != '/' && !Character.isJavaIdentifierPart(c)) {
// android_res/raw/my_drawable.png => @raw/my_drawable
markReachable(getResourceFromUrl("@" + text.substring(begin, end)));
break;
}
}
}
}
}
/** Adds the resource identifiers found in the given Java source code into the reference map */
public void tokenizeJavaCode(@NonNull String s) {
if (s.length() <= 2) {
return;
}
// Scan looking for R.{type}.name identifiers
// Extremely simple state machine which just avoids comments, line comments
// and strings, and outside of that records any R. identifiers it finds
int index = 0;
int length = s.length();
char c;
char next;
for (; index < length; index++) {
c = s.charAt(index);
if (index == length - 1) {
break;
}
next = s.charAt(index + 1);
if (Character.isWhitespace(c)) {
continue;
}
if (c == '/') {
if (next == '*') {
// Block comment
while (index < length - 2) {
if (s.charAt(index) == '*' && s.charAt(index + 1) == '/') {
break;
}
index++;
}
index++;
} else if (next == '/') {
// Line comment
while (index < length && s.charAt(index) != '\n') {
index++;
}
}
} else if (c == '\'') {
// Character
if (next == '\\') {
// Skip '\c'
index += 2;
} else {
// Skip 'c'
index++;
}
} else if (c == '\"') {
// String: Skip to end
index++;
while (index < length - 1) {
char t = s.charAt(index);
if (t == '\\') {
index++;
} else if (t == '"') {
break;
}
index++;
}
} else if (c == 'R' && next == '.') {
// This might be a pattern
int begin = index;
index += 2;
while (index < length) {
char t = s.charAt(index);
if (t == '.') {
String typeName = s.substring(begin + 2, index);
ResourceType type = ResourceType.getEnum(typeName);
if (type != null) {
index++;
begin = index;
while (index < length &&
Character.isJavaIdentifierPart(s.charAt(index))) {
index++;
}
if (index > begin) {
String name = s.substring(begin, index);
Resource resource = addResource(type, name, null);
markReachable(resource);
}
}
index--;
break;
} else if (!Character.isJavaIdentifierStart(t)) {
break;
}
index++;
}
} else if (Character.isJavaIdentifierPart(c)) {
// Skip to the end of the identifier
while (index < length && Character.isJavaIdentifierPart(s.charAt(index))) {
index++;
}
// Back up so the next character can be checked to see if it's a " etc
index--;
} // else just punctuation/operators ( ) ; etc
}
}
protected void referencedString(@NonNull String string) {
}
private void recordCssUrl(@Nullable Resource from, @NonNull String value) {
if (!referencedUrl(from, value)) {
referencedString(value);
}
}
/**
* See if the given URL is a URL that we can resolve to a specific resource; if so,
* record it and return true, otherwise returns false.
*/
private boolean referencedUrl(@Nullable Resource from, @NonNull String url) {
Resource resource = getResourceFromFilePath(url);
if (resource == null && url.indexOf('/') == -1) {
// URLs are often within the raw folder
resource = getResource(ResourceType.RAW,
LintUtils.getFieldName(LintUtils.getBaseName(url)));
}
if (resource != null) {
if (from != null) {
from.addReference(resource);
} else {
// We don't have an inclusion context, so just assume this resource is reachable
markReachable(resource);
}
return true;
}
return false;
}
private void recordHtmlAttributeValue(@Nullable Resource from, @Nullable String tagName,
@Nullable String attribute, @NonNull String value) {
if ("href".equals(attribute) || "src".equals(attribute)) {
// In general we'd need to unescape the HTML here (e.g. remove entities) but
// those wouldn't be valid characters in the resource name anyway
if (!referencedUrl(from, value)) {
referencedString(value);
}
// If this document includes another, record the reachability of that script/resource
if (from != null) {
from.addReference(getResourceFromFilePath(attribute));
}
}
}
private void recordJsString(@NonNull String string) {
referencedString(string);
}
public List<Resource> getResources() {
return mResources;
}
@NonNull
public Collection<Map<String, Resource>> getResourceMaps() {
return mTypeToName.values();
}
}