blob: 1a2fe3a2ea045b21d590bf62163981dbd549785e [file] [log] [blame]
/*
* 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"));
}
}
}
}
}