| /* |
| * 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; |
| } |
| } |