1 /*
2  * Copyright (C) 2016 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.google.checkcolor.lint;
18 
19 import com.android.SdkConstants;
20 import com.android.annotations.NonNull;
21 import com.android.annotations.Nullable;
22 import com.android.ide.common.resources.ResourceUrl;
23 import com.android.resources.ResourceFolderType;
24 import com.android.resources.ResourceType;
25 import com.android.tools.lint.detector.api.Category;
26 import com.android.tools.lint.detector.api.Context;
27 import com.android.tools.lint.detector.api.Implementation;
28 import com.android.tools.lint.detector.api.Issue;
29 import com.android.tools.lint.detector.api.LintUtils;
30 import com.android.tools.lint.detector.api.Location;
31 import com.android.tools.lint.detector.api.ResourceXmlDetector;
32 import com.android.tools.lint.detector.api.Scope;
33 import com.android.tools.lint.detector.api.Severity;
34 import com.android.tools.lint.detector.api.XmlContext;
35 import com.google.common.collect.ArrayListMultimap;
36 import com.google.common.collect.Multimap;
37 
38 import org.w3c.dom.Attr;
39 import org.w3c.dom.Element;
40 import org.w3c.dom.Node;
41 import org.w3c.dom.NodeList;
42 
43 import java.util.Arrays;
44 import java.util.Collection;
45 import java.util.HashSet;
46 import java.util.List;
47 import java.util.Set;
48 
49 import static com.android.SdkConstants.TAG_COLOR;
50 import static com.android.SdkConstants.TAG_ITEM;
51 import static com.android.SdkConstants.TAG_STYLE;
52 
53 /**
54  * It contains two phases to detect the hardcode colors
55  *
56  * Phase 1:
57  * 1. Check all the direct hardcode color(#ffffff)
58  * 2. Store all the potential indirect hardcode color(Hopefully none)
59  *
60  * Phase 2:
61  * 1. Go through colors.xml, recheck all the indirect hardcoded color
62  */
63 public class HardcodedColorDetector extends ResourceXmlDetector {
64     private static final Implementation IMPLEMENTATION = new Implementation(
65             HardcodedColorDetector.class,
66             Scope.RESOURCE_FILE_SCOPE);
67 
68     public static final Issue ISSUE = Issue.create(
69             "HardcodedColor",
70             "Using hardcoded color",
71             "Hardcoded color values are bad because theme changes cannot be uniformly applied." +
72             "Instead use the theme specific colors such as `?android:attr/textColorPrimary` in " +
73             "attributes.\n" +
74             "This ensures that a theme change from a light to a dark theme can be uniformly" +
75             "applied across the app.",
76             Category.CORRECTNESS,
77             4,
78             Severity.ERROR,
79             IMPLEMENTATION);
80 
81     private static final String ERROR_MESSAGE = "Using hardcoded colors is not allowed";
82 
83     private Multimap<String, Location.Handle> indirectColorMultiMap;
84     private Set<String> hardcodedColorSet;
85     private Set<String> skipAttributes;
86 
HardcodedColorDetector()87     public HardcodedColorDetector() {
88         indirectColorMultiMap = ArrayListMultimap.create();
89         skipAttributes = new HashSet<>();
90         hardcodedColorSet = new HashSet<>();
91 
92         skipAttributes.add("fillColor");
93         skipAttributes.add("strokeColor");
94         skipAttributes.add("text");
95     }
96 
97     @Override
appliesTo(@onNull ResourceFolderType folderType)98     public boolean appliesTo(@NonNull ResourceFolderType folderType) {
99         return folderType == ResourceFolderType.LAYOUT || folderType == ResourceFolderType.VALUES
100                 || folderType == ResourceFolderType.DRAWABLE;
101     }
102 
103     @Override
getApplicableAttributes()104     public Collection<String> getApplicableAttributes() {
105         return ALL;
106     }
107 
108     @Override
109     @Nullable
getApplicableElements()110     public Collection<String> getApplicableElements() {
111         return Arrays.asList(TAG_STYLE, TAG_COLOR);
112     }
113 
114     @Override
visitAttribute(@onNull XmlContext context, @NonNull Attr attribute)115     public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) {
116         if (!LintUtils.isEnglishResource(context, true)) {
117             return;
118         }
119 
120         final String value = attribute.getValue();
121         final ResourceUrl resUrl = ResourceUrl.parse(value);
122         if (!skipAttributes.contains(attribute.getLocalName()) && resUrl == null
123                 && isHardcodedColor(value)) {
124             // TODO: check whether the attr is valid to store the color
125             if (context.isEnabled(ISSUE)) {
126                 context.report(ISSUE, attribute, context.getLocation(attribute),
127                         ERROR_MESSAGE);
128             }
129         } else if (resUrl != null && resUrl.type == ResourceType.COLOR && !resUrl.theme) {
130             addIndirectColor(context, value, attribute);
131         }
132     }
133 
134     @Override
visitElement(@onNull XmlContext context, @NonNull Element element)135     public void visitElement(@NonNull XmlContext context, @NonNull Element element) {
136         if (context.getResourceFolderType() != ResourceFolderType.VALUES) {
137             return;
138         }
139 
140         if (!LintUtils.isEnglishResource(context, true)) {
141             return;
142         }
143 
144         final int phase = context.getPhase();
145         final String tagName = element.getTagName();
146         if (tagName.equals(TAG_STYLE)) {
147             final List<Element> itemNodes = LintUtils.getChildren(element);
148             for (Element childElement : itemNodes) {
149                 if (childElement.getNodeType() == Node.ELEMENT_NODE &&
150                         TAG_ITEM.equals(childElement.getNodeName())) {
151                     final NodeList childNodes = childElement.getChildNodes();
152                     for (int i = 0, n = childNodes.getLength(); i < n; i++) {
153                         final Node child = childNodes.item(i);
154                         if (child.getNodeType() != Node.TEXT_NODE) {
155                             break;
156                         }
157 
158                         final String value = child.getNodeValue();
159                         final ResourceUrl resUrl = ResourceUrl.parse(value);
160                         if (resUrl == null && isHardcodedColor(value)) {
161                             // TODO: check whether the node is valid to store the color
162                             context.report(ISSUE, childElement, context.getLocation(child),
163                                     ERROR_MESSAGE);
164                         } else if (resUrl != null && resUrl.type == ResourceType.COLOR
165                                 && !resUrl.theme) {
166                             addIndirectColor(context, value, child);
167                         }
168                     }
169                 }
170             }
171         } else if (tagName.equals(TAG_COLOR)) {
172             final String name = element.getAttribute(SdkConstants.ATTR_NAME);
173             final String value = element.getFirstChild().getNodeValue();
174             if (isHardcodedColor(value) && context.isEnabled(ISSUE)) {
175                 context.report(ISSUE, element, context.getLocation(element),
176                         ERROR_MESSAGE);
177 
178                 final String fullColorName = "@color/" + name;
179                 hardcodedColorSet.add(fullColorName);
180             }
181         }
182     }
183 
184     @Override
afterCheckProject(@onNull Context context)185     public void afterCheckProject(@NonNull Context context) {
186         super.afterCheckProject(context);
187 
188         if (context.isEnabled(ISSUE)) {
189             for (final String fullColorName : hardcodedColorSet) {
190                 if (indirectColorMultiMap.containsKey(fullColorName)) {
191                     for (Location.Handle handle : indirectColorMultiMap.get(fullColorName)) {
192                         context.report(ISSUE, handle.resolve(), ERROR_MESSAGE);
193                     }
194                 }
195             }
196         }
197     }
198 
199     /**
200      * Test whether {@paramref color} is the hardcoded color using the regex match.
201      * The hex hardcoded color has three types.
202      * 1. #RGB e.g #fff
203      * 2. #RRGGBB e.g #ffffff
204      * 3. #AARRGGBB e.g #ffffffff
205      *
206      * @param color name of the color
207      * @return whether it is hardcoded color
208      */
isHardcodedColor(String color)209     private boolean isHardcodedColor(String color) {
210         return color.matches("#[0-9a-fA-F]{3}")
211                 || color.matches("#[0-9a-fA-F]{6}")
212                 || color.matches("#[0-9a-fA-F]{8}");
213     }
214 
215     /**
216      * Add indirect color for further examination. For example, in layout file we don't know
217      * whether "@color/color_to_examine" is the hardcoded color. So I store the name and location
218      * first
219      * @param context used to create the location handle
220      * @param color name of the indirect color
221      * @param node postion in xml file
222      */
addIndirectColor(XmlContext context, String color, Node node)223     private void addIndirectColor(XmlContext context, String color, Node node) {
224         final Location.Handle handle = context.createLocationHandle(node);
225         handle.setClientData(node);
226 
227         indirectColorMultiMap.put(color, handle);
228     }
229 }