blob: fd0731ead290f63a02b23355637ea5de53c8e32f [file] [log] [blame]
/*
* Copyright (C) 2012, 2017 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.ATTR_TEXT_ALIGNMENT;
import static com.android.SdkConstants.GRAVITY_VALUE_CENTER;
import static com.android.SdkConstants.GRAVITY_VALUE_CENTER_HORIZONTAL;
import static com.android.SdkConstants.GRAVITY_VALUE_CENTER_VERTICAL;
import static com.android.SdkConstants.GRAVITY_VALUE_END;
import static com.android.SdkConstants.GRAVITY_VALUE_FILL;
import static com.android.SdkConstants.GRAVITY_VALUE_FILL_HORIZONTAL;
import static com.android.SdkConstants.GRAVITY_VALUE_FILL_VERTICAL;
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 static com.android.SdkConstants.TextAlignment;
import static com.android.SdkConstants.VALUE_FALSE;
import static com.android.utils.XmlUtils.getFirstSubTagByName;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.resources.configuration.FolderConfiguration;
import com.android.ide.common.resources.configuration.VersionQualifier;
import com.android.tools.lint.client.api.UElementHandler;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Incident;
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.Lint;
import com.android.tools.lint.detector.api.LintFix;
import com.android.tools.lint.detector.api.LintMap;
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.SourceCodeScanner;
import com.android.tools.lint.detector.api.XmlContext;
import com.google.common.annotations.VisibleForTesting;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiField;
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 org.jetbrains.uast.UElement;
import org.jetbrains.uast.USimpleNameReferenceExpression;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
/** Check which looks for RTL issues (right-to-left support) in layouts */
public class RtlDetector extends LayoutDetector implements SourceCodeScanner {
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",
"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/end "
+ "attributes. On older platforms, where RTL is not supported and the start/end "
+ "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",
"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",
"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",
"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",
"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",
"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";
private static final String LEFT_FIELD = "LEFT";
private static final String FQCN_GRAVITY = "android.view.Gravity";
static final String ATTR_SUPPORTS_RTL = "supportsRtl";
/** 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;
private boolean mSpecifiesRtlMode;
/** Constructs a new {@link RtlDetector} */
public RtlDetector() {}
private boolean rtlApplies(@NonNull Context context) {
Project project = context.getMainProject();
if (project.getTargetSdk() < RTL_API) {
return false;
}
if (mEnabledRtlSupport != null && !mEnabledRtlSupport) {
return false;
}
// Enabled RTL support not set? Look for it in the merged manifest
if (mEnabledRtlSupport == null) {
mEnabledRtlSupport = getMergedManifestRtlSetting(context);
if (mEnabledRtlSupport == null) {
mEnabledRtlSupport = false;
}
return mEnabledRtlSupport;
}
return true;
}
@Nullable
private static Boolean getMergedManifestRtlSetting(@NonNull Context context) {
Project mainProject = context.getMainProject();
Document mergedManifest = mainProject.getMergedManifest();
if (mergedManifest == null) {
return null;
}
Element root = mergedManifest.getDocumentElement();
if (root == null) {
return null;
}
Element application = getFirstSubTagByName(root, TAG_APPLICATION);
if (application == null) {
return null;
}
String rtlSupported = application.getAttributeNS(ANDROID_URI, ATTR_SUPPORTS_RTL);
return !VALUE_FALSE.equals(rtlSupported);
}
@Override
public boolean filterIncident(
@NonNull Context context, @NonNull Incident incident, @NonNull LintMap map) {
int flags = map.getInt(KEY_APPLIES, 0);
if (applies(context, flags, incident.getLocation().getFile())) {
String message = incident.getMessage();
if (message.contains("%s")) {
// Patch in the actual minSdkVersion
String minApi = context.getMainProject().getMinSdkVersion().getApiString();
incident.setMessage(String.format(message, minApi));
}
return true;
}
return false;
}
private boolean applies(@NonNull Context context, int flags, @NonNull File location) {
// Access merged manifest from main project to see if RTL is on/off
if ((flags & APPLIES_REQUIRES_RTL) != 0 && !rtlApplies(context)) {
return false;
}
// targetSdkVersion constraints
if ((flags & APPLIES_TARGET_PRE_17) != 0
&& context.getMainProject().getTargetSdk() >= RTL_API) {
return false;
}
// folder and minSdkVersion checks.
if ((flags & APPLIES_FOLDER_OR_MIN_17) != 0) {
return context.getProject().getMinSdk() >= RTL_API
|| getFolderVersion(location) >= RTL_API;
} else if ((flags & APPLIES_MIN_17) != 0) {
return context.getMainProject().getMinSdk() >= RTL_API;
} else if ((flags & APPLIES_MIN_PRE_17) != 0) {
return context.getMainProject().getMinSdk() < RTL_API;
} else {
return true;
}
}
/** For a resource folder config like */
static int getFolderVersion(@NonNull File resourceFile) {
String path = resourceFile.getPath();
int i = path.length() - 1;
for (; i >= 0; i--) {
char c = path.charAt(i);
if (c == '/' || c == '\\') {
i--;
break;
}
}
if (i != 0) {
boolean haveDash = false;
int j = i;
for (; j >= 0; j--) {
char c = path.charAt(j);
if (c == '/' || c == '\\') {
j++;
break;
} else if (c == '-') {
haveDash = true;
}
}
if (haveDash) {
String folderName = path.substring(Math.max(j, 0), i + 1);
FolderConfiguration config = FolderConfiguration.getConfigForFolder(folderName);
if (config != null) {
VersionQualifier qualifier = config.getVersionQualifier();
if (qualifier != null) {
return qualifier.getVersion();
}
}
}
}
return -1;
}
@Override
public void afterCheckRootProject(@NonNull Context context) {
if (mUsesRtlAttributes && !mSpecifiesRtlMode) {
List<File> manifestFile = context.getProject().getManifestFiles();
if (!manifestFile.isEmpty()) {
Location location = Location.create(manifestFile.get(0));
context.report(
new Incident(
ENABLED,
location,
"The project references RTL attributes, but does not explicitly enable "
+ "or disable RTL support with `android:supportsRtl` in the manifest"),
map().put(KEY_APPLIES, APPLIES_REQUIRES_RTL));
}
}
}
// ---- 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 (Lint.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);
}
}
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.equals(TextAlignment.CENTER)) {
return GRAVITY_VALUE_CENTER_HORIZONTAL;
} else 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<>(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) {
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)) {
mSpecifiesRtlMode = true;
mEnabledRtlSupport = Boolean.valueOf(value);
if (!attribute.getOwnerElement().getTagName().equals(TAG_APPLICATION)) {
context.report(
new Incident(
ENABLED,
attribute,
context.getLocation(attribute),
String.format(
"Wrong declaration: `%1$s` should be defined on the `<application>` element",
attribute.getName())));
}
if (mEnabledRtlSupport && context.getProject().getMinSdk() < RTL_API) {
String message =
String.format(
Locale.getDefault(),
"You must set `android:targetSdkVersion` to at least %1$d when "
+ "enabling RTL support",
RTL_API);
reportRtl(
context,
ENABLED,
attribute,
context.getValueLocation(attribute),
message,
null,
APPLIES_TARGET_PRE_17);
}
return;
}
if (name.equals(ATTR_TEXT_ALIGNMENT)) {
if (context.getProject().getReportIssues()) {
mUsesRtlAttributes = true;
}
Element element = attribute.getOwnerElement();
final String gravitySpec;
final Attr gravityNode;
if (element.hasAttributeNS(ANDROID_URI, ATTR_GRAVITY)) {
gravityNode = element.getAttributeNodeNS(ANDROID_URI, ATTR_GRAVITY);
gravitySpec = gravityNode.getValue();
} else if (element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_GRAVITY)) {
gravityNode = element.getAttributeNodeNS(ANDROID_URI, ATTR_LAYOUT_GRAVITY);
gravitySpec = gravityNode.getValue();
} else if (context.getProject().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(
Locale.getDefault(),
// %%: place holder; will be replaced when reporting
"To support older versions than API 17 (project specifies %%s) "
+ "you must **also** specify `gravity` or `layout_gravity=\"%1$s\"`",
expectedGravity);
LintFix fix1 =
fix().set(ANDROID_URI, ATTR_GRAVITY, expectedGravity).build();
LintFix fix2 =
fix().set(ANDROID_URI, ATTR_LAYOUT_GRAVITY, expectedGravity)
.build();
LintFix fix = fix().alternatives(fix1, fix2);
reportRtl(
context,
COMPAT,
attribute,
context.getNameLocation(attribute),
message,
fix,
APPLIES_MIN_PRE_17 | APPLIES_REQUIRES_RTL);
}
}
return;
} else {
return;
}
String expectedGravity = getTextAlignmentToGravity(value);
if (expectedGravity != null && context.isEnabled(COMPAT)) {
List<String> gravities = new ArrayList<>();
for (String g : gravitySpec.split("\\|")) {
g = g.trim();
gravities.add(g);
if (g.equals(GRAVITY_VALUE_CENTER)) {
gravities.add(GRAVITY_VALUE_CENTER_HORIZONTAL);
gravities.add(GRAVITY_VALUE_CENTER_VERTICAL);
}
if (g.equals(GRAVITY_VALUE_FILL)) {
gravities.add(GRAVITY_VALUE_FILL_HORIZONTAL);
gravities.add(GRAVITY_VALUE_FILL_VERTICAL);
}
}
if (gravities.stream().noneMatch(g -> g.equals(expectedGravity))) {
String message =
String.format(
"Inconsistent alignment specification between `textAlignment` and "
+ "`gravity` attributes: was `%1$s`, expected `%2$s`",
gravitySpec, expectedGravity);
Location location = context.getValueLocation(attribute);
Location secondary = context.getValueLocation(gravityNode);
secondary.setMessage("Incompatible direction here");
location.setSecondary(secondary);
reportRtl(
context,
COMPAT,
attribute,
location,
message,
null,
APPLIES_REQUIRES_RTL);
}
}
}
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)) {
reportRtl(
context,
USE_START,
attribute,
context.getValueLocation(attribute),
message,
null,
APPLIES_REQUIRES_RTL);
}
return;
}
// Some other left/right/start/end attribute
// 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);
reportRtl(
context,
SYMMETRY,
attribute,
context.getNameLocation(attribute),
message,
null,
APPLIES_REQUIRES_RTL);
}
}
boolean isOld = isOldAttribute(name);
if (isOld) {
if (!context.isEnabled(USE_START)) {
return;
}
String rtl = convertOldToNew(name);
if (element.hasAttributeNS(ANDROID_URI, rtl)) {
// Warn that left/right isn't needed (provisionally; can't just look
// at current minSdkVersion since it can be higher in the app report
String message =
String.format(
"Redundant attribute `%1$s`; already defining `%2$s` with "
+ "`targetSdkVersion` %3$s",
name, rtl, context.getProject().getTargetSdk());
LintFix fix = fix().unset(ANDROID_URI, name).autoFix().build();
reportRtl(
context,
USE_START,
attribute,
context.getNameLocation(attribute),
message,
fix,
APPLIES_FOLDER_OR_MIN_17 | APPLIES_REQUIRES_RTL);
} else {
// No need to check these levels in the main app; the main app's
// minSdkVersion is always at least as high, and the folder version
// is constant
if (context.getProject().getMinSdk() < RTL_API
&& context.getFolderVersion() < RTL_API) {
String message =
String.format(
"Consider adding `%1$s:%2$s=\"%3$s\"` to better support "
+ "right-to-left layouts",
attribute.getPrefix(), rtl, value);
LintFix lintFix =
fix().name(
String.format(
"Add %1$s:%2$s=\"%3$s\"",
attribute.getPrefix(), rtl, value))
.set(attribute.getNamespaceURI(), rtl, attribute.getValue())
.build();
reportRtl(
context,
USE_START,
attribute,
context.getNameLocation(attribute),
message,
lintFix,
APPLIES_MIN_PRE_17);
}
String 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);
LintFix lintFix =
fix().replace()
.name(
String.format(
"Replace with %1$s:%2$s=\"%3$s\"",
attribute.getPrefix(), rtl, value))
.text(name)
.with(rtl)
.build();
reportRtl(
context,
USE_START,
attribute,
context.getNameLocation(attribute),
message,
lintFix,
APPLIES_MIN_17 | APPLIES_REQUIRES_RTL);
}
} else {
if (context.getProject().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 oldValue = convertNewToOld(value);
String message =
String.format(
Locale.getDefault(),
"To support older versions than API 17 (project specifies %%s) "
+ "you should **also** add `%1$s:%2$s=\"%3$s\"`",
attribute.getPrefix(),
old,
oldValue);
LintFix fix = fix().set(attribute.getNamespaceURI(), old, oldValue).build();
reportRtl(
context,
COMPAT,
attribute,
context.getNameLocation(attribute),
message,
fix,
APPLIES_MIN_PRE_17 | APPLIES_REQUIRES_RTL);
}
}
/** The targetSdkVersion must be less than 17 */
@SuppressWarnings("PointlessBitwiseExpression") // for symmetry
private static final int APPLIES_TARGET_PRE_17 = 1 << 0; // does not require rtl
/** The application must specify that it requires RTL */
private static final int APPLIES_REQUIRES_RTL = 1 << 1;
/** The minSdkVersion must be less than 17 */
private static final int APPLIES_MIN_PRE_17 = 1 << 2; // also requires rtl
/** The minSdkVersion must be at least 17 */
private static final int APPLIES_MIN_17 = 1 << 3; // also requires rtl
/** Either minSdkVersion of resource folder version >= 17 */
private static final int APPLIES_FOLDER_OR_MIN_17 = 1 << 4; // aso requires minSdk >= 17
private static final String KEY_APPLIES = "applies";
private void reportRtl(
@NonNull XmlContext context,
@NonNull Issue issue,
@NonNull Node scope,
@NonNull Location nameLocation,
@NonNull String message,
@Nullable LintFix fix,
int constraint) {
Incident incident = new Incident(issue, scope, nameLocation, message, fix);
context.report(incident, map().put(KEY_APPLIES, constraint));
}
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 SourceCodeScanner ----
@Override
public List<Class<? extends UElement>> getApplicableUastTypes() {
return Collections.singletonList(USimpleNameReferenceExpression.class);
}
@Nullable
@Override
public UElementHandler createUastHandler(@NonNull JavaContext context) {
return new IdentifierChecker(context);
}
private static class IdentifierChecker extends UElementHandler {
private final JavaContext context;
public IdentifierChecker(JavaContext context) {
this.context = context;
}
@Override
public void visitSimpleNameReferenceExpression(
@NonNull USimpleNameReferenceExpression element) {
String identifier = element.getIdentifier();
boolean isLeft = LEFT_FIELD.equals(identifier);
boolean isRight = RIGHT_FIELD.equals(identifier);
if (!isLeft && !isRight) {
return;
}
PsiElement resolved = element.resolve();
if (!(resolved instanceof PsiField)) {
return;
} else {
PsiField field = (PsiField) resolved;
if (!context.getEvaluator().isMemberInClass(field, FQCN_GRAVITY)) {
return;
}
}
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 = context.getLocation(element);
Incident incident = new Incident(USE_START, element, location, message);
context.report(incident, new LintMap().put(KEY_APPLIES, APPLIES_REQUIRES_RTL));
}
}
}