All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.android.tools.lint.checks.RequiredAttributeDetector Maven / Gradle / Ivy

There is a newer version: 25.3.0
Show newest version
/*
 * 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.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 mStyleParents;

    /** Set of style names where the style sets the layout width */
    @Nullable private Set mWidthStyles;

    /** Set of style names where the style sets the layout height */
    @Nullable private Set mHeightStyles;

    /** Set of layout names for layouts that are included by an {@code } tag
     * where the width is set on the include */
    @Nullable private Set mIncludedWidths;

    /** Set of layout names for layouts that are included by an {@code } tag
     * where the height is set on the include */
    @Nullable private Set mIncludedHeights;

    /** Set of layout names for layouts that are included by an {@code } tag
     * where the width is not set on the include */
    @Nullable private Set mNotIncludedWidths;

    /** Set of layout names for layouts that are included by an {@code } tag
     * where the height is not set on the include */
    @Nullable private Set 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 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 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 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 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;
    }

    private static boolean hasLayoutVariations(File file) {
        File parent = file.getParentFile();
        if (parent == null) {
            return false;
        }
        File res = file.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 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 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 args = call.astArguments();

        String layout = null;
        int index = 0;
        for (Iterator 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 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);
                }
            }
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy