| /* |
| * 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_HOST; |
| import static com.android.SdkConstants.ATTR_PATH; |
| import static com.android.SdkConstants.ATTR_PATH_PATTERN; |
| import static com.android.SdkConstants.ATTR_PATH_PREFIX; |
| import static com.android.SdkConstants.ATTR_SCHEME; |
| |
| import static com.android.xml.AndroidManifest.ATTRIBUTE_MIME_TYPE; |
| import static com.android.xml.AndroidManifest.ATTRIBUTE_NAME; |
| import static com.android.xml.AndroidManifest.ATTRIBUTE_PORT; |
| import static com.android.xml.AndroidManifest.NODE_ACTION; |
| import static com.android.xml.AndroidManifest.NODE_CATEGORY; |
| import static com.android.xml.AndroidManifest.NODE_DATA; |
| import static com.android.xml.AndroidManifest.NODE_INTENT; |
| |
| import com.android.SdkConstants; |
| import com.android.annotations.NonNull; |
| import com.android.annotations.Nullable; |
| import com.android.ide.common.rendering.api.ResourceValue; |
| import com.android.ide.common.res2.AbstractResourceRepository; |
| import com.android.ide.common.res2.ResourceItem; |
| import com.android.ide.common.resources.ResourceUrl; |
| import com.android.resources.ResourceType; |
| import com.android.tools.lint.client.api.LintClient; |
| import com.android.tools.lint.detector.api.Category; |
| import com.android.tools.lint.detector.api.Detector; |
| 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.Scope; |
| import com.android.tools.lint.detector.api.Severity; |
| import com.android.tools.lint.detector.api.XmlContext; |
| |
| import org.w3c.dom.Attr; |
| import org.w3c.dom.Element; |
| import org.w3c.dom.NamedNodeMap; |
| import org.w3c.dom.Node; |
| import org.w3c.dom.NodeList; |
| |
| import java.util.Collection; |
| import java.util.Collections; |
| import java.util.List; |
| |
| |
| /** |
| * Check if the usage of App Indexing is correct. |
| */ |
| public class AppIndexingApiDetector extends Detector implements Detector.XmlScanner { |
| |
| private static final Implementation IMPLEMENTATION = new Implementation( |
| AppIndexingApiDetector.class, Scope.MANIFEST_SCOPE); |
| |
| public static final Issue ISSUE_ERROR = Issue.create("AppIndexingError", //$NON-NLS-1$ |
| "Wrong Usage of App Indexing", |
| "Ensures the app can correctly handle deep links and integrate with " + |
| "App Indexing for Google search.", |
| Category.USABILITY, 5, Severity.ERROR, IMPLEMENTATION) |
| .addMoreInfo("https://g.co/AppIndexing"); |
| |
| public static final Issue ISSUE_WARNING = Issue.create("AppIndexingWarning", //$NON-NLS-1$ |
| "Missing App Indexing Support", |
| "Ensures the app can correctly handle deep links and integrate with " + |
| "App Indexing for Google search.", |
| Category.USABILITY, 5, Severity.WARNING, IMPLEMENTATION) |
| .addMoreInfo("https://g.co/AppIndexing"); |
| |
| private static final String[] PATH_ATTR_LIST = new String[]{ATTR_PATH_PREFIX, ATTR_PATH, |
| ATTR_PATH_PATTERN}; |
| |
| @Override |
| @Nullable |
| public Collection<String> getApplicableElements() { |
| return Collections.singletonList(NODE_INTENT); |
| } |
| |
| @Override |
| public void visitElement(@NonNull XmlContext context, @NonNull Element intent) { |
| boolean actionView = hasActionView(intent); |
| boolean browsable = isBrowsable(intent); |
| boolean isHttp = false; |
| boolean hasScheme = false; |
| boolean hasHost = false; |
| boolean hasPort = false; |
| boolean hasPath = false; |
| boolean hasMimeType = false; |
| Element firstData = null; |
| NodeList children = intent.getChildNodes(); |
| for (int i = 0; i < children.getLength(); i++) { |
| Node child = children.item(i); |
| if (child.getNodeType() == Node.ELEMENT_NODE && child.getNodeName().equals(NODE_DATA)) { |
| Element data = (Element) child; |
| if (firstData == null) { |
| firstData = data; |
| } |
| if (isHttpSchema(data)) { |
| isHttp = true; |
| } |
| checkSingleData(context, data); |
| |
| for (String name : PATH_ATTR_LIST) { |
| if (data.hasAttributeNS(ANDROID_URI, name)) { |
| hasPath = true; |
| } |
| } |
| |
| if (data.hasAttributeNS(ANDROID_URI, ATTR_SCHEME)) { |
| hasScheme = true; |
| } |
| |
| if (data.hasAttributeNS(ANDROID_URI, ATTR_HOST)) { |
| hasHost = true; |
| } |
| |
| if (data.hasAttributeNS(ANDROID_URI, ATTRIBUTE_PORT)) { |
| hasPort = true; |
| } |
| |
| if (data.hasAttributeNS(ANDROID_URI, ATTRIBUTE_MIME_TYPE)) { |
| hasMimeType = true; |
| } |
| } |
| } |
| |
| // In data field, a URL is consisted by |
| // <scheme>://<host>:<port>[<path>|<pathPrefix>|<pathPattern>] |
| // Each part of the URL should not have illegal character. |
| if ((hasPath || hasHost || hasPort) && !hasScheme) { |
| context.report(ISSUE_ERROR, firstData, context.getLocation(firstData), |
| "android:scheme missing"); |
| } |
| |
| if ((hasPath || hasPort) && !hasHost) { |
| context.report(ISSUE_ERROR, firstData, context.getLocation(firstData), |
| "android:host missing"); |
| } |
| |
| if (actionView && browsable) { |
| if (firstData == null) { |
| // If this activity is an ACTION_VIEW action with category BROWSABLE, but doesn't |
| // have data node, it may be a mistake and we will report error. |
| context.report(ISSUE_ERROR, intent, context.getLocation(intent), |
| "Missing data node?"); |
| } else if (!hasScheme && !hasMimeType) { |
| // If this activity is an action view, is browsable, but has neither a |
| // URL nor mimeType, it may be a mistake and we will report error. |
| context.report(ISSUE_ERROR, firstData, context.getLocation(firstData), |
| "Missing URL for the intent filter?"); |
| } |
| } |
| |
| // If this activity is an ACTION_VIEW action, has a http URL but doesn't have |
| // BROWSABLE, it may be a mistake and and we will report warning. |
| if (actionView && isHttp && !browsable) { |
| context.report(ISSUE_WARNING, intent, context.getLocation(intent), |
| "Activity supporting ACTION_VIEW is not set as BROWSABLE"); |
| } |
| } |
| |
| private static boolean hasActionView(Element intent) { |
| NodeList children = intent.getChildNodes(); |
| for (int i = 0; i < children.getLength(); i++) { |
| Node child = children.item(i); |
| if (child.getNodeType() == Node.ELEMENT_NODE && |
| child.getNodeName().equals(NODE_ACTION)) { |
| Element action = (Element) child; |
| if (action.hasAttributeNS(ANDROID_URI, ATTRIBUTE_NAME)) { |
| Attr attr = action.getAttributeNodeNS(ANDROID_URI, ATTRIBUTE_NAME); |
| if (attr.getValue().equals("android.intent.action.VIEW")) { |
| return true; |
| } |
| } |
| } |
| } |
| return false; |
| } |
| |
| private static boolean isBrowsable(Element intent) { |
| NodeList children = intent.getChildNodes(); |
| for (int i = 0; i < children.getLength(); i++) { |
| Node child = children.item(i); |
| if (child.getNodeType() == Node.ELEMENT_NODE && |
| child.getNodeName().equals(NODE_CATEGORY)) { |
| Element e = (Element) child; |
| if (e.hasAttributeNS(ANDROID_URI, ATTRIBUTE_NAME)) { |
| Attr attr = e.getAttributeNodeNS(ANDROID_URI, ATTRIBUTE_NAME); |
| if (attr.getNodeValue().equals("android.intent.category.BROWSABLE")) { |
| return true; |
| } |
| } |
| } |
| } |
| return false; |
| } |
| |
| private static boolean isHttpSchema(Element data) { |
| if (data.hasAttributeNS(ANDROID_URI, ATTR_SCHEME)) { |
| String value = data.getAttributeNodeNS(ANDROID_URI, ATTR_SCHEME).getValue(); |
| if (value.equalsIgnoreCase("http") || value.equalsIgnoreCase("https")) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private static void checkSingleData(XmlContext context, Element data) { |
| // path, pathPrefix and pathPattern should starts with /. |
| for (String name : PATH_ATTR_LIST) { |
| if (data.hasAttributeNS(ANDROID_URI, name)) { |
| Attr attr = data.getAttributeNodeNS(ANDROID_URI, name); |
| String path = replaceUrlWithValue(context, attr.getValue()); |
| if (!path.startsWith("/") && !path.startsWith(SdkConstants.PREFIX_RESOURCE_REF)) { |
| context.report(ISSUE_ERROR, attr, context.getLocation(attr), |
| "android:" + name + " attribute should start with '/', but it is : " |
| + path); |
| } |
| } |
| } |
| |
| // port should be a legal number. |
| if (data.hasAttributeNS(ANDROID_URI, ATTRIBUTE_PORT)) { |
| Attr attr = data.getAttributeNodeNS(ANDROID_URI, ATTRIBUTE_PORT); |
| try { |
| String port = replaceUrlWithValue(context, attr.getValue()); |
| Integer.parseInt(port); |
| } catch (NumberFormatException e) { |
| context.report(ISSUE_ERROR, attr, context.getLocation(attr), |
| "android:port is not a legal number"); |
| } |
| } |
| |
| // Each field should be non empty. |
| NamedNodeMap attrs = data.getAttributes(); |
| for (int i = 0; i < attrs.getLength(); i++) { |
| Node item = attrs.item(i); |
| if (item.getNodeType() == Node.ATTRIBUTE_NODE) { |
| Attr attr = (Attr) attrs.item(i); |
| if (attr.getValue().isEmpty()) { |
| context.report(ISSUE_ERROR, attr, context.getLocation(attr), |
| attr.getName() + " cannot be empty"); |
| } |
| } |
| } |
| } |
| |
| private static String replaceUrlWithValue(@NonNull XmlContext context, |
| @NonNull String str) { |
| Project project = context.getProject(); |
| LintClient client = context.getClient(); |
| if (!client.supportsProjectResources()) { |
| return str; |
| } |
| ResourceUrl style = ResourceUrl.parse(str); |
| if (style == null || style.type != ResourceType.STRING || style.framework) { |
| return str; |
| } |
| AbstractResourceRepository resources = client.getProjectResources(project, true); |
| if (resources == null) { |
| return str; |
| } |
| List<ResourceItem> items = resources.getResourceItem(ResourceType.STRING, style.name); |
| if (items == null || items.isEmpty()) { |
| return str; |
| } |
| ResourceValue resourceValue = items.get(0).getResourceValue(false); |
| if (resourceValue == null) { |
| return str; |
| } |
| return resourceValue.getValue() == null ? str : resourceValue.getValue(); |
| } |
| } |