| /* |
| * Copyright (C) 2014 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_ID; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ABOVE; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BASELINE; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BOTTOM; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_END; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_LEFT; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_BOTTOM; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_END; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_LEFT; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_RIGHT; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_START; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_TOP; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_RIGHT; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_START; |
| import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_TOP; |
| import static com.android.SdkConstants.ATTR_LAYOUT_BELOW; |
| import static com.android.SdkConstants.ATTR_LAYOUT_TO_END_OF; |
| import static com.android.SdkConstants.ATTR_LAYOUT_TO_LEFT_OF; |
| import static com.android.SdkConstants.ATTR_LAYOUT_TO_RIGHT_OF; |
| import static com.android.SdkConstants.ATTR_LAYOUT_TO_START_OF; |
| import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; |
| import static com.android.SdkConstants.ATTR_TEXT; |
| import static com.android.SdkConstants.ATTR_VISIBILITY; |
| import static com.android.SdkConstants.PREFIX_RESOURCE_REF; |
| import static com.android.SdkConstants.PREFIX_THEME_REF; |
| import static com.android.SdkConstants.RELATIVE_LAYOUT; |
| import static com.android.SdkConstants.VALUE_TRUE; |
| import static com.android.SdkConstants.VALUE_WRAP_CONTENT; |
| import static com.android.SdkConstants.VIEW; |
| import static com.android.SdkConstants.VIEW_INCLUDE; |
| import static com.android.tools.lint.checks.RequiredAttributeDetector.PERCENT_RELATIVE_LAYOUT; |
| |
| import com.android.annotations.NonNull; |
| import com.android.annotations.Nullable; |
| import com.android.tools.lint.detector.api.Category; |
| import com.android.tools.lint.detector.api.Implementation; |
| import com.android.tools.lint.detector.api.Issue; |
| 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.XmlContext; |
| import com.google.common.base.Joiner; |
| import com.google.common.collect.Maps; |
| import java.util.Arrays; |
| import java.util.Collection; |
| import java.util.LinkedHashSet; |
| import java.util.Map; |
| import java.util.Set; |
| import org.w3c.dom.Element; |
| import org.w3c.dom.Node; |
| import org.w3c.dom.NodeList; |
| |
| /** |
| * Check for potential item overlaps in a RelativeLayout when left- and right-aligned text items are |
| * used. |
| */ |
| public class RelativeOverlapDetector extends LayoutDetector { |
| public static final Issue ISSUE = |
| Issue.create( |
| "RelativeOverlap", |
| "Overlapping items in RelativeLayout", |
| "If relative layout has text or button items aligned to left and right " |
| + "sides they can overlap each other due to localized text expansion " |
| + "unless they have mutual constraints like `toEndOf`/`toStartOf`.", |
| Category.I18N, |
| 3, |
| Severity.WARNING, |
| new Implementation(RelativeOverlapDetector.class, Scope.RESOURCE_FILE_SCOPE)); |
| |
| private static class LayoutNode { |
| private enum Bucket { |
| TOP, |
| BOTTOM, |
| SKIP |
| } |
| |
| private final int mIndex; |
| private boolean mProcessed; |
| private final Element mNode; |
| private Bucket mBucket; |
| private LayoutNode mToLeft; |
| private LayoutNode mToRight; |
| private boolean mLastLeft; |
| private boolean mLastRight; |
| |
| public LayoutNode(@NonNull Element node, int index) { |
| mNode = node; |
| mIndex = index; |
| mProcessed = false; |
| mLastLeft = true; |
| mLastRight = true; |
| } |
| |
| @NonNull |
| public String getNodeId() { |
| String nodeid = mNode.getAttributeNS(ANDROID_URI, ATTR_ID); |
| if (nodeid.isEmpty()) { |
| return String.format("%1$s-%2$d", mNode.getTagName(), mIndex); |
| } else { |
| return uniformId(nodeid); |
| } |
| } |
| |
| @NonNull |
| public String getNodeTextId() { |
| String text = mNode.getAttributeNS(ANDROID_URI, ATTR_TEXT); |
| if (text.isEmpty()) { |
| return getNodeId(); |
| } else { |
| return uniformId(text); |
| } |
| } |
| |
| @NonNull |
| @Override |
| public String toString() { |
| return getNodeTextId(); |
| } |
| |
| public boolean isInvisible() { |
| String visibility = mNode.getAttributeNS(ANDROID_URI, ATTR_VISIBILITY); |
| return visibility.equals("gone") || visibility.equals("invisible"); |
| } |
| |
| /** Determine if not can grow due to localization or not. */ |
| public boolean fixedWidth() { |
| String width = mNode.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH); |
| if (width.equals(VALUE_WRAP_CONTENT)) { |
| // First check child nodes. If at least one of them is not |
| // fixed-width, |
| // treat whole layout as non-fixed-width |
| NodeList childNodes = mNode.getChildNodes(); |
| for (int i = 0; i < childNodes.getLength(); i++) { |
| Node child = childNodes.item(i); |
| if (child.getNodeType() == Node.ELEMENT_NODE) { |
| LayoutNode childLayout = new LayoutNode((Element) child, i); |
| if (!childLayout.fixedWidth()) { |
| return false; |
| } |
| } |
| } |
| // If node contains text attribute, consider it fixed-width if |
| // text is hard-coded, otherwise it is not fixed-width. |
| String text = mNode.getAttributeNS(ANDROID_URI, ATTR_TEXT); |
| if (!text.isEmpty()) { |
| return !text.startsWith(PREFIX_RESOURCE_REF) |
| && !text.startsWith(PREFIX_THEME_REF); |
| } |
| |
| String nodeName = mNode.getTagName(); |
| if (nodeName.contains("Image") |
| || nodeName.contains("Progress") |
| || nodeName.contains("Radio")) { |
| return true; |
| } else if (nodeName.contains("Button") || nodeName.contains("Text")) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| @NonNull |
| public Element getNode() { |
| return mNode; |
| } |
| |
| /** |
| * Process a node of a layout. Put it into one of three processing units and determine its |
| * right and left neighbours. |
| */ |
| public void processNode(@NonNull Map<String, LayoutNode> nodes) { |
| if (mProcessed) { |
| return; |
| } |
| mProcessed = true; |
| |
| if (isInvisible() |
| || hasAttr(ATTR_LAYOUT_ALIGN_RIGHT) |
| || hasAttr(ATTR_LAYOUT_ALIGN_END) |
| || hasAttr(ATTR_LAYOUT_ALIGN_LEFT) |
| || hasAttr(ATTR_LAYOUT_ALIGN_START)) { |
| mBucket = Bucket.SKIP; |
| } else if (hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_TOP)) { |
| mBucket = Bucket.TOP; |
| } else if (hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_BOTTOM)) { |
| mBucket = Bucket.BOTTOM; |
| } else { |
| if (hasAttr(ATTR_LAYOUT_ABOVE) || hasAttr(ATTR_LAYOUT_BELOW)) { |
| mBucket = Bucket.SKIP; |
| } else { |
| String[] checkAlignment = { |
| ATTR_LAYOUT_ALIGN_TOP, ATTR_LAYOUT_ALIGN_BOTTOM, ATTR_LAYOUT_ALIGN_BASELINE |
| }; |
| for (String alignment : checkAlignment) { |
| String value = mNode.getAttributeNS(ANDROID_URI, alignment); |
| if (!value.isEmpty()) { |
| LayoutNode otherNode = nodes.get(uniformId(value)); |
| if (otherNode != null) { |
| otherNode.processNode(nodes); |
| mBucket = otherNode.mBucket; |
| } |
| } |
| } |
| } |
| } |
| if (mBucket == null) { |
| mBucket = Bucket.TOP; |
| } |
| |
| // Check relative placement |
| boolean positioned = false; |
| mToLeft = findNodeByAttr(nodes, ATTR_LAYOUT_TO_START_OF); |
| if (mToLeft == null) { |
| mToLeft = findNodeByAttr(nodes, ATTR_LAYOUT_TO_LEFT_OF); |
| } |
| // Avoid circular dependency |
| for (LayoutNode n = mToLeft; n != null; n = n.mToLeft) { |
| if (n.equals(this)) { |
| mToLeft = null; |
| mBucket = Bucket.SKIP; |
| break; |
| } |
| } |
| if (mToLeft != null) { |
| mToLeft.mLastLeft = false; |
| mLastRight = false; |
| positioned = true; |
| } |
| mToRight = findNodeByAttr(nodes, ATTR_LAYOUT_TO_END_OF); |
| if (mToRight == null) { |
| mToRight = findNodeByAttr(nodes, ATTR_LAYOUT_TO_RIGHT_OF); |
| } |
| // Avoid circular dependency |
| for (LayoutNode n = mToRight; n != null; n = n.mToRight) { |
| if (n.equals(this)) { |
| mToRight = null; |
| mBucket = Bucket.SKIP; |
| break; |
| } |
| } |
| if (mToRight != null) { |
| mToRight.mLastRight = false; |
| mLastLeft = false; |
| positioned = true; |
| } |
| |
| if (hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_END) |
| || hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_RIGHT)) { |
| mLastRight = false; |
| positioned = true; |
| } |
| if (hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_START) |
| || hasTrueAttr(ATTR_LAYOUT_ALIGN_PARENT_LEFT)) { |
| mLastLeft = false; |
| positioned = true; |
| } |
| // Treat any node that does not have explicit relative placement |
| // same as if it has layout_alignParentStart = true; |
| if (!positioned) { |
| mLastLeft = false; |
| } |
| } |
| |
| @NonNull |
| public Set<LayoutNode> canGrowLeft() { |
| Set<LayoutNode> nodes; |
| if (mToRight != null) { |
| nodes = mToRight.canGrowLeft(); |
| } else { |
| nodes = new LinkedHashSet<>(); |
| } |
| if (!fixedWidth()) { |
| nodes.add(this); |
| } |
| return nodes; |
| } |
| |
| @NonNull |
| public Set<LayoutNode> canGrowRight() { |
| Set<LayoutNode> nodes; |
| if (mToLeft != null) { |
| nodes = mToLeft.canGrowRight(); |
| } else { |
| nodes = new LinkedHashSet<>(); |
| } |
| if (!fixedWidth()) { |
| nodes.add(this); |
| } |
| return nodes; |
| } |
| |
| /** Determines if not should be skipped from checking. */ |
| public boolean skip() { |
| if (mBucket == Bucket.SKIP) { |
| return true; |
| } |
| |
| // Skip all includes and Views |
| return mNode.getTagName().equals(VIEW_INCLUDE) || mNode.getTagName().equals(VIEW); |
| } |
| |
| public boolean sameBucket(@NonNull LayoutNode node) { |
| return mBucket == node.mBucket; |
| } |
| |
| @Nullable |
| private LayoutNode findNodeByAttr( |
| @NonNull Map<String, LayoutNode> nodes, @NonNull String attrName) { |
| String value = mNode.getAttributeNS(ANDROID_URI, attrName); |
| if (!value.isEmpty()) { |
| return nodes.get(uniformId(value)); |
| } else { |
| return null; |
| } |
| } |
| |
| private boolean hasAttr(@NonNull String key) { |
| return mNode.hasAttributeNS(ANDROID_URI, key); |
| } |
| |
| private boolean hasTrueAttr(@NonNull String key) { |
| return mNode.getAttributeNS(ANDROID_URI, key).equals(VALUE_TRUE); |
| } |
| |
| @NonNull |
| private static String uniformId(@NonNull String value) { |
| return value.replaceFirst("@\\+", "@"); |
| } |
| } |
| |
| public RelativeOverlapDetector() {} |
| |
| @Override |
| public Collection<String> getApplicableElements() { |
| return Arrays.asList(RELATIVE_LAYOUT, PERCENT_RELATIVE_LAYOUT); |
| } |
| |
| @Override |
| public void visitElement(@NonNull XmlContext context, @NonNull Element element) { |
| // Traverse all child elements |
| NodeList childNodes = element.getChildNodes(); |
| int count = childNodes.getLength(); |
| Map<String, LayoutNode> nodes = Maps.newHashMap(); |
| for (int i = 0; i < count; i++) { |
| Node node = childNodes.item(i); |
| if (node.getNodeType() == Node.ELEMENT_NODE) { |
| LayoutNode ln = new LayoutNode((Element) node, i); |
| nodes.put(ln.getNodeId(), ln); |
| } |
| } |
| |
| // Node map is populated, recalculate nodes sizes |
| for (LayoutNode ln : nodes.values()) { |
| ln.processNode(nodes); |
| } |
| for (LayoutNode right : nodes.values()) { |
| if (!right.mLastLeft || right.skip()) { |
| continue; |
| } |
| Set<LayoutNode> canGrowLeft = right.canGrowLeft(); |
| for (LayoutNode left : nodes.values()) { |
| if (left == right || !left.mLastRight || left.skip() || !left.sameBucket(right)) { |
| continue; |
| } |
| Set<LayoutNode> canGrowRight = left.canGrowRight(); |
| if (!canGrowLeft.isEmpty() || !canGrowRight.isEmpty()) { |
| canGrowRight.addAll(canGrowLeft); |
| LayoutNode nodeToBlame = right; |
| LayoutNode otherNode = left; |
| if (!canGrowRight.contains(right) && canGrowRight.contains(left)) { |
| nodeToBlame = left; |
| otherNode = right; |
| } |
| Element blameNode = nodeToBlame.getNode(); |
| context.report( |
| ISSUE, |
| blameNode, |
| context.getElementLocation(blameNode), |
| String.format( |
| "`%1$s` can overlap `%2$s` if %3$s %4$s due to localized text expansion", |
| nodeToBlame.getNodeId(), |
| otherNode.getNodeId(), |
| Joiner.on(", ").join(canGrowRight), |
| canGrowRight.size() > 1 ? "grow" : "grows")); |
| } |
| } |
| } |
| } |
| } |