blob: 851d7c1703d84a38f0095d64beb1a62961a3d431 [file] [log] [blame]
/*
* Copyright (C) 2011 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_URI;
import static com.android.SdkConstants.ATTR_BACKGROUND;
import static com.android.SdkConstants.ATTR_CONTEXT;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_PARENT;
import static com.android.SdkConstants.ATTR_THEME;
import static com.android.SdkConstants.ATTR_TILE_MODE;
import static com.android.SdkConstants.CLASS_ACTIVITY;
import static com.android.SdkConstants.DOT_XML;
import static com.android.SdkConstants.DRAWABLE_PREFIX;
import static com.android.SdkConstants.NULL_RESOURCE;
import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX;
import static com.android.SdkConstants.TAG_ACTIVITY;
import static com.android.SdkConstants.TAG_APPLICATION;
import static com.android.SdkConstants.TAG_BITMAP;
import static com.android.SdkConstants.TAG_STYLE;
import static com.android.SdkConstants.TOOLS_URI;
import static com.android.SdkConstants.TRANSPARENT_COLOR;
import static com.android.SdkConstants.VALUE_DISABLED;
import static com.android.ide.common.resources.ResourcesUtil.resourceNameToFieldName;
import static com.android.tools.lint.detector.api.Lint.endsWith;
import static com.android.tools.lint.detector.api.Lint.getMethodName;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.tools.lint.client.api.ResourceReference;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.JavaContext;
import com.android.tools.lint.detector.api.LayoutDetector;
import com.android.tools.lint.detector.api.Lint;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Project;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.SourceCodeScanner;
import com.android.tools.lint.detector.api.XmlContext;
import com.android.utils.Pair;
import com.intellij.psi.PsiClass;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.jetbrains.uast.UCallExpression;
import org.jetbrains.uast.UClass;
import org.jetbrains.uast.UElement;
import org.jetbrains.uast.UExpression;
import org.jetbrains.uast.USimpleNameReferenceExpression;
import org.jetbrains.uast.visitor.AbstractUastVisitor;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* Check which looks for overdraw problems where view areas are painted and then painted over,
* meaning that the bottom paint operation is a waste of time.
*/
public class OverdrawDetector extends LayoutDetector implements SourceCodeScanner {
private static final String SET_THEME = "setTheme";
/** The main issue discovered by this detector */
public static final Issue ISSUE =
Issue.create(
"Overdraw",
"Overdraw: Painting regions more than once",
"If you set a background drawable on a root view, then you should use a "
+ "custom theme where the theme background is null. Otherwise, the theme background "
+ "will be painted first, only to have your custom background completely cover it; "
+ "this is called \"overdraw\".\n"
+ "\n"
+ "NOTE: This detector relies on figuring out which layouts are associated with "
+ "which activities based on scanning the Java code, and it's currently doing that "
+ "using an inexact pattern matching algorithm. Therefore, it can incorrectly "
+ "conclude which activity the layout is associated with and then wrongly complain "
+ "that a background-theme is hidden.\n"
+ "\n"
+ "If you want your custom background on multiple pages, then you should consider "
+ "making a custom theme with your custom background and just using that theme "
+ "instead of a root element background.\n"
+ "\n"
+ "Of course it's possible that your custom drawable is translucent and you want "
+ "it to be mixed with the background. However, you will get better performance "
+ "if you pre-mix the background with your drawable and use that resulting image or "
+ "color as a custom theme background instead.\n",
Category.PERFORMANCE,
3,
Severity.WARNING,
new Implementation(
OverdrawDetector.class,
EnumSet.of(Scope.MANIFEST, Scope.JAVA_FILE, Scope.ALL_RESOURCE_FILES)));
/** Mapping from FQN activity names to theme names registered in the manifest */
private Map<String, String> activityToTheme;
/** The default theme declared in the manifest, or null */
private String manifestTheme;
/** Mapping from layout name (not including {@code @layout/} prefix) to activity FQN */
private Map<String, List<String>> layoutToActivity;
/** List of theme names registered in the project which have blank backgrounds */
private List<String> blankThemes;
/**
* List of drawable resources that are not flagged for overdraw (XML drawables except for {@code
* <bitmap>} drawables without tiling)
*/
private List<String> validDrawables;
/**
* List of pairs of (location, background drawable) corresponding to root elements in layouts
* that define a given background drawable. These should be checked to see if they are painting
* on top of a non-transparent theme.
*/
private List<Pair<Location, String>> rootAttributes;
/** Constructs a new {@link OverdrawDetector} */
public OverdrawDetector() {}
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
// Look in layouts for drawable resources
return super.appliesTo(folderType)
// and in resource files for theme definitions
|| folderType == ResourceFolderType.VALUES
// and in drawable files for bitmap tiling modes
|| folderType == ResourceFolderType.DRAWABLE;
}
/** Is the given theme a "blank" theme (one not painting its background) */
private boolean isBlankTheme(String name) {
if (name.startsWith("@android:style/Theme_")) {
if (name.contains("NoFrame")
|| name.contains("Theme_Wallpaper")
|| name.contains("Theme_Holo_Wallpaper")
|| name.contains("Theme_Translucent")
|| name.contains("Theme_Dialog_NoFrame")
|| name.contains("Theme_Holo_Dialog_Alert")
|| name.contains("Theme_Holo_Light_Dialog_Alert")
|| name.contains("Theme_Dialog_Alert")
|| name.contains("Theme_Panel")
|| name.contains("Theme_Light_Panel")
|| name.contains("Theme_Holo_Panel")
|| name.contains("Theme_Holo_Light_Panel")) {
return true;
}
}
return blankThemes != null && blankThemes.contains(name);
}
@Override
public void afterCheckRootProject(@NonNull Context context) {
if (rootAttributes != null) {
for (Pair<Location, String> pair : rootAttributes) {
Location location = pair.getFirst();
Object clientData = location.getClientData();
if (clientData instanceof Node) {
if (context.getDriver().isSuppressed(null, ISSUE, (Node) clientData)) {
return;
}
}
String layoutName = location.getFile().getName();
if (endsWith(layoutName, DOT_XML)) {
layoutName = layoutName.substring(0, layoutName.length() - DOT_XML.length());
}
String theme = getTheme(context, layoutName);
if (theme == null || !isBlankTheme(theme)) {
String drawable = pair.getSecond();
String message =
String.format(
"Possible overdraw: Root element paints background `%1$s` with "
+ "a theme that also paints a background (inferred theme is `%2$s`)",
drawable, theme);
// TODO: Compute applicable scope node
context.report(ISSUE, location, message);
}
}
}
}
/** Return the theme to be used for the given layout */
private String getTheme(Context context, String layoutName) {
if (activityToTheme != null && layoutToActivity != null) {
List<String> activities = layoutToActivity.get(layoutName);
if (activities != null) {
for (String activity : activities) {
String theme = activityToTheme.get(activity);
if (theme != null) {
return theme;
}
}
}
}
if (manifestTheme != null) {
return manifestTheme;
}
Project project = context.getMainProject();
int apiLevel = project.getTargetSdk();
if (apiLevel == -1) {
apiLevel = project.getMinSdk();
}
if (apiLevel >= 11) {
return "@android:style/Theme.Holo";
} else {
return "@android:style/Theme";
}
}
// ---- Implements XmlScanner ----
@Override
public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) {
// Ignore tools:background and any other custom attribute that isn't actually the
// android View background attribute
if (!ANDROID_URI.equals(attribute.getNamespaceURI())) {
return;
}
// Only consider the root element's background
Element documentElement = attribute.getOwnerDocument().getDocumentElement();
if (documentElement == attribute.getOwnerElement()) {
// If the drawable is a non-repeated pattern then the overdraw might be
// intentional since the image isn't covering the whole screen
String background = attribute.getValue();
if (validDrawables != null && validDrawables.contains(background)) {
return;
}
if (background.equals(TRANSPARENT_COLOR) || background.equals(NULL_RESOURCE)) {
return;
}
if (background.startsWith("@android:drawable/")) {
// We haven't had a chance to study the builtin drawables the way we
// check the project local ones in scanBitmap() and beforeCheckFile(),
// but many of these are not bitmaps, so ignore these
return;
}
String name = context.file.getName();
if (name.contains("list_") || name.contains("_item")) {
// Canonical list_item layout name: don't warn about these, it's
// pretty common to want to paint custom list item backgrounds
return;
}
if (!context.getProject().getReportIssues()) {
// If this is a library project not being analyzed, ignore it
return;
}
Location location = context.getLocation(attribute);
location.setData(attribute);
if (rootAttributes == null) {
rootAttributes = new ArrayList<>();
}
rootAttributes.add(Pair.of(location, attribute.getValue()));
String activity = documentElement.getAttributeNS(TOOLS_URI, ATTR_CONTEXT);
if (activity != null && !activity.isEmpty()) {
if (activity.startsWith(".")) {
activity = context.getProject().getPackage() + activity;
}
registerLayoutActivity(Lint.getLayoutName(context.file), activity);
}
}
}
@Override
public Collection<String> getApplicableAttributes() {
return Collections.singletonList(
// Layouts: Look for background attributes on root elements for possible overdraw
ATTR_BACKGROUND);
}
@Override
public Collection<String> getApplicableElements() {
return Arrays.asList(
// Manifest: Look at theme registrations
TAG_ACTIVITY,
TAG_APPLICATION,
// Resource files: Look at theme definitions
TAG_STYLE,
// Bitmaps
TAG_BITMAP);
}
@Override
public void beforeCheckFile(@NonNull Context context) {
if (endsWith(context.file.getName(), DOT_XML)) {
// Drawable XML files should not be considered for overdraw, except for <bitmap>'s.
// The bitmap elements are handled in the scanBitmap() method; it will clear
// out anything added by this method.
File parent = context.file.getParentFile();
ResourceFolderType type = ResourceFolderType.getFolderType(parent.getName());
if (type == ResourceFolderType.DRAWABLE) {
if (validDrawables == null) {
validDrawables = new ArrayList<>();
}
String resource = getDrawableResource(context.file);
validDrawables.add(resource);
}
}
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
String tag = element.getTagName();
if (tag.equals(TAG_STYLE)) {
scanTheme(element);
} else if (tag.equals(TAG_ACTIVITY)) {
scanActivity(context, element);
} else if (tag.equals(TAG_APPLICATION)) {
if (element.hasAttributeNS(ANDROID_URI, ATTR_THEME)) {
manifestTheme = element.getAttributeNS(ANDROID_URI, ATTR_THEME);
}
} else if (tag.equals(TAG_BITMAP)) {
scanBitmap(context, element);
}
}
private static String getDrawableResource(File drawableFile) {
String resource = drawableFile.getName();
if (endsWith(resource, DOT_XML)) {
resource = resource.substring(0, resource.length() - DOT_XML.length());
}
return DRAWABLE_PREFIX + resource;
}
private void scanBitmap(Context context, Element element) {
String tileMode = element.getAttributeNS(ANDROID_URI, ATTR_TILE_MODE);
if (!(tileMode.equals(VALUE_DISABLED) || tileMode.isEmpty())) {
if (validDrawables != null) {
String resource = getDrawableResource(context.file);
validDrawables.remove(resource);
}
}
}
private void scanActivity(Context context, Element element) {
String name = element.getAttributeNS(ANDROID_URI, ATTR_NAME);
if (name.indexOf('$') != -1) {
name = name.replace('$', '.');
}
if (name.startsWith(".")) {
String pkg = context.getProject().getPackage();
if (pkg != null && !pkg.isEmpty()) {
name = pkg + name;
}
}
String theme = element.getAttributeNS(ANDROID_URI, ATTR_THEME);
if (theme != null && !theme.isEmpty()) {
if (activityToTheme == null) {
activityToTheme = new HashMap<>();
}
activityToTheme.put(name, resourceNameToFieldName(theme));
}
}
private void scanTheme(Element element) {
// Look for theme definitions, and record themes that provide a null background.
String styleName = element.getAttribute(ATTR_NAME);
String parent = element.getAttribute(ATTR_PARENT);
if (parent == null) {
// Eclipse DOM workaround
parent = "";
}
if (parent.isEmpty()) {
int index = styleName.lastIndexOf('.');
if (index != -1) {
parent = styleName.substring(0, index);
}
}
parent = parent.replace('.', '_');
String resource = STYLE_RESOURCE_PREFIX + resourceNameToFieldName(styleName);
NodeList items = element.getChildNodes();
for (int i = 0, n = items.getLength(); i < n; i++) {
if (items.item(i).getNodeType() == Node.ELEMENT_NODE) {
Element item = (Element) items.item(i);
String name = item.getAttribute(ATTR_NAME);
if (name.equals("android:windowBackground")) {
NodeList textNodes = item.getChildNodes();
for (int j = 0, m = textNodes.getLength(); j < m; j++) {
Node textNode = textNodes.item(j);
if (textNode.getNodeType() == Node.TEXT_NODE) {
String text = textNode.getNodeValue();
String trim = text.trim();
if (!trim.isEmpty()) {
if (trim.equals(NULL_RESOURCE)
|| trim.equals(TRANSPARENT_COLOR)
|| validDrawables != null
&& validDrawables.contains(trim)) {
if (blankThemes == null) {
blankThemes = new ArrayList<>();
}
blankThemes.add(resource);
}
}
}
}
return;
}
}
}
if (isBlankTheme(parent)) {
if (blankThemes == null) {
blankThemes = new ArrayList<>();
}
blankThemes.add(resource);
}
}
private void registerLayoutActivity(String layout, String classFqn) {
if (layoutToActivity == null) {
layoutToActivity = new HashMap<>();
}
List<String> list = layoutToActivity.get(layout);
if (list == null) {
list = new ArrayList<>();
layoutToActivity.put(layout, list);
}
list.add(classFqn);
}
// ---- implements SourceCodeScanner ----
@Nullable
@Override
public List<String> applicableSuperClasses() {
return Collections.singletonList(CLASS_ACTIVITY);
}
@Override
public List<Class<? extends UElement>> getApplicableUastTypes() {
return Collections.singletonList(UClass.class);
}
@Override
public void visitClass(@NonNull JavaContext context, @NonNull UClass declaration) {
if (!context.getProject().getReportIssues()) {
return;
}
String name = declaration.getQualifiedName();
if (name != null) {
declaration.accept(new OverdrawVisitor(name, declaration));
}
}
private class OverdrawVisitor extends AbstractUastVisitor {
private final String name;
private final PsiClass cls;
public OverdrawVisitor(String name, PsiClass cls) {
this.name = name;
this.cls = cls;
}
@Override
public boolean visitClass(UClass node) {
// Don't go into inner classes
if (cls.equals(node.getPsi())) {
return true;
}
return super.visitClass(node);
}
@Override
public boolean visitSimpleNameReferenceExpression(USimpleNameReferenceExpression node) {
ResourceReference reference = ResourceReference.get(node);
if (reference != null && reference.getType() == ResourceType.LAYOUT) {
registerLayoutActivity(reference.getName(), name);
}
return super.visitSimpleNameReferenceExpression(node);
}
@Override
public boolean visitCallExpression(UCallExpression node) {
if (SET_THEME.equals(getMethodName(node)) && node.getValueArgumentCount() == 1) {
// Look at argument
UExpression arg = node.getValueArguments().get(0);
ResourceReference reference = ResourceReference.get(arg);
if (reference != null && reference.getType() == ResourceType.STYLE) {
String style = reference.getName();
if (activityToTheme == null) {
activityToTheme = new HashMap<>();
}
activityToTheme.put(name, STYLE_RESOURCE_PREFIX + style);
}
}
return super.visitCallExpression(node);
}
}
}