blob: 61b7c9673fd8aa937a5a9bfa5b5d6716a0322621 [file] [log] [blame]
/*
* Copyright (C) 2015 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_WIDTH;
import static com.android.SdkConstants.TAG_CLIP_PATH;
import static com.android.SdkConstants.TAG_VECTOR;
import static com.android.SdkConstants.UNIT_DIP;
import static com.android.SdkConstants.UNIT_DP;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.builder.model.Variant;
import com.android.ide.common.gradle.model.IdeAndroidProject;
import com.android.ide.common.repository.GradleVersion;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceUrl;
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.Project;
import com.android.tools.lint.detector.api.ResourceXmlDetector;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.XmlContext;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.function.Predicate;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/** Looks for issues with converting vector drawables to bitmaps for backward compatibility. */
public class VectorDetector extends ResourceXmlDetector {
/** The main issue discovered by this detector */
public static final Issue ISSUE =
Issue.create(
"VectorRaster",
"Vector Image Generation",
"Vector icons require API 21 or API 24 depending on used features, "
+ "but when `minSdkVersion` is less than 21 or 24 and Android Gradle plugin 1.4 or "
+ "higher is used, a vector drawable placed in the `drawable` folder is automatically "
+ "moved to `drawable-anydpi-v21` or `drawable-anydpi-v24` and bitmap images are "
+ "generated for different screen resolutions for backwards compatibility.\n"
+ "\n"
+ "However, there are some limitations to this raster image generation, and this "
+ "lint check flags elements and attributes that are not fully supported. "
+ "You should manually check whether the generated output is acceptable for those "
+ "older devices.",
Category.CORRECTNESS,
5,
Severity.WARNING,
new Implementation(VectorDetector.class, Scope.RESOURCE_FILE_SCOPE));
/** Constructs a new {@link VectorDetector} */
public VectorDetector() {}
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return folderType == ResourceFolderType.DRAWABLE;
}
/**
* Returns true if the given Gradle project model supports raster image generation for vector
* drawables.
*
* @param project the project to check
* @return true if the plugin supports raster image generation
*/
public static boolean isVectorGenerationSupported(@NonNull Project project) {
GradleVersion modelVersion = project.getGradleModelVersion();
// Requires 1.4.x or higher.
return modelVersion != null && modelVersion.isAtLeastIncludingPreviews(1, 4, 0);
}
/**
* Returns true if the given Gradle project model supports raster image generation for vector
* drawables with gradients.
*
* @param project the project to check
* @return true if the plugin supports raster image generation
*/
public static boolean isVectorGenerationSupportedForGradient(@NonNull Project project) {
GradleVersion modelVersion = project.getGradleModelVersion();
// Requires 3.1.x or higher.
return modelVersion != null && modelVersion.isAtLeastIncludingPreviews(3, 1, 0);
}
/**
* Returns true if the given Gradle project model supports raster image generation for vector
* drawables with the android:fillType attribute.
*
* @param project the project to check
* @return true if the plugin supports raster image generation
*/
public static boolean isVectorGenerationSupportedForFillType(@NonNull Project project) {
GradleVersion modelVersion = project.getGradleModelVersion();
// Requires 3.2.x or higher.
return modelVersion != null && modelVersion.isAtLeastIncludingPreviews(3, 2, 0);
}
@Override
public void visitDocument(@NonNull XmlContext context, @NonNull Document document) {
checkSize(context, document);
// If minSdkVersion >= 24, we're not generating compatibility bitmap icons.
int apiThreshold = 24;
Project project = context.getMainProject();
if (project.getMinSdkVersion().getFeatureLevel() >= apiThreshold) {
return;
}
// Vector generation is only done for Gradle projects
if (!project.isGradleProject()) {
return;
}
// Not using a Gradle plugin that supports vector image generation?
if (!isVectorGenerationSupported(project)) {
return;
}
Element root = document.getDocumentElement();
// If this is not actually a vector icon, nothing to do in this detector
if (root == null || !root.getTagName().equals(TAG_VECTOR)) {
return;
}
// If this vector asset is in a -v24 folder, we're not generating bitmap icons.
if (context.getFolderVersion() >= apiThreshold) {
return;
}
if (usingSupportLibVectors(project)) {
return;
}
// TODO: Check to see if there already is a -?dpi version of the file; if so,
// we also won't be generating a bitmap image.
boolean generationDueToGradient =
containsGradient(document) && isVectorGenerationSupportedForGradient(project);
if (!generationDueToGradient) {
boolean generationDueToFillType =
containsFillType(document) && isVectorGenerationSupportedForFillType(project);
if (!generationDueToFillType) {
apiThreshold = 21;
// If minSdkVersion >= 21, we're not generating compatibility bitmap icons.
if (project.getMinSdkVersion().getFeatureLevel() >= apiThreshold) {
return;
}
// If this vector asset is in a -v21 folder, we're not generating bitmap icons.
if (context.getFolderVersion() >= apiThreshold) {
return;
}
}
}
checkSupported(context, root, apiThreshold);
}
private static void checkSize(XmlContext context, Document document) {
Element root = document.getDocumentElement();
if (root == null) {
return;
}
Attr widthAttribute = root.getAttributeNodeNS(ANDROID_URI, ATTR_WIDTH);
Attr heightAttribute = root.getAttributeNodeNS(ANDROID_URI, ATTR_WIDTH);
if (widthAttribute == null || heightAttribute == null) {
return;
}
try {
int width = getDipSize(widthAttribute);
int height = getDipSize(heightAttribute);
Attr wrong;
if (width > 200) {
wrong = widthAttribute;
} else if (height > 200) {
wrong = heightAttribute;
} else {
return;
}
context.report(
ISSUE,
wrong,
context.getValueLocation(wrong),
"Limit vector icons sizes to 200\u00D7200 to keep icon drawing "
+ "fast; see https://developer.android.com/studio/write/vector-asset-studio#when for more");
} catch (NumberFormatException ignore) {
}
}
private static int getDipSize(Attr attribute) {
String s = attribute.getValue();
if (s.isEmpty() || !Character.isDigit(s.charAt(0))) {
return -1;
}
if (s.endsWith(UNIT_DP)) {
s = s.substring(0, s.length() - UNIT_DP.length());
} else if (s.endsWith(UNIT_DIP)) {
s = s.substring(0, s.length() - UNIT_DIP.length());
} else {
return -1;
}
try {
return Integer.parseInt(s);
} catch (NumberFormatException ignore) {
return -1;
}
}
private static boolean containsGradient(@NonNull Document document) {
return findElement(document, element -> "gradient".equals(element.getTagName())) != null;
}
private static boolean containsFillType(Document document) {
return findElement(document, element -> element.hasAttributeNS(ANDROID_URI, "fillType"))
!= null;
}
@Nullable
private static Element findElement(
@NonNull Document document, @NonNull Predicate<Element> predicate) {
Deque<Element> elements = new ArrayDeque<>();
elements.add(document.getDocumentElement());
Element element;
while ((element = elements.poll()) != null) {
if (predicate.test(element)) {
return element;
}
NodeList children = element.getChildNodes();
for (int i = 0, n = children.getLength(); i < n; i++) {
Node child = children.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE) {
elements.add((Element) child);
}
}
}
return null;
}
static boolean usingSupportLibVectors(@NonNull Project project) {
GradleVersion version = project.getGradleModelVersion();
if (version == null || version.getMajor() < 2) {
return false;
}
Variant currentVariant = project.getCurrentVariant();
return currentVariant != null
&& Boolean.TRUE.equals(
currentVariant
.getMergedFlavor()
.getVectorDrawables()
.getUseSupportLibrary());
}
/** Recursive element check for unsupported attributes and tags */
private static void checkSupported(
@NonNull XmlContext context, @NonNull Element element, int apiThreshold) {
// Unsupported tags
String tag = element.getTagName();
if (TAG_CLIP_PATH.equals(tag)) {
String message =
String.format(
"This tag is not supported in images generated from this vector icon for "
+ "API < %d; check generated icon to make sure it looks acceptable",
apiThreshold);
context.report(ISSUE, element, context.getLocation(element), message);
} else if ("group".equals(tag)) {
IdeAndroidProject model = context.getMainProject().getGradleProjectModel();
if (model != null && model.getModelVersion().startsWith("1.4.")) {
String message =
"Update Gradle plugin version to 1.5+ to correctly handle "
+ "`<group>` tags in generated bitmaps";
context.report(ISSUE, element, context.getElementLocation(element), message);
}
}
NamedNodeMap attributes = element.getAttributes();
for (int i = 0, n = attributes.getLength(); i < n; i++) {
Attr attr = (Attr) attributes.item(i);
String name = attr.getLocalName();
if (("autoMirrored".equals(name)
|| "trimPathStart".equals(name)
|| "trimPathEnd".equals(name)
|| "trimPathOffset".equals(name))
&& ANDROID_URI.equals(attr.getNamespaceURI())) {
String message =
String.format(
"This attribute is not supported in images generated from this vector icon "
+ "for API < %d; check generated icon to make sure it looks acceptable",
apiThreshold);
context.report(ISSUE, attr, context.getNameLocation(attr), message);
}
String value = attr.getValue();
if (ResourceUrl.parse(value) != null) {
String message =
String.format(
"Resource references will not work correctly in images generated for this "
+ "vector icon for API < %d; check generated icon to make sure it looks "
+ "acceptable",
apiThreshold);
context.report(ISSUE, attr, context.getValueLocation(attr), message);
}
}
NodeList children = element.getChildNodes();
for (int i = 0, n = children.getLength(); i < n; i++) {
Node child = children.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE) {
checkSupported(context, (Element) child, apiThreshold);
}
}
}
}