/* * 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_URI; import static com.android.SdkConstants.ATTR_DRAWABLE_END; import static com.android.SdkConstants.ATTR_DRAWABLE_LEFT; import static com.android.SdkConstants.ATTR_DRAWABLE_RIGHT; import static com.android.SdkConstants.ATTR_DRAWABLE_START; import static com.android.SdkConstants.ATTR_GRAVITY; 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_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_RIGHT; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_START; import static com.android.SdkConstants.ATTR_LAYOUT_GRAVITY; import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN; import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_END; import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_LEFT; import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_RIGHT; import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_START; 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_LIST_PREFERRED_ITEM_PADDING_END; import static com.android.SdkConstants.ATTR_LIST_PREFERRED_ITEM_PADDING_LEFT; import static com.android.SdkConstants.ATTR_LIST_PREFERRED_ITEM_PADDING_RIGHT; import static com.android.SdkConstants.ATTR_LIST_PREFERRED_ITEM_PADDING_START; import static com.android.SdkConstants.ATTR_PADDING; import static com.android.SdkConstants.ATTR_PADDING_END; import static com.android.SdkConstants.ATTR_PADDING_LEFT; import static com.android.SdkConstants.ATTR_PADDING_RIGHT; import static com.android.SdkConstants.ATTR_PADDING_START; import static com.android.SdkConstants.GRAVITY_VALUE_END; import static com.android.SdkConstants.GRAVITY_VALUE_LEFT; import static com.android.SdkConstants.GRAVITY_VALUE_RIGHT; import static com.android.SdkConstants.GRAVITY_VALUE_START; import static com.android.SdkConstants.TAG_APPLICATION; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.annotations.VisibleForTesting; import com.android.tools.lint.client.api.JavaParser; 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.LintUtils; 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.Speed; import com.android.tools.lint.detector.api.XmlContext; import org.w3c.dom.Attr; import org.w3c.dom.Element; import java.io.File; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; import java.util.List; import java.util.Locale; import lombok.ast.AstVisitor; import lombok.ast.EnumConstant; import lombok.ast.ForwardingAstVisitor; import lombok.ast.Identifier; import lombok.ast.ImportDeclaration; import lombok.ast.Node; import lombok.ast.Select; import lombok.ast.VariableDefinitionEntry; import lombok.ast.VariableReference; /** * Check which looks for RTL issues (right-to-left support) in layouts */ public class RtlDetector extends LayoutDetector implements Detector.JavaScanner { @SuppressWarnings("unchecked") private static final Implementation IMPLEMENTATION = new Implementation( RtlDetector.class, EnumSet.of(Scope.RESOURCE_FILE, Scope.JAVA_FILE, Scope.MANIFEST), Scope.RESOURCE_FILE_SCOPE, Scope.JAVA_FILE_SCOPE, Scope.MANIFEST_SCOPE ); public static final Issue USE_START = Issue.create( "RtlHardcoded", //$NON-NLS-1$ "Using left/right instead of start/end attributes", "Using `Gravity#LEFT` and `Gravity#RIGHT` can lead to problems when a layout is " + "rendered in locales where text flows from right to left. Use `Gravity#START` " + "and `Gravity#END` instead. Similarly, in XML `gravity` and `layout_gravity` " + "attributes, use `start` rather than `left`.\n" + "\n" + "For XML attributes such as paddingLeft and `layout_marginLeft`, use `paddingStart` " + "and `layout_marginStart`. *NOTE*: If your `minSdkVersion` is less than 17, you should " + "add *both* the older left/right attributes *as well as* the new start/right " + "attributes. On older platforms, where RTL is not supported and the start/right " + "attributes are unknown and therefore ignored, you need the older left/right " + "attributes. There is a separate lint check which catches that type of error.\n" + "\n" + "(Note: For `Gravity#LEFT` and `Gravity#START`, you can use these constants even " + "when targeting older platforms, because the `start` bitmask is a superset of the " + "`left` bitmask. Therefore, you can use `gravity=\"start\"` rather than " + "`gravity=\"left|start\"`.)", Category.RTL, 5, Severity.WARNING, IMPLEMENTATION); public static final Issue COMPAT = Issue.create( "RtlCompat", //$NON-NLS-1$ "Right-to-left text compatibility issues", "API 17 adds a `textAlignment` attribute to specify text alignment. However, " + "if you are supporting older versions than API 17, you must *also* specify a " + "gravity or layout_gravity attribute, since older platforms will ignore the " + "`textAlignment` attribute.", Category.RTL, 6, Severity.ERROR, IMPLEMENTATION); public static final Issue SYMMETRY = Issue.create( "RtlSymmetry", //$NON-NLS-1$ "Padding and margin symmetry", "If you specify padding or margin on the left side of a layout, you should " + "probably also specify padding on the right side (and vice versa) for " + "right-to-left layout symmetry.", Category.RTL, 6, Severity.WARNING, IMPLEMENTATION); public static final Issue ENABLED = Issue.create( "RtlEnabled", //$NON-NLS-1$ "Using RTL attributes without enabling RTL support", "To enable right-to-left support, when running on API 17 and higher, you must " + "set the `android:supportsRtl` attribute in the manifest `<application>` element.\n" + "\n" + "If you have started adding RTL attributes, but have not yet finished the " + "migration, you can set the attribute to false to satisfy this lint check.", Category.RTL, 3, Severity.WARNING, IMPLEMENTATION); /* TODO: public static final Issue FIELD = Issue.create( "RtlFieldAccess", //$NON-NLS-1$ "Accessing margin and padding fields directly", "Modifying the padding and margin constants in view objects directly is " + "problematic when using RTL support, since it can lead to inconsistent states. You " + "*must* use the corresponding setter methods instead (`View#setPadding` etc).", Category.RTL, 3, Severity.WARNING, IMPLEMENTATION).setEnabledByDefault(false); public static final Issue AWARE = Issue.create( "RtlAware", //$NON-NLS-1$ "View code not aware of RTL APIs", "When manipulating views, and especially when implementing custom layouts, " + "the code may need to be aware of RTL APIs. This lint check looks for usages of " + "APIs that frequently require adjustments for right-to-left text, and warns if it " + "does not also see text direction look-ups indicating that the code has already " + "been updated to handle RTL layouts.", Category.RTL, 3, Severity.WARNING, IMPLEMENTATION).setEnabledByDefault(false); */ private static final String RIGHT_FIELD = "RIGHT"; //$NON-NLS-1$ private static final String LEFT_FIELD = "LEFT"; //$NON-NLS-1$ private static final String GRAVITY_CLASS = "Gravity"; //$NON-NLS-1$ private static final String FQCN_GRAVITY = "android.view.Gravity"; //$NON-NLS-1$ private static final String FQCN_GRAVITY_PREFIX = "android.view.Gravity."; //$NON-NLS-1$ private static final String ATTR_SUPPORTS_RTL = "supportsRtl"; //$NON-NLS-1$ private static final String ATTR_TEXT_ALIGNMENT = "textAlignment"; //$NON-NLS-1$ /** API version in which RTL support was added */ private static final int RTL_API = 17; private static final String LEFT = "Left"; private static final String START = "Start"; private static final String RIGHT = "Right"; private static final String END = "End"; private Boolean mEnabledRtlSupport; private boolean mUsesRtlAttributes; /** Constructs a new {@link RtlDetector} */ public RtlDetector() { } @Override @NonNull public Speed getSpeed() { return Speed.NORMAL; } private boolean rtlApplies(@NonNull Context context) { Project project = context.getMainProject(); if (project.getTargetSdk() < RTL_API) { return false; } int buildTarget = project.getBuildSdk(); if (buildTarget != -1 && buildTarget < RTL_API) { return false; } //noinspection RedundantIfStatement if (mEnabledRtlSupport != null && !mEnabledRtlSupport) { return false; } return true; } @Override public void afterCheckProject(@NonNull Context context) { if (mUsesRtlAttributes && mEnabledRtlSupport == null && rtlApplies(context)) { List<File> manifestFile = context.getMainProject().getManifestFiles(); if (!manifestFile.isEmpty()) { Location location = Location.create(manifestFile.get(0)); context.report(ENABLED, location, "The project references RTL attributes, but does not explicitly enable " + "or disable RTL support with `android:supportsRtl` in the manifest"); } } } // ---- Implements XmlDetector ---- @VisibleForTesting static final String[] ATTRIBUTES = new String[] { // Pairs, from left/right constants to corresponding start/end constants ATTR_LAYOUT_ALIGN_PARENT_LEFT, ATTR_LAYOUT_ALIGN_PARENT_START, ATTR_LAYOUT_ALIGN_PARENT_RIGHT, ATTR_LAYOUT_ALIGN_PARENT_END, ATTR_LAYOUT_MARGIN_LEFT, ATTR_LAYOUT_MARGIN_START, ATTR_LAYOUT_MARGIN_RIGHT, ATTR_LAYOUT_MARGIN_END, ATTR_PADDING_LEFT, ATTR_PADDING_START, ATTR_PADDING_RIGHT, ATTR_PADDING_END, ATTR_DRAWABLE_LEFT, ATTR_DRAWABLE_START, ATTR_DRAWABLE_RIGHT, ATTR_DRAWABLE_END, ATTR_LIST_PREFERRED_ITEM_PADDING_LEFT, ATTR_LIST_PREFERRED_ITEM_PADDING_START, ATTR_LIST_PREFERRED_ITEM_PADDING_RIGHT, ATTR_LIST_PREFERRED_ITEM_PADDING_END, // RelativeLayout ATTR_LAYOUT_TO_LEFT_OF, ATTR_LAYOUT_TO_START_OF, ATTR_LAYOUT_TO_RIGHT_OF, ATTR_LAYOUT_TO_END_OF, ATTR_LAYOUT_ALIGN_LEFT, ATTR_LAYOUT_ALIGN_START, ATTR_LAYOUT_ALIGN_RIGHT, ATTR_LAYOUT_ALIGN_END, }; static { if (LintUtils.assertionsEnabled()) { for (int i = 0; i < ATTRIBUTES.length; i += 2) { String replace = ATTRIBUTES[i]; String with = ATTRIBUTES[i + 1]; assert with.equals(convertOldToNew(replace)); assert replace.equals(convertNewToOld(with)); } } } public static boolean isRtlAttributeName(@NonNull String attribute) { for (int i = 1; i < ATTRIBUTES.length; i += 2) { if (attribute.equals(ATTRIBUTES[i])) { return true; } } return false; } @VisibleForTesting static String convertOldToNew(String attribute) { if (attribute.contains(LEFT)) { return attribute.replace(LEFT, START); } else { return attribute.replace(RIGHT, END); } } @VisibleForTesting static String convertNewToOld(String attribute) { if (attribute.contains(START)) { return attribute.replace(START, LEFT); } else { return attribute.replace(END, RIGHT); } } @VisibleForTesting static String convertToOppositeDirection(String attribute) { if (attribute.contains(LEFT)) { return attribute.replace(LEFT, RIGHT); } else if (attribute.contains(RIGHT)) { return attribute.replace(RIGHT, LEFT); } else if (attribute.contains(START)) { return attribute.replace(START, END); } else { return attribute.replace(END, START); } } @Nullable static String getTextAlignmentToGravity(String attribute) { if (attribute.endsWith(START)) { // textStart, viewStart, ... return GRAVITY_VALUE_START; } else if (attribute.endsWith(END)) { // textEnd, viewEnd, ... return GRAVITY_VALUE_END; } else { return null; // inherit, others } } @Override public Collection<String> getApplicableAttributes() { int size = ATTRIBUTES.length + 4; List<String> attributes = new ArrayList<String>(size); // For detecting whether RTL support is enabled attributes.add(ATTR_SUPPORTS_RTL); // For detecting left/right attributes which should probably be // migrated to start/end attributes.add(ATTR_GRAVITY); attributes.add(ATTR_LAYOUT_GRAVITY); // For detecting existing attributes which indicate an attempt to // use RTL attributes.add(ATTR_TEXT_ALIGNMENT); // Add conversion attributes: left/right attributes to nominate // attributes that should be added as start/end, and start/end // attributes to use to look up elements that should have compatibility // left/right ones as well Collections.addAll(attributes, ATTRIBUTES); assert attributes.size() == size : attributes.size(); return attributes; } @Override public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) { Project project = context.getMainProject(); String value = attribute.getValue(); if (!ANDROID_URI.equals(attribute.getNamespaceURI())) { // Layout attribute not in the Android namespace (or a custom namespace). // This is likely an application error (which should get caught by // the MissingPrefixDetector) return; } String name = attribute.getLocalName(); assert name != null : attribute.getName(); if (name.equals(ATTR_SUPPORTS_RTL)) { mEnabledRtlSupport = Boolean.valueOf(value); if (!attribute.getOwnerElement().getTagName().equals(TAG_APPLICATION)) { context.report(ENABLED, attribute, context.getLocation(attribute), String.format( "Wrong declaration: `%1$s` should be defined on the `<application>` element", attribute.getName())); } int targetSdk = project.getTargetSdk(); if (mEnabledRtlSupport && targetSdk < RTL_API) { String message = String.format( "You must set `android:targetSdkVersion` to at least %1$d when " + "enabling RTL support (is %2$d)", RTL_API, project.getTargetSdk()); context.report(ENABLED, attribute, context.getValueLocation(attribute), message); } return; } if (!rtlApplies(context)) { return; } if (name.equals(ATTR_TEXT_ALIGNMENT)) { if (context.getProject().getReportIssues()) { mUsesRtlAttributes = true; } Element element = attribute.getOwnerElement(); final String gravity; final Attr gravityNode; if (element.hasAttributeNS(ANDROID_URI, ATTR_GRAVITY)) { gravityNode = element.getAttributeNodeNS(ANDROID_URI, ATTR_GRAVITY); gravity = gravityNode.getValue(); } else if (element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_GRAVITY)) { gravityNode = element.getAttributeNodeNS(ANDROID_URI, ATTR_LAYOUT_GRAVITY); gravity = gravityNode.getValue(); } else if (project.getMinSdk() < RTL_API) { int folderVersion = context.getFolderVersion(); if (folderVersion < RTL_API && context.isEnabled(COMPAT)) { String expectedGravity = getTextAlignmentToGravity(value); if (expectedGravity != null) { String message = String.format( "To support older versions than API 17 (project specifies %1$d) " + "you must *also* specify `gravity` or `layout_gravity=\"%2$s\"`", project.getMinSdk(), expectedGravity); context.report(COMPAT, attribute, context.getNameLocation(attribute), message); } } return; } else { return; } String expectedGravity = getTextAlignmentToGravity(value); if (expectedGravity != null && !gravity.contains(expectedGravity) && context.isEnabled(COMPAT)) { String message = String.format("Inconsistent alignment specification between " + "`textAlignment` and `gravity` attributes: was `%1$s`, expected `%2$s`", gravity, expectedGravity); Location location = context.getValueLocation(attribute); context.report(COMPAT, attribute, location, message); Location secondary = context.getValueLocation(gravityNode); secondary.setMessage("Incompatible direction here"); location.setSecondary(secondary); } return; } if (name.equals(ATTR_GRAVITY) || name.equals(ATTR_LAYOUT_GRAVITY)) { boolean isLeft = value.contains(GRAVITY_VALUE_LEFT); boolean isRight = value.contains(GRAVITY_VALUE_RIGHT); if (!isLeft && !isRight) { if ((value.contains(GRAVITY_VALUE_START) || value.contains(GRAVITY_VALUE_END)) && context.getProject().getReportIssues()) { mUsesRtlAttributes = true; } return; } String message = String.format( "Use \"`%1$s`\" instead of \"`%2$s`\" to ensure correct behavior in " + "right-to-left locales", isLeft ? GRAVITY_VALUE_START : GRAVITY_VALUE_END, isLeft ? GRAVITY_VALUE_LEFT : GRAVITY_VALUE_RIGHT); if (context.isEnabled(USE_START)) { context.report(USE_START, attribute, context.getValueLocation(attribute), message); } return; } // Some other left/right/start/end attribute int targetSdk = project.getTargetSdk(); // TODO: If attribute is drawableLeft or drawableRight, add note that you might // want to consider adding a specialized image in the -ldrtl folder as well Element element = attribute.getOwnerElement(); boolean isPaddingAttribute = isPaddingAttribute(name); if (isPaddingAttribute || isMarginAttribute(name)) { String opposite = convertToOppositeDirection(name); if (element.hasAttributeNS(ANDROID_URI, opposite)) { String oldValue = element.getAttributeNS(ANDROID_URI, opposite); if (value.equals(oldValue)) { return; } } else if (isPaddingAttribute && !element.hasAttributeNS(ANDROID_URI, isOldAttribute(opposite) ? convertOldToNew(opposite) : convertNewToOld(opposite)) && context.isEnabled(SYMMETRY)) { String message = String.format( "When you define `%1$s` you should probably also define `%2$s` for " + "right-to-left symmetry", name, opposite); context.report(SYMMETRY, attribute, context.getNameLocation(attribute), message); } } boolean isOld = isOldAttribute(name); if (isOld) { if (!context.isEnabled(USE_START)) { return; } String rtl = convertOldToNew(name); if (element.hasAttributeNS(ANDROID_URI, rtl)) { if (project.getMinSdk() >= RTL_API || context.getFolderVersion() >= RTL_API) { // Warn that left/right isn't needed String message = String.format( "Redundant attribute `%1$s`; already defining `%2$s` with " + "`targetSdkVersion` %3$s", name, rtl, targetSdk); context.report(USE_START, attribute, context.getNameLocation(attribute), message); } } else { String message; if (project.getMinSdk() >= RTL_API || context.getFolderVersion() >= RTL_API) { message = String.format( "Consider replacing `%1$s` with `%2$s:%3$s=\"%4$s\"` to better support " + "right-to-left layouts", attribute.getName(), attribute.getPrefix(), rtl, value); } else { message = String.format( "Consider adding `%1$s:%2$s=\"%3$s\"` to better support " + "right-to-left layouts", attribute.getPrefix(), rtl, value); } context.report(USE_START, attribute, context.getNameLocation(attribute), message); } } else { if (project.getMinSdk() >= RTL_API || !context.isEnabled(COMPAT)) { // Only supporting 17+: no need to define older attributes return; } int folderVersion = context.getFolderVersion(); if (folderVersion >= RTL_API) { // In a -v17 folder or higher: no need to define older attributes return; } String old = convertNewToOld(name); if (element.hasAttributeNS(ANDROID_URI, old)) { return; } String message = String.format( "To support older versions than API 17 (project specifies %1$d) " + "you should *also* add `%2$s:%3$s=\"%4$s\"`", project.getMinSdk(), attribute.getPrefix(), old, convertNewToOld(value)); context.report(COMPAT, attribute, context.getNameLocation(attribute), message); } } private static boolean isOldAttribute(String name) { return name.contains(LEFT) || name.contains(RIGHT); } private static boolean isMarginAttribute(@NonNull String name) { return name.startsWith(ATTR_LAYOUT_MARGIN); } private static boolean isPaddingAttribute(@NonNull String name) { return name.startsWith(ATTR_PADDING); } // ---- Implements JavaScanner ---- @Override public boolean appliesTo(@NonNull Context context, @NonNull File file) { return true; } @Override public List<Class<? extends Node>> getApplicableNodeTypes() { return Collections.<Class<? extends Node>>singletonList(Identifier.class); } @Override public AstVisitor createJavaVisitor(@NonNull JavaContext context) { if (rtlApplies(context)) { return new IdentifierChecker(context); } return new ForwardingAstVisitor() { }; } private static class IdentifierChecker extends ForwardingAstVisitor { private final JavaContext mContext; public IdentifierChecker(JavaContext context) { mContext = context; } @Override public boolean visitIdentifier(Identifier node) { String identifier = node.astValue(); boolean isLeft = LEFT_FIELD.equals(identifier); boolean isRight = RIGHT_FIELD.equals(identifier); if (!isLeft && !isRight) { return false; } Node parent = node.getParent(); if (parent instanceof ImportDeclaration || parent instanceof EnumConstant || parent instanceof VariableDefinitionEntry) { return false; } JavaParser.ResolvedNode resolved = mContext.resolve(node); if (resolved != null) { if (!(resolved instanceof JavaParser.ResolvedField)) { return false; } else { JavaParser.ResolvedField field = (JavaParser.ResolvedField) resolved; if (!field.getContainingClass().matches(FQCN_GRAVITY)) { return false; } } } else { // Can't resolve types (for example while editing code with errors): // rely on heuristics like import statements and class qualifiers if (parent instanceof Select && !(GRAVITY_CLASS.equals(((Select) parent).astOperand().toString()))) { return false; } if (parent instanceof VariableReference) { // No operand: make sure it's statically imported if (!LintUtils.isImported(mContext.getCompilationUnit(), FQCN_GRAVITY_PREFIX + identifier)) { return false; } } } String message = String.format( "Use \"`Gravity.%1$s`\" instead of \"`Gravity.%2$s`\" to ensure correct " + "behavior in right-to-left locales", (isLeft ? GRAVITY_VALUE_START : GRAVITY_VALUE_END).toUpperCase(Locale.US), (isLeft ? GRAVITY_VALUE_LEFT : GRAVITY_VALUE_RIGHT).toUpperCase(Locale.US)); Location location = mContext.getLocation(node); mContext.report(USE_START, node, location, message); return true; } } }