blob: 805321189751dd5290abecdd3f6b2797a1f0bf14 [file] [log] [blame]
/*
* Copyright (C) 2011 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_HINT;
import static com.android.SdkConstants.ATTR_ID;
import static com.android.SdkConstants.ATTR_INPUT_METHOD;
import static com.android.SdkConstants.ATTR_INPUT_TYPE;
import static com.android.SdkConstants.ATTR_PASSWORD;
import static com.android.SdkConstants.ATTR_PHONE_NUMBER;
import static com.android.SdkConstants.ATTR_STYLE;
import static com.android.SdkConstants.EDIT_TEXT;
import static com.android.SdkConstants.ID_PREFIX;
import static com.android.SdkConstants.NEW_ID_PREFIX;
import com.android.annotations.NonNull;
import com.android.annotations.VisibleForTesting;
import com.android.ide.common.rendering.api.ResourceValue;
import com.android.tools.lint.client.api.LintClient;
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.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 org.w3c.dom.Node;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
* Checks for usability problems in text fields: omitting inputType, or omitting a hint.
*/
public class TextFieldDetector extends LayoutDetector {
/** The main issue discovered by this detector */
public static final Issue ISSUE = Issue.create(
"TextFields", //$NON-NLS-1$
"Missing `inputType` or `hint`",
"Providing an `inputType` attribute on a text field improves usability " +
"because depending on the data to be input, optimized keyboards can be shown " +
"to the user (such as just digits and parentheses for a phone number). Similarly," +
"a hint attribute displays a hint to the user for what is expected in the " +
"text field.\n" +
"\n" +
"The lint detector also looks at the `id` of the view, and if the id offers a " +
"hint of the purpose of the field (for example, the `id` contains the phrase " +
"`phone` or `email`), then lint will also ensure that the `inputType` contains " +
"the corresponding type attributes.\n" +
"\n" +
"If you really want to keep the text field generic, you can suppress this warning " +
"by setting `inputType=\"text\"`.",
Category.USABILITY,
5,
Severity.WARNING,
new Implementation(
TextFieldDetector.class,
Scope.RESOURCE_FILE_SCOPE));
/** Constructs a new {@link TextFieldDetector} */
public TextFieldDetector() {
}
@NonNull
@Override
public Speed getSpeed() {
return Speed.FAST;
}
@Override
public Collection<String> getApplicableElements() {
return Collections.singletonList(EDIT_TEXT);
}
@Override
public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
Node inputTypeNode = element.getAttributeNodeNS(ANDROID_URI, ATTR_INPUT_TYPE);
String inputType = "";
if (inputTypeNode != null) {
inputType = inputTypeNode.getNodeValue();
}
boolean haveHint = false;
if (inputTypeNode == null) {
haveHint = element.hasAttributeNS(ANDROID_URI, ATTR_HINT);
String style = element.getAttribute(ATTR_STYLE);
if (style != null && !style.isEmpty()) {
LintClient client = context.getClient();
if (client.supportsProjectResources()) {
Project project = context.getMainProject();
List<ResourceValue> styles = LintUtils.getStyleAttributes(project, client,
style, ANDROID_URI, ATTR_INPUT_TYPE);
if (styles != null && !styles.isEmpty()) {
ResourceValue value = styles.get(0);
inputType = value.getValue();
inputTypeNode = element;
} else if (!haveHint) {
styles = LintUtils.getStyleAttributes(project, client, style,
ANDROID_URI, ATTR_HINT);
if (styles != null && !styles.isEmpty()) {
haveHint = true;
}
}
} else {
// The input type might be specified via a style. This will require
// us to track these (similar to what is done for the
// RequiredAttributeDetector to track layout_width and layout_height
// in style declarations). For now, simply ignore these elements
// to avoid producing false positives.
return;
}
}
}
if (inputTypeNode == null && !haveHint) {
// Also make sure the EditText does not set an inputMethod in which case
// an inputType might be provided from the input.
if (element.hasAttributeNS(ANDROID_URI, ATTR_INPUT_METHOD)) {
return;
}
context.report(ISSUE, element, context.getLocation(element),
"This text field does not specify an `inputType` or a `hint`");
}
Attr idNode = element.getAttributeNodeNS(ANDROID_URI, ATTR_ID);
if (idNode == null) {
return;
}
String id = idNode.getValue();
if (id.isEmpty()) {
return;
}
if (id.startsWith("editText")) { //$NON-NLS-1$
// Just the default label
return;
}
// TODO: See if the name is just the default names (button1, editText1 etc)
// and if so, do nothing
// TODO: Unit test this
if (containsWord(id, "phone", true, true)) { //$NON-NLS-1$
if (!inputType.contains("phone") //$NON-NLS-1$
&& element.getAttributeNodeNS(ANDROID_URI, ATTR_PHONE_NUMBER) == null) {
String message = String.format("The view name (`%1$s`) suggests this is a phone "
+ "number, but it does not include '`phone`' in the `inputType`", id);
reportMismatch(context, idNode, inputTypeNode, message);
}
return;
}
if (containsWord(id, "width", false, true)
|| containsWord(id, "height", false, true)
|| containsWord(id, "size", false, true)
|| containsWord(id, "length", false, true)
|| containsWord(id, "weight", false, true)
|| containsWord(id, "number", false, true)) {
if (!inputType.contains("number") && !inputType.contains("phone")) { //$NON-NLS-1$
String message = String.format("The view name (`%1$s`) suggests this is a number, "
+ "but it does not include a numeric `inputType` (such as '`numberSigned`')",
id);
reportMismatch(context, idNode, inputTypeNode, message);
}
return;
}
if (containsWord(id, "password", true, true)) { //$NON-NLS-1$
if (!(inputType.contains("Password")) //$NON-NLS-1$
&& element.getAttributeNodeNS(ANDROID_URI, ATTR_PASSWORD) == null) {
String message = String.format("The view name (`%1$s`) suggests this is a password, "
+ "but it does not include '`textPassword`' in the `inputType`", id);
reportMismatch(context, idNode, inputTypeNode, message);
}
return;
}
if (containsWord(id, "email", true, true)) { //$NON-NLS-1$
if (!inputType.contains("Email")) { //$NON-NLS-1$
String message = String.format("The view name (`%1$s`) suggests this is an e-mail "
+ "address, but it does not include '`textEmail`' in the `inputType`", id);
reportMismatch(context, idNode, inputTypeNode, message);
}
return;
}
if (endsWith(id, "pin", false, true)) { //$NON-NLS-1$
if (!(inputType.contains("numberPassword")) //$NON-NLS-1$
&& element.getAttributeNodeNS(ANDROID_URI, ATTR_PASSWORD) == null) {
String message = String.format("The view name (`%1$s`) suggests this is a password, "
+ "but it does not include '`numberPassword`' in the `inputType`", id);
reportMismatch(context, idNode, inputTypeNode, message);
}
return;
}
if ((containsWord(id, "uri") || containsWord(id, "url"))
&& !inputType.contains("textUri")) {
String message = String.format("The view name (`%1$s`) suggests this is a URI, "
+ "but it does not include '`textUri`' in the `inputType`", id);
reportMismatch(context, idNode, inputTypeNode, message);
}
if ((containsWord(id, "date")) //$NON-NLS-1$
&& !inputType.contains("date")) { //$NON-NLS-1$
String message = String.format("The view name (`%1$s`) suggests this is a date, "
+ "but it does not include '`date`' or '`datetime`' in the `inputType`", id);
reportMismatch(context, idNode, inputTypeNode, message);
}
}
private static void reportMismatch(XmlContext context, Attr idNode, Node inputTypeNode,
String message) {
Location location;
if (inputTypeNode != null) {
location = context.getLocation(inputTypeNode);
Location secondary = context.getLocation(idNode);
secondary.setMessage("id defined here");
location.setSecondary(secondary);
} else {
location = context.getLocation(idNode);
}
context.report(ISSUE, idNode.getOwnerElement(), location, message);
}
/** Returns true if the given sentence contains a given word */
@VisibleForTesting
static boolean containsWord(String sentence, String word) {
return containsWord(sentence, word, false, false);
}
/**
* Returns true if the given sentence contains a given word
* @param sentence the full sentence to search within
* @param word the word to look for
* @param allowPrefix if true, allow a prefix match even if the next character
* is in the same word (same case or not an underscore)
* @param allowSuffix if true, allow a suffix match even if the preceding character
* is in the same word (same case or not an underscore)
* @return true if the word is contained in the sentence
*/
@VisibleForTesting
static boolean containsWord(String sentence, String word, boolean allowPrefix,
boolean allowSuffix) {
return indexOfWord(sentence, word, allowPrefix, allowSuffix) != -1;
}
/** Returns true if the given sentence <b>ends</b> with a given word */
private static boolean endsWith(String sentence, String word, boolean allowPrefix,
boolean allowSuffix) {
int index = indexOfWord(sentence, word, allowPrefix, allowSuffix);
if (index != -1) {
return index == sentence.length() - word.length();
}
return false;
}
/**
* Returns the index of the given word in the given sentence, if any. It will match
* across cases, and ignore words that seem to be just a substring in the middle
* of another word.
*
* @param sentence the full sentence to search within
* @param word the word to look for
* @param allowPrefix if true, allow a prefix match even if the next character
* is in the same word (same case or not an underscore)
* @param allowSuffix if true, allow a suffix match even if the preceding character
* is in the same word (same case or not an underscore)
* @return true if the word is contained in the sentence
*/
private static int indexOfWord(String sentence, String word, boolean allowPrefix,
boolean allowSuffix) {
if (sentence.isEmpty()) {
return -1;
}
int wordLength = word.length();
if (wordLength > sentence.length()) {
return -1;
}
char firstUpper = Character.toUpperCase(word.charAt(0));
char firstLower = Character.toLowerCase(firstUpper);
int start = 0;
if (sentence.startsWith(NEW_ID_PREFIX)) {
start += NEW_ID_PREFIX.length();
} else if (sentence.startsWith(ID_PREFIX)) {
start += ID_PREFIX.length();
}
for (int i = start, n = sentence.length(), m = n - (wordLength - 1); i < m; i++) {
char c = sentence.charAt(i);
if (c == firstUpper || c == firstLower) {
if (sentence.regionMatches(true, i, word, 0, wordLength)) {
if (i <= start && allowPrefix) {
return i;
}
if (i == m - 1 && allowSuffix) {
return i;
}
if (i <= start || (sentence.charAt(i - 1) == '_')
|| Character.isUpperCase(c)) {
if (i == m - 1) {
return i;
}
char after = sentence.charAt(i + wordLength);
if (after == '_' || Character.isUpperCase(after)) {
return i;
}
}
}
}
}
return -1;
}
}