blob: a78ca032e464ee98367d8bfadd7876d639e854e6 [file] [log] [blame]
/*
* Copyright (C) 2012 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_NS_NAME_PREFIX;
import static com.android.SdkConstants.ANDROID_STYLE_RESOURCE_PREFIX;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_LAYOUT;
import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT;
import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_PARENT;
import static com.android.SdkConstants.ATTR_STYLE;
import static com.android.SdkConstants.FD_RES_LAYOUT;
import static com.android.SdkConstants.FN_RESOURCE_BASE;
import static com.android.SdkConstants.FQCN_GRID_LAYOUT_V7;
import static com.android.SdkConstants.GRID_LAYOUT;
import static com.android.SdkConstants.LAYOUT_RESOURCE_PREFIX;
import static com.android.SdkConstants.REQUEST_FOCUS;
import static com.android.SdkConstants.STYLE_RESOURCE_PREFIX;
import static com.android.SdkConstants.TABLE_LAYOUT;
import static com.android.SdkConstants.TABLE_ROW;
import static com.android.SdkConstants.TAG_ITEM;
import static com.android.SdkConstants.TAG_STYLE;
import static com.android.SdkConstants.VIEW_INCLUDE;
import static com.android.SdkConstants.VIEW_MERGE;
import static com.android.resources.ResourceFolderType.LAYOUT;
import static com.android.resources.ResourceFolderType.VALUES;
import static com.android.tools.lint.detector.api.LintUtils.getLayoutName;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.resources.ResourceFolderType;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Detector;
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.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.Speed;
import com.android.tools.lint.detector.api.XmlContext;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import java.io.File;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.ast.AstVisitor;
import lombok.ast.Expression;
import lombok.ast.MethodInvocation;
import lombok.ast.NullLiteral;
import lombok.ast.Select;
import lombok.ast.StrictListAccessor;
import lombok.ast.VariableReference;
/**
* Ensures that layout width and height attributes are specified
*/
public class RequiredAttributeDetector extends LayoutDetector implements Detector.JavaScanner {
/** The main issue discovered by this detector */
public static final Issue ISSUE = Issue.create(
"RequiredSize", //$NON-NLS-1$
"Missing `layout_width` or `layout_height` attributes",
"All views must specify an explicit `layout_width` and `layout_height` attribute. " +
"There is a runtime check for this, so if you fail to specify a size, an exception " +
"is thrown at runtime.\n" +
"\n" +
"It's possible to specify these widths via styles as well. GridLayout, as a special " +
"case, does not require you to specify a size.",
Category.CORRECTNESS,
4,
Severity.ERROR,
new Implementation(
RequiredAttributeDetector.class,
EnumSet.of(Scope.JAVA_FILE, Scope.ALL_RESOURCE_FILES)));
/** Map from each style name to parent style */
@Nullable private Map<String, String> mStyleParents;
/** Set of style names where the style sets the layout width */
@Nullable private Set<String> mWidthStyles;
/** Set of style names where the style sets the layout height */
@Nullable private Set<String> mHeightStyles;
/** Set of layout names for layouts that are included by an {@code <include>} tag
* where the width is set on the include */
@Nullable private Set<String> mIncludedWidths;
/** Set of layout names for layouts that are included by an {@code <include>} tag
* where the height is set on the include */
@Nullable private Set<String> mIncludedHeights;
/** Set of layout names for layouts that are included by an {@code <include>} tag
* where the width is <b>not</b> set on the include */
@Nullable private Set<String> mNotIncludedWidths;
/** Set of layout names for layouts that are included by an {@code <include>} tag
* where the height is <b>not</b> set on the include */
@Nullable private Set<String> mNotIncludedHeights;
/** Whether the width was set in a theme definition */
private boolean mSetWidthInTheme;
/** Whether the height was set in a theme definition */
private boolean mSetHeightInTheme;
/** Constructs a new {@link RequiredAttributeDetector} */
public RequiredAttributeDetector() {
}
@NonNull
@Override
public Speed getSpeed() {
return Speed.FAST;
}
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return folderType == LAYOUT || folderType == VALUES;
}
@Override
public void afterCheckProject(@NonNull Context context) {
// Process checks in two phases:
// Phase 1: Gather styles and includes (styles are encountered after the layouts
// so we can't do it in a single phase, and includes can be affected by includes from
// layouts we haven't seen yet)
// Phase 2: Process layouts, using gathered style and include data, and mark layouts
// not known.
//
if (context.getPhase() == 1) {
checkSizeSetInTheme();
context.requestRepeat(this, Scope.RESOURCE_FILE_SCOPE);
}
}
private boolean isWidthStyle(String style) {
return isSizeStyle(style, mWidthStyles);
}
private boolean isHeightStyle(String style) {
return isSizeStyle(style, mHeightStyles);
}
private boolean isSizeStyle(String style, Set<String> sizeStyles) {
if (isFrameworkSizeStyle(style)) {
return true;
}
if (sizeStyles == null) {
return false;
}
return isSizeStyle(stripStylePrefix(style), sizeStyles, 0);
}
private static boolean isFrameworkSizeStyle(String style) {
// The styles Widget.TextView.ListSeparator (and several theme variations, such as
// Widget.Holo.TextView.ListSeparator, Widget.Holo.Light.TextView.ListSeparator, etc)
// define layout_width and layout_height.
// These are exposed through the listSeparatorTextViewStyle style.
if (style.equals("?android:attr/listSeparatorTextViewStyle") //$NON-NLS-1$
|| style.equals("?android/listSeparatorTextViewStyle")) { //$NON-NLS-1$
return true;
}
// It's also set on Widget.QuickContactBadge and Widget.QuickContactBadgeSmall
// These are exposed via a handful of attributes with a common prefix
if (style.startsWith("?android:attr/quickContactBadgeStyle")) { //$NON-NLS-1$
return true;
}
// Finally, the styles are set on MediaButton and Widget.Holo.Tab (and
// Widget.Holo.Light.Tab) but these are not exposed via attributes.
return false;
}
private boolean isSizeStyle(
@NonNull String style,
@NonNull Set<String> sizeStyles, int depth) {
if (depth == 30) {
// Cycle between local and framework attribute style missed
// by the fact that we're stripping the distinction between framework
// and local styles here
return false;
}
assert !style.startsWith(STYLE_RESOURCE_PREFIX)
&& !style.startsWith(ANDROID_STYLE_RESOURCE_PREFIX);
if (sizeStyles.contains(style)) {
return true;
}
if (mStyleParents != null) {
String parentStyle = mStyleParents.get(style);
if (parentStyle != null) {
parentStyle = stripStylePrefix(parentStyle);
if (isSizeStyle(parentStyle, sizeStyles, depth + 1)) {
return true;
}
}
}
int index = style.lastIndexOf('.');
if (index > 0) {
return isSizeStyle(style.substring(0, index), sizeStyles, depth + 1);
}
return false;
}
private void checkSizeSetInTheme() {
// Look through the styles and determine whether each style is a theme
if (mStyleParents == null) {
return;
}
Map<String, Boolean> isTheme = Maps.newHashMap();
for (String style : mStyleParents.keySet()) {
if (isTheme(stripStylePrefix(style), isTheme, 0)) {
mSetWidthInTheme = true;
mSetHeightInTheme = true;
break;
}
}
}
private boolean isTheme(String style, Map<String, Boolean> isTheme, int depth) {
if (depth == 30) {
// Cycle between local and framework attribute style missed
// by the fact that we're stripping the distinction between framework
// and local styles here
return false;
}
assert !style.startsWith(STYLE_RESOURCE_PREFIX)
&& !style.startsWith(ANDROID_STYLE_RESOURCE_PREFIX);
Boolean known = isTheme.get(style);
if (known != null) {
return known;
}
if (style.contains("Theme")) { //$NON-NLS-1$
isTheme.put(style, true);
return true;
}
if (mStyleParents != null) {
String parentStyle = mStyleParents.get(style);
if (parentStyle != null) {
parentStyle = stripStylePrefix(parentStyle);
if (isTheme(parentStyle, isTheme, depth + 1)) {
isTheme.put(style, true);
return true;
}
}
}
int index = style.lastIndexOf('.');
if (index > 0) {
String parentStyle = style.substring(0, index);
boolean result = isTheme(parentStyle, isTheme, depth + 1);
isTheme.put(style, result);
return result;
}
return false;
}
@VisibleForTesting
static boolean hasLayoutVariations(File file) {
File parent = file.getParentFile();
if (parent == null) {
return false;
}
File res = parent.getParentFile();
if (res == null) {
return false;
}
String name = file.getName();
File[] folders = res.listFiles();
if (folders == null) {
return false;
}
for (File folder : folders) {
if (!folder.getName().startsWith(FD_RES_LAYOUT)) {
continue;
}
if (folder.equals(parent)) {
continue;
}
File other = new File(folder, name);
if (other.exists()) {
return true;
}
}
return false;
}
private static String stripStylePrefix(@NonNull String style) {
if (style.startsWith(STYLE_RESOURCE_PREFIX)) {
style = style.substring(STYLE_RESOURCE_PREFIX.length());
} else if (style.startsWith(ANDROID_STYLE_RESOURCE_PREFIX)) {
style = style.substring(ANDROID_STYLE_RESOURCE_PREFIX.length());
}
return style;
}
private static boolean isRootElement(@NonNull Node node) {
return node == node.getOwnerDocument().getDocumentElement();
}
// ---- Implements XmlScanner ----
@Override
public Collection<String> getApplicableElements() {
return ALL;
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
ResourceFolderType folderType = context.getResourceFolderType();
int phase = context.getPhase();
if (phase == 1 && folderType == VALUES) {
String tag = element.getTagName();
if (TAG_STYLE.equals(tag)) {
String parent = element.getAttribute(ATTR_PARENT);
if (parent != null && !parent.isEmpty()) {
String name = element.getAttribute(ATTR_NAME);
if (name != null && !name.isEmpty()) {
if (mStyleParents == null) {
mStyleParents = Maps.newHashMap();
}
mStyleParents.put(name, parent);
}
}
} else if (TAG_ITEM.equals(tag)
&& TAG_STYLE.equals(element.getParentNode().getNodeName())) {
String name = element.getAttribute(ATTR_NAME);
if (name.endsWith(ATTR_LAYOUT_WIDTH) &&
name.equals(ANDROID_NS_NAME_PREFIX + ATTR_LAYOUT_WIDTH)) {
if (mWidthStyles == null) {
mWidthStyles = Sets.newHashSet();
}
String styleName = ((Element) element.getParentNode()).getAttribute(ATTR_NAME);
mWidthStyles.add(styleName);
}
if (name.endsWith(ATTR_LAYOUT_HEIGHT) &&
name.equals(ANDROID_NS_NAME_PREFIX + ATTR_LAYOUT_HEIGHT)) {
if (mHeightStyles == null) {
mHeightStyles = Sets.newHashSet();
}
String styleName = ((Element) element.getParentNode()).getAttribute(ATTR_NAME);
mHeightStyles.add(styleName);
}
}
} else if (folderType == LAYOUT) {
if (phase == 1) {
// Gather includes
if (element.getTagName().equals(VIEW_INCLUDE)) {
String layout = element.getAttribute(ATTR_LAYOUT);
if (layout != null && !layout.isEmpty()) {
recordIncludeWidth(layout,
element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH));
recordIncludeHeight(layout,
element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT));
}
}
} else {
assert phase == 2; // Check everything using style data and include data
boolean hasWidth = element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH);
boolean hasHeight = element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT);
if (mSetWidthInTheme) {
hasWidth = true;
}
if (mSetHeightInTheme) {
hasHeight = true;
}
if (hasWidth && hasHeight) {
return;
}
String tag = element.getTagName();
if (VIEW_MERGE.equals(tag)
|| VIEW_INCLUDE.equals(tag)
|| REQUEST_FOCUS.equals(tag)) {
return;
}
String parentTag = element.getParentNode() != null
? element.getParentNode().getNodeName() : "";
if (TABLE_LAYOUT.equals(parentTag)
|| TABLE_ROW.equals(parentTag)
|| GRID_LAYOUT.equals(parentTag)
|| FQCN_GRID_LAYOUT_V7.equals(parentTag)) {
return;
}
if (!context.getProject().getReportIssues()) {
// If this is a library project not being analyzed, ignore it
return;
}
boolean certain = true;
boolean isRoot = isRootElement(element);
if (isRoot || isRootElement(element.getParentNode())
&& VIEW_MERGE.equals(parentTag)) {
String name = LAYOUT_RESOURCE_PREFIX + getLayoutName(context.file);
if (!hasWidth && mIncludedWidths != null) {
hasWidth = mIncludedWidths.contains(name);
// If the layout is *also* included in a context where the width
// was not set, we're not certain; it's possible that
if (mNotIncludedWidths != null && mNotIncludedWidths.contains(name)) {
hasWidth = false;
// If we only have a single layout we know that this layout isn't
// always included with layout_width or layout_height set, but
// if there are multiple layouts, it's possible that at runtime
// we only load the size-less layout by the tag which includes
// the size
certain = !hasLayoutVariations(context.file);
}
}
if (!hasHeight && mIncludedHeights != null) {
hasHeight = mIncludedHeights.contains(name);
if (mNotIncludedHeights != null && mNotIncludedHeights.contains(name)) {
hasHeight = false;
certain = !hasLayoutVariations(context.file);
}
}
if (hasWidth && hasHeight) {
return;
}
}
if (!hasWidth || !hasHeight) {
String style = element.getAttribute(ATTR_STYLE);
if (style != null && !style.isEmpty()) {
if (!hasWidth) {
hasWidth = isWidthStyle(style);
}
if (!hasHeight) {
hasHeight = isHeightStyle(style);
}
}
if (hasWidth && hasHeight) {
return;
}
}
String message;
if (!(hasWidth || hasHeight)) {
if (certain) {
message = "The required `layout_width` and `layout_height` attributes " +
"are missing";
} else {
message = "The required `layout_width` and `layout_height` attributes " +
"*may* be missing";
}
} else {
String attribute = hasWidth ? ATTR_LAYOUT_HEIGHT : ATTR_LAYOUT_WIDTH;
if (certain) {
message = String.format("The required `%1$s` attribute is missing",
attribute);
} else {
message = String.format("The required `%1$s` attribute *may* be missing",
attribute);
}
}
context.report(ISSUE, element, context.getLocation(element),
message);
}
}
}
private void recordIncludeWidth(String layout, boolean providesWidth) {
if (providesWidth) {
if (mIncludedWidths == null) {
mIncludedWidths = Sets.newHashSet();
}
mIncludedWidths.add(layout);
} else {
if (mNotIncludedWidths == null) {
mNotIncludedWidths = Sets.newHashSet();
}
mNotIncludedWidths.add(layout);
}
}
private void recordIncludeHeight(String layout, boolean providesHeight) {
if (providesHeight) {
if (mIncludedHeights == null) {
mIncludedHeights = Sets.newHashSet();
}
mIncludedHeights.add(layout);
} else {
if (mNotIncludedHeights == null) {
mNotIncludedHeights = Sets.newHashSet();
}
mNotIncludedHeights.add(layout);
}
}
// ---- Implements JavaScanner ----
@Override
@Nullable
public List<String> getApplicableMethodNames() {
return Collections.singletonList("inflate"); //$NON-NLS-1$
}
@Override
public void visitMethod(
@NonNull JavaContext context,
@Nullable AstVisitor visitor,
@NonNull MethodInvocation call) {
// Handle
// View#inflate(Context context, int resource, ViewGroup root)
// LayoutInflater#inflate(int resource, ViewGroup root)
// LayoutInflater#inflate(int resource, ViewGroup root, boolean attachToRoot)
StrictListAccessor<Expression, MethodInvocation> args = call.astArguments();
String layout = null;
int index = 0;
for (Iterator<Expression> iterator = args.iterator(); iterator.hasNext(); index++) {
Expression expression = iterator.next();
if (expression instanceof Select) {
Select outer = (Select) expression;
Expression operand = outer.astOperand();
if (operand instanceof Select) {
Select inner = (Select) operand;
if (inner.astOperand() instanceof VariableReference) {
VariableReference reference = (VariableReference) inner.astOperand();
if (FN_RESOURCE_BASE.equals(reference.astIdentifier().astValue())
// TODO: constant
&& "layout".equals(inner.astIdentifier().astValue())) {
layout = LAYOUT_RESOURCE_PREFIX + outer.astIdentifier().astValue();
break;
}
}
}
}
}
if (layout == null) {
lombok.ast.Node method = StringFormatDetector.getParentMethod(call);
if (method != null) {
// Must track local types
index = 0;
String name = StringFormatDetector.getResourceArg(method, call, index);
if (name == null) {
index = 1;
name = StringFormatDetector.getResourceArg(method, call, index);
}
if (name != null) {
layout = LAYOUT_RESOURCE_PREFIX + name;
}
}
if (layout == null) {
// Flow analysis didn't succeed
return;
}
}
// In all the applicable signatures, the view root argument is immediately after
// the layout resource id.
int viewRootPos = index + 1;
if (viewRootPos < args.size()) {
int i = 0;
Iterator<Expression> iterator = args.iterator();
while (iterator.hasNext() && i < viewRootPos) {
iterator.next();
i++;
}
if (iterator.hasNext()) {
Expression viewRoot = iterator.next();
if (viewRoot instanceof NullLiteral) {
// Yep, this one inflates the given view with a null parent:
// Tag it as such. For now just use the include data structure since
// it has the same net effect
recordIncludeWidth(layout, true);
recordIncludeHeight(layout, true);
}
}
}
}
}